diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3f580b0dc1..9943b03fd8 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -571,7 +571,8 @@ export type SessionsContextOutput = { } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + readonly finish?: string + readonly settlement?: "completed" | "failed" | "interrupted" readonly cost?: number readonly tokens?: { readonly input: number @@ -770,7 +771,7 @@ export type SessionsEventsOutput = readonly timestamp: number readonly sessionID: string readonly assistantMessageID: string - readonly finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + readonly finish: string readonly cost: number readonly tokens: { readonly input: number @@ -1196,7 +1197,8 @@ export type SessionsMessageOutput = { } > readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray } - readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + readonly finish?: string + readonly settlement?: "completed" | "failed" | "interrupted" readonly cost?: number readonly tokens?: { readonly input: number diff --git a/packages/core/src/session/message-updater.ts b/packages/core/src/session/message-updater.ts index 2c92af8583..006dda7e6e 100644 --- a/packages/core/src/session/message-updater.ts +++ b/packages/core/src/session/message-updater.ts @@ -210,6 +210,7 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.data.timestamp draft.finish = event.data.finish + draft.settlement = "completed" draft.cost = event.data.cost draft.tokens = event.data.tokens if (event.data.snapshot || event.data.files) @@ -224,13 +225,14 @@ export function update(adapter: Adapter, event: SessionEvent.Event) { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.data.timestamp draft.finish = "error" + draft.settlement = "failed" draft.error = event.data.error }) }, "session.next.step.interrupted": (event) => { return updateOwnedAssistant(event.data.assistantMessageID, (draft) => { draft.time.completed = event.data.timestamp - draft.finish = "interrupted" + draft.settlement = "interrupted" }) }, "session.next.text.started": (event) => { diff --git a/packages/core/src/session/runner/to-llm-message.ts b/packages/core/src/session/runner/to-llm-message.ts index dcc6cd99a7..4fdb5cef33 100644 --- a/packages/core/src/session/runner/to-llm-message.ts +++ b/packages/core/src/session/runner/to-llm-message.ts @@ -70,7 +70,7 @@ const toolResult = (tool: SessionMessage.AssistantTool, providerMetadata: Provid const assistant = (message: SessionMessage.Assistant, model: Model) => { const sameModel = String(message.model.providerID) === String(model.provider) && String(message.model.id) === String(model.id) - const reuseProviderMetadata = sameModel && message.error === undefined && message.finish !== "interrupted" + const reuseProviderMetadata = sameModel && message.error === undefined && message.settlement !== "interrupted" const content = message.content.flatMap((item): ContentPart[] => { if (item.type === "text") return [{ type: "text", text: item.text }] if (item.type === "reasoning") diff --git a/packages/core/test/session-runner-message.test.ts b/packages/core/test/session-runner-message.test.ts index 1c33dfc26e..0b0a793366 100644 --- a/packages/core/test/session-runner-message.test.ts +++ b/packages/core/test/session-runner-message.test.ts @@ -361,7 +361,7 @@ Recent work time: { created, completed: created }, }), ], - finish: "interrupted", + settlement: "interrupted", time: { created, completed: created }, }), ], diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts index edc1607e2a..b1d02aa7bc 100644 --- a/packages/core/test/session-runner.test.ts +++ b/packages/core/test/session-runner.test.ts @@ -519,6 +519,7 @@ const verifyPartialFlushOnFailure = (kind: FragmentKind) => { type: "assistant", finish: "error", + settlement: "failed", error: { type: "unknown", message: "Provider unavailable" }, content: [fixture.expectedContent], }, @@ -563,7 +564,7 @@ const verifyPartialFlushOnInterruption = (kind: FragmentKind) => { type: "user", text: prompt }, { type: "assistant", - finish: "interrupted", + settlement: "interrupted", content: [ kind === "tool input" ? { type: "tool", id: fragmentID(kind, "interrupted"), state: { status: "error" } } @@ -2669,7 +2670,7 @@ describe("SessionRunnerLLM", () => { state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, }, ], - finish: "interrupted", + settlement: "interrupted", }, ]) }), @@ -2814,7 +2815,7 @@ describe("SessionRunnerLLM", () => { state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } }, }, ], - finish: "interrupted", + settlement: "interrupted", }, ]) }), diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts index 8b40e296c2..2f92291325 100644 --- a/packages/schema/src/session-event.ts +++ b/packages/schema/src/session-event.ts @@ -165,7 +165,8 @@ export namespace Step { schema: { ...Base, assistantMessageID: SessionMessage.ID, - finish: SessionMessage.Finish, + // Step.Ended v2 was originally persisted with an open string schema. + finish: Schema.String, cost: Schema.Finite, tokens: Schema.Struct({ input: Schema.Finite, diff --git a/packages/schema/src/session-message.ts b/packages/schema/src/session-message.ts index 90356e60fe..0c50c41d9e 100644 --- a/packages/schema/src/session-message.ts +++ b/packages/schema/src/session-message.ts @@ -28,10 +28,12 @@ export const Finish = Schema.Literals([ "content-filter", "error", "unknown", - "interrupted", ]) export type Finish = typeof Finish.Type +export const Settlement = Schema.Literals(["completed", "failed", "interrupted"]) +export type Settlement = typeof Settlement.Type + const Base = { id: ID, metadata: Schema.Record(Schema.String, Schema.Unknown).pipe(optional), @@ -180,7 +182,9 @@ export const Assistant = Schema.Struct({ end: Schema.String.pipe(optional), files: Schema.Array(RelativePath).pipe(optional), }).pipe(optional), - finish: Finish.pipe(optional), + // Projected histories predate the typed provider finish model and may contain arbitrary values. + finish: Schema.String.pipe(optional), + settlement: Settlement.pipe(optional), cost: Schema.Finite.pipe(optional), tokens: Schema.Struct({ input: Schema.Finite, diff --git a/packages/schema/test/event-manifest.test.ts b/packages/schema/test/event-manifest.test.ts index bc81467d36..94c3de67e2 100644 --- a/packages/schema/test/event-manifest.test.ts +++ b/packages/schema/test/event-manifest.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { Schema } from "effect" import { FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src" import { EventManifest } from "../src/event-manifest" import { IdeEvent } from "../src/ide-event" @@ -53,4 +54,17 @@ describe("public event manifest", () => { expect(EventManifest.Durable.has("session.next.step.ended.1")).toBe(false) expect(EventManifest.Durable.get("session.next.step.ended.2")).toBe(SessionEvent.Step.Ended) }) + + test("decodes legacy Step.Ended v2 finish strings", () => { + const event = Schema.decodeUnknownSync(SessionEvent.Step.Ended.data)({ + sessionID: "ses_legacy", + timestamp: 0, + assistantMessageID: "msg_legacy", + finish: "legacy-provider-reason", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + expect(event.finish).toBe("legacy-provider-reason") + }) }) diff --git a/packages/schema/test/session-message.test.ts b/packages/schema/test/session-message.test.ts new file mode 100644 index 0000000000..df0ef623f1 --- /dev/null +++ b/packages/schema/test/session-message.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test" +import { Schema } from "effect" +import { SessionMessage } from "../src/session-message" + +test("does not model interruption as a provider finish reason", () => { + expect(() => Schema.decodeUnknownSync(SessionMessage.Finish)("interrupted")).toThrow() + expect(Schema.decodeUnknownSync(SessionMessage.Finish)("error")).toBe("error") +}) + +test("decodes projected assistant histories with arbitrary finish strings", () => { + const message = Schema.decodeUnknownSync(SessionMessage.Message)({ + id: "msg_legacy", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [], + finish: "legacy-provider-reason", + time: { created: 0, completed: 1 }, + }) + + expect(message).toMatchObject({ type: "assistant", finish: "legacy-provider-reason" }) +}) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5cc34c1c65..2902fc85c2 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -931,7 +931,7 @@ export type GlobalEvent = { timestamp: number sessionID: string assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish: string cost: number tokens: { input: number @@ -3378,7 +3378,7 @@ export type SyncEventSessionNextStepEnded = { timestamp: number sessionID: string assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish: string cost: number tokens: { input: number @@ -4022,7 +4022,8 @@ export type SessionMessageAssistant = { end?: string files?: Array } - finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish?: string + settlement?: "completed" | "failed" | "interrupted" cost?: number tokens?: { input: number @@ -4281,7 +4282,7 @@ export type SessionNextStepEnded = { timestamp: number sessionID: string assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish: string cost: number tokens: { input: number @@ -5355,7 +5356,7 @@ export type V2EventSessionNextStepEnded = { timestamp: number sessionID: string assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish: string cost: number tokens: { input: number @@ -6970,7 +6971,7 @@ export type EventSessionNextStepEnded = { timestamp: number sessionID: string assistantMessageID: string - finish: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown" | "interrupted" + finish: string cost: number tokens: { input: number diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index 9b2e58907a..7c3197c49f 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -227,6 +227,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (!currentAssistant) return currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = event.data.finish + currentAssistant.settlement = "completed" currentAssistant.cost = event.data.cost currentAssistant.tokens = event.data.tokens if (event.data.snapshot) @@ -239,9 +240,18 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ if (!currentAssistant) return currentAssistant.time.completed = event.data.timestamp currentAssistant.finish = "error" + currentAssistant.settlement = "failed" currentAssistant.error = event.data.error }) break + case "session.next.step.interrupted": + message.update(event.data.sessionID, (draft) => { + const currentAssistant = message.assistant(draft, event.data.assistantMessageID) + if (!currentAssistant) return + currentAssistant.time.completed = event.data.timestamp + currentAssistant.settlement = "interrupted" + }) + break case "session.next.text.started": message.update(event.data.sessionID, (draft) => { message.assistant(draft, event.data.assistantMessageID)?.content.push({ diff --git a/packages/tui/test/cli/tui/data.test.tsx b/packages/tui/test/cli/tui/data.test.tsx index 279ba4d065..49dcfd3be5 100644 --- a/packages/tui/test/cli/tui/data.test.tsx +++ b/packages/tui/test/cli/tui/data.test.tsx @@ -370,6 +370,72 @@ test("settles pending tools when a live failure arrives", async () => { } }) +test("marks an interrupted assistant inactive without a finish reason", async () => { + const events = createEventSource() + const calls = createFetch(undefined, events) + let sync!: ReturnType + let ready!: () => void + const mounted = new Promise((resolve) => { + ready = resolve + }) + + function Probe() { + sync = useData() + onMount(ready) + return + } + + const app = await testRender(() => ( + + + + + + + + + + )) + + try { + await mounted + emitEvent(events, { + id: "evt_step_started_interrupted", + type: "session.next.step.started", + properties: { + sessionID: "session-interrupted", + assistantMessageID: "msg_interrupted", + timestamp: 1, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + }, + }) + emitEvent(events, { + id: "evt_step_interrupted", + type: "session.next.step.interrupted", + properties: { + sessionID: "session-interrupted", + assistantMessageID: "msg_interrupted", + timestamp: 2, + }, + }) + + await wait(() => { + const message = sync.session.message.list("session-interrupted")?.[0] + return message?.type === "assistant" && message.time.completed === 2 + }) + const assistant = sync.session.message.list("session-interrupted")?.[0] + expect(assistant).toMatchObject({ + type: "assistant", + settlement: "interrupted", + time: { completed: 2 }, + }) + expect(assistant).not.toHaveProperty("finish") + } finally { + app.renderer.destroy() + } +}) + test("renders admitted prompts only after they become model-visible", async () => { const events = createEventSource() const calls = createFetch(undefined, events)