Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/opencode/src/session/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -935,7 +935,7 @@ export namespace Session {
// Complete orphaned assistant messages that never got time.completed
const msgs = Database.use((db) =>
db
.select({ id: MessageTable.id, updated: MessageTable.time_updated })
.select({ id: MessageTable.id, data: MessageTable.data, updated: MessageTable.time_updated })
.from(MessageTable)
.where(
and(
Expand All @@ -948,10 +948,25 @@ export namespace Session {
if (msgs.length > 0) {
log.info("recovering orphaned assistant messages", { count: msgs.length })
for (const msg of msgs) {
const data = msg.data as MessageV2.Assistant
if (data?.role !== "assistant" || !data?.time) continue
const patched: MessageV2.Assistant = {
...data,
role: "assistant",
time: {
...data.time,
completed: msg.updated,
},
error:
data.error ??
new MessageV2.AbortedError({
message: "Server restarted while the response was in progress",
}).toObject(),
}
Database.use((db) =>
db
.update(MessageTable)
.set({ data: sql`json_set(${MessageTable.data}, '$.time.completed', ${msg.updated})` })
.set({ data: patched })
.where(eq(MessageTable.id, msg.id))
.run(),
)
Expand Down
74 changes: 74 additions & 0 deletions packages/opencode/test/session/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,77 @@ describe("step-finish token propagation via Bus event", () => {
{ timeout: 30000 },
)
})

describe("session recovery", () => {
test("marks orphaned assistant messages as aborted on restart recovery", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const session = await Session.create({})
const user = await Session.updateMessage({
id: MessageID.ascending(),
sessionID: session.id,
role: "user",
time: { created: Date.now() },
agent: "user",
model: { providerID: "test", modelID: "test" },
tools: {},
mode: "",
} as unknown as MessageV2.Info)

const assistant = await Session.updateMessage({
id: MessageID.ascending(),
sessionID: session.id,
role: "assistant",
time: { created: Date.now() },
parentID: user.id,
modelID: "test",
providerID: "test",
mode: "build",
agent: "build",
path: { cwd: projectRoot, root: projectRoot },
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
} as unknown as MessageV2.Info)

await Session.updatePart({
id: PartID.ascending(),
messageID: assistant.id,
sessionID: session.id,
type: "tool",
tool: "bash",
callID: "call-recover",
state: {
status: "running",
input: { command: "echo stuck" },
time: { start: Date.now() },
},
} satisfies MessageV2.ToolPart)

Session.recover()

const recovered = await MessageV2.get({
sessionID: session.id,
messageID: assistant.id,
})
const tool = recovered.parts.find((part): part is MessageV2.ToolPart => part.type === "tool")

expect(recovered.info.role).toBe("assistant")
if (recovered.info.role !== "assistant") throw new Error("expected recovered assistant message")
expect(recovered.info.time.completed).toBeDefined()
expect(recovered.info.error?.name).toBe("MessageAbortedError")
expect(recovered.info.error?.data.message).toContain("Server restarted")
expect(tool?.state.status).toBe("error")
if (!tool || tool.state.status !== "error") throw new Error("expected recovered tool error")
expect(tool.state.error).toBe("Tool execution aborted: server restarted")

await Session.remove(session.id)
},
})
})
})
Loading