add run --replay mode (#30239)
This commit is contained in:
30
bun.lock
30
bun.lock
@@ -611,9 +611,9 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.3.0",
|
"@opentui/core": ">=0.3.1",
|
||||||
"@opentui/keymap": ">=0.3.0",
|
"@opentui/keymap": ">=0.3.1",
|
||||||
"@opentui/solid": ">=0.3.0",
|
"@opentui/solid": ">=0.3.1",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -862,9 +862,9 @@
|
|||||||
"@npmcli/arborist": "9.4.0",
|
"@npmcli/arborist": "9.4.0",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@opentui/core": "0.3.0",
|
"@opentui/core": "0.3.1",
|
||||||
"@opentui/keymap": "0.3.0",
|
"@opentui/keymap": "0.3.1",
|
||||||
"@opentui/solid": "0.3.0",
|
"@opentui/solid": "0.3.1",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.59.1",
|
||||||
"@sentry/solid": "10.36.0",
|
"@sentry/solid": "10.36.0",
|
||||||
@@ -1758,23 +1758,23 @@
|
|||||||
|
|
||||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.3.0", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.0", "@opentui/core-darwin-x64": "0.3.0", "@opentui/core-linux-arm64": "0.3.0", "@opentui/core-linux-x64": "0.3.0", "@opentui/core-win32-arm64": "0.3.0", "@opentui/core-win32-x64": "0.3.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wvNESYGYGRLuvarZ3QY4CTB+BziZ/j6Snd9qRKD4fQ7SF6G4UpYElLTFrg7uzRo1v7WJTqbquymcTvWEHMnpYA=="],
|
"@opentui/core": ["@opentui/core@0.3.1", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.1", "@opentui/core-darwin-x64": "0.3.1", "@opentui/core-linux-arm64": "0.3.1", "@opentui/core-linux-x64": "0.3.1", "@opentui/core-win32-arm64": "0.3.1", "@opentui/core-win32-x64": "0.3.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-kQFSsSCgtlasSqTigCgKmM67xaquGvTg+vwimDnFSZtcBEt4E3dz7qLrbeh5FVvTA+RMbwe+Bozq03PW+SgjXw=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/eDfAcutAHJqR9spwHMLuo6LMqngymev/m+i6uqlk98gX1EJiJe2pJ16sKbp3RctgH/Gz/8TYOhVHpPGYJl7yQ=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krvVfiBpeBY+727R8yogdqIcxkK3RUVcI97bqjl8jTeDMcWOkFFfHezssRMPmbR5x++1tX669Fz3fuxoe7XUIg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-/j6EWAvdwhz1wU/mWfXepAf3+NuMYz2Ic5ozaid5LdwIpPomIkM9yCUDm76mQhRBbjsAl/7UeSeUA0qSCMSZBg=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-D/6ec5H8SPpSBMr01/sqgSddIl1Qc1QMKsDl/wV5MpbxYc7Qvie9qlNvvoSsWNfAXAbafLRb1jQBzouk41cp1w=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-uUFVT3V35KkM1m8gaLmRcTV9dsJzXnxwM+dv6+NjScx0W/Y0CJKbW9wDYwnLyPnBNgaFUi171zmJra5gTtFTsw=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-E/FFBoAsWJyS/EO/cF7h7DuEENYa9nAdSv1W/TIyKXpBisN6K3U1Xgbk528TkfWjrwJjhGs+9OMYdXuAHd5LTw=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-73bNNNU2OaqZQLIlvzDOdAzQmzBAqf+cSilmJ+Y9JnybrBn1d6VShC66+V4xxIgonq1swk7BD+SUHYbwwGilQA=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Btb7Q4BOC55Aj2qCs0VoxGuj87DNfUEaSx0z89oeU4npTN+6SpJApyGZTCNNeSe2sdmOGeh/8eAR4X96ORjcKg=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jg5KrV/4mVQ0mdkcL9CtQVtBk0NAtQ+2rCKoZ/jNHB6GxGK0ot9vDV6P3X68hZVkvpb2pdXfg6GRsZJ+Np4hZA=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-+lt24u3KwEPG69oXDOLz9N484wPcAHvrPbDNU77OT6DvWew+StAjh40eY+Zeu0TkTNDWfj7qnQKV0GKWtFA3cw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-kiM3C5bwQBTfrJKAOfb+L3U6MMkPSQlMhAERlLMjqSurc+llcyqygr/wbXSvfAqJtKlIpf3MKJRnVFTyfRIdng=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.1", "", { "os": "win32", "cpu": "x64" }, "sha512-eVkKMYirYgpn92lI0YT/GKru4J+UiXjzwyzNRFX+P59OHXvL3GFdqJMcJmX4/zvyjg4c8HDnU79YLnyG+TlXLw=="],
|
||||||
|
|
||||||
"@opentui/keymap": ["@opentui/keymap@0.3.0", "", { "dependencies": { "@opentui/core": "0.3.0" }, "peerDependencies": { "@opentui/react": "0.3.0", "@opentui/solid": "0.3.0", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-lJN57DanKujy3u0IhfSMCShvXIobRjhprdkrdM3brQoX6wxk7gTFE8fTCCz9z1nINkXNsKHQ6grZO1dsT/0mzA=="],
|
"@opentui/keymap": ["@opentui/keymap@0.3.1", "", { "dependencies": { "@opentui/core": "0.3.1" }, "peerDependencies": { "@opentui/react": "0.3.1", "@opentui/solid": "0.3.1", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-BTj+ggsarO2uyvd6CWzvgfsekA8c4aEclbAPKPZGVjBI3Fo5+KAHUrXvteFO5qpGMANfEJTtVHoRu5cic1Nlaw=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.3.0", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.0", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-AUtNzvgkdW81Ftl0sahAy3tY1LIPSMzBw3APBC8jiDAzzPv4kYVdyWXryTxLbU2q+Pgtr57VwKwHgc5wsNrd2w=="],
|
"@opentui/solid": ["@opentui/solid@0.3.1", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.1", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2R6wEijfMub9COTBCm8IKVj2y7+Sc4fZZjJawxk8sE6+++mzeUaokKNJTlYhZXpMju4LKMv6j9CjWkG8JYfbcg=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
@@ -38,9 +38,9 @@
|
|||||||
"@types/cross-spawn": "6.0.6",
|
"@types/cross-spawn": "6.0.6",
|
||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.3.0",
|
"@opentui/core": "0.3.1",
|
||||||
"@opentui/keymap": "0.3.0",
|
"@opentui/keymap": "0.3.1",
|
||||||
"@opentui/solid": "0.3.0",
|
"@opentui/solid": "0.3.1",
|
||||||
"ulid": "3.0.1",
|
"ulid": "3.0.1",
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
"@types/luxon": "3.7.1",
|
"@types/luxon": "3.7.1",
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ export const RunCommand = effectCmd({
|
|||||||
.option("replay", {
|
.option("replay", {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
default: false,
|
default: false,
|
||||||
describe: "replay visible session history on interactive resume",
|
describe: "replay interactive session history on resume and after resize",
|
||||||
})
|
})
|
||||||
.option("replay-limit", {
|
.option("replay-limit", {
|
||||||
type: "number",
|
type: "number",
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export class RunFooter implements FooterApi {
|
|||||||
private queue: StreamCommit[] = []
|
private queue: StreamCommit[] = []
|
||||||
private pending = false
|
private pending = false
|
||||||
private flushing: Promise<void> = Promise.resolve()
|
private flushing: Promise<void> = Promise.resolve()
|
||||||
|
private flushError: unknown
|
||||||
// Fixed portion of footer height above the textarea.
|
// Fixed portion of footer height above the textarea.
|
||||||
private base: number
|
private base: number
|
||||||
private rows = TEXTAREA_MIN_ROWS
|
private rows = TEXTAREA_MIN_ROWS
|
||||||
@@ -204,6 +205,15 @@ export class RunFooter implements FooterApi {
|
|||||||
private requestExitHandler: (() => boolean) | undefined
|
private requestExitHandler: (() => boolean) | undefined
|
||||||
private scrollback: RunScrollbackStream
|
private scrollback: RunScrollbackStream
|
||||||
|
|
||||||
|
private createScrollback(wrote: boolean): RunScrollbackStream {
|
||||||
|
return new RunScrollbackStream(this.renderer, this.options.theme, {
|
||||||
|
diffStyle: this.options.diffStyle,
|
||||||
|
wrote,
|
||||||
|
sessionID: this.options.sessionID,
|
||||||
|
treeSitterClient: this.options.treeSitterClient,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private renderer: CliRenderer,
|
private renderer: CliRenderer,
|
||||||
private options: RunFooterOptions,
|
private options: RunFooterOptions,
|
||||||
@@ -257,12 +267,7 @@ export class RunFooter implements FooterApi {
|
|||||||
this.queuedPrompts = queuedPrompts
|
this.queuedPrompts = queuedPrompts
|
||||||
this.setQueuedPrompts = setQueuedPrompts
|
this.setQueuedPrompts = setQueuedPrompts
|
||||||
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
this.base = Math.max(1, renderer.footerHeight - TEXTAREA_MIN_ROWS)
|
||||||
this.scrollback = new RunScrollbackStream(renderer, options.theme, {
|
this.scrollback = this.createScrollback(options.wrote ?? false)
|
||||||
diffStyle: options.diffStyle,
|
|
||||||
wrote: options.wrote,
|
|
||||||
sessionID: options.sessionID,
|
|
||||||
treeSitterClient: options.treeSitterClient,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
this.renderer.on(CliRenderEvents.DESTROY, this.handleDestroy)
|
||||||
|
|
||||||
@@ -465,7 +470,9 @@ export class RunFooter implements FooterApi {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch(() => {})
|
.catch((error) => {
|
||||||
|
this.flushError = error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private present(view: FooterView): void {
|
private present(view: FooterView): void {
|
||||||
@@ -523,6 +530,12 @@ export class RunFooter implements FooterApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.flushing.then(async () => {
|
return this.flushing.then(async () => {
|
||||||
|
if (this.flushError !== undefined) {
|
||||||
|
const error = this.flushError
|
||||||
|
this.flushError = undefined
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
if (this.isGone) {
|
if (this.isGone) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -535,6 +548,15 @@ export class RunFooter implements FooterApi {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public resetForReplay(wrote: boolean): void {
|
||||||
|
if (this.isGone) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollback.destroy()
|
||||||
|
this.scrollback = this.createScrollback(wrote)
|
||||||
|
}
|
||||||
|
|
||||||
public close(): void {
|
public close(): void {
|
||||||
if (this.closed) {
|
if (this.closed) {
|
||||||
return
|
return
|
||||||
@@ -936,6 +958,8 @@ export class RunFooter implements FooterApi {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.catch(() => {})
|
.catch((error) => {
|
||||||
|
this.flushError = error
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
//
|
//
|
||||||
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
// Also wires SIGINT so Ctrl-c clears a live prompt draft first, then falls
|
||||||
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
// back to the usual two-press exit sequence through RunFooter.requestExit().
|
||||||
import { createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
import { CliRenderEvents, createCliRenderer, type CliRenderer, type ScrollbackWriter } from "@opentui/core"
|
||||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||||
import { Session as SessionApi } from "@/session/session"
|
import { Session as SessionApi } from "@/session/session"
|
||||||
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
import { registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
|
||||||
@@ -75,6 +75,8 @@ export type LifecycleInput = {
|
|||||||
|
|
||||||
export type Lifecycle = {
|
export type Lifecycle = {
|
||||||
footer: FooterApi
|
footer: FooterApi
|
||||||
|
onResize(fn: () => void): () => void
|
||||||
|
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +309,46 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
footer,
|
footer,
|
||||||
|
onResize(fn) {
|
||||||
|
let width = renderer.terminalWidth
|
||||||
|
let height = renderer.terminalHeight
|
||||||
|
const resize = () => {
|
||||||
|
if (width === renderer.terminalWidth && height === renderer.terminalHeight) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
width = renderer.terminalWidth
|
||||||
|
height = renderer.terminalHeight
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
renderer.on(CliRenderEvents.RESIZE, resize)
|
||||||
|
return () => renderer.off(CliRenderEvents.RESIZE, resize)
|
||||||
|
},
|
||||||
|
async resetForReplay(next) {
|
||||||
|
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||||
|
throw new Error("runtime closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
await footer.idle()
|
||||||
|
if (closed || renderer.isDestroyed || footer.isClosed) {
|
||||||
|
throw new Error("runtime closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
footer.resetForReplay(true)
|
||||||
|
renderer.resetSplitFooterForReplay({ clearSavedLines: true })
|
||||||
|
const splash = splashInfo(next.sessionTitle ?? input.sessionTitle, next.history)
|
||||||
|
renderer.writeToScrollback(
|
||||||
|
entrySplash({
|
||||||
|
...splashMeta({
|
||||||
|
title: splash.title,
|
||||||
|
session_id: next.sessionID ?? input.getSessionID?.() ?? input.sessionID,
|
||||||
|
}),
|
||||||
|
theme: theme.splash,
|
||||||
|
showSession: splash.showSession,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
renderer.requestRender()
|
||||||
|
},
|
||||||
close,
|
close,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -162,7 +162,14 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
state.active = prompt
|
const sent =
|
||||||
|
prompt.mode === "shell"
|
||||||
|
? prompt
|
||||||
|
: {
|
||||||
|
...prompt,
|
||||||
|
messageID: prompt.messageID ?? queued?.messageID ?? MessageID.ascending(),
|
||||||
|
}
|
||||||
|
state.active = sent
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
{
|
{
|
||||||
@@ -185,18 +192,24 @@ export async function runPromptQueue(input: QueueInput): Promise<void> {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prompt.mode !== "shell") {
|
if (sent.mode !== "shell") {
|
||||||
const commit = { kind: "user", text: prompt.text, phase: "start", source: "system" } as const
|
const commit = {
|
||||||
|
kind: "user",
|
||||||
|
text: sent.text,
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: sent.messageID,
|
||||||
|
} as const
|
||||||
input.trace?.write("ui.commit", commit)
|
input.trace?.write("ui.commit", commit)
|
||||||
input.footer.append(commit)
|
input.footer.append(commit)
|
||||||
}
|
}
|
||||||
input.onSend?.(prompt)
|
input.onSend?.(sent)
|
||||||
|
|
||||||
if (state.closed) {
|
if (state.closed) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
const task = input.run(prompt, ctrl.signal).then(
|
const task = input.run(sent, ctrl.signal).then(
|
||||||
() => ({ type: "done" as const }),
|
() => ({ type: "done" as const }),
|
||||||
(error) => ({ type: "error" as const, error }),
|
(error) => ({ type: "error" as const, error }),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,13 +14,14 @@
|
|||||||
// 4. runs the prompt queue until the footer closes.
|
// 4. runs the prompt queue until the footer closes.
|
||||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { MessageID } from "@/session/schema"
|
||||||
import { createRunDemo } from "./demo"
|
import { createRunDemo } from "./demo"
|
||||||
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
import { resolveModelInfo, resolveRunTuiConfig, resolveSessionInfo } from "./runtime.boot"
|
||||||
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
import { createRuntimeLifecycle } from "./runtime.lifecycle"
|
||||||
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
import { recordRunSpanError, setRunSpanAttributes, withRunSpan } from "./otel"
|
||||||
import { trace } from "./trace"
|
import { trace } from "./trace"
|
||||||
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
import { cycleVariant, formatModelLabel, resolveSavedVariant, resolveVariant, saveVariant } from "./variant.shared"
|
||||||
import type { RunInput, RunPrompt, RunProvider } from "./types"
|
import type { LocalReplayAnchor, LocalReplayRow, RunInput, RunPrompt, RunProvider, StreamCommit } from "./types"
|
||||||
|
|
||||||
/** @internal Exported for testing */
|
/** @internal Exported for testing */
|
||||||
export { pickVariant, resolveVariant } from "./variant.shared"
|
export { pickVariant, resolveVariant } from "./variant.shared"
|
||||||
@@ -114,6 +115,7 @@ type RuntimeState = {
|
|||||||
activeVariant: string | undefined
|
activeVariant: string | undefined
|
||||||
sessionID: string
|
sessionID: string
|
||||||
history: RunPrompt[]
|
history: RunPrompt[]
|
||||||
|
localRows: LocalReplayRow[]
|
||||||
sessionTitle?: string
|
sessionTitle?: string
|
||||||
agent: string | undefined
|
agent: string | undefined
|
||||||
switching?: Promise<void>
|
switching?: Promise<void>
|
||||||
@@ -139,6 +141,9 @@ function variantsFor(providers: RunProvider[], model: RunInput["model"]) {
|
|||||||
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
return Object.keys(providers.find((item) => item.id === model.providerID)?.models?.[model.modelID]?.variants ?? {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REPLAY_RESIZE_DELAY = 250
|
||||||
|
const LOCAL_REPLAY_ROW_LIMIT = 100
|
||||||
|
|
||||||
async function resolveExitTitle(
|
async function resolveExitTitle(
|
||||||
ctx: BootContext,
|
ctx: BootContext,
|
||||||
input: RunRuntimeInput,
|
input: RunRuntimeInput,
|
||||||
@@ -196,6 +201,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
activeVariant: resolveVariant(ctx.variant, session.variant, savedVariant, []),
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
history: [...session.history],
|
history: [...session.history],
|
||||||
|
localRows: [],
|
||||||
sessionTitle: ctx.sessionTitle,
|
sessionTitle: ctx.sessionTitle,
|
||||||
agent: ctx.agent,
|
agent: ctx.agent,
|
||||||
}
|
}
|
||||||
@@ -374,6 +380,9 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
const footer = shell.footer
|
const footer = shell.footer
|
||||||
|
const rememberLocal = (commit: StreamCommit, after?: LocalReplayAnchor) => {
|
||||||
|
state.localRows = [...state.localRows, { commit, after }].slice(-LOCAL_REPLAY_ROW_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
const loadCatalog = async (): Promise<void> => {
|
const loadCatalog = async (): Promise<void> => {
|
||||||
if (footer.isClosed) {
|
if (footer.isClosed) {
|
||||||
@@ -510,6 +519,36 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let replayResizeTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const offResize = input.replay
|
||||||
|
? shell.onResize(() => {
|
||||||
|
if (replayResizeTimer) {
|
||||||
|
clearTimeout(replayResizeTimer)
|
||||||
|
}
|
||||||
|
|
||||||
|
replayResizeTimer = setTimeout(() => {
|
||||||
|
replayResizeTimer = undefined
|
||||||
|
if (footer.isClosed || !state.stream) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
void state.stream
|
||||||
|
.then((item) =>
|
||||||
|
item.handle.replayOnResize({
|
||||||
|
localRows: () => state.localRows,
|
||||||
|
reset: () =>
|
||||||
|
shell.resetForReplay({
|
||||||
|
sessionTitle: state.sessionTitle,
|
||||||
|
sessionID: state.sessionID,
|
||||||
|
history: state.history,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.catch(() => {})
|
||||||
|
}, REPLAY_RESIZE_DELAY)
|
||||||
|
})
|
||||||
|
: () => {}
|
||||||
|
|
||||||
const runQueue = async () => {
|
const runQueue = async () => {
|
||||||
let includeFiles = true
|
let includeFiles = true
|
||||||
if (state.demo) {
|
if (state.demo) {
|
||||||
@@ -525,6 +564,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
onSend: (prompt) => {
|
onSend: (prompt) => {
|
||||||
state.shown = true
|
state.shown = true
|
||||||
state.history.push(prompt)
|
state.history.push(prompt)
|
||||||
|
if (prompt.mode !== "shell") {
|
||||||
|
rememberLocal({
|
||||||
|
kind: "user",
|
||||||
|
text: prompt.text,
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: prompt.messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onNewSession: createSession
|
onNewSession: createSession
|
||||||
? async () => {
|
? async () => {
|
||||||
@@ -545,6 +593,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
state.sessionTitle = created.sessionTitle
|
state.sessionTitle = created.sessionTitle
|
||||||
state.agent = created.agent ?? state.agent
|
state.agent = created.agent ?? state.agent
|
||||||
state.history = []
|
state.history = []
|
||||||
|
state.localRows = []
|
||||||
includeFiles = true
|
includeFiles = true
|
||||||
state.demo = input.demo
|
state.demo = input.demo
|
||||||
? createRunDemo({
|
? createRunDemo({
|
||||||
@@ -598,12 +647,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
status: "failed to start new session",
|
status: "failed to start new session",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
footer.append({
|
const commit = {
|
||||||
kind: "error",
|
kind: "error",
|
||||||
text: error instanceof Error ? error.message : String(error),
|
text: error instanceof Error ? error.message : String(error),
|
||||||
phase: "start",
|
phase: "start",
|
||||||
source: "system",
|
source: "system",
|
||||||
})
|
messageID: MessageID.ascending(),
|
||||||
|
} as const
|
||||||
|
rememberLocal(commit)
|
||||||
|
footer.append(commit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -614,6 +666,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
|
|
||||||
await state.switching?.catch(() => {})
|
await state.switching?.catch(() => {})
|
||||||
|
|
||||||
|
let outputAnchor: LocalReplayAnchor | undefined
|
||||||
return withRunSpan(
|
return withRunSpan(
|
||||||
"RunInteractive.turn",
|
"RunInteractive.turn",
|
||||||
{
|
{
|
||||||
@@ -644,8 +697,16 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
prompt,
|
prompt,
|
||||||
files: input.files,
|
files: input.files,
|
||||||
includeFiles,
|
includeFiles,
|
||||||
|
onVisibleOutput: (anchor) => {
|
||||||
|
outputAnchor = anchor
|
||||||
|
},
|
||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
|
if (prompt.messageID) {
|
||||||
|
state.localRows = state.localRows.filter(
|
||||||
|
(row) => row.commit.kind !== "user" || row.commit.messageID !== prompt.messageID,
|
||||||
|
)
|
||||||
|
}
|
||||||
includeFiles = false
|
includeFiles = false
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (signal.aborted || footer.isClosed) {
|
if (signal.aborted || footer.isClosed) {
|
||||||
@@ -656,7 +717,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
const text =
|
const text =
|
||||||
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
(await state.stream?.then((item) => item.mod).catch(() => undefined))?.formatUnknownError(error) ??
|
||||||
(error instanceof Error ? error.message : String(error))
|
(error instanceof Error ? error.message : String(error))
|
||||||
footer.append({ kind: "error", text, phase: "start", source: "system" })
|
const commit = {
|
||||||
|
kind: "error",
|
||||||
|
text,
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: prompt.messageID,
|
||||||
|
} as const
|
||||||
|
rememberLocal(commit, outputAnchor)
|
||||||
|
footer.append(commit)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -683,6 +752,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
await runQueue()
|
await runQueue()
|
||||||
} finally {
|
} finally {
|
||||||
|
if (replayResizeTimer) {
|
||||||
|
clearTimeout(replayResizeTimer)
|
||||||
|
}
|
||||||
|
offResize()
|
||||||
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
await state.stream?.then((item) => item.handle.close()).catch(() => {})
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ type SessionCommit = StreamCommit
|
|||||||
// - part: part ID → "assistant" | "reasoning" (text parts only)
|
// - part: part ID → "assistant" | "reasoning" (text parts only)
|
||||||
// - text: part ID → full accumulated text so far
|
// - text: part ID → full accumulated text so far
|
||||||
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
// - sent: part ID → byte offset of last flushed text (for incremental output)
|
||||||
|
// - visible: part ID → rendered text for an active part after display transforms
|
||||||
// - end: part IDs whose time.end has arrived (part is finished)
|
// - end: part IDs whose time.end has arrived (part is finished)
|
||||||
// - shell: shell call ID → chosen transcript source for direct shell calls
|
// - shell: shell call ID → chosen transcript source for direct shell calls
|
||||||
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
// - echo: message ID → bash outputs to strip from the next assistant chunk
|
||||||
@@ -82,6 +83,7 @@ export type SessionData = {
|
|||||||
part: Map<string, PartKind>
|
part: Map<string, PartKind>
|
||||||
text: Map<string, string>
|
text: Map<string, string>
|
||||||
sent: Map<string, number>
|
sent: Map<string, number>
|
||||||
|
visible: Map<string, string>
|
||||||
end: Set<string>
|
end: Set<string>
|
||||||
echo: Map<string, Set<string>>
|
echo: Map<string, Set<string>>
|
||||||
}
|
}
|
||||||
@@ -119,6 +121,7 @@ export function createSessionData(
|
|||||||
part: new Map(),
|
part: new Map(),
|
||||||
text: new Map(),
|
text: new Map(),
|
||||||
sent: new Map(),
|
sent: new Map(),
|
||||||
|
visible: new Map(),
|
||||||
end: new Set(),
|
end: new Set(),
|
||||||
echo: new Map(),
|
echo: new Map(),
|
||||||
}
|
}
|
||||||
@@ -538,6 +541,7 @@ function flushPart(data: SessionData, commits: SessionCommit[], partID: string,
|
|||||||
|
|
||||||
if (chunk) {
|
if (chunk) {
|
||||||
data.sent.set(partID, text.length)
|
data.sent.set(partID, text.length)
|
||||||
|
data.visible.set(partID, (data.visible.get(partID) ?? "") + chunk)
|
||||||
commits.push({
|
commits.push({
|
||||||
kind,
|
kind,
|
||||||
text: chunk,
|
text: chunk,
|
||||||
@@ -567,6 +571,7 @@ function drop(data: SessionData, partID: string) {
|
|||||||
data.part.delete(partID)
|
data.part.delete(partID)
|
||||||
data.text.delete(partID)
|
data.text.delete(partID)
|
||||||
data.sent.delete(partID)
|
data.sent.delete(partID)
|
||||||
|
data.visible.delete(partID)
|
||||||
data.msg.delete(partID)
|
data.msg.delete(partID)
|
||||||
data.end.delete(partID)
|
data.end.delete(partID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
import type { Event, PermissionRequest, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||||
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
import { bootstrapSessionData, createSessionData, reduceSessionData, type SessionData } from "./session-data"
|
||||||
import { messagePrompt, type SessionMessages } from "./session.shared"
|
import { messagePrompt, type SessionMessages } from "./session.shared"
|
||||||
import type { FooterPatch, StreamCommit } from "./types"
|
import type { FooterPatch, LocalReplayRow, StreamCommit } from "./types"
|
||||||
|
|
||||||
type ReplayInput = {
|
type ReplayInput = {
|
||||||
messages: SessionMessages
|
messages: SessionMessages
|
||||||
@@ -186,3 +186,112 @@ export function replaySession(input: ReplayInput): SessionReplay {
|
|||||||
patch: replayPatch(data, patch),
|
patch: replayPatch(data, patch),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function replayLocalRows(messages: SessionMessages, commits: StreamCommit[], rows: LocalReplayRow[]): StreamCommit[] {
|
||||||
|
const persisted = new Set(messages.map((message) => message.info.id))
|
||||||
|
return rows.reduce((out, local) => {
|
||||||
|
const row = local.commit
|
||||||
|
if (row.kind === "user" && row.messageID && persisted.has(row.messageID)) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row.messageID) {
|
||||||
|
return [...out, row]
|
||||||
|
}
|
||||||
|
|
||||||
|
const exact = local.after
|
||||||
|
? out.findIndex(
|
||||||
|
(commit) =>
|
||||||
|
commit.kind === local.after?.kind &&
|
||||||
|
commit.text === local.after.text &&
|
||||||
|
commit.phase === local.after.phase &&
|
||||||
|
commit.toolState === local.after.toolState &&
|
||||||
|
(local.after.partID ? commit.partID === local.after.partID : commit.messageID === local.after.messageID),
|
||||||
|
)
|
||||||
|
: -1
|
||||||
|
const anchored =
|
||||||
|
exact !== -1
|
||||||
|
? exact
|
||||||
|
: local.after
|
||||||
|
? out.findLastIndex((commit) =>
|
||||||
|
local.after?.partID
|
||||||
|
? commit.partID === local.after.partID
|
||||||
|
: commit.kind === local.after?.kind && commit.messageID === local.after.messageID,
|
||||||
|
)
|
||||||
|
: -1
|
||||||
|
if (anchored !== -1) {
|
||||||
|
const commit = out[anchored]
|
||||||
|
const visible = local.after?.visible
|
||||||
|
if (commit && visible && commit.text.startsWith(visible) && commit.text.length > visible.length) {
|
||||||
|
return [
|
||||||
|
...out.slice(0, anchored),
|
||||||
|
{ ...commit, text: visible },
|
||||||
|
row,
|
||||||
|
{ ...commit, text: commit.text.slice(visible.length) },
|
||||||
|
...out.slice(anchored + 1),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...out.slice(0, anchored + 1), row, ...out.slice(anchored + 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const after = out.findIndex((commit) => commit.kind === "user" && commit.messageID === row.messageID)
|
||||||
|
if (after !== -1) {
|
||||||
|
return [...out.slice(0, after + 1), row, ...out.slice(after + 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = out.findIndex((commit) => commit.messageID && row.messageID! < commit.messageID)
|
||||||
|
if (before === -1) {
|
||||||
|
return [...out, row]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...out.slice(0, before), row, ...out.slice(before)]
|
||||||
|
}, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replayActiveText(data: SessionData, current: SessionData): StreamCommit[] {
|
||||||
|
return [...current.part.entries()].flatMap(([partID, kind]) => {
|
||||||
|
if (kind === "user" || current.end.has(partID) || data.ids.has(partID)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = current.text.get(partID) ?? ""
|
||||||
|
const existing = data.text.get(partID) ?? ""
|
||||||
|
const sent = current.sent.get(partID) ?? 0
|
||||||
|
const existingSent = data.sent.get(partID) ?? 0
|
||||||
|
const visible = current.visible.get(partID) ?? ""
|
||||||
|
const existingVisible = data.visible.get(partID) ?? ""
|
||||||
|
if (!text.startsWith(existing) || existingSent > sent || !visible.startsWith(existingVisible)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
data.part.set(partID, kind)
|
||||||
|
data.text.set(partID, text)
|
||||||
|
data.sent.set(partID, sent)
|
||||||
|
data.visible.set(partID, visible)
|
||||||
|
const messageID = current.msg.get(partID)
|
||||||
|
if (messageID) {
|
||||||
|
data.msg.set(partID, messageID)
|
||||||
|
const role = current.role.get(messageID)
|
||||||
|
if (role) {
|
||||||
|
data.role.set(messageID, role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = visible.slice(existingVisible.length)
|
||||||
|
if (!chunk) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
kind,
|
||||||
|
text: chunk,
|
||||||
|
phase: "progress",
|
||||||
|
source: kind,
|
||||||
|
...(messageID ? { messageID } : {}),
|
||||||
|
partID,
|
||||||
|
},
|
||||||
|
] satisfies StreamCommit[]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
reduceSessionData,
|
reduceSessionData,
|
||||||
type SessionData,
|
type SessionData,
|
||||||
} from "./session-data"
|
} from "./session-data"
|
||||||
import { replaySession } from "./session-replay"
|
import { replayActiveText, replayLocalRows, replaySession } from "./session-replay"
|
||||||
import {
|
import {
|
||||||
bootstrapSubagentCalls,
|
bootstrapSubagentCalls,
|
||||||
bootstrapSubagentData,
|
bootstrapSubagentData,
|
||||||
@@ -51,6 +51,8 @@ import type {
|
|||||||
FooterSubagentState,
|
FooterSubagentState,
|
||||||
FooterSubagentTab,
|
FooterSubagentTab,
|
||||||
FooterView,
|
FooterView,
|
||||||
|
LocalReplayAnchor,
|
||||||
|
LocalReplayRow,
|
||||||
RunFilePart,
|
RunFilePart,
|
||||||
RunInput,
|
RunInput,
|
||||||
RunPrompt,
|
RunPrompt,
|
||||||
@@ -81,6 +83,7 @@ type Wait = {
|
|||||||
tick: number
|
tick: number
|
||||||
armed: boolean
|
armed: boolean
|
||||||
live: boolean
|
live: boolean
|
||||||
|
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
|
||||||
done: Deferred.Deferred<void, unknown>
|
done: Deferred.Deferred<void, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +94,22 @@ export type SessionTurnInput = {
|
|||||||
prompt: RunPrompt
|
prompt: RunPrompt
|
||||||
files: RunFilePart[]
|
files: RunFilePart[]
|
||||||
includeFiles: boolean
|
includeFiles: boolean
|
||||||
|
onVisibleOutput?: (anchor: LocalReplayAnchor) => void
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SessionTransport = {
|
export type SessionTransport = {
|
||||||
runPromptTurn(input: SessionTurnInput): Promise<void>
|
runPromptTurn(input: SessionTurnInput): Promise<void>
|
||||||
selectSubagent(sessionID: string | undefined): void
|
selectSubagent(sessionID: string | undefined): void
|
||||||
|
replayOnResize(input: SessionResizeReplayInput): Promise<boolean>
|
||||||
close(): Promise<void>
|
close(): Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionResizeReplayInput = {
|
||||||
|
localRows: () => LocalReplayRow[]
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
type State = {
|
type State = {
|
||||||
data: SessionData
|
data: SessionData
|
||||||
subagent: SubagentData
|
subagent: SubagentData
|
||||||
@@ -115,6 +125,7 @@ type State = {
|
|||||||
type TransportService = {
|
type TransportService = {
|
||||||
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
|
readonly runPromptTurn: (input: SessionTurnInput) => Effect.Effect<void, unknown>
|
||||||
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
|
readonly selectSubagent: (sessionID: string | undefined) => Effect.Effect<void>
|
||||||
|
readonly replayOnResize: (input: SessionResizeReplayInput) => Effect.Effect<boolean>
|
||||||
readonly close: () => Effect.Effect<void>
|
readonly close: () => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,6 +451,9 @@ function createLayer(input: StreamInput) {
|
|||||||
blockers: new Map(),
|
blockers: new Map(),
|
||||||
}
|
}
|
||||||
let booting = true
|
let booting = true
|
||||||
|
let replaying = false
|
||||||
|
let replayDisabled = false
|
||||||
|
let replayPending: SessionResizeReplayInput | undefined
|
||||||
const buffered: Event[] = []
|
const buffered: Event[] = []
|
||||||
const replayedParts = new Set<string>()
|
const replayedParts = new Set<string>()
|
||||||
const recovering = new Set<string>()
|
const recovering = new Set<string>()
|
||||||
@@ -594,6 +608,38 @@ function createLayer(input: StreamInput) {
|
|||||||
Effect.orElseSucceed(() => []),
|
Effect.orElseSucceed(() => []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const replayMessages = () =>
|
||||||
|
Effect.promise(() =>
|
||||||
|
input.sdk.session.messages({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
...(input.replayLimit === undefined
|
||||||
|
? {}
|
||||||
|
: { limit: Math.max(input.replayLimit, SUBAGENT_BOOTSTRAP_LIMIT) }),
|
||||||
|
}),
|
||||||
|
).pipe(Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))))
|
||||||
|
|
||||||
|
const replayRequests = () =>
|
||||||
|
Effect.all(
|
||||||
|
[
|
||||||
|
Effect.promise(() => input.sdk.permission.list()).pipe(
|
||||||
|
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
|
||||||
|
),
|
||||||
|
Effect.promise(() => input.sdk.question.list()).pipe(
|
||||||
|
Effect.flatMap((item) => (item.error ? Effect.fail(item.error) : Effect.succeed(item.data ?? []))),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ concurrency: "unbounded" },
|
||||||
|
)
|
||||||
|
|
||||||
|
const markReplayedParts = (data: SessionData) => {
|
||||||
|
replayedParts.clear()
|
||||||
|
for (const [partID] of data.text) {
|
||||||
|
if (data.part.has(partID)) {
|
||||||
|
replayedParts.add(partID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* (
|
const bootstrapSubagentHistory = Effect.fn("RunStreamTransport.bootstrapSubagentHistory")(function* (
|
||||||
sessions: string[],
|
sessions: string[],
|
||||||
) {
|
) {
|
||||||
@@ -681,7 +727,6 @@ function createLayer(input: StreamInput) {
|
|||||||
})
|
})
|
||||||
: history
|
: history
|
||||||
|
|
||||||
replayedParts.clear()
|
|
||||||
if (history) {
|
if (history) {
|
||||||
state.data = history.data
|
state.data = history.data
|
||||||
}
|
}
|
||||||
@@ -695,14 +740,8 @@ function createLayer(input: StreamInput) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replay) {
|
if (history) {
|
||||||
for (const [partID] of replay.data.text) {
|
markReplayedParts(history.data)
|
||||||
if (!replay.data.part.has(partID)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
replayedParts.add(partID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrapSubagentData({
|
bootstrapSubagentData({
|
||||||
@@ -862,6 +901,20 @@ function createLayer(input: StreamInput) {
|
|||||||
limits: input.limits(),
|
limits: input.limits(),
|
||||||
})
|
})
|
||||||
state.data = next.data
|
state.data = next.data
|
||||||
|
const visible = next.commits.at(-1)
|
||||||
|
if (visible) {
|
||||||
|
state.wait?.onVisibleOutput?.({
|
||||||
|
kind: visible.kind,
|
||||||
|
text: visible.text,
|
||||||
|
phase: visible.phase,
|
||||||
|
messageID: visible.messageID,
|
||||||
|
partID: visible.partID,
|
||||||
|
toolState: visible.toolState,
|
||||||
|
...(visible.partID && state.data.visible.has(visible.partID)
|
||||||
|
? { visible: state.data.visible.get(visible.partID) }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type === "message.part.updated" &&
|
event.type === "message.part.updated" &&
|
||||||
@@ -910,15 +963,163 @@ function createLayer(input: StreamInput) {
|
|||||||
yield* applyEvent(event)
|
yield* applyEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!changed) {
|
const arrived = buffered.splice(0)
|
||||||
|
if (!changed && arrived.length === 0) {
|
||||||
buffered.push(...next)
|
buffered.push(...next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pending = next
|
pending = [...next, ...arrived]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const replayOnResize: (next: SessionResizeReplayInput) => Effect.Effect<boolean> = Effect.fn(
|
||||||
|
"RunStreamTransport.replayOnResize",
|
||||||
|
)(function* (next: SessionResizeReplayInput) {
|
||||||
|
if (!input.replay || replayDisabled || booting || closed || input.footer.isClosed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaying) {
|
||||||
|
replayPending = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const finish: () => Effect.Effect<void> = Effect.fnUntraced(function* () {
|
||||||
|
yield* drainBuffered()
|
||||||
|
const pending = replayPending
|
||||||
|
replayPending = undefined
|
||||||
|
if (!pending || replayDisabled || closed || input.footer.isClosed) {
|
||||||
|
replaying = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
replaying = false
|
||||||
|
yield* replayOnResize(pending).pipe(Effect.asVoid)
|
||||||
|
})
|
||||||
|
|
||||||
|
replayedParts.clear()
|
||||||
|
replaying = true
|
||||||
|
input.trace?.write("replay.resize.start", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
const source = yield* Effect.all([replayMessages(), replayRequests()], { concurrency: "unbounded" }).pipe(
|
||||||
|
Effect.exit,
|
||||||
|
)
|
||||||
|
if (Exit.isFailure(source)) {
|
||||||
|
input.trace?.write("replay.resize.abort", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
phase: "snapshot",
|
||||||
|
})
|
||||||
|
yield* finish()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const [messagesList, [permissions, questions]] = source.value
|
||||||
|
const sessionPermissions = permissions.filter((item) => item.sessionID === input.sessionID)
|
||||||
|
const sessionQuestions = questions.filter((item) => item.sessionID === input.sessionID)
|
||||||
|
const snapshot = yield* Effect.try({
|
||||||
|
try: () => {
|
||||||
|
const history = replaySession({
|
||||||
|
messages: messagesList,
|
||||||
|
permissions: sessionPermissions,
|
||||||
|
questions: sessionQuestions,
|
||||||
|
thinking: input.thinking,
|
||||||
|
limits: input.limits(),
|
||||||
|
})
|
||||||
|
const activeCommits = replayActiveText(history.data, state.data)
|
||||||
|
return {
|
||||||
|
history,
|
||||||
|
activeCommits,
|
||||||
|
patch:
|
||||||
|
history.data.part.size > 0 || history.data.tools.size > 0
|
||||||
|
? { ...history.patch, phase: "running" as const }
|
||||||
|
: history.patch,
|
||||||
|
visible:
|
||||||
|
input.replayLimit !== undefined && messagesList.length > input.replayLimit
|
||||||
|
? replaySession({
|
||||||
|
messages: messagesList.slice(-input.replayLimit),
|
||||||
|
permissions: sessionPermissions,
|
||||||
|
questions: sessionQuestions,
|
||||||
|
thinking: input.thinking,
|
||||||
|
limits: input.limits(),
|
||||||
|
})
|
||||||
|
: history,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
catch: (error) => error,
|
||||||
|
}).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(snapshot)) {
|
||||||
|
input.trace?.write("replay.resize.abort", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
phase: "snapshot",
|
||||||
|
})
|
||||||
|
yield* finish()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const idle = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(idle) || closed || input.footer.isClosed) {
|
||||||
|
yield* finish()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = yield* Effect.promise(() => next.reset()).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(reset)) {
|
||||||
|
replayDisabled = true
|
||||||
|
input.trace?.write("replay.resize.disable", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
phase: "reset",
|
||||||
|
})
|
||||||
|
input.footer.append({
|
||||||
|
kind: "error",
|
||||||
|
text: "resize replay failed; disabled for this session",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
})
|
||||||
|
yield* finish()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
state.data = snapshot.value.history.data
|
||||||
|
for (const request of [...state.data.permissions, ...state.data.questions]) {
|
||||||
|
seedBlocker(request.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const commit of replayLocalRows(
|
||||||
|
messagesList,
|
||||||
|
[...snapshot.value.visible.commits, ...snapshot.value.activeCommits],
|
||||||
|
next.localRows(),
|
||||||
|
)) {
|
||||||
|
input.trace?.write("ui.commit", commit)
|
||||||
|
input.footer.append(commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFooter([], snapshot.value.patch, currentSubagentState())
|
||||||
|
const rebuilt = yield* Effect.promise(() => input.footer.idle()).pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(rebuilt)) {
|
||||||
|
replayDisabled = true
|
||||||
|
input.trace?.write("replay.resize.disable", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
phase: "rebuild",
|
||||||
|
})
|
||||||
|
input.footer.append({
|
||||||
|
kind: "error",
|
||||||
|
text: "resize replay failed; disabled for this session",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
})
|
||||||
|
yield* finish()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
input.trace?.write("replay.resize.complete", {
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
})
|
||||||
|
yield* finish()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
const watch = Effect.fn("RunStreamTransport.watch")(() =>
|
||||||
Stream.fromAsyncIterable(events.stream, (error) =>
|
Stream.fromAsyncIterable(events.stream, (error) =>
|
||||||
error instanceof Error ? error : new Error(String(error)),
|
error instanceof Error ? error : new Error(String(error)),
|
||||||
@@ -943,7 +1144,7 @@ function createLayer(input: StreamInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionID = sid(event)
|
const sessionID = sid(event)
|
||||||
if (booting) {
|
if (booting || replaying) {
|
||||||
if (sessionID) {
|
if (sessionID) {
|
||||||
input.trace?.write("recv.event", event)
|
input.trace?.write("recv.event", event)
|
||||||
buffered.push(event)
|
buffered.push(event)
|
||||||
@@ -1005,6 +1206,7 @@ function createLayer(input: StreamInput) {
|
|||||||
tick: state.tick,
|
tick: state.tick,
|
||||||
armed: false,
|
armed: false,
|
||||||
live: false,
|
live: false,
|
||||||
|
onVisibleOutput: next.onVisibleOutput,
|
||||||
done: yield* Deferred.make<void, unknown>(),
|
done: yield* Deferred.make<void, unknown>(),
|
||||||
}
|
}
|
||||||
state.wait = item
|
state.wait = item
|
||||||
@@ -1020,6 +1222,7 @@ function createLayer(input: StreamInput) {
|
|||||||
|
|
||||||
const req = {
|
const req = {
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
messageID: next.prompt.messageID,
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: next.model,
|
model: next.model,
|
||||||
variant: next.variant,
|
variant: next.variant,
|
||||||
@@ -1081,6 +1284,7 @@ function createLayer(input: StreamInput) {
|
|||||||
input.sdk.session.command(
|
input.sdk.session.command(
|
||||||
{
|
{
|
||||||
sessionID: input.sessionID,
|
sessionID: input.sessionID,
|
||||||
|
messageID: next.prompt.messageID,
|
||||||
agent: next.agent,
|
agent: next.agent,
|
||||||
model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined,
|
model: next.model ? `${next.model.providerID}/${next.model.modelID}` : undefined,
|
||||||
variant: next.variant,
|
variant: next.variant,
|
||||||
@@ -1231,6 +1435,7 @@ function createLayer(input: StreamInput) {
|
|||||||
return Service.of({
|
return Service.of({
|
||||||
runPromptTurn,
|
runPromptTurn,
|
||||||
selectSubagent,
|
selectSubagent,
|
||||||
|
replayOnResize,
|
||||||
close,
|
close,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -1254,6 +1459,7 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||||||
return {
|
return {
|
||||||
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
|
runPromptTurn: (next) => runtime.runPromise((svc) => svc.runPromptTurn(next)),
|
||||||
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
|
selectSubagent: (sessionID) => runtime.runSync((svc) => svc.selectSubagent(sessionID)),
|
||||||
|
replayOnResize: (next) => runtime.runPromise((svc) => svc.replayOnResize(next)),
|
||||||
close: () => runtime.runPromise((svc) => svc.close()),
|
close: () => runtime.runPromise((svc) => svc.close()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,21 @@ export type StreamCommit = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LocalReplayAnchor = {
|
||||||
|
kind: EntryKind
|
||||||
|
text: string
|
||||||
|
phase: StreamPhase
|
||||||
|
messageID?: string
|
||||||
|
partID?: string
|
||||||
|
toolState?: StreamToolState
|
||||||
|
visible?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalReplayRow = {
|
||||||
|
commit: StreamCommit
|
||||||
|
after?: LocalReplayAnchor
|
||||||
|
}
|
||||||
|
|
||||||
// The public contract between the stream transport / prompt queue and
|
// The public contract between the stream transport / prompt queue and
|
||||||
// the footer. RunFooter implements this. The transport and queue never
|
// the footer. RunFooter implements this. The transport and queue never
|
||||||
// touch the renderer directly -- they go through this interface.
|
// touch the renderer directly -- they go through this interface.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Options:
|
|||||||
--variant model variant (provider-specific reasoning effort, e.g., high,
|
--variant model variant (provider-specific reasoning effort, e.g., high,
|
||||||
max, minimal) [string]
|
max, minimal) [string]
|
||||||
--thinking show thinking blocks [boolean]
|
--thinking show thinking blocks [boolean]
|
||||||
--replay replay visible session history on interactive resume
|
--replay replay interactive session history on resume and after resize
|
||||||
[boolean] [default: false]
|
[boolean] [default: false]
|
||||||
--replay-limit cap visible interactive replay to the newest N messages
|
--replay-limit cap visible interactive replay to the newest N messages
|
||||||
[number]
|
[number]
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ describe("run runtime queue", () => {
|
|||||||
text: "hello",
|
text: "hello",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
source: "system",
|
source: "system",
|
||||||
|
messageID: expect.any(String),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -225,6 +226,7 @@ describe("run runtime queue", () => {
|
|||||||
text: " hello ",
|
text: " hello ",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
source: "system",
|
source: "system",
|
||||||
|
messageID: expect.any(String),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
@@ -260,6 +262,7 @@ describe("run runtime queue", () => {
|
|||||||
text: "/fmt bash",
|
text: "/fmt bash",
|
||||||
phase: "start",
|
phase: "start",
|
||||||
source: "system",
|
source: "system",
|
||||||
|
messageID: expect.any(String),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
ui.api.close()
|
ui.api.close()
|
||||||
@@ -321,7 +324,7 @@ describe("run runtime queue", () => {
|
|||||||
await Promise.resolve()
|
await Promise.resolve()
|
||||||
|
|
||||||
expect(turns.map((item) => item.text)).toEqual(["one"])
|
expect(turns.map((item) => item.text)).toEqual(["one"])
|
||||||
expect(turns[0]?.messageID).toBeUndefined()
|
expect(turns[0]?.messageID).toEqual(expect.any(String))
|
||||||
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
expect(ui.commits.map((item) => item.text)).toEqual(["one"])
|
||||||
const first = ui.events.find((item) => item.type === "queued.prompts")
|
const first = ui.events.find((item) => item.type === "queued.prompts")
|
||||||
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
const event = ui.events.findLast((item) => item.type === "queued.prompts")
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { replaySession } from "@/cli/cmd/run/session-replay"
|
import { replayLocalRows, replaySession } from "@/cli/cmd/run/session-replay"
|
||||||
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
import type { SessionMessages } from "@/cli/cmd/run/session.shared"
|
||||||
|
|
||||||
function userMessage(id: string, text: string): SessionMessages[number] {
|
function userMessage(id: string, text: string): SessionMessages[number] {
|
||||||
@@ -156,4 +156,286 @@ describe("run session replay", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("merges failed local rows ahead of later persisted prompts", () => {
|
||||||
|
const persisted = {
|
||||||
|
kind: "user",
|
||||||
|
text: "successful",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-2",
|
||||||
|
} as const
|
||||||
|
const failed = {
|
||||||
|
kind: "user",
|
||||||
|
text: "failed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "network unavailable",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(replayLocalRows([userMessage("msg-user-2", "successful")], [persisted], [{ commit: failed }, { commit: error }])).toEqual([
|
||||||
|
failed,
|
||||||
|
error,
|
||||||
|
persisted,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retains local errors but not duplicate local prompts once a prompt persists", () => {
|
||||||
|
const persisted = {
|
||||||
|
kind: "user",
|
||||||
|
text: "failed after persistence",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "connection closed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(replayLocalRows([userMessage("msg-user-1", "failed after persistence")], [persisted], [{ commit: persisted }, { commit: error }])).toEqual([
|
||||||
|
persisted,
|
||||||
|
error,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a local turn failure below assistant output already visible for that turn", () => {
|
||||||
|
const first = {
|
||||||
|
kind: "user",
|
||||||
|
text: "start",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const answer = {
|
||||||
|
kind: "assistant",
|
||||||
|
text: "partial answer",
|
||||||
|
phase: "progress",
|
||||||
|
source: "assistant",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "stream failed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const second = {
|
||||||
|
kind: "user",
|
||||||
|
text: "retry",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-2",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
replayLocalRows(
|
||||||
|
[userMessage("msg-user-1", "start"), userMessage("msg-user-2", "retry")],
|
||||||
|
[first, answer, second],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
commit: error,
|
||||||
|
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-assistant-1" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
),
|
||||||
|
).toEqual([first, answer, error, second])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps a local failure above assistant output received after the failure", () => {
|
||||||
|
const first = {
|
||||||
|
kind: "user",
|
||||||
|
text: "start",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "request failed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const late = {
|
||||||
|
kind: "assistant",
|
||||||
|
text: "late answer",
|
||||||
|
phase: "progress",
|
||||||
|
source: "assistant",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(replayLocalRows([userMessage("msg-user-1", "start")], [first, late], [{ commit: error }])).toEqual([
|
||||||
|
first,
|
||||||
|
error,
|
||||||
|
late,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("inserts a local failure between persisted output chunks spanning that failure", () => {
|
||||||
|
const first = {
|
||||||
|
kind: "user",
|
||||||
|
text: "start",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const complete = {
|
||||||
|
kind: "assistant",
|
||||||
|
text: "before after",
|
||||||
|
phase: "progress",
|
||||||
|
source: "assistant",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
partID: "part-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "stream failed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
replayLocalRows([userMessage("msg-user-1", "start")], [first, complete], [
|
||||||
|
{
|
||||||
|
commit: error,
|
||||||
|
after: {
|
||||||
|
kind: "assistant",
|
||||||
|
text: "before ",
|
||||||
|
phase: "progress",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
partID: "part-1",
|
||||||
|
visible: "before ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([first, { ...complete, text: "before " }, error, { ...complete, text: "after" }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("places an unpersisted failed prompt before live output from that turn", () => {
|
||||||
|
const prompt = {
|
||||||
|
kind: "user",
|
||||||
|
text: "start",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-1",
|
||||||
|
} as const
|
||||||
|
const answer = {
|
||||||
|
kind: "assistant",
|
||||||
|
text: "partial answer",
|
||||||
|
phase: "progress",
|
||||||
|
source: "assistant",
|
||||||
|
messageID: "msg-2",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "stream failed",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
replayLocalRows([], [answer], [
|
||||||
|
{ commit: prompt },
|
||||||
|
{
|
||||||
|
commit: error,
|
||||||
|
after: { kind: "assistant", text: "partial answer", phase: "progress", messageID: "msg-2" },
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([prompt, answer, error])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("anchors a failure after the visible start of a tool that later completes", () => {
|
||||||
|
const prompt = {
|
||||||
|
kind: "user",
|
||||||
|
text: "run ls",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const running = {
|
||||||
|
kind: "tool",
|
||||||
|
text: "running bash",
|
||||||
|
phase: "start",
|
||||||
|
source: "tool",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
partID: "part-tool-1",
|
||||||
|
toolState: "running",
|
||||||
|
} as const
|
||||||
|
const completed = {
|
||||||
|
kind: "tool",
|
||||||
|
text: "file.txt",
|
||||||
|
phase: "final",
|
||||||
|
source: "tool",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
partID: "part-tool-1",
|
||||||
|
toolState: "completed",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "connection lost",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
replayLocalRows([userMessage("msg-user-1", "run ls")], [prompt, running, completed], [
|
||||||
|
{
|
||||||
|
commit: error,
|
||||||
|
after: {
|
||||||
|
kind: "tool",
|
||||||
|
text: "running bash",
|
||||||
|
phase: "start",
|
||||||
|
messageID: "msg-assistant-1",
|
||||||
|
partID: "part-tool-1",
|
||||||
|
toolState: "running",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
).toEqual([prompt, running, error, completed])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("retains an unpersisted local diagnostic before later persisted prompts", () => {
|
||||||
|
const first = {
|
||||||
|
kind: "user",
|
||||||
|
text: "before",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-1",
|
||||||
|
} as const
|
||||||
|
const error = {
|
||||||
|
kind: "error",
|
||||||
|
text: "failed to start new session",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-2",
|
||||||
|
} as const
|
||||||
|
const second = {
|
||||||
|
kind: "user",
|
||||||
|
text: "after",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-user-3",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
expect(
|
||||||
|
replayLocalRows([userMessage("msg-user-1", "before"), userMessage("msg-user-3", "after")], [first, second], [
|
||||||
|
{ commit: error },
|
||||||
|
]),
|
||||||
|
).toEqual([first, error, second])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
|
import { OpencodeClient, type GlobalEvent } from "@opencode-ai/sdk/v2"
|
||||||
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
|
import { createSessionTransport } from "@/cli/cmd/run/stream.transport"
|
||||||
import type { FooterApi, FooterEvent, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
import type { FooterApi, FooterEvent, LocalReplayRow, RunFilePart, StreamCommit } from "@/cli/cmd/run/types"
|
||||||
|
|
||||||
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
|
type EventStream = Awaited<ReturnType<OpencodeClient["event"]["subscribe"]>>["stream"]
|
||||||
type GlobalEventStream = Awaited<ReturnType<OpencodeClient["global"]["event"]>>["stream"]
|
type GlobalEventStream = Awaited<ReturnType<OpencodeClient["global"]["event"]>>["stream"]
|
||||||
@@ -11,6 +11,7 @@ type SessionChild = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["ch
|
|||||||
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
|
type SessionToolPart = Extract<SessionMessage["parts"][number], { type: "tool" }>
|
||||||
type SessionStatusMap = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["status"]>>["data"]>
|
type SessionStatusMap = NonNullable<Awaited<ReturnType<OpencodeClient["session"]["status"]>>["data"]>
|
||||||
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
|
type TextPart = Extract<SessionMessage["parts"][number], { type: "text" }>
|
||||||
|
type ReasoningPart = Extract<SessionMessage["parts"][number], { type: "reasoning" }>
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
mock.restore()
|
mock.restore()
|
||||||
@@ -298,6 +299,29 @@ function textUpdated(part: TextPart): SdkEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reasoningPart(id: string, messageID: string, text: string): ReasoningPart {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
sessionID: "session-1",
|
||||||
|
messageID,
|
||||||
|
type: "reasoning",
|
||||||
|
text,
|
||||||
|
time: { start: 1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasoningUpdated(part: ReasoningPart): SdkEvent {
|
||||||
|
return {
|
||||||
|
id: `evt-${part.id}-updated`,
|
||||||
|
type: "message.part.updated",
|
||||||
|
properties: {
|
||||||
|
sessionID: part.sessionID,
|
||||||
|
part,
|
||||||
|
time: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function toolUpdated(part: SessionToolPart): SdkEvent {
|
function toolUpdated(part: SessionToolPart): SdkEvent {
|
||||||
return {
|
return {
|
||||||
id: `evt-${part.id}-updated`,
|
id: `evt-${part.id}-updated`,
|
||||||
@@ -721,6 +745,444 @@ describe("run stream transport", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rebuilds session output on resize and continues live deltas from replayed state", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let calls = 0
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-1",
|
||||||
|
parts: [textPart("text-1", "msg-1", "Hello")],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
const localRows: LocalReplayRow[] = [
|
||||||
|
{ commit: { kind: "user", text: "pending prompt", phase: "start", source: "system", messageID: "msg-pending" } },
|
||||||
|
]
|
||||||
|
const reset = mock(() => {
|
||||||
|
localRows.push({
|
||||||
|
commit: {
|
||||||
|
kind: "user",
|
||||||
|
text: "sent during reset",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
messageID: "msg-during-reset",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(
|
||||||
|
await transport.replayOnResize({
|
||||||
|
localRows: () => localRows,
|
||||||
|
reset,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ui.commits).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ kind: "assistant", text: "Hello" }),
|
||||||
|
expect.objectContaining({ kind: "user", text: "sent during reset", messageID: "msg-during-reset" }),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
src.push(textUpdated(textPart("text-1", "msg-1", "Hello world")))
|
||||||
|
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === " world"))
|
||||||
|
expect(ui.commits.filter((commit) => commit.kind === "assistant").map((commit) => commit.text)).toEqual([
|
||||||
|
"Hello",
|
||||||
|
" world",
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("coalesces active resize requests into one trailing replay", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const firstReset = defer()
|
||||||
|
const resetA = mock(() => firstReset.promise)
|
||||||
|
const resetB = mock(() => Promise.resolve())
|
||||||
|
const resetC = mock(() => Promise.resolve())
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({ stream: src.stream }),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||||
|
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||||
|
expect(resetB).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
firstReset.resolve()
|
||||||
|
expect(await active).toBe(true)
|
||||||
|
expect(resetA).toHaveBeenCalledTimes(1)
|
||||||
|
expect(resetB).not.toHaveBeenCalled()
|
||||||
|
expect(resetC).toHaveBeenCalledTimes(1)
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keeps coalescing resize requests while buffered events drain", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const firstReset = defer()
|
||||||
|
const statusGate = defer()
|
||||||
|
const statusStarted = defer()
|
||||||
|
let blockStatus = false
|
||||||
|
const trace = mock((_type: string, _data?: unknown) => {})
|
||||||
|
const resetA = mock(() => firstReset.promise)
|
||||||
|
const resetB = mock(() => Promise.resolve())
|
||||||
|
const resetC = mock(() => Promise.resolve())
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
status: async () => {
|
||||||
|
if (blockStatus) {
|
||||||
|
statusStarted.resolve()
|
||||||
|
await statusGate.promise
|
||||||
|
}
|
||||||
|
return ok(statusMap(true))
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
trace: { write: trace },
|
||||||
|
})
|
||||||
|
const turn = transport.runPromptTurn({
|
||||||
|
agent: undefined,
|
||||||
|
model: undefined,
|
||||||
|
variant: undefined,
|
||||||
|
prompt: { text: "active", parts: [] },
|
||||||
|
files: [],
|
||||||
|
includeFiles: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor(() => ui.events.find((event) => event.type === "turn.wait"))
|
||||||
|
const active = transport.replayOnResize({ localRows: () => [], reset: resetA })
|
||||||
|
await waitFor(() => (resetA.mock.calls.length === 1 ? true : undefined))
|
||||||
|
blockStatus = true
|
||||||
|
src.push(busy())
|
||||||
|
src.push(idle())
|
||||||
|
await waitFor(() => (trace.mock.calls.filter((call) => call[0] === "recv.event").length >= 2 ? true : undefined))
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: resetB })).toBe(false)
|
||||||
|
firstReset.resolve()
|
||||||
|
await Promise.race([
|
||||||
|
statusStarted.promise,
|
||||||
|
Bun.sleep(1_000).then(() => {
|
||||||
|
throw new Error("timed out waiting for buffered status drain")
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: resetC })).toBe(false)
|
||||||
|
expect(resetC).not.toHaveBeenCalled()
|
||||||
|
blockStatus = false
|
||||||
|
statusGate.resolve()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await Promise.race([
|
||||||
|
active,
|
||||||
|
Bun.sleep(1_000).then(() => {
|
||||||
|
throw new Error("timed out waiting for trailing resize replay")
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
).toBe(true)
|
||||||
|
expect(resetB).not.toHaveBeenCalled()
|
||||||
|
expect(resetC).toHaveBeenCalledTimes(1)
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
await turn
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves assistant deltas not yet persisted when replaying during a live stream", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let calls = 0
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-live",
|
||||||
|
parts: [textPart("text-live", "msg-live", "")],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
src.push(assistant("msg-live"))
|
||||||
|
src.push(textUpdated(textPart("text-live", "msg-live", "")))
|
||||||
|
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||||
|
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||||
|
ui.commits.length = 0
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||||
|
src.push(textDelta("msg-live", "text-live", "Hello"))
|
||||||
|
src.push(
|
||||||
|
textUpdated({
|
||||||
|
...textPart("text-live", "msg-live", "HelloHello"),
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text === "Hello").length === 2
|
||||||
|
? true
|
||||||
|
: undefined,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||||
|
).toEqual(["Hello", "Hello"])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves the display prefix for active reasoning restored during replay", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let calls = 0
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-thinking",
|
||||||
|
parts: [reasoningPart("thinking-1", "msg-thinking", "")],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
src.push(assistant("msg-thinking"))
|
||||||
|
src.push(reasoningUpdated(reasoningPart("thinking-1", "msg-thinking", "")))
|
||||||
|
src.push(textDelta("msg-thinking", "thinking-1", "plan"))
|
||||||
|
await waitFor(() => ui.commits.find((commit) => commit.kind === "reasoning" && commit.text === "Thinking: plan"))
|
||||||
|
ui.commits.length = 0
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||||
|
expect(ui.commits.filter((commit) => commit.kind === "reasoning").map((commit) => commit.text)).toEqual([
|
||||||
|
"Thinking: plan",
|
||||||
|
])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not overlay stale active text when persistence completes during replay", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let calls = 0
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok([
|
||||||
|
assistantMessage({
|
||||||
|
sessionID: "session-1",
|
||||||
|
id: "msg-finished",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
...textPart("text-finished", "msg-finished", "Hello"),
|
||||||
|
time: { start: 1, end: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
src.push(assistant("msg-finished"))
|
||||||
|
src.push(textUpdated(textPart("text-finished", "msg-finished", "")))
|
||||||
|
src.push(textDelta("msg-finished", "text-finished", "Hello"))
|
||||||
|
await waitFor(() => ui.commits.find((commit) => commit.kind === "assistant" && commit.text === "Hello"))
|
||||||
|
ui.commits.length = 0
|
||||||
|
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset: () => Promise.resolve() })).toBe(true)
|
||||||
|
expect(
|
||||||
|
ui.commits.filter((commit) => commit.kind === "assistant" && commit.text).map((commit) => commit.text),
|
||||||
|
).toEqual(["Hello"])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not clear the terminal when resize replay snapshot fetch fails", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let calls = 0
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({
|
||||||
|
stream: src.stream,
|
||||||
|
messages: async () => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 1) {
|
||||||
|
return ok([])
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("snapshot failed")
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
const reset = mock(() => Promise.resolve())
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||||
|
expect(reset).not.toHaveBeenCalled()
|
||||||
|
expect(ui.commits).toEqual([])
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disables resize replay for the session after terminal reset fails", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({ stream: src.stream }),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
const reset = mock(() => Promise.reject(new Error("clear failed")))
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ui.commits).toContainEqual({
|
||||||
|
kind: "error",
|
||||||
|
text: "resize replay failed; disabled for this session",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("disables resize replay when rebuilding scrollback fails after terminal reset", async () => {
|
||||||
|
const src = eventFeed()
|
||||||
|
const ui = footer()
|
||||||
|
let cleared = false
|
||||||
|
const idle = ui.api.idle
|
||||||
|
ui.api.idle = () => (cleared ? Promise.reject(new Error("render failed")) : idle())
|
||||||
|
const transport = await createSessionTransport({
|
||||||
|
sdk: sdk({ stream: src.stream }),
|
||||||
|
sessionID: "session-1",
|
||||||
|
thinking: true,
|
||||||
|
replay: true,
|
||||||
|
limits: () => ({}),
|
||||||
|
footer: ui.api,
|
||||||
|
})
|
||||||
|
const reset = mock(() => {
|
||||||
|
cleared = true
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||||
|
expect(await transport.replayOnResize({ localRows: () => [], reset })).toBe(false)
|
||||||
|
expect(reset).toHaveBeenCalledTimes(1)
|
||||||
|
expect(ui.commits).toContainEqual({
|
||||||
|
kind: "error",
|
||||||
|
text: "resize replay failed; disabled for this session",
|
||||||
|
phase: "start",
|
||||||
|
source: "system",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
src.close()
|
||||||
|
await transport.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test("drops completed historical subagent tabs during bootstrap", async () => {
|
test("drops completed historical subagent tabs during bootstrap", async () => {
|
||||||
const src = eventFeed()
|
const src = eventFeed()
|
||||||
const ui = footer()
|
const ui = footer()
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.3.0",
|
"@opentui/core": ">=0.3.1",
|
||||||
"@opentui/keymap": ">=0.3.0",
|
"@opentui/keymap": ">=0.3.1",
|
||||||
"@opentui/solid": ">=0.3.0"
|
"@opentui/solid": ">=0.3.1"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
|
|||||||
Reference in New Issue
Block a user