fix(core): separate interruption from finish

This commit is contained in:
Kit Langton 2026-06-26 11:04:08 -04:00
commit fd229f7edf
12 changed files with 141 additions and 18 deletions

View file

@ -571,7 +571,8 @@ export type SessionsContextOutput = {
}
>
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
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<string> }
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

View file

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

View file

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

View file

@ -361,7 +361,7 @@ Recent work
time: { created, completed: created },
}),
],
finish: "interrupted",
settlement: "interrupted",
time: { created, completed: created },
}),
],

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<typeof useData>
let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})
function Probe() {
sync = useData()
onMount(ready)
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider url="http://test" directory={directory} events={events.source} fetch={calls.fetch}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
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)