Skip to content

Commit 5970c12

Browse files
authored
run: replay session history on interactive resume (#26880)
1 parent 116a4e3 commit 5970c12

10 files changed

Lines changed: 1119 additions & 75 deletions

File tree

packages/opencode/src/cli/cmd/run.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,15 @@ export const RunCommand = effectCmd({
218218
type: "boolean",
219219
describe: "show thinking blocks",
220220
})
221+
.option("replay", {
222+
type: "boolean",
223+
default: false,
224+
describe: "replay visible session history on interactive resume",
225+
})
226+
.option("replay-limit", {
227+
type: "number",
228+
describe: "cap visible interactive replay to the newest N messages",
229+
})
221230
.option("interactive", {
222231
alias: ["i"],
223232
type: "boolean",
@@ -269,6 +278,21 @@ export const RunCommand = effectCmd({
269278
die("--interactive cannot be used with --format json")
270279
}
271280

281+
if (args.replay && !args.interactive) {
282+
die("--replay requires --interactive")
283+
}
284+
285+
if (args["replay-limit"] !== undefined && !args.interactive) {
286+
die("--replay-limit requires --interactive")
287+
}
288+
289+
if (
290+
args["replay-limit"] !== undefined &&
291+
(!Number.isInteger(args["replay-limit"]) || args["replay-limit"] <= 0)
292+
) {
293+
die("--replay-limit must be a positive integer")
294+
}
295+
272296
if (args.interactive && !process.stdout.isTTY) {
273297
die("--interactive requires a TTY stdout")
274298
}
@@ -281,6 +305,8 @@ export const RunCommand = effectCmd({
281305
}
282306
}
283307

308+
const replay = args.replay || args["replay-limit"] !== undefined
309+
284310
const root = Filesystem.resolve(process.env.PWD ?? process.cwd())
285311
const directory = (() => {
286312
if (!args.dir) return args.attach ? undefined : root
@@ -786,6 +812,8 @@ export const RunCommand = effectCmd({
786812
sessionID,
787813
sessionTitle: sess.title,
788814
resume: Boolean(args.session || args.continue) && !args.fork,
815+
replay,
816+
replayLimit: args["replay-limit"],
789817
agent,
790818
model,
791819
variant: args.variant,
@@ -821,6 +849,8 @@ export const RunCommand = effectCmd({
821849
agent: args.agent,
822850
model,
823851
variant: args.variant,
852+
replay,
853+
replayLimit: args["replay-limit"],
824854
files,
825855
initialInput,
826856
thinking,

packages/opencode/src/cli/cmd/run/runtime.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ type RunRuntimeInput = {
5151
files: RunInput["files"]
5252
initialInput?: string
5353
thinking: boolean
54+
replay?: boolean
55+
replayLimit?: number
5456
demo?: RunInput["demo"]
5557
}
5658

@@ -67,6 +69,8 @@ type RunLocalInput = {
6769
files: RunInput["files"]
6870
initialInput?: string
6971
thinking: boolean
72+
replay?: boolean
73+
replayLimit?: number
7074
demo?: RunInput["demo"]
7175
}
7276

@@ -490,6 +494,8 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
490494
directory: ctx.directory,
491495
sessionID: state.sessionID,
492496
thinking: input.thinking,
497+
replay: input.replay,
498+
replayLimit: input.replayLimit,
493499
limits: () => state.limits,
494500
footer,
495501
trace: log,
@@ -722,6 +728,8 @@ export async function runInteractiveLocalMode(input: RunLocalInput): Promise<voi
722728
files: input.files,
723729
initialInput: input.initialInput,
724730
thinking: input.thinking,
731+
replay: input.replay,
732+
replayLimit: input.replayLimit,
725733
demo: input.demo,
726734
resolveSession: () => {
727735
if (session) {
@@ -774,6 +782,8 @@ export async function runInteractiveMode(input: RunInput & { createSession?: Cre
774782
files: input.files,
775783
initialInput: input.initialInput,
776784
thinking: input.thinking,
785+
replay: input.replay,
786+
replayLimit: input.replayLimit,
777787
demo: input.demo,
778788
boot: async () => ({
779789
sdk: input.sdk,
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
2+
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
3+
import { messagePrompt, type SessionMessages } from "./session.shared"
4+
import type { FooterPatch, StreamCommit } from "./types"
5+
6+
type ReplayInput = {
7+
messages: SessionMessages
8+
permissions: PermissionRequest[]
9+
questions: QuestionRequest[]
10+
thinking: boolean
11+
limits: Record<string, number>
12+
}
13+
14+
export type SessionReplay = {
15+
data: SessionData
16+
commits: StreamCommit[]
17+
patch?: FooterPatch
18+
}
19+
20+
type ReplayMessage = {
21+
commits: StreamCommit[]
22+
patch?: FooterPatch
23+
}
24+
25+
function apply(data: SessionData, event: Event, sessionID: string, thinking: boolean, limits: Record<string, number>) {
26+
return reduceSessionData({
27+
data,
28+
event,
29+
sessionID,
30+
thinking,
31+
limits,
32+
})
33+
}
34+
35+
function mergePatch(left: FooterPatch | undefined, right: FooterPatch | undefined) {
36+
if (!left) {
37+
return right
38+
}
39+
40+
if (!right) {
41+
return left
42+
}
43+
44+
return {
45+
...left,
46+
...right,
47+
}
48+
}
49+
50+
function active(data: SessionData) {
51+
return data.part.size > 0 || data.tools.size > 0
52+
}
53+
54+
function replayPatch(data: SessionData, patch: FooterPatch | undefined) {
55+
if (active(data)) {
56+
if (!patch) {
57+
return {
58+
phase: "running",
59+
} satisfies FooterPatch
60+
}
61+
62+
return {
63+
...patch,
64+
phase: "running",
65+
} satisfies FooterPatch
66+
}
67+
68+
if (data.permissions.length > 0 || data.questions.length > 0) {
69+
if (!patch) {
70+
return {
71+
phase: "idle",
72+
} satisfies FooterPatch
73+
}
74+
75+
return {
76+
...patch,
77+
phase: "idle",
78+
} satisfies FooterPatch
79+
}
80+
81+
if (!patch) {
82+
return undefined
83+
}
84+
85+
return {
86+
...patch,
87+
phase: "idle",
88+
status: "",
89+
} satisfies FooterPatch
90+
}
91+
92+
function replayMessage(
93+
data: SessionData,
94+
message: SessionMessages[number],
95+
thinking: boolean,
96+
limits: Record<string, number>,
97+
): ReplayMessage {
98+
if (message.info.role === "user") {
99+
const prompt = messagePrompt(message)
100+
if (!prompt.text.trim()) {
101+
return {
102+
commits: [],
103+
}
104+
}
105+
106+
return {
107+
commits: [
108+
{
109+
kind: "user",
110+
text: prompt.text,
111+
phase: "start",
112+
source: "system",
113+
messageID: message.info.id,
114+
},
115+
],
116+
}
117+
}
118+
119+
const commits: StreamCommit[] = []
120+
let patch: FooterPatch | undefined
121+
122+
const info = apply(
123+
data,
124+
{
125+
id: `bootstrap:message:${message.info.id}`,
126+
type: "message.updated",
127+
properties: {
128+
sessionID: message.info.sessionID,
129+
info: message.info,
130+
},
131+
},
132+
message.info.sessionID,
133+
thinking,
134+
limits,
135+
)
136+
commits.push(...info.commits)
137+
patch = mergePatch(patch, info.footer?.patch)
138+
139+
for (const part of message.parts) {
140+
const next = apply(
141+
data,
142+
{
143+
id: `bootstrap:part:${part.id}`,
144+
type: "message.part.updated",
145+
properties: {
146+
sessionID: part.sessionID,
147+
part,
148+
time: 0,
149+
},
150+
},
151+
message.info.sessionID,
152+
thinking,
153+
limits,
154+
)
155+
patch = mergePatch(patch, next.footer?.patch)
156+
commits.push(...next.commits)
157+
}
158+
159+
return {
160+
commits,
161+
patch,
162+
}
163+
}
164+
165+
export function replaySession(input: ReplayInput): SessionReplay {
166+
const data = createSessionData()
167+
const commits: StreamCommit[] = []
168+
let patch: FooterPatch | undefined
169+
170+
bootstrapSessionData({
171+
data,
172+
messages: input.messages,
173+
permissions: input.permissions,
174+
questions: input.questions,
175+
})
176+
177+
for (const message of input.messages) {
178+
const next = replayMessage(data, message, input.thinking, input.limits)
179+
commits.push(...next.commits)
180+
patch = mergePatch(patch, next.patch)
181+
}
182+
183+
return {
184+
data,
185+
commits,
186+
patch: replayPatch(data, patch),
187+
}
188+
}

packages/opencode/src/cli/cmd/run/session.shared.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ function fileSource(
6060
}
6161
}
6262

63-
function prompt(msg: SessionMessages[number]): RunPrompt {
63+
export function messagePrompt(msg: SessionMessages[number]): RunPrompt {
6464
const parts: RunPrompt["parts"] = []
6565
let text = msg.parts
6666
.filter((part): part is Extract<SessionMessages[number]["parts"][number], { type: "text" }> => {
@@ -135,7 +135,7 @@ function turn(msg: SessionMessages[number]): Turn | undefined {
135135
}
136136

137137
return {
138-
prompt: prompt(msg),
138+
prompt: messagePrompt(msg),
139139
provider: msg.info.model.providerID,
140140
model: msg.info.model.modelID,
141141
variant: msg.info.model.variant,

0 commit comments

Comments
 (0)