fix(core): separate interruption from finish
This commit is contained in:
parent
db664db5f3
commit
fd229f7edf
12 changed files with 141 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -361,7 +361,7 @@ Recent work
|
|||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
finish: "interrupted",
|
||||
settlement: "interrupted",
|
||||
time: { created, completed: created },
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
22
packages/schema/test/session-message.test.ts
Normal file
22
packages/schema/test/session-message.test.ts
Normal 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" })
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue