* fix: reconcile busy sessions after managed OpenCode restart Forced health-check restarts previously rebound the event stream without settling in-flight turns, so sessions stayed busy with no terminal state. Interrupt those sessions, classify health failures, and retain bounded process diagnostics for post-restart diagnosis. Fixes #2943 Co-authored-by: serkraser <serkraser@gmail.com> * fix: surface interrupted chats after OpenCode restart Complete unfinished assistant turns as aborted once the session is authoritatively idle, and show a persistent toast so users can continue instead of remaining silently stranded. Fixes #2943 Co-authored-by: serkraser <serkraser@gmail.com> * fix: redact Basic auth credentials in restart diagnostics The key/value sanitizer stopped at whitespace, so Authorization: Basic credentials survived in stderr tails and health snapshots. Redact the scheme token before that rule runs. Co-authored-by: serkraser <serkraser@gmail.com>
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
/**
|
|
* 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`
|
|
* completes the assistant message as aborted and finalizes orphaned parts.
|
|
*/
|
|
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)
|
|
expect(result!.messages[0]).toEqual({
|
|
...unfinishedAssistantMessage("msg_1"),
|
|
time: { created: 10, completed: 5000 },
|
|
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
|
})
|
|
})
|
|
|
|
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("unfinished assistant with no tools is completed as aborted", () => {
|
|
const store = state({
|
|
session_status: { ses_1: { type: "idle" } },
|
|
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
|
part: {},
|
|
})
|
|
|
|
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.parts).toBe(undefined)
|
|
expect(result!.messages[0]).toEqual({
|
|
...unfinishedAssistantMessage("msg_1"),
|
|
time: { created: 10, completed: 5000 },
|
|
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
|
})
|
|
})
|
|
|
|
test("completed tools are untouched while the unfinished assistant is aborted", () => {
|
|
const completed = completedTool("tool_2", "msg_1")
|
|
const store = state({
|
|
session_status: { ses_1: { type: "idle" } },
|
|
message: { ses_1: [unfinishedAssistantMessage("msg_1")] },
|
|
part: { msg_1: [completed] },
|
|
})
|
|
|
|
const result = interruptedTurnToolParts(store, "ses_1", 5000)
|
|
expect(result).not.toBeNull()
|
|
expect(result!.parts).toBe(undefined)
|
|
expect(store.part.msg_1[0]).toBe(completed)
|
|
expect(result!.messages[0]).toEqual({
|
|
...unfinishedAssistantMessage("msg_1"),
|
|
time: { created: 10, completed: 5000 },
|
|
error: { name: "MessageAbortedError", data: { message: "aborted" }, message: "aborted" },
|
|
})
|
|
})
|
|
})
|