feat(core): expose session switching endpoints

This commit is contained in:
Dax Raad 2026-06-21 23:59:01 -04:00
parent 9dadc2455f
commit 35b3fc85d0
7 changed files with 283 additions and 4 deletions

View File

@ -129,7 +129,7 @@ export interface Interface {
readonly switchAgent: (input: {
sessionID: SessionSchema.ID
agent: string
}) => Effect.Effect<void, OperationUnavailableError>
}) => Effect.Effect<void, NotFoundError>
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)

View File

@ -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")
}),
)

View File

@ -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) => ({

View File

@ -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<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
agent?: string
},
options?: Options<never, ThrowOnError>,
) {
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<ThrowOnError extends boolean = false>(
parameters: {
sessionID: string
model?: {
id: string
providerID: string
variant?: string
}
},
options?: Options<never, ThrowOnError>,
) {
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
*

View File

@ -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 = {
/**
* <No Content>
*/
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 = {
/**
* <No Content>
*/
204: void
}
export type V2SessionSwitchModelResponse = V2SessionSwitchModelResponses[keyof V2SessionSwitchModelResponses]
export type V2SessionPromptData = {
body: {
id?: string

View File

@ -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 },

View File

@ -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) {