From 8c27c8485eb72e58aca67fc4496e9fc0e218503b Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 11 Aug 2026 10:52:14 -0500 Subject: [PATCH] feat(session): persist previous selections (#41771) --- .../context/server-session-v2-reducer.test.ts | 57 +++++++++++++++++++ .../src/context/server-session-v2-reducer.ts | 16 ++++-- packages/client/src/effect/api/api.ts | 12 +++- .../client/src/promise/generated/types.ts | 4 +- packages/core/src/session.ts | 4 +- packages/core/src/session/message-updater.ts | 4 +- packages/core/test/session-create.test.ts | 14 ++++- packages/protocol/openapi.json | 6 ++ packages/schema/src/session-event.ts | 2 + packages/www/openapi.json | 6 ++ packages/www/public/openapi.json | 6 ++ 11 files changed, 117 insertions(+), 14 deletions(-) diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index 86387226175..ba1048a398f 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -69,6 +69,63 @@ describe("v2 session reducer", () => { }) }) + test("prefers durable selection predecessors and derives them for older events", () => { + const source: SessionMessageInfo[] = [ + { id: "msg_previous_agent", type: "agent-switched", agent: "build", time: { created: 1 } }, + { + id: "msg_previous_model", + type: "model-switched", + model: { id: "old", providerID: "provider" }, + time: { created: 1 }, + }, + ] + const reducer = createV2SessionReducer() + + const agent = reducer.reduce( + source, + event({ + ...base, + id: "evt_agent", + type: "session.agent.selected", + data: { sessionID: "ses_1", agent: "plan", previous: "review" }, + }), + ) + const model = reducer.reduce( + source, + event({ + ...base, + id: "evt_model", + type: "session.model.selected", + data: { + sessionID: "ses_1", + model: { id: "new", providerID: "provider" }, + previous: { id: "durable", providerID: "provider" }, + }, + }), + ) + const legacyAgent = reducer.reduce( + source, + event({ + ...base, + id: "evt_legacy_agent", + type: "session.agent.selected", + data: { sessionID: "ses_1", agent: "plan" }, + }), + ) + + expect(agent?.messages.at(-1)).toMatchObject({ type: "agent-switched", agent: "plan", previous: "review" }) + expect(model?.messages.at(-1)).toMatchObject({ + type: "model-switched", + model: { id: "new" }, + previous: { id: "durable" }, + }) + expect(legacyAgent?.messages.at(-1)).toMatchObject({ + type: "agent-switched", + agent: "plan", + previous: "build", + }) + }) + test("folds tool, retry, and completion events", () => { const reducer = createV2SessionReducer() let messages: SessionMessageInfo[] = [] diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts index 9c4540d53f0..f572d97887b 100644 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -61,6 +61,12 @@ export function createV2SessionReducer() { type: "agent-switched", metadata: event.metadata, agent: event.data.agent, + previous: + event.data.previous ?? + source.findLast( + (item): item is Extract => + item.type === "agent-switched" || item.type === "assistant", + )?.agent, time: { created: event.created }, }) case "session.model.selected": @@ -69,10 +75,12 @@ export function createV2SessionReducer() { type: "model-switched", metadata: event.metadata, model: event.data.model, - previous: source.findLast( - (item): item is Extract => - item.type === "model-switched" || item.type === "assistant", - )?.model, + previous: + event.data.previous ?? + source.findLast( + (item): item is Extract => + item.type === "model-switched" || item.type === "assistant", + )?.model, time: { created: event.created }, }) case "session.synthetic": diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 52b848b2ea6..7691c2cde68 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -339,7 +339,11 @@ export type Endpoint5_31Output = readonly type: "session.agent.selected" readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly location?: Location.Ref | undefined - readonly data: { readonly sessionID: Session.ID; readonly agent: Agent.ID } + readonly data: { + readonly sessionID: Session.ID + readonly agent: Agent.ID + readonly previous?: Agent.ID | undefined + } } | { readonly id: Event.ID @@ -348,7 +352,11 @@ export type Endpoint5_31Output = readonly type: "session.model.selected" readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version } readonly location?: Location.Ref | undefined - readonly data: { readonly sessionID: Session.ID; readonly model: Model.Ref } + readonly data: { + readonly sessionID: Session.ID + readonly model: Model.Ref + readonly previous?: Model.Ref | undefined + } } | { readonly id: Event.ID diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 2aeb01694fb..4625160b933 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -436,7 +436,7 @@ export type SessionAgentSelected = { type: "session.agent.selected" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; agent: string } + data: { sessionID: string; agent: string; previous?: string } } export type SessionModelSelected = { @@ -446,7 +446,7 @@ export type SessionModelSelected = { type: "session.model.selected" durable: { aggregateID: string; seq: number; version: 1 } location?: LocationRef - data: { sessionID: string; model: ModelRef } + data: { sessionID: string; model: ModelRef; previous?: ModelRef } } export type SessionMoved = { diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index fb49b19cb86..7836b333c41 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -716,10 +716,11 @@ const layer = Layer.effect( .pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid) }), switchAgent: Effect.fn("Session.switchAgent")(function* (input) { - yield* result.get(input.sessionID) + const session = yield* result.get(input.sessionID) yield* bus.publish(SessionEvent.AgentSelected, { sessionID: input.sessionID, agent: input.agent, + previous: session.agent, }) }), switchModel: Effect.fn("Session.switchModel")(function* (input) { @@ -733,6 +734,7 @@ const layer = Layer.effect( yield* bus.publish(SessionEvent.ModelSelected, { sessionID: input.sessionID, model: input.model, + previous: session.model, }) }), rename: Effect.fn("Session.rename")(function* (input) { diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index c9e35f54170..31baa23270e 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -61,7 +61,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { "session.usage.recorded": () => Effect.void, "session.agent.selected": (event) => { return Effect.gen(function* () { - const previous = yield* adapter.getAgent() + const previous = event.data.previous ?? (yield* adapter.getAgent()) yield* adapter.appendMessage( SessionMessage.AgentSelected.make({ id: SessionMessage.ID.fromEvent(event.id), @@ -76,7 +76,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { }, "session.model.selected": (event) => { return Effect.gen(function* () { - const previous = yield* adapter.getModel() + const previous = event.data.previous ?? (yield* adapter.getModel()) yield* adapter.appendMessage( SessionMessage.ModelSelected.make({ id: SessionMessage.ID.fromEvent(event.id), diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index b8a7160b3d6..e365ab1fa0c 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -654,7 +654,7 @@ describe("Session.create", () => { expect(yield* session.get(created.id)).toMatchObject({ agent: "plan" }) expect( Array.from(yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect)), - ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan" } }]) + ).toMatchObject([{ type: "session.agent.selected", data: { agent: "plan", previous: "build" } }]) expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([ { type: "agent-switched", agent: "plan", previous: "build" }, ]) @@ -678,7 +678,12 @@ describe("Session.create", () => { it.effect("switches the selected model through the durable Session event", () => Effect.gen(function* () { const session = yield* Session.Service - const created = yield* session.create({ location }) + const previous = Model.Ref.make({ + id: Model.ID.make("haiku"), + providerID: Provider.ID.anthropic, + variant: Model.VariantID.make("default"), + }) + const created = yield* session.create({ location, model: previous }) const model = Model.Ref.make({ id: Model.ID.make("sonnet"), providerID: Provider.ID.anthropic, @@ -692,7 +697,10 @@ describe("Session.create", () => { yield* logEvents(session, created.id, true).pipe(Stream.drop(1), Stream.take(1), Stream.runCollect), ) expect(bus).toMatchObject([{ type: "session.model.selected" }]) - expect(bus[0]?.data).toEqual({ sessionID: created.id, model }) + expect(bus[0]?.data).toEqual({ sessionID: created.id, model, previous }) + expect(yield* session.messages({ sessionID: created.id, order: "asc" })).toMatchObject([ + { type: "model-switched", model, previous }, + ]) }), ) diff --git a/packages/protocol/openapi.json b/packages/protocol/openapi.json index 186566dfc6b..34746d0570a 100644 --- a/packages/protocol/openapi.json +++ b/packages/protocol/openapi.json @@ -14300,9 +14300,15 @@ "agent": { "type": "string" }, + "previous": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" + }, "version": { "type": "string" } diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 2ae3f7c4059..813f1cf7498 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -69,6 +69,7 @@ export const AgentSelected = Event.durable({ schema: { ...Base, agent: Agent.ID, + previous: Agent.ID.pipe(optional), }, }) export type AgentSelected = typeof AgentSelected.Type @@ -79,6 +80,7 @@ export const ModelSelected = Event.durable({ schema: { ...Base, model: Model.Ref, + previous: Model.Ref.pipe(optional), }, }) export type ModelSelected = typeof ModelSelected.Type diff --git a/packages/www/openapi.json b/packages/www/openapi.json index 186566dfc6b..34746d0570a 100644 --- a/packages/www/openapi.json +++ b/packages/www/openapi.json @@ -14300,9 +14300,15 @@ "agent": { "type": "string" }, + "previous": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" + }, "version": { "type": "string" } diff --git a/packages/www/public/openapi.json b/packages/www/public/openapi.json index 186566dfc6b..34746d0570a 100644 --- a/packages/www/public/openapi.json +++ b/packages/www/public/openapi.json @@ -14300,9 +14300,15 @@ "agent": { "type": "string" }, + "previous": { + "type": "string" + }, "model": { "$ref": "#/components/schemas/Model.Ref" }, + "previous": { + "$ref": "#/components/schemas/Model.Ref" + }, "version": { "type": "string" }