diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index b5a3bf486..90736fb65 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -129,7 +129,7 @@ export interface Interface { readonly switchAgent: (input: { sessionID: SessionSchema.ID agent: string - }) => Effect.Effect + }) => Effect.Effect readonly switchModel: (input: { sessionID: SessionSchema.ID model: ModelV2.Ref @@ -376,8 +376,14 @@ export const layer = Layer.effect( skill: Effect.fn("V2Session.skill")(function* () { return yield* new OperationUnavailableError({ operation: "skill" }) }), - switchAgent: Effect.fn("V2Session.switchAgent")(function* () { - return yield* new OperationUnavailableError({ operation: "switchAgent" }) + switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) { + yield* result.get(input.sessionID) + yield* events.publish(SessionEvent.AgentSwitched, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: yield* DateTime.now, + agent: input.agent, + }) }), switchModel: Effect.fn("V2Session.switchModel")(function* (input) { yield* result.get(input.sessionID) diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index 471e86ff9..96c7c9bd2 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -336,7 +336,34 @@ describe("SessionV2.create", () => { expect(yield* unavailable(session.shell({ sessionID: created.id, command: "pwd" }))).toBe("shell") expect(yield* unavailable(session.skill({ sessionID: created.id, skill: "review" }))).toBe("skill") - expect(yield* unavailable(session.switchAgent({ sessionID: created.id, agent: "build" }))).toBe("switchAgent") + }), + ) + + it.effect("switches the selected agent through the durable Session event", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const created = yield* session.create({ location }) + + yield* session.switchAgent({ sessionID: created.id, agent: "plan" }) + + expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) + expect( + Array.from(yield* session.events({ sessionID: created.id }).pipe(Stream.take(1), Stream.runCollect)), + ).toMatchObject([{ type: "session.next.agent.switched", data: { agent: "plan" } }]) + }), + ) + + it.effect("rejects an agent switch for a missing Session", () => + Effect.gen(function* () { + const session = yield* SessionV2.Service + const missing = SessionV2.ID.make("ses_missing_agent_switch") + + expect( + yield* session.switchAgent({ sessionID: missing, agent: "plan" }).pipe( + Effect.flip, + Effect.map((error) => error._tag), + ), + ).toBe("Session.NotFoundError") }), ) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index b1f7bd8b7..5febf9cb2 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -955,6 +955,24 @@ const scenarios: Scenario[] = [ headers: ctx.headers(), })) .json(200, data(object)), + http.protected + .post("/api/session/{sessionID}/agent", "v2.session.switchAgent") + .seeded((ctx) => ctx.session({ title: "Switch agent" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/agent", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { agent: "plan" }, + })) + .status(204, undefined, "none"), + http.protected + .post("/api/session/{sessionID}/model", "v2.session.switchModel") + .seeded((ctx) => ctx.session({ title: "Switch model" })) + .at((ctx) => ({ + path: route("/api/session/{sessionID}/model", { sessionID: ctx.state.id }), + headers: { ...ctx.headers(), "content-type": "application/json" }, + body: { model: { providerID: "opencode", id: "big-pickle" } }, + })) + .status(204, undefined, "none"), http.protected .get("/api/session/{sessionID}/context", "v2.session.context") .at((ctx) => ({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 7c1c9108d..7bf19806e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -353,6 +353,10 @@ import type { V2SessionQuestionRejectResponses, V2SessionQuestionReplyErrors, V2SessionQuestionReplyResponses, + V2SessionSwitchAgentErrors, + V2SessionSwitchAgentResponses, + V2SessionSwitchModelErrors, + V2SessionSwitchModelResponses, V2SessionWaitErrors, V2SessionWaitResponses, V2SkillListErrors, @@ -5337,6 +5341,88 @@ export class Session3 extends HeyApiClient { }) } + /** + * Switch session agent + * + * Switch the agent used by subsequent session activity. + */ + public switchAgent( + parameters: { + sessionID: string + agent?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "agent" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchAgentResponses, + V2SessionSwitchAgentErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/agent", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Switch session model + * + * Switch the model used by subsequent session activity. + */ + public switchModel( + parameters: { + sessionID: string + model?: { + id: string + providerID: string + variant?: string + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "body", key: "model" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + V2SessionSwitchModelResponses, + V2SessionSwitchModelErrors, + ThrowOnError + >({ + url: "/api/session/{sessionID}/model", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + /** * Send message * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 1c4fd8f5d..d2c9e2989 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -9521,6 +9521,84 @@ export type V2SessionGetResponses = { export type V2SessionGetResponse = V2SessionGetResponses[keyof V2SessionGetResponses] +export type V2SessionSwitchAgentData = { + body: { + agent: string + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/agent" +} + +export type V2SessionSwitchAgentErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchAgentError = V2SessionSwitchAgentErrors[keyof V2SessionSwitchAgentErrors] + +export type V2SessionSwitchAgentResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchAgentResponse = V2SessionSwitchAgentResponses[keyof V2SessionSwitchAgentResponses] + +export type V2SessionSwitchModelData = { + body: { + model: { + id: string + providerID: string + variant?: string + } + } + path: { + sessionID: string + } + query?: never + url: "/api/session/{sessionID}/model" +} + +export type V2SessionSwitchModelErrors = { + /** + * InvalidRequestError + */ + 400: InvalidRequestError + /** + * UnauthorizedError + */ + 401: UnauthorizedError + /** + * SessionNotFoundError + */ + 404: SessionNotFoundError +} + +export type V2SessionSwitchModelError = V2SessionSwitchModelErrors[keyof V2SessionSwitchModelErrors] + +export type V2SessionSwitchModelResponses = { + /** + * + */ + 204: void +} + +export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses] + export type V2SessionPromptData = { body: { id?: string diff --git a/packages/server/src/groups/session.ts b/packages/server/src/groups/session.ts index a208de82f..ac4418d39 100644 --- a/packages/server/src/groups/session.ts +++ b/packages/server/src/groups/session.ts @@ -140,6 +140,38 @@ export const SessionGroup = HttpApiGroup.make("server.session") }), ), ) + .add( + HttpApiEndpoint.post("session.switchAgent", "/api/session/:sessionID/agent", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ agent: AgentV2.ID }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchAgent", + summary: "Switch session agent", + description: "Switch the agent used by subsequent session activity.", + }), + ), + ) + .add( + HttpApiEndpoint.post("session.switchModel", "/api/session/:sessionID/model", { + params: { sessionID: SessionV2.ID }, + payload: Schema.Struct({ model: ModelV2.Ref }), + success: HttpApiSchema.NoContent, + error: SessionNotFoundError, + }) + .middleware(SessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "v2.session.switchModel", + summary: "Switch session model", + description: "Switch the model used by subsequent session activity.", + }), + ), + ) .add( HttpApiEndpoint.post("session.prompt", "/api/session/:sessionID/prompt", { params: { sessionID: SessionV2.ID }, diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts index 66383cfbf..1fe860e52 100644 --- a/packages/server/src/handlers/session.ts +++ b/packages/server/src/handlers/session.ts @@ -92,6 +92,38 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl } }), ) + .handle( + "session.switchAgent", + Effect.fn(function* (ctx) { + yield* session.switchAgent({ sessionID: ctx.params.sessionID, agent: ctx.payload.agent }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) + .handle( + "session.switchModel", + Effect.fn(function* (ctx) { + yield* session.switchModel({ sessionID: ctx.params.sessionID, model: ctx.payload.model }).pipe( + Effect.catchTag("Session.NotFoundError", (error) => + Effect.fail( + new SessionNotFoundError({ + sessionID: error.sessionID, + message: `Session not found: ${error.sessionID}`, + }), + ), + ), + ) + return HttpApiSchema.NoContent.make() + }), + ) .handle( "session.prompt", Effect.fn(function* (ctx) {