From 85df9f6a370f01d6f339ece6a838a3109b69ddf9 Mon Sep 17 00:00:00 2001 From: Kingsword Date: Wed, 23 Sep 2026 19:55:12 +0800 Subject: [PATCH 1/2] feat(tui): inspect and recover dynamic workflow runs --- README.md | 1 + package.json | 2 +- packages/zcode-tui/src/dynamic-workflows.ts | 138 ++++++++++++++++++++ packages/zcode-tui/src/index.ts | 86 +++++++++++- packages/zcode-tui/src/types.ts | 4 + scripts/sync-runtime.ts | 16 +++ test/dynamic-workflows.test.ts | 80 ++++++++++++ test/tui/dynamic-workflows.test.ts | 23 ++++ test/tui/fixtures/dynamic-workflows.ts | 23 ++++ 9 files changed, 371 insertions(+), 2 deletions(-) create mode 100644 packages/zcode-tui/src/dynamic-workflows.ts create mode 100644 test/dynamic-workflows.test.ts create mode 100644 test/tui/dynamic-workflows.test.ts create mode 100644 test/tui/fixtures/dynamic-workflows.ts diff --git a/README.md b/README.md index 8d13dbd..12535ff 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,7 @@ picker to return to input selection, then `Esc` again to close rewind. /status inspect detailed runtime and session status /rename rename the current session /activity inspect every active tool and open task +/workflows inspect workflow progress, artifacts and recovery /tasks inspect and manage background tasks /tasks message <id> <text> send guidance to a running background agent /tasks resume <id> [text] resume a stopped or failed background agent diff --git a/package.json b/package.json index 2c47c3c..df771c6 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/session-rename.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/dynamic-workflows.ts b/packages/zcode-tui/src/dynamic-workflows.ts new file mode 100644 index 0000000..31aedff --- /dev/null +++ b/packages/zcode-tui/src/dynamic-workflows.ts @@ -0,0 +1,138 @@ +import { asString, isRecord, type RuntimeAdapter, type UnknownRecord } from "./types.ts"; +import { sanitizeTerminalText } from "./terminal-text.ts"; + +const maximumRuns = 20; +const maximumBufferedEvents = 2_000; + +export class DynamicWorkflows { + private state: unknown; + private summaries: UnknownRecord[] = []; + private epoch = 0; + private loading?: Promise<void>; + private buffered: UnknownRecord[] = []; + private watermarks = new Map<string, number>(); + private lastEvents = new Map<string, string>(); + private incomplete = false; + error?: string; + + constructor(private readonly adapter: Pick<RuntimeAdapter, "listWorkflowRuns" | "replayWorkflowRuns" | "reduceWorkflowRuns">) {} + + reset(): void { + this.epoch++; + this.state = undefined; + this.summaries = []; + this.loading = undefined; + this.buffered = []; + this.watermarks.clear(); + this.lastEvents.clear(); + this.error = undefined; + this.incomplete = false; + } + + runs(): UnknownRecord[] { + const runs = isRecord(this.state) && Array.isArray(this.state.runs) ? this.state.runs.filter(isRecord) : []; + const byId = new Map(runs.map((run) => [run.runId, run])); + const summaries = this.summaries.map((summary) => { + const run = byId.get(summary.runId); + byId.delete(summary.runId); + return run ? { ...run, label: summary.label, updatedAt: summary.updatedAt } : summary; + }); + return [...summaries, ...byId.values()].slice(0, maximumRuns) + .map((run) => this.incomplete ? { ...run, progressIncomplete: true } : run); + } + + accept(event: unknown): boolean { + if (!isRecord(event) || typeof event.runId !== "string" || typeof event.eventType !== "string") return false; + if (this.loading) { + if (this.buffered.length < maximumBufferedEvents) this.buffered.push(event); + else { + this.incomplete = true; + this.error = "Some workflow progress was omitted. Live runs cannot be fully replayed in this process; use /dwf list for runtime status."; + } + return false; + } + return this.apply(event); + } + + private apply(event: UnknownRecord): boolean { + if (!this.adapter.reduceWorkflowRuns) return false; + const id = String(event.runId); + const sequence = typeof event.sequence === "number" ? event.sequence : undefined; + const watermark = this.watermarks.get(id) ?? -1; + const fingerprint = JSON.stringify(event); + if (sequence !== undefined && (sequence < watermark + || sequence === watermark && this.lastEvents.get(id) === fingerprint)) return false; + try { + const state = this.adapter.reduceWorkflowRuns(this.state, event); + if (sequence !== undefined) this.watermarks.set(id, sequence); + this.lastEvents.set(id, fingerprint); + if (!state) return false; + this.state = state; + // The runtime bounds its retained runs; mirror its retention for cursors. + if (isRecord(state) && Array.isArray(state.runs)) { + const ids = new Set(state.runs.filter(isRecord).map((run) => String(run.runId))); + for (const key of this.watermarks.keys()) if (!ids.has(key)) { + this.watermarks.delete(key); + this.lastEvents.delete(key); + } + } + return true; + } catch (error) { + this.error = `Workflow progress could not be read: ${error instanceof Error ? error.message : String(error)}`; + return false; + } + } + + hydrate(): Promise<void> { + if (this.loading) return this.loading; + const epoch = this.epoch; + if (!this.incomplete) this.error = undefined; + const operation = Promise.resolve().then(async () => { + // Read replay before summaries. Live events are buffered until replay is + // applied, then sequence watermarks discard duplicate deliveries. + const results = await Promise.allSettled([ + this.adapter.replayWorkflowRuns?.({ excludeRunIds: new Set<string>() }), + this.adapter.listWorkflowRuns?.() + ]); + if (epoch !== this.epoch) return; + const [replay, summaries] = results; + if (replay.status === "fulfilled" && Array.isArray(replay.value)) { + for (const event of replay.value) if (isRecord(event)) this.apply(event); + } + if (summaries.status === "fulfilled" && Array.isArray(summaries.value)) { + this.summaries = summaries.value.filter((value) => isRecord(value) && typeof value.runId === "string").slice(0, maximumRuns); + } + for (const result of results) if (result.status === "rejected") { + this.error = `Workflow history could not be loaded: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}. Run /workflows to retry.`; + } + }).finally(() => { + if (epoch !== this.epoch) return; + this.loading = undefined; + for (const event of this.buffered) this.apply(event); + this.buffered = []; + }); + this.loading = operation; + return operation; + } +} + +function safe(value: unknown): string { + return sanitizeTerminalText(typeof value === "string" ? value : "", { preserveSgr: false }).slice(0, 2_000); +} + +export function workflowRunDetail(run: UnknownRecord): string { + const nodes = Array.isArray(run.nodes) ? run.nodes.filter(isRecord) : undefined; + const actors = Array.isArray(run.actors) ? run.actors.filter(isRecord) : []; + const artifacts = Array.isArray(run.artifacts) ? run.artifacts.filter(isRecord) : []; + const usage = isRecord(run.usage) ? run.usage : undefined; + const lines = [safe(run.label) || safe(run.runId), `Status: ${safe(run.status) || "unknown"}${run.stopReason ? ` · ${safe(run.stopReason)}` : ""}`]; + if (run.progressIncomplete === true) lines.push("Progress is incomplete. Use /dwf list for runtime status."); + if (nodes) lines.push(`Steps: ${nodes.filter((node) => node.phase === "settled").length}/${nodes.length} observed steps settled`); + if (typeof usage?.spentTokens === "number") lines.push(`Tokens: ${usage.spentTokens.toLocaleString()}`); + if (run.resumable === true) lines.push("This run can be resumed."); + if (actors.length) lines.push("", "Agents", ...actors.slice(0, 12).map((actor) => `${safe(actor.name) || safe(actor.sessionId) || "Agent"} · ${safe(actor.status)}`)); + if (artifacts.length) lines.push("", "Artifacts", ...artifacts.slice(0, 12).map((artifact) => safe(artifact.title) || safe(artifact.artifactId) || safe(artifact.id))); + if (asString(run.error)) lines.push("", `Error: ${safe(run.error)}`); + if (asString(run.resultPreview)) lines.push("", safe(run.resultPreview)); + return lines.join("\n"); +} diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 7c0c5f8..541b740 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -167,6 +167,7 @@ import { isVisibleProtocolPart, ProtocolPartView } from "./protocol-part-view.ts import { InputQueue, type QueuedSubmission } from "./input-queue.ts"; import { QueuedInputView } from "./queued-input-view.ts"; import { RuntimeActivityView } from "./runtime-activity-view.ts"; +import { DynamicWorkflows, workflowRunDetail } from "./dynamic-workflows.ts"; import { runtimeActivityActive, runtimePollInterval, @@ -676,6 +677,9 @@ class ZCodeTui { private readonly turnWork = new TurnWorkTracker(); private readonly backgroundCoordinatorMessageIds = new Set<string>(); private workflowPanel?: Record<string, unknown>; + private readonly dynamicWorkflows: DynamicWorkflows; + private dynamicWorkflowView?: Text; + private selectedDynamicWorkflow?: string; private workflowView?: Markdown; private workflowRefreshInFlight = false; private readonly permissionRequests = new PermissionRequestQueue(); @@ -723,6 +727,7 @@ class ZCodeTui { private removeStreamErrorGuards?: () => void; constructor(private readonly options: TuiOptions) { + this.dynamicWorkflows = new DynamicWorkflows(options); this.animateTurnTimer = turnTimerAnimationEnabled(); this.colorsEnabled = !options.noColor && !process.env.NO_COLOR; this.themePreference = themePreference(options.theme); @@ -900,6 +905,7 @@ class ZCodeTui { this.onSessionEvent(event); }) ?? undefined; } + void this.dynamicWorkflows.hydrate().then(() => this.renderDynamicWorkflow()); if (this.options.subscribeWorkflowEvents) { this.unsubscribeWorkflow = this.options.subscribeWorkflowEvents((event) => { this.debugEvent("workflow", event); @@ -1375,6 +1381,7 @@ class ZCodeTui { { name: "paste-image", description: "Attach an image from the system clipboard" }, { name: "attachments", description: "Manage or clear pending attachments", argumentHint: "[clear]" }, { name: "activity", description: "Inspect every active tool and open task" }, + { name: "workflows", description: "Inspect workflow progress, results and recovery" }, { name: "tasks", description: "Inspect, message or recover background tasks", @@ -1595,6 +1602,7 @@ class ZCodeTui { if (input === "/cls") { this.clearTranscriptProjection(); this.workflowView = undefined; + this.dynamicWorkflowView = undefined; this.ui.requestRender(true); return; } @@ -1630,6 +1638,10 @@ class ZCodeTui { await this.showActivityDetails(); return; } + if (input === "/workflows" || input === "/workflows list") { + await this.showDynamicWorkflows(); + return; + } if (input === "/tasks" || input === "/tasks list") { await this.showBackgroundTasks(); return; @@ -2154,6 +2166,9 @@ class ZCodeTui { ): Promise<void> { if (!isRecord(result)) return; if (result.resetSessionProjection === true) { + this.dynamicWorkflows.reset(); + this.dynamicWorkflowView = undefined; + this.selectedDynamicWorkflow = undefined; this.executionStateRevision++; this.clearTranscriptProjection(); this.workflowView = undefined; @@ -2216,6 +2231,7 @@ class ZCodeTui { this.updateMetadata(); this.ui.requestRender(); if (this.sessionModelIssue) await this.recoverSessionModel(); + void this.dynamicWorkflows.hydrate().then(() => this.renderDynamicWorkflow()); } } @@ -2224,6 +2240,7 @@ class ZCodeTui { if (turnEpoch !== undefined && turnEpoch !== this.activeTurnEpoch) return; const event = normalizeEvent(value); if (!event || this.isForeignSessionEvent(event)) return; + if (this.handleDynamicWorkflowEvent(value, event.type)) return; const taskScoped = this.backgroundTaskEvents.isTaskScoped(event); this.applyBackgroundTaskEvent(event); if (!taskScoped && event.kind && toolLifecycleEventKinds.has(event.kind)) this.turnHadWorkActivity = true; @@ -2399,12 +2416,79 @@ class ZCodeTui { this.debugEvent("session-subscription", value); const event = normalizeEvent(value); if (!event || this.isForeignSessionEvent(event)) return; + if (this.handleDynamicWorkflowEvent(value, event.type)) return; this.applyBackgroundTaskEvent(event); this.scheduleRuntimeRefresh(); } private isForeignSessionEvent(event: StreamEvent): boolean { - return Boolean(this.sessionId && event.sessionId && event.sessionId !== this.sessionId); + const sessionId = this.options.getMainSessionId?.() ?? this.sessionId; + return Boolean(sessionId && event.sessionId && event.sessionId !== sessionId); + } + + private handleDynamicWorkflowEvent(value: unknown, type: string | undefined): boolean { + if (type !== "dynamic_workflow_run_progress" || !isRecord(value)) return false; + if (this.dynamicWorkflows.accept(value.payload)) this.renderDynamicWorkflow(); + return true; + } + + private renderDynamicWorkflow(): void { + if (!this.dynamicWorkflowView || !this.selectedDynamicWorkflow) return; + const run = this.dynamicWorkflows.runs().find((run) => run.runId === this.selectedDynamicWorkflow); + if (run) this.dynamicWorkflowView.setText(workflowRunDetail(run)); + this.ui.requestRender(); + } + + private async showDynamicWorkflows(): Promise<void> { + if (!this.options.listWorkflowRuns) { + this.addNotice("Workflow inspection is unavailable in this runtime.", "warning"); + return; + } + await this.dynamicWorkflows.hydrate(); + if (this.dynamicWorkflows.error) this.addNotice(this.dynamicWorkflows.error, "warning"); + const runs = this.dynamicWorkflows.runs(); + if (runs.length === 0) { + this.addNotice("No workflow runs in this session.", "muted"); + return; + } + const choice = await this.showChoice({ + title: "Workflow runs", prompt: "Select a run to inspect its progress and results.", + items: runs.map((run) => ({ value: String(run.runId), label: asString(run.label) || String(run.runId), + description: [run.status, run.resumable === true ? "resumable" : undefined].filter(Boolean).join(" · ") })) + }); + if (!choice) return; + this.selectedDynamicWorkflow = choice.value; + const run = this.dynamicWorkflows.runs().find((run) => run.runId === choice.value); + if (!run) { + this.addNotice("This run is no longer in the current workflow list. Open /workflows again.", "muted"); + return; + } + this.dynamicWorkflowView = new Text(workflowRunDetail(run), 1, 0); + this.transcript.addBlock(this.dynamicWorkflowView); + this.ui.requestRender(); + const action = await this.showChoice({ + title: "Workflow actions", prompt: "Progress remains visible in the transcript.", + items: [ + { value: "close", label: "Back to prompt" }, + ...(["pending", "running"].includes(String(run.status)) ? [{ value: "cancel", label: "Stop workflow" }] : []), + ...(run.resumable === true ? [{ value: "resume", label: "Resume workflow" }] : []) + ] + }); + if (!action || action.value === "close") return; + // The upstream command owns cancellation/resume validation and operates on + // this same app. Never spawn another app-server to control the live run. + if (!/^[A-Za-z0-9_.:-]+$/u.test(choice.value)) { + this.addNotice("This workflow ID cannot be passed to the runtime command.", "error"); + return; + } + try { + const result = await this.options.submitPrompt(`/dwf ${action.value} ${choice.value}`, {}); + await this.handleResult(result); + await this.dynamicWorkflows.hydrate(); + this.renderDynamicWorkflow(); + } catch (error) { + this.addNotice(error instanceof Error ? error.message : String(error), "error"); + } } private isBackgroundCoordinatorReasoning(event: StreamEvent): boolean { diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index a7d1ae2..1bb538c 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -71,6 +71,10 @@ export type ListPluginReferences = () => Promise<unknown>; /** Stable boundary consumed by the local TUI; upstream details stay in the bridge. */ export interface RuntimeAdapter { + listWorkflowRuns?: () => Promise<unknown>; + replayWorkflowRuns?: (input: { excludeRunIds: ReadonlySet<string> }) => Promise<unknown>; + reduceWorkflowRuns?: (state: unknown, event: unknown) => unknown; + getMainSessionId?: () => string | undefined; loadSessionTranscript?: () => Promise<unknown>; loadSessionContextMessages?: () => Promise<unknown>; listPluginReferences?: ListPluginReferences; diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 0428818..e07cc9f 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -1008,6 +1008,18 @@ export function patchRuntimeModelCatalogReload(runtime: string): string { throw new Error("ZCode runtime is incompatible with model catalog reload (registry bridge anchor missing)."); } +/** Use the runtime's own workflow reducer so live and persisted progress agree. */ +export function patchRuntimeWorkflowReducer(runtime: string): string { + if (runtime.includes("reduceWorkflowRuns:(")) return runtime; + const reducer = /[A-Za-z_$][\w$]*\(([A-Za-z_$][\w$]*),"reduceWorkflowRunsState"\)/u.exec(runtime); + const init = reducer && [...runtime.slice(0, reducer.index).matchAll( + /([A-Za-z_$][\w$]*)=[A-Za-z_$][\w$]*\(\(\)=>\{/gu + )].at(-1)?.[1]; + const option = /replayWorkflowRuns:([A-Za-z_$][\w$]*)\.replayWorkflowRuns/u.exec(runtime); + if (!reducer || !init || !option) throw new Error("ZCode runtime is incompatible with the workflow reducer bridge."); + return runtime.replace(option[0], `${option[0]},reduceWorkflowRuns:($zState,$zEvent)=>{${init}();return ${reducer[1]}($zState,$zEvent)}`); +} + export function patchRuntimeSharedConfig(runtime: string): string { if (runtime.includes('ZCODE_CLI_MIGRATE_CONFIG==="1"')) return runtime; const file = /([A-Za-z_$][\w$]*)="config.json",([A-Za-z_$][\w$]*)="~\/\.zcode\/cli"/u.exec(runtime); @@ -1558,6 +1570,10 @@ const terminalProjectionMarkers = [ ] as const; export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ + { + id: "tui-workflow-reducer", requirement: "optional", apply: patchRuntimeWorkflowReducer, + verify: runtime => runtime.includes("reduceWorkflowRuns:(") + }, { id: "tui-execution-state", requirement: "required", apply: patchRuntimeTuiExecutionState, verify: runtime => runtime.includes('"readExecutionState"') && runtime.includes('"setPlanEnabled"') diff --git a/test/dynamic-workflows.test.ts b/test/dynamic-workflows.test.ts new file mode 100644 index 0000000..552b92a --- /dev/null +++ b/test/dynamic-workflows.test.ts @@ -0,0 +1,80 @@ +import { expect, test } from "bun:test"; +import { DynamicWorkflows, workflowRunDetail } from "../packages/zcode-tui/src/dynamic-workflows.ts"; +import { patchRuntimeWorkflowReducer } from "../scripts/sync-runtime.ts"; + +test("workflow bridge exposes the native reducer and is idempotent", () => { + const source = 'var init=boot(()=>{label(reduce,"reduceWorkflowRunsState")});function reduce(state,event){return{state,event}};const opts={replayWorkflowRuns:handler.replayWorkflowRuns};'; + const patched = patchRuntimeWorkflowReducer(source); + expect(patchRuntimeWorkflowReducer(patched)).toBe(patched); + let initialized = false; + const options = new Function("boot", "label", "handler", `${patched};return opts`)( + (fn: () => void) => () => { initialized = true; fn(); }, () => {}, {} + ); + expect(options.reduceWorkflowRuns("state", "event")).toEqual({ state: "state", event: "event" }); + expect(initialized).toBeTrue(); + expect(() => patchRuntimeWorkflowReducer("unsupported")).toThrow("incompatible"); +}); + +test("replay precedes live updates and duplicate sequences do not regress progress", async () => { + let release!: (value: unknown) => void; + const sequences: number[] = []; + const workflows = new DynamicWorkflows({ + listWorkflowRuns: async () => [{ runId: "run", label: "Review", status: "stopped", resumable: true }], + replayWorkflowRuns: () => new Promise((resolve) => { release = resolve; }), + reduceWorkflowRuns: (_state, value) => { + const event = value as { sequence: number }; + sequences.push(event.sequence); + return { runs: [{ runId: "run", status: "running", resumable: false }] }; + } + }); + const hydration = workflows.hydrate(); + await Promise.resolve(); + workflows.accept({ runId: "run", sequence: 2, eventType: "run-started" }); + release([{ runId: "run", sequence: 1, eventType: "run-started" }]); + await hydration; + workflows.accept({ runId: "run", sequence: 1, eventType: "run-settled" }); + expect(sequences).toEqual([1, 2]); + expect(workflows.runs()[0]).toMatchObject({ label: "Review", status: "running", resumable: false }); +}); + +test("reset rejects late old-session hydration and exposes recoverable errors", async () => { + let release!: (value: unknown) => void; + const workflows = new DynamicWorkflows({ listWorkflowRuns: () => new Promise((resolve) => { release = resolve; }) }); + const hydration = workflows.hydrate(); + await Promise.resolve(); + workflows.reset(); + release([{ runId: "old", status: "completed" }]); + await hydration; + expect(workflows.runs()).toEqual([]); + const failing = new DynamicWorkflows({ listWorkflowRuns: async () => { throw new Error("Store unavailable"); } }); + await failing.hydrate(); + expect(failing.error).toContain("/workflows to retry"); +}); + +test("a resumed run replaces synthetic interrupted replay at the same sequence", async () => { + const events: string[] = []; + const workflows = new DynamicWorkflows({ + replayWorkflowRuns: async () => [{ runId: "run", sequence: 2, eventType: "run-settled", payload: { resumable: true } }], + reduceWorkflowRuns: (_state, value) => { + const event = value as { eventType: string }; + events.push(event.eventType); + return { runs: [{ runId: "run", status: event.eventType === "run-started" ? "running" : "stopped" }] }; + } + }); + await workflows.hydrate(); + const start = { runId: "run", sequence: 2, eventType: "run-started", payload: {} }; + workflows.accept(start); + workflows.accept(start); + expect(events).toEqual(["run-settled", "run-started"]); + expect(workflows.runs()[0]?.status).toBe("running"); +}); + +test("details use observed counts and sanitize runtime text", () => { + const detail = workflowRunDetail({ runId: "run", label: "Review\x1b[31m", status: "running", + nodes: [{ phase: "settled" }, { phase: "executing" }], usage: { spentTokens: 123 }, + artifacts: [{ title: "report.md" }], actors: [{ name: "Reviewer", status: "working" }] }); + expect(detail).toContain("1/2 observed steps settled"); + expect(detail).toContain("123"); + expect(detail).toContain("report.md"); + expect(detail).not.toContain("\x1b"); +}); diff --git a/test/tui/dynamic-workflows.test.ts b/test/tui/dynamic-workflows.test.ts new file mode 100644 index 0000000..db5771a --- /dev/null +++ b/test/tui/dynamic-workflows.test.ts @@ -0,0 +1,23 @@ +import { test } from "bun:test"; +import { join } from "node:path"; +import { writeFile } from "node:fs/promises"; +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +test.each([60, 110])("workflow inspection restores progress and resumes via keyboard at %i columns", async (cols) => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-workflows-" }); + await using session = TerminalSession.start({ command: [process.execPath, join(import.meta.dir, "fixtures/dynamic-workflows.ts")], workspace, cols }); + await session.waitForScreen("ready", /scenario\/model/u); + await session.sendAndWait("/workflows\r", "run list", /Review workspace/u); + session.send("\r"); + await session.waitForScreen("actions", /Resume workflow/u); + await session.waitForHistory("restored progress", /1\/2 observed steps settled/u); + await session.waitForHistory("artifact", /report\.md/u); + if (process.env.ZCODE_TEST_SCREEN_DIRECTORY) { + await writeFile(join(process.env.ZCODE_TEST_SCREEN_DIRECTORY, `workflow-${cols}.txt`), session.screenText()); + } + session.send("\x1b[B\r"); + await session.waitForHistory("resume result", /Resumed dynamic workflow run run-1/u); + await session.waitForHistory("live progress", /2\/2 observed steps settled/u); + await session.exit(); +}, 20_000); diff --git a/test/tui/fixtures/dynamic-workflows.ts b/test/tui/fixtures/dynamic-workflows.ts new file mode 100644 index 0000000..7cf4786 --- /dev/null +++ b/test/tui/fixtures/dynamic-workflows.ts @@ -0,0 +1,23 @@ +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +let sink: ((event: unknown) => void | Promise<void>) | undefined; +let resumed = false; +const run = () => ({ runId: "run-1", label: "Review workspace", status: resumed ? "running" : "stopped", + resumable: !resumed, nodes: [{ phase: "settled" }, { phase: resumed ? "settled" : "waiting" }], + usage: { spentTokens: 321 }, actors: [{ name: "Reviewer", status: "waiting" }], artifacts: [{ title: "report.md" }] }); +await runTui({ + initialModel: "scenario/model", workspaceDirectory: process.cwd(), + getMainSessionId: () => "main-session", + subscribeSessionEvents: (listener) => { sink = listener; return () => { sink = undefined; }; }, + listWorkflowRuns: async () => [run()], + replayWorkflowRuns: async () => [{ runId: "run-1", sequence: resumed ? 2 : 1, eventType: "run-settled", payload: run() }], + reduceWorkflowRuns: (_state, event) => ({ runs: [(event as { payload: unknown }).payload] }), + submitPrompt: async (input) => { + if (input === "/dwf resume run-1") { + resumed = true; + await sink?.({ type: "dynamic_workflow_run_progress", sessionId: "main-session", + payload: { runId: "run-1", sequence: 2, eventType: "run-started", payload: run() } }); + return { response: "Resumed dynamic workflow run run-1." }; + } + return { response: String(input) }; + } +}); From eeb80e887ec1324076ee4bd5926b9731179e50fd Mon Sep 17 00:00:00 2001 From: Kingsword <kingsword09@gmail.com> Date: Wed, 23 Sep 2026 20:00:35 +0800 Subject: [PATCH 2/2] fix(tui): preserve legacy workflow command handling --- packages/zcode-tui/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 541b740..f120153 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -1638,7 +1638,7 @@ class ZCodeTui { await this.showActivityDetails(); return; } - if (input === "/workflows" || input === "/workflows list") { + if ((input === "/workflows" || input === "/workflows list") && this.options.listWorkflowRuns) { await this.showDynamicWorkflows(); return; }