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
21 changes: 21 additions & 0 deletions packages/opencode/src/cli/cmd/run/session-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type SessionCommit = StreamCommit
//
// - ids: parts and error keys we've already committed (dedup guard)
// - tools: tool parts we've emitted a "start" for but not yet completed
// - toolPart: latest full tool payload for active tool parts
// - call: tool call inputs, keyed by msg:call, for enriching permission views
// - role: message ID → "assistant" | "user", learned from message.updated
// - msg: part ID → message ID
Expand All @@ -74,6 +75,7 @@ export type SessionData = {
announced: boolean
ids: Set<string>
tools: Set<string>
toolPart: Map<string, ToolPart>
call: Map<string, Dict>
shell: Map<string, ShellCall>
permissions: PermissionRequest[]
Expand Down Expand Up @@ -112,6 +114,7 @@ export function createSessionData(
announced: false,
ids: new Set(),
tools: new Set(),
toolPart: new Map(),
call: new Map(),
shell: new Map(),
permissions: [],
Expand Down Expand Up @@ -758,6 +761,21 @@ export function flushInterrupted(data: SessionData, commits: SessionCommit[]) {
data.ids.add(partID)
drop(data, partID)
}

for (const partID of data.tools) {
const part = data.toolPart.get(partID)
data.tools.delete(partID)
data.toolPart.delete(partID)
if (!part || data.ids.has(partID)) {
continue
}

data.ids.add(partID)
commits.push({
...failTool(part, "Tool execution interrupted"),
interrupted: true,
})
}
}

// The main reducer. Takes one SDK event and returns scrollback commits and
Expand Down Expand Up @@ -930,6 +948,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
}

if (part.state.status === "running") {
data.toolPart.set(part.id, part)
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
Expand All @@ -946,6 +965,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
const seen = data.tools.has(part.id)
const mode = toolView(part.tool)
data.tools.delete(part.id)
data.toolPart.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
Expand Down Expand Up @@ -982,6 +1002,7 @@ export function reduceSessionData(input: SessionDataInput): SessionDataOutput {
if (part.state.status === "error") {
const seen = data.tools.has(part.id)
data.tools.delete(part.id)
data.toolPart.delete(part.id)
if (data.ids.has(part.id)) {
return out(data, commits, view)
}
Expand Down
94 changes: 94 additions & 0 deletions packages/opencode/src/cli/cmd/run/session-replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, test } from "bun:test"
import { replaySession } from "./session-replay"
import type { SessionMessages } from "./session.shared"

const sessionID = "ses_replay_interrupted"
const userID = "msg_user"
const assistantID = "msg_assistant"

const messages: SessionMessages = [
{
info: {
id: userID,
sessionID,
role: "user",
time: { created: 1 },
agent: "build",
model: { providerID: "provider", modelID: "model" },
},
parts: [{ id: "prt_user", sessionID, messageID: userID, type: "text", text: "check health" }],
},
{
info: {
id: assistantID,
sessionID,
role: "assistant",
time: { created: 2 },
agent: "build",
parentID: userID,
modelID: "model",
providerID: "provider",
mode: "build",
path: { cwd: "/project", root: "/project" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [
{
id: "prt_tool",
sessionID,
messageID: assistantID,
type: "tool",
callID: "call_health",
tool: "bash",
state: {
status: "running",
input: { command: "bun ops prod health" },
title: "bun ops prod health",
time: { start: 3 },
},
},
],
},
]

describe("replaySession", () => {
test("keeps replayed active work running by default", () => {
const replay = replaySession({
messages,
permissions: [],
questions: [],
thinking: true,
limits: {},
})

expect(replay.patch?.phase).toBe("running")
expect(replay.commits).not.toContainEqual(
expect.objectContaining({
partID: "prt_tool",
interrupted: true,
}),
)
})

test("settles replayed active work when the session is no longer running", () => {
const replay = replaySession({
messages,
permissions: [],
questions: [],
thinking: true,
limits: {},
settleActive: true,
})

expect(replay.patch?.phase).toBe("idle")
expect(replay.commits).toContainEqual(
expect.objectContaining({
kind: "tool",
phase: "final",
partID: "prt_tool",
interrupted: true,
}),
)
})
})
13 changes: 12 additions & 1 deletion packages/opencode/src/cli/cmd/run/session-replay.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
import {
bootstrapSessionData,
createSessionData,
flushInterrupted,
reduceSessionData,
type SessionData,
} from "./session-data"
import { messagePrompt, type SessionMessages } from "./session.shared"
import { messageTurnSummaryCommit } from "./turn-summary"
import type { FooterPatch, LocalReplayRow, RunProvider, StreamCommit } from "./types"
Expand All @@ -11,6 +17,7 @@ type ReplayInput = {
thinking: boolean
limits: Record<string, number>
providers?: RunProvider[]
settleActive?: boolean
}

type ReplayConfig = {
Expand Down Expand Up @@ -253,6 +260,10 @@ export function replaySession(input: ReplayInput): SessionReplay {
patch = mergePatch(patch, next.patch)
}

if (input.settleActive) {
flushInterrupted(data, commits)
}

return {
data,
commits,
Expand Down
10 changes: 9 additions & 1 deletion packages/opencode/src/cli/cmd/run/stream.transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -674,7 +674,7 @@ function createLayer(input: StreamInput) {
})

const bootstrap = Effect.fn("RunStreamTransport.bootstrap")(function* () {
const [messagesList, children, permissions, questions] = yield* Effect.all(
const [messagesList, children, permissions, questions, statuses] = yield* Effect.all(
[
messages(
input.sessionID,
Expand All @@ -700,6 +700,10 @@ function createLayer(input: StreamInput) {
Effect.map((item) => item.data ?? []),
Effect.orElseSucceed(() => []),
),
Effect.promise(() => input.sdk.session.status()).pipe(
Effect.map((item): Record<string, { type: string }> => item.data ?? {}),
Effect.orElseSucceed((): Record<string, { type: string }> => ({})),
),
],
{
concurrency: "unbounded",
Expand All @@ -708,6 +712,8 @@ function createLayer(input: StreamInput) {

const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
const status = statuses[input.sessionID]?.type
const settleActive = status !== "busy" && status !== "retry"
const history = input.replay
? replaySession({
messages: messagesList,
Expand All @@ -716,6 +722,7 @@ function createLayer(input: StreamInput) {
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
settleActive,
})
: undefined
const replay =
Expand All @@ -727,6 +734,7 @@ function createLayer(input: StreamInput) {
thinking: input.thinking,
limits: input.limits(),
providers: input.providers?.(),
settleActive,
})
: history

Expand Down
Loading