feat(core): finalize session event lifecycle (#35272)

This commit is contained in:
Kit Langton 2026-07-06 18:14:06 -04:00 committed by GitHub
parent b046bbe4e3
commit bf01264661
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
82 changed files with 5847 additions and 4358 deletions

View file

@ -28,11 +28,20 @@ function prompted(inputID: string): V2Event {
}
function settled(outcome: "success" | "interrupted" = "success"): V2Event {
if (outcome === "interrupted")
return {
id: "evt_interrupted",
created: 0,
type: "session.execution.interrupted",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: { sessionID: "ses_1", reason: "user" },
}
return {
id: "evt_settled",
id: "evt_succeeded",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome },
type: "session.execution.succeeded",
durable: { aggregateID: "ses_1", seq: 1, version: 1 },
data: { sessionID: "ses_1" },
}
}
@ -58,8 +67,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.form, "list").mockImplementation(
(request) =>
ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form, "cancel").mockImplementation(() => ok(undefined) as never)
spyOn(sdk.session, "prompt").mockImplementation((request) => {

View file

@ -2,7 +2,12 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
import { pathToFileURL } from "node:url"
import { OpenCode, type EventSubscribeOutput, type MessageListOutput, type OpenCodeClient } from "@opencode-ai/client/promise"
import {
OpenCode,
type EventSubscribeOutput,
type MessageListOutput,
type OpenCodeClient,
} from "@opencode-ai/client/promise"
import { createSessionTransport } from "@opencode-ai/cli/mini/stream-v2.transport"
import type { FooterApi, FooterEvent, StreamCommit } from "@opencode-ai/cli/mini/types"
import { tmpdir } from "../../fixture/fixture"
@ -200,15 +205,16 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
ordinal: 0,
delta: "answer",
},
})
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await turn
@ -253,8 +259,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok({
@ -347,8 +354,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok({
@ -444,8 +452,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok({
@ -693,7 +702,7 @@ describe("V2 mini transport", () => {
type: "assistant",
agent: "build",
model: { providerID: "test", id: "model" },
content: [{ type: "text", id: "txt_1", text: "the answer" }],
content: [{ type: "text", text: "the answer" }],
time: { created: 2, completed: 3 },
},
],
@ -705,6 +714,17 @@ describe("V2 mini transport", () => {
reset = resolve
})
const replay = transport.replayOnResize({ localRows: () => [], reset: () => resetting })
events.push({
id: "evt_text_started",
created: 0,
type: "session.text.started",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
ordinal: 0,
},
})
events.push({
id: "evt_text",
created: 0,
@ -712,7 +732,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
textID: "txt_1",
ordinal: 0,
delta: "answer",
},
})
@ -725,7 +745,7 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("scopes repeated text and reasoning ids by assistant message", async () => {
test("scopes text and reasoning ordinals by assistant message", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
@ -738,8 +758,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", id: "reasoning-0", text: "second thought" },
{ type: "text", id: "text-0", text: "second answer" },
{ type: "reasoning", text: "second thought" },
{ type: "text", text: "second answer" },
],
time: { created: 4, completed: 5 },
},
@ -749,8 +769,8 @@ describe("V2 mini transport", () => {
agent: "build",
model: { providerID: "test", id: "model" },
content: [
{ type: "reasoning", id: "reasoning-0", text: "first thought" },
{ type: "text", id: "text-0", text: "first answer" },
{ type: "reasoning", text: "first thought" },
{ type: "text", text: "first answer" },
],
time: { created: 2, completed: 3 },
},
@ -798,7 +818,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
reasoningID: "reasoning_1",
ordinal: 0,
text: "considering",
},
})
@ -808,6 +828,52 @@ describe("V2 mini transport", () => {
await transport.close()
})
test("renders a live tool start when the call begins", async () => {
const events = feed()
events.push(connected())
const client = sdk({ streams: [events] })
const ui = footer()
const transport = await createSessionTransport({
sdk: client,
sessionID: "ses_1",
thinking: false,
limits: () => ({}),
footer: ui.api,
})
events.push({
id: "evt_tool_input",
created: 1,
type: "session.tool.input.started",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_read",
name: "read",
},
})
events.push({
id: "evt_tool_called",
created: 2,
type: "session.tool.called",
durable: durable("ses_1", 1),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_assistant",
callID: "call_read",
input: { path: "README.md" },
executed: false,
},
})
await Bun.sleep(0)
expect(ui.commits).toContainEqual(
expect.objectContaining({ kind: "tool", phase: "start", partID: "prt_call_read", tool: "read" }),
)
await transport.close()
})
test("resolves an interrupted turn even when promotion never arrived", async () => {
const events = feed()
events.push(connected())
@ -849,8 +915,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
})
await turn
@ -913,8 +980,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await turn
@ -975,8 +1043,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.interrupted",
durable: durable("ses_1"),
data: { sessionID: "ses_1", reason: "user" },
})
await turn
@ -1413,8 +1482,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok({
@ -1488,8 +1558,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
})
return ok(undefined) as never
@ -1569,8 +1640,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_unrelated_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await Bun.sleep(0)
await Bun.sleep(0)
@ -1590,8 +1662,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_skill_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_1", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_1"),
data: { sessionID: "ses_1" },
})
await turn
@ -1701,18 +1774,19 @@ describe("V2 mini transport", () => {
],
},
})
spyOn(client.session, "get").mockImplementation(() =>
ok({
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never,
spyOn(client.session, "get").mockImplementation(
() =>
ok({
id: "ses_child",
parentID: "ses_1",
projectID: "proj_1",
agent: "explore",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 1 },
title: "Find files",
location: { directory: "/tmp" },
}) as never,
)
const ui = footer()
const transport = await createSessionTransport({
@ -1750,7 +1824,7 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_child_a",
textID: "txt_child",
ordinal: 0,
delta: "child answer",
},
})
@ -1760,8 +1834,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "success" },
type: "session.execution.succeeded",
durable: durable("ses_child"),
data: { sessionID: "ses_child" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "completed"))) await Bun.sleep(0)
await transport.close()
@ -1801,7 +1876,9 @@ describe("V2 mini transport", () => {
})
await Bun.sleep(0)
expect(
states().at(-1)?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"),
states()
.at(-1)
?.details.ses_child?.commits.some((item) => item.messageID === "msg_child_prompt"),
).toBe(false)
events.push({
@ -1955,19 +2032,36 @@ describe("V2 mini transport", () => {
data: {
sessionID: "ses_child",
assistantMessageID: "msg_overflow_assistant",
textID: `txt_overflow_${index}`,
ordinal: index,
delta: `live ${index}`,
},
})
while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")) await Bun.sleep(0)
while (
!states()
.at(-1)
?.details.ses_child?.commits.some((item) => item.text === "live 64")
)
await Bun.sleep(0)
releaseStale()
while (childRequests < 2) await Bun.sleep(0)
expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true)
expect(
states()
.at(-1)
?.details.ses_child?.commits.some((item) => item.text === "live 64"),
).toBe(true)
releaseRetry()
while (!states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "baseline history"))
while (
!states()
.at(-1)
?.details.ses_child?.commits.some((item) => item.text === "baseline history")
)
await Bun.sleep(0)
expect(states().at(-1)?.details.ses_child?.commits.some((item) => item.text === "live 64")).toBe(true)
expect(
states()
.at(-1)
?.details.ses_child?.commits.some((item) => item.text === "live 64"),
).toBe(true)
expect(childRequests).toBe(2)
await transport.close()
})
@ -2032,7 +2126,7 @@ describe("V2 mini transport", () => {
durable: durable("ses_child", seq),
data: { sessionID: "ses_child", assistantMessageID: "msg_tool_projected", callID, name },
})
const called = (callID: string, tool: string, input: Record<string, unknown>, seq: number) =>
const called = (callID: string, input: Record<string, unknown>, seq: number) =>
events.push({
id: `evt_called_${callID}`,
created: seq,
@ -2042,14 +2136,13 @@ describe("V2 mini transport", () => {
sessionID: "ses_child",
assistantMessageID: "msg_tool_projected",
callID,
tool,
input,
provider: { executed: true },
executed: true,
},
})
inputStarted("call_terminal", "grep", 0)
called("call_terminal", "grep", { pattern: "needle" }, 1)
called("call_terminal", { pattern: "needle" }, 1)
await Bun.sleep(0)
transport.selectSubagent("ses_child")
while (!childHydrating) await Bun.sleep(0)
@ -2064,11 +2157,11 @@ describe("V2 mini transport", () => {
callID: "call_terminal",
structured: {},
content: [{ type: "text", text: "found" }],
provider: { executed: true },
executed: true,
},
})
inputStarted("call_overlap", "bash", 3)
called("call_overlap", "bash", { command: "stale" }, 4)
called("call_overlap", { command: "stale" }, 4)
await Bun.sleep(0)
const beforeHydration = states().length
releaseHydration()
@ -2137,8 +2230,9 @@ describe("V2 mini transport", () => {
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
type: "session.execution.interrupted",
durable: durable("ses_child"),
data: { sessionID: "ses_child", reason: "user" },
})
await Bun.sleep(0)
resolveGet?.()
@ -2192,6 +2286,18 @@ describe("V2 mini transport", () => {
},
})
// Parent's background subagent tool.success adopts the child mid-discovery.
events.push({
id: "evt_parent_input",
created: 0,
type: "session.tool.input.started",
durable: durable("ses_1"),
data: {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
name: "subagent",
},
})
events.push({
id: "evt_parent_call",
created: 0,
@ -2201,9 +2307,8 @@ describe("V2 mini transport", () => {
sessionID: "ses_1",
assistantMessageID: "msg_parent_a",
callID: "call_sub",
tool: "subagent",
input: { agent: "explore", description: "Find things", prompt: "go", background: true },
provider: { executed: true },
executed: true,
},
})
events.push({
@ -2217,15 +2322,16 @@ describe("V2 mini transport", () => {
callID: "call_sub",
structured: { sessionID: "ses_child", status: "running", output: "" },
content: [],
provider: { executed: true },
executed: true,
},
})
// The settled event arrives after adoption, so it applies directly.
events.push({
id: "evt_child_settled",
created: 0,
type: "session.execution.settled",
data: { sessionID: "ses_child", outcome: "interrupted" },
type: "session.execution.interrupted",
durable: durable("ses_child"),
data: { sessionID: "ses_child", reason: "shutdown" },
})
while (!states().some((state) => state.tabs.some((tab) => tab.status === "cancelled"))) await Bun.sleep(0)

View file

@ -101,7 +101,7 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
ordinal: 0,
},
} satisfies SessionEvent.Event),
)
@ -115,7 +115,7 @@ test.skip("text ended populates assistant text content", () => {
data: {
sessionID,
assistantMessageID,
textID: "text-1",
ordinal: 0,
text: "hello assistant",
},
} satisfies SessionEvent.Event),
@ -123,7 +123,7 @@ test.skip("text ended populates assistant text content", () => {
expect(state.messages[0]?.type).toBe("assistant")
if (state.messages[0]?.type !== "assistant") return
expect(state.messages[0].content).toEqual([{ type: "text", id: "text-1", text: "hello assistant" }])
expect(state.messages[0].content).toEqual([{ type: "text", text: "hello assistant" }])
})
test.skip("tool completion stores completed timestamp", () => {
@ -176,9 +176,9 @@ test.skip("tool completion stores completed timestamp", () => {
sessionID,
assistantMessageID,
callID,
tool: "bash",
input: { command: "pwd" },
provider: { executed: true, metadata: { fake: { source: "provider" } } },
executed: true,
state: { source: "provider" },
},
} satisfies SessionEvent.Event),
)
@ -195,7 +195,8 @@ test.skip("tool completion stores completed timestamp", () => {
callID,
structured: {},
content: [{ type: "text", text: "/tmp" }],
provider: { executed: true, metadata: { fake: { status: "done" } } },
executed: true,
resultState: { status: "done" },
},
} satisfies SessionEvent.Event),
)
@ -205,7 +206,11 @@ test.skip("tool completion stores completed timestamp", () => {
expect(state.messages[0].content[0]?.type).toBe("tool")
if (state.messages[0].content[0]?.type !== "tool") return
expect(state.messages[0].content[0].time.completed).toEqual(DateTime.makeUnsafe(4))
expect(state.messages[0].content[0].provider).toEqual({ executed: true, metadata: { fake: { status: "done" } } })
expect(state.messages[0].content[0]).toMatchObject({
executed: true,
providerState: { source: "provider" },
providerResultState: { status: "done" },
})
})
test("compaction events reduce to compaction message only when completed", () => {