diff --git a/packages/client/src/effect/api/api.ts b/packages/client/src/effect/api/api.ts index 1eeb14093917..6645a5a86ccf 100644 --- a/packages/client/src/effect/api/api.ts +++ b/packages/client/src/effect/api/api.ts @@ -965,6 +965,8 @@ export type SessionLogOutput = readonly data: { readonly sessionID: Session.ID readonly reason: "auto" | "manual" + readonly model?: Model.Ref | undefined + readonly providerState?: SessionMessage.ProviderState | undefined readonly text: string readonly recent: string } diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index e8036b9f5417..81f60180d878 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -138,17 +138,6 @@ export type SessionMessageCompactionRunning = { recent: string } -export type SessionMessageCompactionCompleted = { - type: "compaction" - id: string - metadata?: { [x: string]: JsonValue } - time: { created: number } - status: "completed" - reason: "auto" | "manual" - summary: string - recent: string -} - export type SessionActive = { type: "running" } export type SessionInboxDelivery = "steer" | "queue" @@ -521,6 +510,19 @@ export type SessionMessageAssistantReasoning = { time?: { created: number; completed?: number } } +export type SessionMessageCompactionCompleted = { + type: "compaction" + id: string + metadata?: { [x: string]: JsonValue } + time: { created: number } + status: "completed" + reason: "auto" | "manual" + model?: ModelRef + providerState?: SessionMessageProviderState + summary: string + recent: string +} + export type ToolContent = ToolTextContent | ToolFileContent export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError } @@ -809,16 +811,6 @@ export type SessionCompactionStarted = { data: { sessionID: string; reason: "auto" | "manual"; recent: string; inputID?: string } } -export type SessionCompactionEnded = { - id: string - created: number - metadata?: { [x: string]: any } - type: "session.compaction.ended" - durable: { aggregateID: string; seq: number; version: 1 } - location?: LocationRef - data: { sessionID: string; reason: "auto" | "manual"; text: string; recent: string } -} - export type SessionCompactionFailed = { id: string created: number @@ -1351,6 +1343,23 @@ export type SessionToolCalled = { } } +export type SessionCompactionEnded = { + id: string + created: number + metadata?: { [x: string]: any } + type: "session.compaction.ended" + durable: { aggregateID: string; seq: number; version: 1 } + location?: LocationRef + data: { + sessionID: string + reason: "auto" | "manual" + model?: ModelRef + providerState?: SessionMessageProviderState1 + text: string + recent: string + } +} + export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 } export type SessionMessageAssistantReasoning1 = { @@ -3063,6 +3072,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } @@ -3340,6 +3351,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } @@ -3617,6 +3630,8 @@ export type SessionImportInput = { readonly time: { readonly created: number } readonly status: "completed" readonly reason: "auto" | "manual" + readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string } + readonly providerState?: { readonly [x: string]: JsonValue } readonly summary: string readonly recent: string } diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index d18de9071266..9afebba3024c 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -1038,6 +1038,8 @@ export function createData(config: CreateDataInput) { Object.assign(current, { status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, }) @@ -1048,6 +1050,8 @@ export function createData(config: CreateDataInput) { type: "compaction", status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, time: { created: event.created }, diff --git a/packages/client/test/solid-compaction.test.ts b/packages/client/test/solid-compaction.test.ts index 85848a08ae34..f33596cc5641 100644 --- a/packages/client/test/solid-compaction.test.ts +++ b/packages/client/test/solid-compaction.test.ts @@ -98,13 +98,15 @@ test.each(["started", "cancelled", "failed"])( expect(fixture.data.session.pending.list(sessionID)).toEqual([]) if (kind === "started") { expect(fixture.data.session.message.list(sessionID)).toMatchObject([{ type: "compaction", status: "running" }]) + const model = { providerID: "demo", id: "model" } + const providerState = { responseId: "summary-response" } fixture.emit({ ...event, type: "session.compaction.ended", - data: { sessionID, reason: "manual", text: "Summary", recent: "Recent" }, + data: { sessionID, reason: "manual", model, providerState, text: "Summary", recent: "Recent" }, }) expect(fixture.data.session.message.list(sessionID)).toMatchObject([ - { type: "compaction", status: "completed", summary: "Summary" }, + { type: "compaction", status: "completed", summary: "Summary", model, providerState }, ]) } }, diff --git a/packages/core/src/session/compaction.ts b/packages/core/src/session/compaction.ts index c266e6fc6099..912a9ac498e6 100644 --- a/packages/core/src/session/compaction.ts +++ b/packages/core/src/session/compaction.ts @@ -367,6 +367,7 @@ export const layer = Layer.effect( const chunks: string[] = [] let failure: SessionError.Error | undefined let usage: SessionUsage.Recorded | undefined + let providerState: SessionMessage.ProviderState | undefined const recordUsage = Effect.suspend(() => usage ? bus.publish(SessionEvent.UsageRecorded, { @@ -407,6 +408,7 @@ export const layer = Layer.effect( // Ignored tool calls never enter the follow-up history or need fabricated results. for (let attempt = 0; attempt < 2; attempt++) { chunks.length = 0 + providerState = undefined yield* llm .stream( attempt === 0 @@ -436,6 +438,10 @@ export const layer = Layer.effect( }) } if (LLMEvent.is.stepFinish(event)) { + providerState = + event.providerMetadata?.[ + context.model.model.route.providerMetadataKey ?? context.model.model.provider + ] const step = SessionUsage.record(event.usage, context.model.cost) usage = usage ? SessionUsage.add(usage, step) : step } @@ -482,6 +488,8 @@ export const layer = Layer.effect( yield* bus.publish(SessionEvent.Compaction.Ended, { sessionID: context.session.id, reason: input.reason, + model: context.model.ref, + providerState, text: summary, recent: history.recent, }) diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 490b4db56af7..126e9d28b69a 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -410,6 +410,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { ...current, status: "completed", reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, }) @@ -422,6 +424,8 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) { status: "completed", metadata: event.metadata, reason: event.data.reason, + model: event.data.model, + providerState: event.data.providerState, summary: event.data.text, recent: event.data.recent, time: { created }, diff --git a/packages/core/src/session/transfer.ts b/packages/core/src/session/transfer.ts index 83816ba51217..6484602cb93d 100644 --- a/packages/core/src/session/transfer.ts +++ b/packages/core/src/session/transfer.ts @@ -297,6 +297,9 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info { metadata: meta, summary: redact("compaction-summary", message.id, message.summary), recent: redact("compaction-recent", message.id, message.recent), + ...(message.status === "completed" + ? { providerState: metadata("compaction-provider-state", message.id, message.providerState) } + : {}), } } return { ...message, metadata: meta } diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts index ac2c2ab09b56..b30334e9d196 100644 --- a/packages/core/test/session-create.test.ts +++ b/packages/core/test/session-create.test.ts @@ -1243,6 +1243,7 @@ describe("SessionTransfer", () => { const runningCompactionID = SessionMessage.ID.create() const completedCompactionID = SessionMessage.ID.create() const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") }) + const providerState = { responseId: "summary-response" } yield* transfer.import({ data: { @@ -1297,6 +1298,8 @@ describe("SessionTransfer", () => { type: "compaction", status: "completed", reason: "manual", + model, + providerState, summary: "summary", recent: "recent", time: { created: DateTime.makeUnsafe(9) }, @@ -1313,6 +1316,11 @@ describe("SessionTransfer", () => { completedCompactionID, ]) expect(yield* Bus.latestSequence(db, sessionID)).toBe(4) + expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState }) + expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({ + model, + providerState: { redacted: `compaction-provider-state:${completedCompactionID}` }, + }) }), ) diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index 3a6486655a13..f8bc581d617a 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -2141,7 +2141,13 @@ describe("SessionRunnerLLM", () => { yield* s.llm.push( TestLLM.tool("call-prefix", "echo", { text: "x".repeat(4_000) }), TestLLM.textWithUsage("Earlier answer", "prefix-answer", 185_000), - TestLLM.text("## Objective\n- Checkpoint summary", "prefix-summary"), + TestLLM.complete( + { + reason: { normalized: "stop" }, + providerMetadata: { [s.currentModel.provider]: { responseId: "summary" } }, + }, + LLMEvent.textDelta({ id: "prefix-summary", text: "## Objective\n- Checkpoint summary" }), + ), ) yield* s.runPrompt("Review these changes") if (reason === "manual") { @@ -2177,6 +2183,10 @@ describe("SessionRunnerLLM", () => { expect(compact.system.map((part) => part.text)).toContain("Review the project carefully.") expect(requestAgents[2]).toBe(Agent.ID.make("compaction")) expect(s.executions).toEqual(["x".repeat(4_000)]) + expect((yield* s.messages).find((message) => message.type === "compaction")).toMatchObject({ + model: { id: s.currentModel.id, providerID: s.currentModel.provider, variant }, + providerState: { responseId: "summary" }, + }) // Compare wire content without the cache breakpoints that move to the new final message. const before = yield* compileRequest(LLMRequest.update(normal, { cache: "none" })) @@ -2209,8 +2219,14 @@ describe("SessionRunnerLLM", () => { ) : TestLLM.text("Let me search the codebase. I will fill in ## Objective later.", "invalid-summary") yield* s.llm.push( - invalid, - summary ? TestLLM.text("### Active\n- Recovered summary", "summary-recovered") : invalid, + invalid.map((event) => + LLMEvent.is.stepFinish(event) + ? { ...event, providerMetadata: { openai: { responseId: "rejected-summary-state" } } } + : event, + ), + summary + ? [LLMEvent.textDelta({ id: "summary-recovered", text: "### Active\n- Recovered summary" })] + : invalid, ) const compact = yield* s.session.compact({ sessionID }) yield* s.resume @@ -2220,6 +2236,7 @@ describe("SessionRunnerLLM", () => { expect(userTexts(s.requests[1]).at(-1)).toContain("did not fill in the required summary template") expect(s.requests.every((request) => request.toolChoice === undefined)).toBe(true) expect(s.executions).toEqual([]) + expect(JSON.stringify(yield* s.messages)).not.toContain("rejected-summary-state") expect((yield* s.messages).find((message) => message.id === compact.id)).toMatchObject( summary ? { status: "completed", summary: "### Active\n- Recovered summary" } diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index c627366452fa..34ebe69b1067 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -585,6 +585,8 @@ export namespace Compaction { schema: { ...Base, reason: Started.data.fields.reason, + model: SessionMessage.CompactionCompleted.fields.model, + providerState: SessionMessage.CompactionCompleted.fields.providerState, text: Schema.String, recent: Schema.String, }, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 64b82ee50230..c3f2ae30dd6a 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -250,6 +250,8 @@ export const CompactionCompleted = Schema.Struct({ ...CompactionBase, status: Schema.tag("completed"), reason: Schema.Literals(["auto", "manual"]), + model: Model.Ref.pipe(optional), + providerState: ProviderState.pipe(optional), summary: Schema.String, recent: Schema.String, }).annotate({ identifier: "Session.Message.Compaction.Completed" })