fix(sync): finalize tool parts orphaned by an interrupted turn after settlement
When a managed OpenCode process dies mid-turn (crash, health-check restart), the persisted turn never settles: the trailing assistant message has no time.completed and its tool parts stay pending/running forever — the server never finalizes them (anomalyco/opencode#19023). The existing settle-triggered tail refresh refetches the same stale records, so the UI kept running tool timers and working styling indefinitely (#2577). Now, when a session is authoritatively settled (session.idle/ session.error event, or an authoritative status snapshot lowering a previously busy session) and the trailing assistant message is still unfinished with active tool parts and no pending question/permission, the orphaned parts are finalized locally as error/"Interrupted" with an end time — the same shape OpenCode itself writes for cancelled tools. The mark is gated on an explicit idle status (absent status is "unknown", never judged), never applies while busy (including question/permission waits), and a later terminal event or refresh supersedes it while a stale running refresh cannot regress it (the reducer and materializer already preserve final statuses). Fixes #2577
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* Tests for interrupted-turn reconciliation (#2577): when a managed OpenCode
|
||||
* process dies mid-turn, the persisted turn never settles — the trailing
|
||||
* assistant message has no time.completed and its tool parts stay running.
|
||||
* Once the session is authoritatively settled, `interruptedTurnToolParts`
|
||||
* finalizes the orphaned parts locally.
|
||||
*/
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
|
||||
import { interruptedTurnToolParts } from "../sync-context"
|
||||
import type { DirectoryStore } from "../child-store"
|
||||
import { INITIAL_STATE } from "../types"
|
||||
|
||||
function state(overrides: Partial<DirectoryStore> = {}): DirectoryStore {
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
session_status: {},
|
||||
message: {},
|
||||
part: {},
|
||||
question: {},
|
||||
permission: {},
|
||||
...overrides,
|
||||
} as unknown as DirectoryStore
|
||||
}
|
||||
|
||||
function runningTool(id: string, messageID: string, start = 1000): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "running", time: { start }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function completedTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "completed", time: { start: 1000, end: 2000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function pendingTool(id: string, messageID: string): Part {
|
||||
return {
|
||||
id,
|
||||
messageID,
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
tool: "bash",
|
||||
state: { status: "pending", time: { start: 1000 }, input: {} },
|
||||
} as unknown as Part
|
||||
}
|
||||
|
||||
function unfinishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10 } } as unknown as Message
|
||||
}
|
||||
|
||||
function finishedAssistantMessage(id: string): Message {
|
||||
return { id, sessionID: "ses_1", role: "assistant", parentID: "", modelID: "", providerID: "", mode: "primary", system: "", agent: "", model: "", time: { created: 10, completed: 2000 } } as unknown as Message
|
||||
}
|
||||
|
||||
describe("interruptedTurnToolParts (#2577)", () => {
|
||||
test("settled session with unfinished message and running tool finalizes the part", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const part = result!.parts[0] as { state: { status: string; error: string; time: { end: number } } }
|
||||
expect(part.state.status).toBe("error")
|
||||
expect(part.state.error).toBe("Interrupted")
|
||||
expect(part.state.time.end).toBe(5000)
|
||||
})
|
||||
|
||||
test("busy session is never marked (live work)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("absent status is unknown, not settled — never marked", () => {
|
||||
const store = state({
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("finished message is not an interruption (tail refresh reconciles it)", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [finishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending question means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
question: { ses_1: [{ id: "q_1", sessionID: "ses_1", questions: [{ question: "?", header: "h", options: [{ label: "a", description: "" }] }] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("pending permission means the turn is waiting for input, not interrupted", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [runningTool("tool_1", "msg_1")] },
|
||||
permission: { ses_1: [{ id: "p_1", sessionID: "ses_1", permission: "bash", patterns: [], metadata: {}, always: [] }] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
|
||||
test("only active parts are finalized; completed parts are untouched", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: {
|
||||
msg_1: [runningTool("tool_1", "msg_1"), completedTool("tool_2", "msg_1"), pendingTool("tool_3", "msg_1")],
|
||||
},
|
||||
})
|
||||
|
||||
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
||||
expect(result).not.toBeNull()
|
||||
const statuses = result!.parts.map((part) => (part as { state: { status: string } }).state.status)
|
||||
expect(statuses).toEqual(["error", "completed", "error"])
|
||||
})
|
||||
|
||||
test("no active parts → no change", () => {
|
||||
const store = state({
|
||||
session_status: { ses_1: { type: "idle" } },
|
||||
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
||||
part: { msg_1: [completedTool("tool_2", "msg_1")] },
|
||||
})
|
||||
expect(interruptedTurnToolParts(store, "ses_1")).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -167,6 +167,39 @@ describe("materializeSessionSnapshots", () => {
|
||||
expect(mergedPart.state?.time?.end).toBe(2000)
|
||||
})
|
||||
|
||||
test("does not regress a locally interrupted tool (error + end) when a stale running snapshot arrives", () => {
|
||||
// The #2577 mark writes status "error" + end time; a later stale refresh
|
||||
// that still reports the part as running must not undo it.
|
||||
const interruptedTool = {
|
||||
id: "prt_1",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
state: { status: "error", error: "Interrupted", time: { start: 1000, end: 5000 } },
|
||||
} as unknown as Part
|
||||
const staleRunningTool = {
|
||||
id: "prt_1",
|
||||
messageID: "msg_1",
|
||||
sessionID: "ses_1",
|
||||
type: "tool",
|
||||
state: { status: "running", time: { start: 1000 } },
|
||||
} as unknown as Part
|
||||
const state = {
|
||||
message: { ses_1: [message("msg_1")] },
|
||||
part: { msg_1: [interruptedTool] },
|
||||
}
|
||||
|
||||
const result = materializeSessionSnapshots(
|
||||
state,
|
||||
"ses_1",
|
||||
[{ info: message("msg_1"), parts: [staleRunningTool] }],
|
||||
)
|
||||
|
||||
expect(result.part.msg_1[0]).toBe(interruptedTool)
|
||||
expect(result.part.msg_1[0]).not.toBe(staleRunningTool)
|
||||
expect((result.part.msg_1[0] as { state: { status: string } }).state.status).toBe("error")
|
||||
})
|
||||
|
||||
test("does not regress a completed tool when a stale running snapshot arrives", () => {
|
||||
const completedTool = {
|
||||
id: "prt_1",
|
||||
|
||||
Reference in New Issue
Block a user