diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts index d10de0b259..776862e7eb 100644 --- a/packages/client/src/generated-effect/client.ts +++ b/packages/client/src/generated-effect/client.ts @@ -151,49 +151,81 @@ const Endpoint4_8 = (raw: RawClient["server.session"]) => (input: Endpoint4_8Inp Effect.map((value) => value.data), ) -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly id?: Endpoint4_9Request["payload"]["id"] - readonly skill: Endpoint4_9Request["payload"]["skill"] + readonly command: Endpoint4_9Request["payload"]["command"] + readonly arguments?: Endpoint4_9Request["payload"]["arguments"] + readonly agent?: Endpoint4_9Request["payload"]["agent"] + readonly model?: Endpoint4_9Request["payload"]["model"] + readonly files?: Endpoint4_9Request["payload"]["files"] + readonly agents?: Endpoint4_9Request["payload"]["agents"] + readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly resume?: Endpoint4_9Request["payload"]["resume"] } const Endpoint4_9 = (raw: RawClient["server.session"]) => (input: Endpoint4_9Input) => + raw["session.command"]({ + params: { sessionID: input["sessionID"] }, + payload: { + id: input["id"], + command: input["command"], + arguments: input["arguments"], + agent: input["agent"], + model: input["model"], + files: input["files"], + agents: input["agents"], + delivery: input["delivery"], + resume: input["resume"], + }, + }).pipe( + Effect.mapError(mapClientError), + Effect.map((value) => value.data), + ) + +type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Input = { + readonly sessionID: Endpoint4_10Request["params"]["sessionID"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly skill: Endpoint4_10Request["payload"]["skill"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] +} +const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => raw["session.skill"]({ params: { sessionID: input["sessionID"] }, payload: { id: input["id"], skill: input["skill"], resume: input["resume"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_10Request = Parameters[0] -type Endpoint4_10Input = { - readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly text: Endpoint4_10Request["payload"]["text"] - readonly description?: Endpoint4_10Request["payload"]["description"] - readonly metadata?: Endpoint4_10Request["payload"]["metadata"] +type Endpoint4_11Request = Parameters[0] +type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly text: Endpoint4_11Request["payload"]["text"] + readonly description?: Endpoint4_11Request["payload"]["description"] + readonly metadata?: Endpoint4_11Request["payload"]["metadata"] } -const Endpoint4_10 = (raw: RawClient["server.session"]) => (input: Endpoint4_10Input) => +const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => raw["session.synthetic"]({ params: { sessionID: input["sessionID"] }, payload: { text: input["text"], description: input["description"], metadata: input["metadata"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_11Request = Parameters[0] -type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } -const Endpoint4_11 = (raw: RawClient["server.session"]) => (input: Endpoint4_11Input) => - raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } const Endpoint4_12 = (raw: RawClient["server.session"]) => (input: Endpoint4_12Input) => + raw["session.compact"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_13Request = Parameters[0] +type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_13Request = Parameters[0] -type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly messageID: Endpoint4_13Request["payload"]["messageID"] - readonly files?: Endpoint4_13Request["payload"]["files"] +type Endpoint4_14Request = Parameters[0] +type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly messageID: Endpoint4_14Request["payload"]["messageID"] + readonly files?: Endpoint4_14Request["payload"]["files"] } -const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13Input) => +const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => raw["session.revert.stage"]({ params: { sessionID: input["sessionID"] }, payload: { messageID: input["messageID"], files: input["files"] }, @@ -202,72 +234,72 @@ const Endpoint4_13 = (raw: RawClient["server.session"]) => (input: Endpoint4_13I Effect.map((value) => value.data), ) -type Endpoint4_14Request = Parameters[0] -type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -const Endpoint4_14 = (raw: RawClient["server.session"]) => (input: Endpoint4_14Input) => - raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } const Endpoint4_15 = (raw: RawClient["server.session"]) => (input: Endpoint4_15Input) => - raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } const Endpoint4_16 = (raw: RawClient["server.session"]) => (input: Endpoint4_16Input) => + raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } +const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_17Request = Parameters[0] -type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -const Endpoint4_17 = (raw: RawClient["server.session"]) => (input: Endpoint4_17Input) => +type Endpoint4_18Request = Parameters[0] +type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => raw["session.context.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), ) -type Endpoint4_18Request = Parameters[0] -type Endpoint4_18Input = { - readonly sessionID: Endpoint4_18Request["params"]["sessionID"] - readonly key: Endpoint4_18Request["params"]["key"] - readonly value: Endpoint4_18Request["payload"]["value"] +type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Input = { + readonly sessionID: Endpoint4_19Request["params"]["sessionID"] + readonly key: Endpoint4_19Request["params"]["key"] + readonly value: Endpoint4_19Request["payload"]["value"] } -const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) => +const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => raw["session.context.entry.put"]({ params: { sessionID: input["sessionID"], key: input["key"] }, payload: { value: input["value"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_19Request = Parameters[0] -type Endpoint4_19Input = { - readonly sessionID: Endpoint4_19Request["params"]["sessionID"] - readonly key: Endpoint4_19Request["params"]["key"] +type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Input = { + readonly sessionID: Endpoint4_20Request["params"]["sessionID"] + readonly key: Endpoint4_20Request["params"]["key"] } -const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) => +const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => raw["session.context.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe( Effect.mapError(mapClientError), ) -type Endpoint4_20Request = Parameters[0] -type Endpoint4_20Input = { - readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly limit?: Endpoint4_20Request["query"]["limit"] - readonly after?: Endpoint4_20Request["query"]["after"] +type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Input = { + readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly limit?: Endpoint4_21Request["query"]["limit"] + readonly after?: Endpoint4_21Request["query"]["after"] } -const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) => +const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => raw["session.history"]({ params: { sessionID: input["sessionID"] }, query: { limit: input["limit"], after: input["after"] }, }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_21Request = Parameters[0] -type Endpoint4_21Input = { - readonly sessionID: Endpoint4_21Request["params"]["sessionID"] - readonly after?: Endpoint4_21Request["query"]["after"] +type Endpoint4_22Request = Parameters[0] +type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] } -const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21Input) => +const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => Stream.unwrap( raw["session.events"]({ params: { sessionID: input["sessionID"] }, query: { after: input["after"] } }).pipe( Effect.mapError(mapClientError), @@ -275,22 +307,22 @@ const Endpoint4_21 = (raw: RawClient["server.session"]) => (input: Endpoint4_21I ), ) -type Endpoint4_22Request = Parameters[0] -type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -const Endpoint4_22 = (raw: RawClient["server.session"]) => (input: Endpoint4_22Input) => - raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) - -type Endpoint4_23Request = Parameters[0] +type Endpoint4_23Request = Parameters[0] type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } const Endpoint4_23 = (raw: RawClient["server.session"]) => (input: Endpoint4_23Input) => + raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) + +type Endpoint4_24Request = Parameters[0] +type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)) -type Endpoint4_24Request = Parameters[0] -type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_25Request = Parameters[0] +type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] } -const Endpoint4_24 = (raw: RawClient["server.session"]) => (input: Endpoint4_24Input) => +const Endpoint4_25 = (raw: RawClient["server.session"]) => (input: Endpoint4_25Input) => raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe( Effect.mapError(mapClientError), Effect.map((value) => value.data), @@ -306,22 +338,23 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({ switchModel: Endpoint4_6(raw), rename: Endpoint4_7(raw), prompt: Endpoint4_8(raw), - skill: Endpoint4_9(raw), - synthetic: Endpoint4_10(raw), - compact: Endpoint4_11(raw), - wait: Endpoint4_12(raw), - revertStage: Endpoint4_13(raw), - revertClear: Endpoint4_14(raw), - revertCommit: Endpoint4_15(raw), - context: Endpoint4_16(raw), - listContextEntries: Endpoint4_17(raw), - putContextEntry: Endpoint4_18(raw), - removeContextEntry: Endpoint4_19(raw), - history: Endpoint4_20(raw), - events: Endpoint4_21(raw), - interrupt: Endpoint4_22(raw), - background: Endpoint4_23(raw), - message: Endpoint4_24(raw), + command: Endpoint4_9(raw), + skill: Endpoint4_10(raw), + synthetic: Endpoint4_11(raw), + compact: Endpoint4_12(raw), + wait: Endpoint4_13(raw), + revertStage: Endpoint4_14(raw), + revertClear: Endpoint4_15(raw), + revertCommit: Endpoint4_16(raw), + context: Endpoint4_17(raw), + listContextEntries: Endpoint4_18(raw), + putContextEntry: Endpoint4_19(raw), + removeContextEntry: Endpoint4_20(raw), + history: Endpoint4_21(raw), + events: Endpoint4_22(raw), + interrupt: Endpoint4_23(raw), + background: Endpoint4_24(raw), + message: Endpoint4_25(raw), }) type Endpoint5_0Request = Parameters[0] diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 7f7bbf1223..485ee78039 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -23,6 +23,8 @@ import type { SessionRenameOutput, SessionPromptInput, SessionPromptOutput, + SessionCommandInput, + SessionCommandOutput, SessionSkillInput, SessionSkillOutput, SessionSyntheticInput, @@ -459,6 +461,28 @@ export function make(options: ClientOptions) { }, requestOptions, ).then((value) => value.data), + command: (input: SessionCommandInput, requestOptions?: RequestOptions) => + request<{ readonly data: SessionCommandOutput }>( + { + method: "POST", + path: `/api/session/${encodeURIComponent(input.sessionID)}/command`, + body: { + id: input["id"], + command: input["command"], + arguments: input["arguments"], + agent: input["agent"], + model: input["model"], + files: input["files"], + agents: input["agents"], + delivery: input["delivery"], + resume: input["resume"], + }, + successStatus: 200, + declaredStatuses: [409, 404, 500, 400, 401], + empty: false, + }, + requestOptions, + ).then((value) => value.data), skill: (input: SessionSkillInput, requestOptions?: RequestOptions) => request( { diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 2f84ab968d..abfc8cd926 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -50,6 +50,22 @@ export type ConflictError = { export const isConflictError = (value: unknown): value is ConflictError => typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ConflictError" +export type CommandNotFoundError = { + readonly _tag: "CommandNotFoundError" + readonly command: string + readonly message: string +} +export const isCommandNotFoundError = (value: unknown): value is CommandNotFoundError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandNotFoundError" + +export type CommandEvaluationError = { + readonly _tag: "CommandEvaluationError" + readonly command: string + readonly message: string +} +export const isCommandEvaluationError = (value: unknown): value is CommandEvaluationError => + typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "CommandEvaluationError" + export type SkillNotFoundError = { readonly _tag: "SkillNotFoundError" readonly skill: string @@ -564,6 +580,206 @@ export type SessionPromptOutput = { } }["data"] +export type SessionCommandInput = { + readonly sessionID: { readonly sessionID: string }["sessionID"] + readonly id?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["id"] + readonly command: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["command"] + readonly arguments?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["arguments"] + readonly agent?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["agent"] + readonly model?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["model"] + readonly files?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["files"] + readonly agents?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["agents"] + readonly delivery?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["delivery"] + readonly resume?: { + readonly id?: string | null + readonly command: string + readonly arguments?: string | null + readonly agent?: string | null + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } | null + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly delivery?: "steer" | "queue" | null + readonly resume?: boolean | null + }["resume"] +} + +export type SessionCommandOutput = { + readonly data: { + readonly admittedSeq: number + readonly id: string + readonly sessionID: string + readonly prompt: { + readonly text: string + readonly files?: ReadonlyArray<{ + readonly uri: string + readonly mime: string + readonly name?: string + readonly description?: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + readonly agents?: ReadonlyArray<{ + readonly name: string + readonly source?: { readonly start: number; readonly end: number; readonly text: string } + }> + } + readonly delivery: "steer" | "queue" + readonly timeCreated: number + readonly promotedSeq?: number + } +}["data"] + export type SessionSkillInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] readonly id?: { @@ -4114,6 +4330,14 @@ export type EventSubscribeOutput = readonly location?: { readonly directory: string; readonly workspaceID?: string } readonly data: { readonly projectID: string } } + | { + readonly id: string + readonly metadata?: { readonly [x: string]: unknown } + readonly type: "command.updated" + readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number } + readonly location?: { readonly directory: string; readonly workspaceID?: string } + readonly data: {} + } | { readonly id: string readonly metadata?: { readonly [x: string]: unknown } diff --git a/packages/core/src/command.ts b/packages/core/src/command.ts index 0f6d83d92e..44f3138ba7 100644 --- a/packages/core/src/command.ts +++ b/packages/core/src/command.ts @@ -1,17 +1,39 @@ export * as CommandV2 from "./command" import { makeLocationNode } from "./effect/app-node" -import { Context, Effect, Layer, Types } from "effect" +import { Context, Effect, Layer, Schema, Types } from "effect" import { Command } from "@opencode-ai/schema/command" import { State } from "./state" +import { MCP } from "./mcp/index" +import { EventV2 } from "./event" +import { AppProcess } from "./process" +import { ChildProcess } from "effect/unstable/process" +import { Config } from "./config" +import { Location } from "./location" +import { ShellSelect } from "./shell/select" export const Info = Command.Info export type Info = Command.Info +export const Event = Command.Event + +export type Evaluation = { + readonly text: string +} export type Data = { commands: Map> } +export class NotFoundError extends Schema.TaggedErrorClass()("Command.NotFoundError", { + command: Schema.String, + message: Schema.String, +}) {} + +export class EvaluationError extends Schema.TaggedErrorClass()("Command.EvaluationError", { + command: Schema.String, + message: Schema.String, +}) {} + export type Draft = { list: () => readonly Info[] get: (name: string) => Info | undefined @@ -22,13 +44,22 @@ export type Draft = { export interface Interface extends State.Transformable { readonly get: (name: string) => Effect.Effect readonly list: () => Effect.Effect + readonly evaluate: (input: { + readonly name: string + readonly arguments?: string + }) => Effect.Effect } export class Service extends Context.Service()("@opencode/v2/Command") {} const layer = Layer.effect( Service, - Effect.sync(() => { + Effect.gen(function* () { + const mcp = yield* MCP.Service + const events = yield* EventV2.Service + const processes = yield* AppProcess.Service + const config = yield* Config.Service + const location = yield* Location.Service const state = State.create({ initial: () => ({ commands: new Map() }), draft: (draft) => ({ @@ -44,19 +75,172 @@ const layer = Layer.effect( draft.commands.delete(name) }, }), + finalize: () => events.publish(Event.Updated, {}).pipe(Effect.asVoid), + }) + const staticCommand = (name: string) => state.get().commands.get(name) as Info | undefined + const mcpCommands = Effect.fnUntraced(function* () { + return (yield* mcp.prompts()).map((prompt) => + Info.make({ + name: mcpCommandName(prompt.server, prompt.name), + template: "", + description: prompt.description, + }), + ) }) return Service.of({ reload: state.reload, transform: state.transform, get: Effect.fn("CommandV2.get")(function* (name) { - return state.get().commands.get(name) + const command = staticCommand(name) + if (command) return command + return (yield* mcpCommands()).find((command) => command.name === name) }), list: Effect.fn("CommandV2.list")(function* () { - return Array.from(state.get().commands.values()) + const commands = Array.from(state.get().commands.values()) as Info[] + const names = new Set(commands.map((command) => command.name)) + return [ + ...commands, + ...(yield* mcpCommands()).filter((command) => !names.has(command.name)), + ] + }), + evaluate: Effect.fn("CommandV2.evaluate")(function* (input) { + const command = staticCommand(input.name) + if (command) return yield* evaluateTemplate(input.name, command.template, input.arguments ?? "", { + config, + location, + processes, + }) + + const prompt = (yield* mcp.prompts()).find((prompt) => mcpCommandName(prompt.server, prompt.name) === input.name) + if (!prompt) return yield* new NotFoundError({ command: input.name, message: `Command not found: ${input.name}` }) + const result = yield* mcp + .prompt({ + server: prompt.server, + name: prompt.name, + args: Object.fromEntries( + (prompt.arguments ?? []).map((argument, index) => [ + argument.name, + parseArguments(input.arguments ?? "")[index] ?? "", + ]), + ), + }) + .pipe( + Effect.catchTag( + "MCP.NotFoundError", + () => + Effect.fail( + new EvaluationError({ + command: input.name, + message: `MCP server could not be found while evaluating prompt: ${prompt.server}`, + }), + ), + ), + ) + if (!result) + return yield* new EvaluationError({ + command: input.name, + message: `MCP prompt could not be evaluated: ${prompt.server}:${prompt.name}`, + }) + return { text: result.messages.map((message) => promptMessageText(message.content)).join("\n").trim() } }), }) }), ) -export const node = makeLocationNode({ service: Service, layer, deps: [] }) +function evaluateTemplate( + command: string, + template: string, + input: string, + services: { + readonly config: Config.Interface + readonly location: Location.Info + readonly processes: AppProcess.Interface + }, +) { + return Effect.gen(function* () { + const expanded = evaluateArguments(template, input) + return { text: yield* evaluateShell(command, expanded, services) } + }) +} + +function evaluateArguments(template: string, input: string) { + const args = parseArguments(input) + const placeholders = template.match(placeholderRegex) ?? [] + const last = Math.max(0, ...placeholders.map((item) => Number(item.slice(1)))) + const expanded = template.replaceAll(placeholderRegex, (_, index) => { + const position = Number(index) + const argIndex = position - 1 + if (argIndex >= args.length) return "" + if (position === last) return args.slice(argIndex).join(" ") + return args[argIndex] + }) + const withArguments = expanded.replaceAll("$ARGUMENTS", input) + if (placeholders.length === 0 && !template.includes("$ARGUMENTS") && input.trim()) return `${withArguments}\n\n${input}`.trim() + return withArguments.trim() +} + +const evaluateShell = Effect.fnUntraced(function* ( + command: string, + text: string, + services: { + readonly config: Config.Interface + readonly location: Location.Info + readonly processes: AppProcess.Interface + }, +) { + const matches = Array.from(text.matchAll(shellRegex)) + if (matches.length === 0) return text + const shell = ShellSelect.preferred(Config.latest(yield* services.config.entries(), "shell")) + const outputs = yield* Effect.forEach( + matches, + (match) => { + const source = match[1] ?? "" + return services.processes + .run(ChildProcess.make(shell, ShellSelect.args(shell, source), { cwd: services.location.directory, stdin: "ignore" }), { + combineOutput: true, + }) + .pipe( + Effect.map((result) => (result.output ?? Buffer.concat([result.stdout, result.stderr])).toString("utf8")), + Effect.mapError( + (error) => + new EvaluationError({ command, message: `Shell interpolation failed for ${JSON.stringify(source)}: ${error.message}` }), + ), + ) + }, + { concurrency: 2 }, + ) + const iterator = outputs[Symbol.iterator]() + return text.replace(shellRegex, () => iterator.next().value ?? "") +}) + +function parseArguments(input: string) { + return (input.match(argsRegex) ?? []).map((arg) => arg.replace(quoteTrimRegex, "")) +} + +function promptMessageText(content: unknown) { + if (typeof content === "string") return content + if (!content || typeof content !== "object") return "" + if (!("type" in content) || content.type !== "text") return "" + if (!("text" in content) || typeof content.text !== "string") return "" + return content.text +} + +function mcpCommandName(server: string, prompt: string) { + return `${sanitize(server)}:${sanitize(prompt)}` +} + +function sanitize(value: string) { + return value.replace(/[^a-zA-Z0-9_-]/g, "_") +} + +const argsRegex = /(?:\[Image\s+\d+\]|"[^"]*"|'[^']*'|[^\s"']+)/gi +const placeholderRegex = /\$(\d+)/g +const quoteTrimRegex = /^["']|["']$/g +const shellRegex = /!`([^`]+)`/g + +export const node = makeLocationNode({ + service: Service, + layer, + deps: [MCP.node, EventV2.node, AppProcess.node, Config.node, Location.node], +}) diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 4a9b17f6b1..b1ef6f390b 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -9,8 +9,12 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import { CallToolResultSchema, + GetPromptResultSchema, + ListPromptsResultSchema, ListRootsRequestSchema, ListToolsResultSchema, + PromptListChangedNotificationSchema, + PromptSchema, type LoggingMessageNotification, LoggingMessageNotificationSchema, ToolListChangedNotificationSchema, @@ -30,6 +34,9 @@ type Transport = StdioClientTransport | StreamableHTTPClientTransport const TolerantListToolsResult = ListToolsResultSchema.extend({ tools: ToolSchema.omit({ outputSchema: true }).array(), }) +const TolerantListPromptsResult = ListPromptsResultSchema.extend({ + prompts: PromptSchema.array(), +}) export class NeedsAuthError extends Schema.TaggedErrorClass()("MCP.NeedsAuthError", { server: Schema.String, @@ -46,6 +53,25 @@ export interface ToolDefinition { readonly inputSchema: unknown } +export interface PromptDefinition { + readonly name: string + readonly description: string | undefined + readonly arguments: ReadonlyArray<{ + readonly name: string + readonly description: string | undefined + readonly required: boolean | undefined + }> | undefined +} + +export interface PromptMessage { + readonly role: string + readonly content: unknown +} + +export interface PromptResult { + readonly messages: ReadonlyArray +} + export type CallToolContent = | { readonly type: "text"; readonly text: string } | { readonly type: "media"; readonly data: string; readonly mimeType: string } @@ -68,6 +94,13 @@ export interface Connection { readonly instructions: string | undefined /** Lists the server's tools; returns [] when the server doesn't advertise tool support, fails on a transport error. */ readonly tools: () => Effect.Effect + /** Lists the server's prompts; returns [] when the server doesn't advertise prompt support, fails on a transport error. */ + readonly prompts: () => Effect.Effect + /** Invokes a prompt on the server. Interruption aborts the in-flight request. */ + readonly prompt: (input: { + readonly name: string + readonly args?: Record + }) => Effect.Effect /** Invokes a tool on the server. Interruption aborts the in-flight request. */ readonly callTool: (input: { readonly name: string @@ -78,6 +111,8 @@ export interface Connection { readonly onLog: (callback: (message: LogMessage) => void) => void /** Registers a callback fired when the server announces its tool list changed; no-op if unsupported. */ readonly onToolsChanged: (callback: () => void) => void + /** Registers a callback fired when the server announces its prompt list changed; no-op if unsupported. */ + readonly onPromptsChanged: (callback: () => void) => void } /** Connects an MCP server; closing the calling scope tears down the transport and any spawned process. */ @@ -166,6 +201,48 @@ export const connect = Effect.fnUntraced(function* ( inputSchema: tool.inputSchema, })) }), + prompts: () => + Effect.gen(function* () { + if (!client.getServerCapabilities()?.prompts) return [] + const prompts = yield* Effect.tryPromise({ + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { + timeout: requestTimeout, + }) + }, + (result) => result.prompts, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.tapError((error) => Effect.logWarning("failed to list MCP prompts", { server, error: error.message })), + ) + return prompts.map((prompt) => ({ + name: prompt.name, + description: prompt.description, + arguments: prompt.arguments?.map((argument) => ({ + name: argument.name, + description: argument.description, + required: argument.required, + })), + })) + }), + prompt: (input) => + Effect.tryPromise({ + try: (signal) => + client.request( + { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, + GetPromptResultSchema, + { signal, timeout: requestTimeout, resetTimeoutOnProgress: true, onprogress: () => {} }, + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }).pipe( + Effect.map((result) => ({ + messages: result.messages.map((message) => ({ role: message.role, content: message.content })), + })), + ), callTool: (input) => Effect.tryPromise({ try: (signal) => @@ -207,6 +284,10 @@ export const connect = Effect.fnUntraced(function* ( if (!client.getServerCapabilities()?.tools?.listChanged) return client.setNotificationHandler(ToolListChangedNotificationSchema, async () => callback()) }, + onPromptsChanged: (callback) => { + if (!client.getServerCapabilities()?.prompts?.listChanged) return + client.setNotificationHandler(PromptListChangedNotificationSchema, async () => callback()) + }, } satisfies Connection } diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index 4e41795ec3..6992942674 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -2,6 +2,7 @@ export * as MCP from "./index" import { Mcp } from "@opencode-ai/schema/mcp" import { McpEvent } from "@opencode-ai/schema/mcp-event" +import { Command } from "@opencode-ai/schema/command" import { createHash } from "node:crypto" import { Cause, Context, Deferred, Effect, Exit, FiberSet, Layer, Schema, Scope, Stream } from "effect" import { makeLocationNode } from "../effect/app-node" @@ -139,6 +140,7 @@ type ServerEntry = { scope?: Scope.Closeable client?: MCPClient.Connection tools?: ReadonlyArray + prompts?: ReadonlyArray // Set when a remote server is registered as an OAuth integration; the credential lives in the global store. integrationID?: Integration.ID } @@ -309,6 +311,21 @@ export const layer = Layer.effect( const toTool = (server: ServerName, def: MCPClient.ToolDefinition) => new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema }) + const toPrompt = (server: ServerName, def: MCPClient.PromptDefinition) => + new Prompt({ + server, + name: def.name, + description: def.description, + arguments: def.arguments?.map( + (argument) => + new PromptArgument({ + name: argument.name, + description: argument.description, + required: argument.required, + }), + ), + }) + const refreshTools = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => connection.tools().pipe( Effect.map((defs) => { @@ -316,6 +333,17 @@ export const layer = Layer.effect( }), ) + const refreshPrompts = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => + connection.prompts().pipe( + Effect.map((defs) => { + entry.prompts = defs.map((def) => toPrompt(name, def)) + }), + Effect.andThen(events.publish(Command.Event.Updated, {})), + Effect.catch(() => + Effect.sync(() => (entry.prompts = [])).pipe(Effect.andThen(events.publish(Command.Event.Updated, {}))), + ), + ) + const watch = (name: ServerName, entry: ServerEntry, connection: MCPClient.Connection) => { connection.onClose(() => { // A reconnect closes the previous scope, but the SDK may fire this onclose after the new @@ -323,8 +351,10 @@ export const layer = Layer.effect( if (entry.client !== connection) return entry.client = undefined entry.tools = undefined + entry.prompts = undefined entry.status = { status: "failed", error: "Connection closed" } fork(events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore)) + fork(events.publish(Command.Event.Updated, {}).pipe(Effect.ignore)) fork(events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore)) }) connection.onLog((message) => fork(serverLog(name, message).pipe(Effect.ignore))) @@ -336,6 +366,9 @@ export const layer = Layer.effect( ), ) }) + connection.onPromptsChanged(() => { + fork(refreshPrompts(name, entry, connection).pipe(Effect.ignore)) + }) } const serverLog = (server: ServerName, message: MCPClient.LogMessage) => { @@ -364,13 +397,14 @@ export const layer = Layer.effect( // List tools as part of connect so a failure here marks the server failed rather than // leaving it connected with a silently empty tool list and no path to recover. const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe( - Effect.flatMap((connection) => connection.tools().pipe(Effect.map((defs) => ({ connection, defs })))), + Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))), Scope.provide(scope), Effect.exit, ) if (Exit.isSuccess(result)) { entry.client = result.value.connection - entry.tools = result.value.defs.map((def) => toTool(name, def)) + entry.tools = result.value.tools.map((def) => toTool(name, def)) + entry.prompts = [] entry.status = { status: "connected" } watch(name, entry, result.value.connection) yield* Effect.logInfo("mcp connected", { server: name, tools: entry.tools.length }) @@ -379,6 +413,7 @@ export const layer = Layer.effect( // stay invisible to the model. yield* events.publish(McpEvent.ToolsChanged, { server: name }).pipe(Effect.ignore) yield* events.publish(McpEvent.StatusChanged, { server: name }).pipe(Effect.ignore) + fork(refreshPrompts(name, entry, result.value.connection).pipe(Effect.ignore)) return } yield* Scope.close(scope, Exit.void) @@ -416,6 +451,8 @@ export const layer = Layer.effect( entry.scope = undefined entry.client = undefined entry.tools = undefined + entry.prompts = undefined + yield* events.publish(Command.Event.Updated, {}).pipe(Effect.ignore) } yield* startServer(name, entry) }) @@ -489,12 +526,25 @@ export const layer = Layer.effect( .toSorted((a, b) => a.server.localeCompare(b.server)) }), prompts: Effect.fn("MCP.prompts")(function* () { - yield* whenAllReady - return [] + return Array.from(runtime.values()) + .flatMap((entry) => entry.prompts ?? []) + .toSorted((a, b) => a.server.localeCompare(b.server) || a.name.localeCompare(b.name)) }), prompt: Effect.fn("MCP.prompt")(function* (input) { - yield* gate(input.server) - return undefined + const target = yield* requireServer(input.server) + yield* Deferred.await(target.entry.startup) + if (!target.entry.client) return undefined + const result = yield* target.entry.client + .prompt({ name: input.name, args: input.args }) + .pipe(Effect.catch(() => Effect.succeed(undefined))) + if (!result) return undefined + return new PromptResult({ + server: target.name, + name: input.name, + messages: result.messages.map( + (message) => new PromptMessage({ role: message.role, content: message.content }), + ), + }) }), resourceCatalog: Effect.fn("MCP.resourceCatalog")(function* () { yield* whenAllReady diff --git a/packages/core/src/plugin/command.ts b/packages/core/src/plugin/command.ts index cbafd68b50..1989641238 100644 --- a/packages/core/src/plugin/command.ts +++ b/packages/core/src/plugin/command.ts @@ -18,7 +18,6 @@ export const Plugin = define({ draft.update("review", (command) => { command.template = PROMPT_REVIEW.replace("${path}", location.project.directory) command.description = "review changes [commit|branch|pr], defaults to uncommitted" - command.subtask = true }) }) }), diff --git a/packages/core/src/plugin/host.ts b/packages/core/src/plugin/host.ts index 1ab46b794d..00ff7c82af 100644 --- a/packages/core/src/plugin/host.ts +++ b/packages/core/src/plugin/host.ts @@ -302,6 +302,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: PluginV2.Int }), get: (input) => runtime.session.get(input.sessionID), prompt: runtime.session.prompt, + command: runtime.session.command, interrupt: (input) => runtime.session.interrupt(input.sessionID), }, } satisfies Interface diff --git a/packages/core/src/plugin/runtime.ts b/packages/core/src/plugin/runtime.ts index c521c9dd30..cb5b40fd17 100644 --- a/packages/core/src/plugin/runtime.ts +++ b/packages/core/src/plugin/runtime.ts @@ -11,7 +11,7 @@ import { SessionV2 } from "../session" export interface Interface { readonly session: Pick< SessionV2.Interface, - "get" | "create" | "messages" | "prompt" | "resume" | "interrupt" | "synthetic" + "get" | "create" | "messages" | "prompt" | "command" | "resume" | "interrupt" | "synthetic" > readonly job: Pick readonly location: { @@ -50,6 +50,7 @@ export const layerWithCell = (cell: Cell) => create: (input) => require(cell, (runtime) => runtime.session.create(input)), messages: (input) => require(cell, (runtime) => runtime.session.messages(input)), prompt: (input) => require(cell, (runtime) => runtime.session.prompt(input)), + command: (input) => require(cell, (runtime) => runtime.session.command(input)), resume: (sessionID) => require(cell, (runtime) => runtime.session.resume(sessionID)), interrupt: (sessionID) => require(cell, (runtime) => runtime.session.interrupt(sessionID)), synthetic: (input) => require(cell, (runtime) => runtime.session.synthetic(input)), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 9af15f7208..797546259f 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -40,6 +40,7 @@ import { FSUtil } from "./fs-util" import { SessionDurable } from "@opencode-ai/schema/durable-event-manifest" import { SkillV2 } from "./skill" import { Job } from "./job" +import { CommandV2 } from "./command" export const RevertState = Revert.State export type RevertState = Revert.State @@ -130,6 +131,8 @@ export type Error = | PromptConflictError | BusyError | SkillNotFoundError + | CommandV2.NotFoundError + | CommandV2.EvaluationError | MessageNotFoundError export interface Interface { @@ -175,6 +178,18 @@ export interface Interface { delivery?: SessionInput.Delivery resume?: boolean }) => Effect.Effect + readonly command: (input: { + id?: SessionMessage.ID + sessionID: SessionSchema.ID + command: string + arguments?: string + agent?: string + model?: ModelV2.Ref + files?: PromptInput.Prompt["files"] + agents?: PromptInput.Prompt["agents"] + delivery?: SessionInput.Delivery + resume?: boolean + }) => Effect.Effect readonly shell: (input: { id?: EventV2.ID sessionID: SessionSchema.ID @@ -450,6 +465,37 @@ const layer = Layer.effect( }), ), ), + command: Effect.fn("V2Session.command")(function* (input) { + const session = yield* result.get(input.sessionID) + const commands = yield* CommandV2.Service.pipe(Effect.provide(locations.get(session.location))) + const command = yield* commands.get(input.command) + if (!command) + return yield* new CommandV2.NotFoundError({ + command: input.command, + message: `Command not found: ${input.command}`, + }) + const evaluated = yield* commands.evaluate({ name: input.command, arguments: input.arguments }) + + // TODO(v2 commands): decide whether command-level subtask/background execution belongs in v2 commands. + const agent = command.agent ?? input.agent + const commandAgent = yield* Effect.gen(function* () { + if (!command.agent) return undefined + const agents = yield* AgentV2.Service.pipe(Effect.provide(locations.get(session.location))) + return yield* agents.get(AgentV2.ID.make(command.agent)) + }) + const model = command.model ?? commandAgent?.model ?? input.model + if (agent !== undefined && session.agent !== AgentV2.ID.make(agent)) + yield* result.switchAgent({ sessionID: input.sessionID, agent }) + if (model !== undefined) yield* result.switchModel({ sessionID: input.sessionID, model }) + + return yield* result.prompt({ + id: input.id, + sessionID: input.sessionID, + prompt: { text: evaluated.text, files: input.files, agents: input.agents }, + delivery: input.delivery, + resume: input.resume, + }) + }), shell: Effect.fn("V2Session.shell")(function* () { return yield* new OperationUnavailableError({ operation: "shell" }) }), diff --git a/packages/core/test/command.test.ts b/packages/core/test/command.test.ts index 3da8523c5b..3c8e4322c0 100644 --- a/packages/core/test/command.test.ts +++ b/packages/core/test/command.test.ts @@ -1,12 +1,22 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" import { CommandV2 } from "@opencode-ai/core/command" +import { Config } from "@opencode-ai/core/config" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" +import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "./fixture/mcp" import { testEffect } from "./lib/effect" -const it = testEffect(AppNodeBuilder.build(CommandV2.node)) +const it = testEffect( + AppNodeBuilder.build(CommandV2.node, [ + [MCP.node, emptyMcpLayer], + [Config.node, emptyConfigLayer], + [Location.node, testLocationLayer], + ]), +) describe("CommandV2", () => { it.effect("applies command transforms and preserves later overrides", () => @@ -53,4 +63,18 @@ describe("CommandV2", () => { ]) }), ) + + it.effect("evaluates command template shell blocks", () => + Effect.gen(function* () { + const command = yield* CommandV2.Service + yield* command.transform((editor) => { + editor.update("review", (command) => { + command.template = "Output: !`printf command-output`" + }) + }) + + expect(yield* command.evaluate({ name: "review" })).toEqual({ text: "Output: command-output" }) + }), + ) + }) diff --git a/packages/core/test/config/command.test.ts b/packages/core/test/config/command.test.ts index f5a08aab63..362a8e5da5 100644 --- a/packages/core/test/config/command.test.ts +++ b/packages/core/test/config/command.test.ts @@ -8,14 +8,23 @@ import { ConfigCommandPlugin } from "@opencode-ai/core/config/plugin/command" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import { AbsolutePath } from "@opencode-ai/core/schema" +import { emptyConfigLayer, emptyMcpLayer, testLocationLayer } from "../fixture/mcp" import { tmpdir } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { host } from "../plugin/host" -const it = testEffect(AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]))) +const it = testEffect( + AppNodeBuilder.build(LayerNode.group([CommandV2.node, FSUtil.node]), [ + [MCP.node, emptyMcpLayer], + [Config.node, emptyConfigLayer], + [Location.node, testLocationLayer], + ]), +) const decode = Schema.decodeUnknownSync(Config.Info) describe("ConfigCommandPlugin.Plugin", () => { diff --git a/packages/core/test/fixture/mcp.ts b/packages/core/test/fixture/mcp.ts new file mode 100644 index 0000000000..2c4d1f86a6 --- /dev/null +++ b/packages/core/test/fixture/mcp.ts @@ -0,0 +1,30 @@ +import { Effect, Layer } from "effect" +import { Config } from "@opencode-ai/core/config" +import { Location } from "@opencode-ai/core/location" +import { MCP } from "@opencode-ai/core/mcp/index" +import { AbsolutePath } from "@opencode-ai/core/schema" +import { location } from "./location" + +export const emptyMcpLayer = Layer.succeed( + MCP.Service, + MCP.Service.of({ + servers: () => Effect.succeed([]), + tools: () => Effect.succeed([]), + callTool: () => Effect.die("unused mcp.callTool"), + instructions: () => Effect.succeed([]), + prompts: () => Effect.succeed([]), + prompt: () => Effect.succeed(undefined), + resourceCatalog: () => Effect.succeed(new MCP.ResourceCatalog({ resources: [], templates: [] })), + readResource: () => Effect.succeed(undefined), + }), +) + +export const emptyConfigLayer = Layer.succeed( + Config.Service, + Config.Service.of({ entries: () => Effect.succeed([]) }), +) + +export const testLocationLayer = Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })), +) diff --git a/packages/core/test/plugin/command.test.ts b/packages/core/test/plugin/command.test.ts index d163cd903a..be84975f64 100644 --- a/packages/core/test/plugin/command.test.ts +++ b/packages/core/test/plugin/command.test.ts @@ -40,7 +40,6 @@ describe("CommandPlugin.Plugin", () => { expect(yield* command.get("review")).toMatchObject({ name: "review", description: "review changes [commit|branch|pr], defaults to uncommitted", - subtask: true, }) }), ) diff --git a/packages/core/test/plugin/host.ts b/packages/core/test/plugin/host.ts index 3395b8278d..b9fc03b9c9 100644 --- a/packages/core/test/plugin/host.ts +++ b/packages/core/test/plugin/host.ts @@ -61,6 +61,7 @@ export function host(overrides: Overrides = {}): PluginContext { create: () => Effect.die("unused session.create"), get: () => Effect.die("unused session.get"), prompt: () => Effect.die("unused session.prompt"), + command: () => Effect.die("unused session.command"), interrupt: () => Effect.die("unused session.interrupt"), }, } diff --git a/packages/plugin/src/v2/effect/generated/api.ts b/packages/plugin/src/v2/effect/generated/api.ts index d85ba66cc5..13c5cb80f5 100644 --- a/packages/plugin/src/v2/effect/generated/api.ts +++ b/packages/plugin/src/v2/effect/generated/api.ts @@ -116,124 +116,140 @@ export type Endpoint4_8Input = { export type Endpoint4_8Output = EffectValue>["data"] export type SessionPromptOperation = (input: Endpoint4_8Input) => Effect.Effect -type Endpoint4_9Request = Parameters[0] +type Endpoint4_9Request = Parameters[0] export type Endpoint4_9Input = { readonly sessionID: Endpoint4_9Request["params"]["sessionID"] readonly id?: Endpoint4_9Request["payload"]["id"] - readonly skill: Endpoint4_9Request["payload"]["skill"] + readonly command: Endpoint4_9Request["payload"]["command"] + readonly arguments?: Endpoint4_9Request["payload"]["arguments"] + readonly agent?: Endpoint4_9Request["payload"]["agent"] + readonly model?: Endpoint4_9Request["payload"]["model"] + readonly files?: Endpoint4_9Request["payload"]["files"] + readonly agents?: Endpoint4_9Request["payload"]["agents"] + readonly delivery?: Endpoint4_9Request["payload"]["delivery"] readonly resume?: Endpoint4_9Request["payload"]["resume"] } -export type Endpoint4_9Output = EffectValue> -export type SessionSkillOperation = (input: Endpoint4_9Input) => Effect.Effect +export type Endpoint4_9Output = EffectValue>["data"] +export type SessionCommandOperation = (input: Endpoint4_9Input) => Effect.Effect -type Endpoint4_10Request = Parameters[0] +type Endpoint4_10Request = Parameters[0] export type Endpoint4_10Input = { readonly sessionID: Endpoint4_10Request["params"]["sessionID"] - readonly text: Endpoint4_10Request["payload"]["text"] - readonly description?: Endpoint4_10Request["payload"]["description"] - readonly metadata?: Endpoint4_10Request["payload"]["metadata"] + readonly id?: Endpoint4_10Request["payload"]["id"] + readonly skill: Endpoint4_10Request["payload"]["skill"] + readonly resume?: Endpoint4_10Request["payload"]["resume"] } -export type Endpoint4_10Output = EffectValue> -export type SessionSyntheticOperation = (input: Endpoint4_10Input) => Effect.Effect +export type Endpoint4_10Output = EffectValue> +export type SessionSkillOperation = (input: Endpoint4_10Input) => Effect.Effect -type Endpoint4_11Request = Parameters[0] -export type Endpoint4_11Input = { readonly sessionID: Endpoint4_11Request["params"]["sessionID"] } -export type Endpoint4_11Output = EffectValue> -export type SessionCompactOperation = (input: Endpoint4_11Input) => Effect.Effect +type Endpoint4_11Request = Parameters[0] +export type Endpoint4_11Input = { + readonly sessionID: Endpoint4_11Request["params"]["sessionID"] + readonly text: Endpoint4_11Request["payload"]["text"] + readonly description?: Endpoint4_11Request["payload"]["description"] + readonly metadata?: Endpoint4_11Request["payload"]["metadata"] +} +export type Endpoint4_11Output = EffectValue> +export type SessionSyntheticOperation = (input: Endpoint4_11Input) => Effect.Effect -type Endpoint4_12Request = Parameters[0] +type Endpoint4_12Request = Parameters[0] export type Endpoint4_12Input = { readonly sessionID: Endpoint4_12Request["params"]["sessionID"] } -export type Endpoint4_12Output = EffectValue> -export type SessionWaitOperation = (input: Endpoint4_12Input) => Effect.Effect +export type Endpoint4_12Output = EffectValue> +export type SessionCompactOperation = (input: Endpoint4_12Input) => Effect.Effect -type Endpoint4_13Request = Parameters[0] -export type Endpoint4_13Input = { - readonly sessionID: Endpoint4_13Request["params"]["sessionID"] - readonly messageID: Endpoint4_13Request["payload"]["messageID"] - readonly files?: Endpoint4_13Request["payload"]["files"] +type Endpoint4_13Request = Parameters[0] +export type Endpoint4_13Input = { readonly sessionID: Endpoint4_13Request["params"]["sessionID"] } +export type Endpoint4_13Output = EffectValue> +export type SessionWaitOperation = (input: Endpoint4_13Input) => Effect.Effect + +type Endpoint4_14Request = Parameters[0] +export type Endpoint4_14Input = { + readonly sessionID: Endpoint4_14Request["params"]["sessionID"] + readonly messageID: Endpoint4_14Request["payload"]["messageID"] + readonly files?: Endpoint4_14Request["payload"]["files"] } -export type Endpoint4_13Output = EffectValue>["data"] -export type SessionRevertStageOperation = (input: Endpoint4_13Input) => Effect.Effect +export type Endpoint4_14Output = EffectValue>["data"] +export type SessionRevertStageOperation = (input: Endpoint4_14Input) => Effect.Effect -type Endpoint4_14Request = Parameters[0] -export type Endpoint4_14Input = { readonly sessionID: Endpoint4_14Request["params"]["sessionID"] } -export type Endpoint4_14Output = EffectValue> -export type SessionRevertClearOperation = (input: Endpoint4_14Input) => Effect.Effect - -type Endpoint4_15Request = Parameters[0] +type Endpoint4_15Request = Parameters[0] export type Endpoint4_15Input = { readonly sessionID: Endpoint4_15Request["params"]["sessionID"] } -export type Endpoint4_15Output = EffectValue> -export type SessionRevertCommitOperation = (input: Endpoint4_15Input) => Effect.Effect +export type Endpoint4_15Output = EffectValue> +export type SessionRevertClearOperation = (input: Endpoint4_15Input) => Effect.Effect -type Endpoint4_16Request = Parameters[0] +type Endpoint4_16Request = Parameters[0] export type Endpoint4_16Input = { readonly sessionID: Endpoint4_16Request["params"]["sessionID"] } -export type Endpoint4_16Output = EffectValue>["data"] -export type SessionContextOperation = (input: Endpoint4_16Input) => Effect.Effect +export type Endpoint4_16Output = EffectValue> +export type SessionRevertCommitOperation = (input: Endpoint4_16Input) => Effect.Effect -type Endpoint4_17Request = Parameters[0] +type Endpoint4_17Request = Parameters[0] export type Endpoint4_17Input = { readonly sessionID: Endpoint4_17Request["params"]["sessionID"] } -export type Endpoint4_17Output = EffectValue< +export type Endpoint4_17Output = EffectValue>["data"] +export type SessionContextOperation = (input: Endpoint4_17Input) => Effect.Effect + +type Endpoint4_18Request = Parameters[0] +export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["sessionID"] } +export type Endpoint4_18Output = EffectValue< ReturnType >["data"] export type SessionListContextEntriesOperation = ( - input: Endpoint4_17Input, -) => Effect.Effect - -type Endpoint4_18Request = Parameters[0] -export type Endpoint4_18Input = { - readonly sessionID: Endpoint4_18Request["params"]["sessionID"] - readonly key: Endpoint4_18Request["params"]["key"] - readonly value: Endpoint4_18Request["payload"]["value"] -} -export type Endpoint4_18Output = EffectValue> -export type SessionPutContextEntryOperation = ( input: Endpoint4_18Input, ) => Effect.Effect -type Endpoint4_19Request = Parameters[0] +type Endpoint4_19Request = Parameters[0] export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] readonly key: Endpoint4_19Request["params"]["key"] + readonly value: Endpoint4_19Request["payload"]["value"] } -export type Endpoint4_19Output = EffectValue> -export type SessionRemoveContextEntryOperation = ( +export type Endpoint4_19Output = EffectValue> +export type SessionPutContextEntryOperation = ( input: Endpoint4_19Input, ) => Effect.Effect -type Endpoint4_20Request = Parameters[0] +type Endpoint4_20Request = Parameters[0] export type Endpoint4_20Input = { readonly sessionID: Endpoint4_20Request["params"]["sessionID"] - readonly limit?: Endpoint4_20Request["query"]["limit"] - readonly after?: Endpoint4_20Request["query"]["after"] + readonly key: Endpoint4_20Request["params"]["key"] } -export type Endpoint4_20Output = EffectValue> -export type SessionHistoryOperation = (input: Endpoint4_20Input) => Effect.Effect +export type Endpoint4_20Output = EffectValue> +export type SessionRemoveContextEntryOperation = ( + input: Endpoint4_20Input, +) => Effect.Effect -type Endpoint4_21Request = Parameters[0] +type Endpoint4_21Request = Parameters[0] export type Endpoint4_21Input = { readonly sessionID: Endpoint4_21Request["params"]["sessionID"] + readonly limit?: Endpoint4_21Request["query"]["limit"] readonly after?: Endpoint4_21Request["query"]["after"] } -export type Endpoint4_21Output = StreamValue>> -export type SessionEventsOperation = (input: Endpoint4_21Input) => Stream.Stream +export type Endpoint4_21Output = EffectValue> +export type SessionHistoryOperation = (input: Endpoint4_21Input) => Effect.Effect -type Endpoint4_22Request = Parameters[0] -export type Endpoint4_22Input = { readonly sessionID: Endpoint4_22Request["params"]["sessionID"] } -export type Endpoint4_22Output = EffectValue> -export type SessionInterruptOperation = (input: Endpoint4_22Input) => Effect.Effect - -type Endpoint4_23Request = Parameters[0] -export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } -export type Endpoint4_23Output = EffectValue> -export type SessionBackgroundOperation = (input: Endpoint4_23Input) => Effect.Effect - -type Endpoint4_24Request = Parameters[0] -export type Endpoint4_24Input = { - readonly sessionID: Endpoint4_24Request["params"]["sessionID"] - readonly messageID: Endpoint4_24Request["params"]["messageID"] +type Endpoint4_22Request = Parameters[0] +export type Endpoint4_22Input = { + readonly sessionID: Endpoint4_22Request["params"]["sessionID"] + readonly after?: Endpoint4_22Request["query"]["after"] } -export type Endpoint4_24Output = EffectValue>["data"] -export type SessionMessageOperation = (input: Endpoint4_24Input) => Effect.Effect +export type Endpoint4_22Output = StreamValue>> +export type SessionEventsOperation = (input: Endpoint4_22Input) => Stream.Stream + +type Endpoint4_23Request = Parameters[0] +export type Endpoint4_23Input = { readonly sessionID: Endpoint4_23Request["params"]["sessionID"] } +export type Endpoint4_23Output = EffectValue> +export type SessionInterruptOperation = (input: Endpoint4_23Input) => Effect.Effect + +type Endpoint4_24Request = Parameters[0] +export type Endpoint4_24Input = { readonly sessionID: Endpoint4_24Request["params"]["sessionID"] } +export type Endpoint4_24Output = EffectValue> +export type SessionBackgroundOperation = (input: Endpoint4_24Input) => Effect.Effect + +type Endpoint4_25Request = Parameters[0] +export type Endpoint4_25Input = { + readonly sessionID: Endpoint4_25Request["params"]["sessionID"] + readonly messageID: Endpoint4_25Request["params"]["messageID"] +} +export type Endpoint4_25Output = EffectValue>["data"] +export type SessionMessageOperation = (input: Endpoint4_25Input) => Effect.Effect export interface SessionApi { readonly list: SessionListOperation @@ -245,6 +261,7 @@ export interface SessionApi { readonly switchModel: SessionSwitchModelOperation readonly rename: SessionRenameOperation readonly prompt: SessionPromptOperation + readonly command: SessionCommandOperation readonly skill: SessionSkillOperation readonly synthetic: SessionSyntheticOperation readonly compact: SessionCompactOperation diff --git a/packages/plugin/src/v2/effect/runtime.ts b/packages/plugin/src/v2/effect/runtime.ts index 1907067320..ca2074a977 100644 --- a/packages/plugin/src/v2/effect/runtime.ts +++ b/packages/plugin/src/v2/effect/runtime.ts @@ -1,3 +1,3 @@ import type { SessionApi } from "./generated/api.js" -export type SessionDomain = Pick, "create" | "get" | "prompt" | "interrupt"> +export type SessionDomain = Pick, "create" | "get" | "prompt" | "command" | "interrupt"> diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 7f6893bae0..fdd3083439 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -89,6 +89,24 @@ export class SkillNotFoundError extends Schema.TaggedErrorClass()( + "CommandNotFoundError", + { + command: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 404 }, +) {} + +export class CommandEvaluationError extends Schema.TaggedErrorClass()( + "CommandEvaluationError", + { + command: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 500 }, +) {} + export class InvalidCursorError extends Schema.TaggedErrorClass()( "InvalidCursorError", { message: Schema.String }, diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index 96b6fd2398..d7285a69c9 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -10,6 +10,8 @@ import { Context, Effect, Encoding, Result, Schema, Struct } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" import { ConflictError, + CommandEvaluationError, + CommandNotFoundError, InvalidCursorError, InvalidRequestError, MessageNotFoundError, @@ -258,6 +260,32 @@ export const makeSessionGroup = (sessionLo }), ), ) + .add( + HttpApiEndpoint.post("session.command", "/api/session/:sessionID/command", { + params: { sessionID: Session.ID }, + payload: Schema.Struct({ + id: SessionMessage.ID.pipe(Schema.optional), + command: Schema.String, + arguments: Schema.String.pipe(Schema.optional), + agent: Schema.String.pipe(Schema.optional), + model: Model.Ref.pipe(Schema.optional), + files: PromptInput.Prompt.fields.files, + agents: PromptInput.Prompt.fields.agents, + delivery: SessionInput.Delivery.pipe(Schema.optional), + resume: Schema.Boolean.pipe(Schema.optional), + }), + success: Schema.Struct({ data: SessionInput.Admitted }), + error: [ConflictError, SessionNotFoundError, CommandNotFoundError, CommandEvaluationError], + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.command", + summary: "Run command", + description: "Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false.", + }), + ), + ) .add( HttpApiEndpoint.post("session.skill", "/api/session/:sessionID/skill", { params: { sessionID: Session.ID }, diff --git a/packages/schema/src/command.ts b/packages/schema/src/command.ts index 35f2a66e18..fb88087dbd 100644 --- a/packages/schema/src/command.ts +++ b/packages/schema/src/command.ts @@ -1,9 +1,12 @@ export * as Command from "./command.js" import { Schema } from "effect" +import { define, inventory } from "./event.js" import { optional } from "./schema.js" import { Model } from "./model.js" +const Updated = define({ type: "command.updated", schema: {} }) + export interface Info extends Schema.Schema.Type {} export const Info = Schema.Struct({ name: Schema.String, @@ -13,3 +16,8 @@ export const Info = Schema.Struct({ model: Model.Ref.pipe(optional), subtask: Schema.Boolean.pipe(optional), }).annotate({ identifier: "CommandV2.Info" }) + +export const Event = { + Updated, + Definitions: inventory(Updated), +} diff --git a/packages/schema/src/event-manifest.ts b/packages/schema/src/event-manifest.ts index 5e8a2871f3..0179547ca0 100644 --- a/packages/schema/src/event-manifest.ts +++ b/packages/schema/src/event-manifest.ts @@ -2,6 +2,7 @@ export * as EventManifest from "./event-manifest.js" import { Agent } from "./agent.js" import { Catalog } from "./catalog.js" +import { Command } from "./command.js" import { Durable } from "./durable-event-manifest.js" import { Event } from "./event.js" import { FileSystem } from "./filesystem.js" @@ -53,6 +54,7 @@ const featureDefinitions = Event.inventory( ...Permission.Event.Definitions, ...Plugin.Event.Definitions, ...ProjectDirectories.Event.Definitions, + ...Command.Event.Definitions, ...Skill.Event.Definitions, ...FileSystemWatcher.Event.Definitions, ...Pty.Event.Definitions, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index a2946a7fc5..244915bf4c 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -142,7 +142,9 @@ import type { ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, + PromptAgentAttachment, PromptInput, + PromptInputFileAttachment, ProviderAuthErrors, ProviderAuthResponses, ProviderListErrors, @@ -181,6 +183,7 @@ import type { SessionChildrenResponses, SessionCommandErrors, SessionCommandResponses, + SessionContextEntryKey, SessionCreateErrors, SessionCreateResponses, SessionDeleteErrors, @@ -349,8 +352,16 @@ import type { V2SessionActiveResponses, V2SessionBackgroundErrors, V2SessionBackgroundResponses, + V2SessionCommandErrors, + V2SessionCommandResponses, V2SessionCompactErrors, V2SessionCompactResponses, + V2SessionContextEntryListErrors, + V2SessionContextEntryListResponses, + V2SessionContextEntryPutErrors, + V2SessionContextEntryPutResponses, + V2SessionContextEntryRemoveErrors, + V2SessionContextEntryRemoveResponses, V2SessionContextErrors, V2SessionContextResponses, V2SessionCreateErrors, @@ -5224,6 +5235,113 @@ export class Revert extends HeyApiClient { } } +export class Entry extends HeyApiClient { + /** + * List context entries + * + * List API-managed context entries attached to the session's system context. + */ + public list( + parameters: { + sessionID: string + }, + options?: Options, + ) { + const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }]) + return (options?.client ?? this.client).get< + V2SessionContextEntryListResponses, + V2SessionContextEntryListErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry", + ...options, + ...params, + }) + } + + /** + * Remove context entry + * + * Remove one context entry; the removal is announced to the model at the next turn boundary. + */ + public remove( + parameters: { + sessionID: string + key: SessionContextEntryKey + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "key" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete< + V2SessionContextEntryRemoveResponses, + V2SessionContextEntryRemoveErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry/{key}", + ...options, + ...params, + }) + } + + /** + * Put context entry + * + * Attach or replace one durable context entry. The value is rendered into the session's system context; changes announce as updates at the next turn boundary. + */ + public put( + parameters: { + sessionID: string + key: SessionContextEntryKey + value?: unknown + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "key" }, + { in: "body", key: "value" }, + ], + }, + ], + ) + return (options?.client ?? this.client).put< + V2SessionContextEntryPutResponses, + V2SessionContextEntryPutErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/context-entry/{key}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } +} + +export class Context extends HeyApiClient { + private _entry?: Entry + get entry(): Entry { + return (this._entry ??= new Entry({ client: this.client })) + } +} + export class Permission2 extends HeyApiClient { /** * List session permission requests @@ -5781,6 +5899,57 @@ export class Session3 extends HeyApiClient { }) } + /** + * Run command + * + * Resolve a slash command into prompt input, admit it durably, and schedule execution unless resume is false. + */ + public command( + parameters: { + sessionID: string + id?: string + command?: string + arguments?: string + agent?: string + model?: ModelRef + files?: Array + agents?: Array + delivery?: "steer" | "queue" + resume?: boolean + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "id" }, + { in: "body", key: "command" }, + { in: "body", key: "arguments" }, + { in: "body", key: "agent" }, + { in: "body", key: "model" }, + { in: "body", key: "files" }, + { in: "body", key: "agents" }, + { in: "body", key: "delivery" }, + { in: "body", key: "resume" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/api/session/{sessionID}/command", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Activate skill * @@ -6089,6 +6258,11 @@ export class Session3 extends HeyApiClient { return (this._revert ??= new Revert({ client: this.client })) } + private _context?: Context + get context2(): Context { + return (this._context ??= new Context({ client: this.client })) + } + private _permission?: Permission2 get permission(): Permission2 { return (this._permission ??= new Permission2({ client: this.client })) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a165e24cf..c65b44c9c8 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -64,6 +64,7 @@ export type Event = | EventPermissionV2Replied | EventPluginAdded | EventProjectDirectoriesUpdated + | EventCommandUpdated | EventSkillUpdated | EventFileWatcherUpdated | EventPtyCreated @@ -1370,6 +1371,13 @@ export type GlobalEvent = { projectID: string } } + | { + id: string + type: "command.updated" + properties: { + [key: string]: unknown + } + } | { id: string type: "skill.updated" @@ -2840,6 +2848,18 @@ export type ConflictError = { resource?: string } +export type CommandNotFoundError = { + _tag: "CommandNotFoundError" + command: string + message: string +} + +export type CommandEvaluationError = { + _tag: "CommandEvaluationError" + command: string + message: string +} + export type SkillNotFoundError = { _tag: "SkillNotFoundError" skill: string @@ -3061,6 +3081,7 @@ export type V2Event = | PermissionV2Replied | PluginAdded | ProjectDirectoriesUpdated + | CommandUpdated | SkillUpdated | FileWatcherUpdated | PtyCreated @@ -4427,6 +4448,13 @@ export type SessionMessage = | SessionMessageAssistant | SessionMessageCompaction +export type SessionContextEntryKey = string + +export type SessionContextEntryInfo = { + key: SessionContextEntryKey + value: unknown +} + export type SessionNextAgentSwitched = { id: string metadata?: { @@ -5923,6 +5951,23 @@ export type ProjectDirectoriesUpdated = { } } +export type CommandUpdated = { + id: string + metadata?: { + [key: string]: unknown + } + type: "command.updated" + durable?: { + aggregateID: string + seq: number + version: number + } + location?: LocationRef + data: { + [key: string]: unknown + } +} + export type SkillUpdated = { id: string metadata?: { @@ -7320,6 +7365,14 @@ export type EventProjectDirectoriesUpdated = { } } +export type EventCommandUpdated = { + id: string + type: "command.updated" + properties: { + [key: string]: unknown + } +} + export type EventSkillUpdated = { id: string type: "skill.updated" @@ -12307,6 +12360,61 @@ export type V2SessionPromptResponses = { export type V2SessionPromptResponse = V2SessionPromptResponses[keyof V2SessionPromptResponses] +export type V2SessionCommandData = { + body: { + id?: string + command: string + arguments?: string + agent?: string + model?: ModelRef + files?: Array + agents?: Array + delivery?: "steer" | "queue" + resume?: boolean + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/command" +} + +export type V2SessionCommandErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError | CommandNotFoundError + */ + 404: CommandNotFoundError | SessionNotFoundError + /** + * ConflictError + */ + 409: ConflictError + /** + * CommandEvaluationError + */ + 500: CommandEvaluationError +} + +export type V2SessionCommandError = V2SessionCommandErrors[keyof V2SessionCommandErrors] + +export type V2SessionCommandResponses = { + /** + * Success + */ + 200: { + data: SessionInputAdmitted + } +} + +export type V2SessionCommandResponse = V2SessionCommandResponses[keyof V2SessionCommandResponses] + export type V2SessionSkillData = { body: { id?: string @@ -12644,6 +12752,121 @@ export type V2SessionContextResponses = { export type V2SessionContextResponse = V2SessionContextResponses[keyof V2SessionContextResponses] +export type V2SessionContextEntryListData = { + body?: never + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/context-entry" +} + +export type V2SessionContextEntryListErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryListError = V2SessionContextEntryListErrors[keyof V2SessionContextEntryListErrors] + +export type V2SessionContextEntryListResponses = { + /** + * Success + */ + 200: { + data: Array + } +} + +export type V2SessionContextEntryListResponse = + V2SessionContextEntryListResponses[keyof V2SessionContextEntryListResponses] + +export type V2SessionContextEntryRemoveData = { + body?: never + path: { + sessionID: string + key: SessionContextEntryKey + } + query?: never + url: "/api/session/{sessionID}/context-entry/{key}" +} + +export type V2SessionContextEntryRemoveErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryRemoveError = + V2SessionContextEntryRemoveErrors[keyof V2SessionContextEntryRemoveErrors] + +export type V2SessionContextEntryRemoveResponses = { + /** + * + */ + 204: void +} + +export type V2SessionContextEntryRemoveResponse = + V2SessionContextEntryRemoveResponses[keyof V2SessionContextEntryRemoveResponses] + +export type V2SessionContextEntryPutData = { + body: { + value: unknown + } + path: { + sessionID: string + key: SessionContextEntryKey + } + query?: never + url: "/api/session/{sessionID}/context-entry/{key}" +} + +export type V2SessionContextEntryPutErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionContextEntryPutError = V2SessionContextEntryPutErrors[keyof V2SessionContextEntryPutErrors] + +export type V2SessionContextEntryPutResponses = { + /** + * + */ + 204: void +} + +export type V2SessionContextEntryPutResponse = + V2SessionContextEntryPutResponses[keyof V2SessionContextEntryPutResponses] + export type V2SessionHistoryData = { body?: never path: { diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 1da980481a..d53574758e 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -6,6 +6,8 @@ import { Api } from "../api" import { SessionsCursor } from "@opencode-ai/protocol/groups/session" import { ConflictError, + CommandEvaluationError, + CommandNotFoundError, InvalidCursorError, MessageNotFoundError, ServiceUnavailableError, @@ -216,6 +218,60 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.command", + Effect.fn(function* (ctx) { + return { + data: yield* session + .command({ + sessionID: ctx.params.sessionID, + id: ctx.payload.id, + command: ctx.payload.command, + arguments: ctx.payload.arguments, + agent: ctx.payload.agent, + model: ctx.payload.model, + files: ctx.payload.files, + agents: ctx.payload.agents, + delivery: ctx.payload.delivery, + resume: ctx.payload.resume, + }) + .pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + Effect.catchTag("Command.NotFoundError", (error) => + Effect.fail( + new CommandNotFoundError({ + command: error.command, + message: error.message, + }), + ), + ), + Effect.catchTag("Command.EvaluationError", (error) => + Effect.fail( + new CommandEvaluationError({ + command: error.command, + message: error.message, + }), + ), + ), + Effect.catchTag("Session.PromptConflictError", (error) => + Effect.fail( + new ConflictError({ + message: `Prompt message ID conflicts with an existing durable record: ${error.messageID}`, + resource: error.messageID, + }), + ), + ), + ), + } + }), + ) .handle( "session.skill", Effect.fn(function* (ctx) { diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index e182d537ff..7b0724e5dc 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -1125,15 +1125,46 @@ export function Prompt(props: PromptProps) { const restOfInput = firstLineEnd === -1 ? "" : inputText.slice(firstLineEnd + 1) const args = firstLineArgs.join(" ") + (restOfInput ? "\n" + restOfInput : "") - void sdk.client.session.command({ - sessionID, - command: command.slice(1), - arguments: args, - agent: agent.id, - model: `${selectedModel.providerID}/${selectedModel.modelID}`, - variant, - parts: nonTextParts.filter((x) => x.type === "file"), - }) + void sdk.api.session + .command({ + sessionID, + command: command.slice(1), + arguments: args, + agent: agent.id, + model: { providerID: selectedModel.providerID, id: selectedModel.modelID, variant }, + files: nonTextParts.flatMap((part) => + part.type === "file" + ? [ + { + uri: part.url, + name: part.filename, + source: part.source + ? { + start: part.source.text.start, + end: part.source.text.end, + text: part.source.text.value, + } + : undefined, + }, + ] + : [], + ), + agents: nonTextParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + source: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), + }) + .catch((error) => { + toast.show({ title: "Failed to run command", message: errorMessage(error), variant: "error" }) + }) } else if ( inputText.startsWith("/") && (data.location.skill.list(currentLocation()) ?? []).some( diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 97b2ec7492..869c18d1fd 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -208,6 +208,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ case "agent.updated": void result.location.agent.refresh(event.location) break + case "command.updated": + void result.location.command.refresh(event.location) + break case "skill.updated": void result.location.skill.refresh(event.location) break