fix(tui): clear stale tool preparation state

Prefer the terminal server projection when refresh observes a completed assistant message, so a missed tool-failure event cannot leave the TUI showing an uncancellable pending tool.

Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
This commit is contained in:
Kit Langton
2026-07-07 20:01:37 +00:00
parent d0733f83bf
commit ac2e4b2d96
2 changed files with 112 additions and 1 deletions
+3 -1
View File
@@ -832,7 +832,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
const messages = [
...loaded.map((message) => {
if (message.type === "user") return message
return liveByID.get(message.id) ?? message
const live = liveByID.get(message.id)
if (!live || ("completed" in message.time && message.time.completed !== undefined)) return message
return live
}),
...live.filter((message) => !loadedIDs.has(message.id)),
].toSorted((a, b) => a.time.created - b.time.created)
+109
View File
@@ -166,6 +166,115 @@ test("restores running manual compaction before applying live deltas", async ()
}
})
test("refresh replaces stale completed messages with the terminal server projection", async () => {
const events = createEventStream()
const sessionID = "session-stale-tool"
const messageID = "message-stale-tool"
const calls = createFetch((url) => {
if (url.pathname === `/api/session/${sessionID}/message`)
return json({
data: [
{
id: messageID,
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [
{
type: "tool",
id: "call-write",
name: "write",
provider: { executed: false },
state: {
status: "error",
input: {},
content: [],
structured: {},
error: { type: "unknown", message: "Tool execution interrupted" },
},
time: { created: 1, completed: 3 },
},
],
finish: "error",
error: { type: "unknown", message: "Step interrupted" },
time: { created: 1, completed: 3 },
},
],
cursor: {},
})
return undefined
}, events)
let data!: ReturnType<typeof useData>
function Probe() {
data = useData()
return <box />
}
const app = await testRender(() => (
<TestTuiContexts>
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
<ProjectProvider>
<DataProvider>
<Probe />
</DataProvider>
</ProjectProvider>
</SDKProvider>
</TestTuiContexts>
))
try {
emitEvent(events, {
id: "evt_step_started_stale_tool",
created: 1,
type: "session.step.started",
durable: durable(sessionID),
data: {
sessionID,
assistantMessageID: messageID,
agent: "build",
model: { id: "model", providerID: "provider" },
},
})
emitEvent(events, {
id: "evt_tool_started_stale_tool",
created: 2,
type: "session.tool.input.started",
durable: durable(sessionID, 1),
data: {
sessionID,
assistantMessageID: messageID,
callID: "call-write",
name: "write",
},
})
emitEvent(events, {
id: "evt_step_failed_stale_tool",
created: 3,
type: "session.step.failed",
durable: durable(sessionID, 2),
data: {
sessionID,
assistantMessageID: messageID,
error: { type: "unknown", message: "Step interrupted" },
},
})
await wait(() => {
const message = data.session.message.get(sessionID, messageID)
return message?.type === "assistant" && message.time.completed === 3
})
await data.session.message.refresh(sessionID)
const message = data.session.message.get(sessionID, messageID)
expect(message?.type).toBe("assistant")
if (message?.type !== "assistant") return
expect(message.content[0]).toMatchObject({ type: "tool", state: { status: "error" } })
} finally {
app.renderer.destroy()
}
})
test("reconnects the event stream and bootstraps fresh data", async () => {
const events = createEventStream()
const requests = { active: 0, event: 0, model: 0 }