diff --git a/README.md b/README.md index d455b07..8aec9b3 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,9 @@ Mermaid previews with source fallback for unsupported or oversized diagrams. **Inspection and navigation.** `/diff` browser for current Git changes and per-turn file changes; `/context` prompt-composition, cache and context-usage details; `/status` session, runtime, goal, MCP and workspace details; -`/activity` and `/tasks` for active tools and background tasks; searchable -transcript navigation with per-block expansion, selected-block copying and +`/activity` and a task center for background status, output, agent conversations +and recovery; searchable transcript navigation with per-block expansion, +selected-block copying and `n`/`N` match traversal; persistent active-tool, background-task and open-plan activity between the transcript and editor. @@ -246,7 +247,10 @@ picker to return to input selection, then `Esc` again to close rewind. /context inspect context usage and source composition /status inspect detailed runtime and session status /activity inspect every active tool and open task -/tasks inspect or stop background tasks +/tasks inspect and manage background tasks +/tasks message send guidance to a running background agent +/tasks resume [text] resume a stopped or failed background agent +/tasks stop stop a running background task /search search retained transcript blocks /search next|prev|clear navigate or close transcript search /transcript latest select the latest transcript block @@ -254,6 +258,23 @@ picker to return to input selection, then `Esc` again to close rewind. /copy copy the selected block, or the latest response ``` +The task center keeps autonomous task output out of the foreground transcript. +The main conversation receives only compact completion, reply and failure +notices; select the task to inspect its output and task-scoped activity. Agent +tasks can receive messages while running, and the official runtime resumes a +terminal agent from its saved child session when messaged. Bash tasks expose a +reviewable rerun request because a stopped process cannot continue from an +execution checkpoint. Saved final task output remains available after a TUI +restart, with large files limited to their latest 64 KiB. Workflow tasks open +their existing run panel and controls. + +Agent calls that finish within one second remain ordinary foreground tools so +their result can feed the current response directly. Longer Agent calls move to +the task center automatically, releasing the foreground turn while they keep +running. Set `subagents.autoBackgroundMs` to a different positive duration, or +to `0` to disable automatic backgrounding. An explicit +`run_in_background: true` still backgrounds an Agent immediately. + While the editor is empty, `Alt+Up` and `Alt+Down` navigate selected transcript blocks. `Ctrl+O` expands only the selected/search-matched block; without a selection it toggles all expandable content. During transcript search, `n` and diff --git a/config.example.json b/config.example.json index 83335eb..d544b2f 100644 --- a/config.example.json +++ b/config.example.json @@ -50,6 +50,9 @@ "skill": true, "mcp": true }, + "subagents": { + "autoBackgroundMs": 1000 + }, "memory": { "use": true, "write": true, diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 9c938fc..14a9df9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -168,6 +168,24 @@ Project-level overrides are read from `zcode.json` or `.zcode/config.json` in the working directory. Running `/model` does not call the provider, so it is a safe configuration check before the first prompt. +### Background agents + +Long-running Agent calls automatically detach from the foreground turn after +one second and remain available through `/tasks`. Short Agent calls stay inline +so the current response can use their result without a notification round trip. +Configure the threshold in milliseconds: + +```json +{ + "subagents": { + "autoBackgroundMs": 1000 + } +} +``` + +Set the value to `0` to disable automatic backgrounding. Agent tool calls that +use `run_in_background: true` detach immediately regardless of this threshold. + ### Request retries and stalled streams The CLI leaves retry classification and execution to the official ZCode @@ -193,6 +211,20 @@ older generated file still contains `600000`. Retryable timeouts, dropped streams, rate limits and server/network errors are retried and shown in the TUI. Authentication and invalid-request responses remain non-retryable. +## Runtime diagnostics + +The interactive TUI captures runtime `stderr` so background diagnostics cannot +overwrite terminal rendering. A non-zero runtime exit prints its status and the +diagnostic path after the TUI stops. The active log is capped at 2 MB and rotated +to `.1` on the next launch; both files use owner-only permissions. + +The default path is `~/.zcode/cli/tui-runtime.log`. Override it when collecting +diagnostics in an isolated environment: + +```bash +ZCODE_TUI_RUNTIME_LOG=/tmp/zcode-tui-runtime.log zcode +``` + ## Theme Set `ui.theme` to `"auto"` (terminal detection), `"dark"`, or `"light"` in the diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index f92eec5..bbafcb2 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -40,7 +40,8 @@ config output, executes `/help`, switches to plan mode, exits, and checks that the launcher forwards terminal SIGHUP shutdown. The offline feature scenario also covers suspended login restoration, selectors, image attachments, nested Agent tools, Markdown, Mermaid, diffs, transcript -navigation, context/status details, MCP actions, background tasks and the +navigation, context/status details, MCP actions, task-scoped background output, +terminal-agent recovery and the workflow panel. A pressure scenario verifies that steering, UTF-8 input and Ctrl+C cancellation remain responsive during rapid Bash progress output. The scenarios advance from observed terminal output instead of fixed timers and do diff --git a/packages/zcode-tui/src/background-task-events.ts b/packages/zcode-tui/src/background-task-events.ts new file mode 100644 index 0000000..d45f5db --- /dev/null +++ b/packages/zcode-tui/src/background-task-events.ts @@ -0,0 +1,367 @@ +import type { StreamEvent } from "./events.ts"; + +export type TaskActivityKind = "assistant" | "error" | "system" | "user"; + +export interface TaskActivityEntry { + id: string; + kind: TaskActivityKind; + text: string; + timestamp: number; + turnId?: string; +} + +export interface TaskEventNotice { + detail?: string; + notification: "completed" | "failed"; + summary: string; + title: string; + tone: "error" | "muted" | "warning"; +} + +export interface TaskEventUpdate { + changed: boolean; + handoffSettled: boolean; + notices: TaskEventNotice[]; +} + +interface HandoffTurn { + source: "background_task" | "subagent_message"; + taskIds: string[]; +} + +const maximumEntriesPerTask = 32; +const maximumCharactersPerTask = 64_000; +const maximumEntryCharacters = 20_000; +const maximumScopedTurnIds = 256; +const maximumScopedToolCallIds = 512; + +function autonomousInputSource(source: string | undefined): source is HandoffTurn["source"] { + return source === "background_task" || source === "subagent_message"; +} + +function taskIdFor(event: StreamEvent): string | undefined { + return event.taskId ?? event.agentId; +} + +function terminalTaskStatus(status: string | undefined): boolean { + return status === "completed" + || status === "failed" + || status === "timed_out" + || status === "cancelled" + || status === "spawn_error" + || status === "lost" + || status === "stopped"; +} + +function taskFailure(status: string | undefined): boolean { + return status === "failed" + || status === "timed_out" + || status === "spawn_error" + || status === "lost"; +} + +function bounded(value: string): string { + return value.length <= maximumEntryCharacters + ? value + : value.slice(0, maximumEntryCharacters) + "\n[truncated]"; +} + +function backgroundAgentToolName(name: string | undefined): boolean { + const normalized = name?.trim().toLowerCase(); + return normalized === "agent" || normalized === "subagent"; +} + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? value as Record + : undefined; +} + +function explicitlyBackgroundAgentInput(value: unknown): boolean { + return record(value)?.run_in_background === true; +} + +function asyncAgentResult(value: unknown): boolean { + const root = record(value); + const candidates = [root, record(root?.output), record(root?.result)]; + return candidates.some((candidate) => candidate?.isAsync === true + || candidate?.status === "async_launched" + || candidate?.status === "backgrounded" + || typeof candidate?.backgroundTaskId === "string"); +} + +function toolScope(event: StreamEvent): { + id?: string; + name?: string; + parentId?: string; +} { + const part = event.part?.type === "tool" ? event.part : undefined; + return { + id: event.toolCallId ?? part?.toolCallId ?? part?.partId, + name: event.toolName ?? part?.toolName, + parentId: event.progress?.parentToolCallId ?? part?.parentToolCallId + }; +} + +export class BackgroundTaskEventStore { + private readonly activities = new Map(); + private readonly handoffTurns = new Map(); + private readonly pendingBackgroundTasks = new Set(); + private readonly pendingSubagentMessages = new Set(); + private readonly scopedTurnIds = new Set(); + private readonly scopedToolCallIds = new Set(); + private readonly toolParents = new Map(); + private readonly seenEventIds = new Set(); + private readonly recentTerminalNotices = new Map(); + + handle(event: StreamEvent): TaskEventUpdate { + this.rememberScopedTool(event); + if (event.turnId && autonomousInputSource(event.inputSource)) this.rememberScopedTurn(event.turnId); + if (event.eventId && !this.claim(event.eventId)) { + return { changed: false, handoffSettled: false, notices: [] }; + } + + const notices: TaskEventNotice[] = []; + let changed = false; + let handoffSettled = false; + const taskId = taskIdFor(event); + + if (event.type === "background_task_completed" && taskId) { + this.pendingBackgroundTasks.add(taskId); + const status = event.taskStatus ?? "completed"; + this.record(taskId, "system", `Task ${status.replaceAll("_", " ")}.`, event.turnId); + if (taskFailure(status) && event.message) this.record(taskId, "error", event.message, event.turnId); + changed = true; + if (this.claimTerminalNotice(taskId, status)) { + notices.push({ + notification: taskFailure(status) ? "failed" : "completed", + summary: `${taskId} · /tasks`, + title: taskFailure(status) ? "Background task needs attention" : "Background task completed", + tone: taskFailure(status) ? "error" : "muted" + }); + } + } else if (event.type === "subagent_message" && taskId) { + this.pendingSubagentMessages.add(taskId); + if (event.message?.trim()) this.record(taskId, "assistant", event.message, event.turnId); + changed = true; + notices.push({ + notification: "completed", + summary: `${taskId} · /tasks`, + title: "Background agent replied", + tone: "muted" + }); + } else if (event.type === "subagent_stopped" && taskId) { + const status = event.taskStatus ?? "stopped"; + const detail = event.message ?? `Agent ${status.replaceAll("_", " ")}.`; + this.record(taskId, taskFailure(status) ? "error" : "system", detail, event.turnId); + changed = true; + if (taskFailure(status) && this.claimTerminalNotice(taskId, status)) { + notices.push({ + notification: "failed", + summary: `${taskId} · /tasks`, + title: "Background agent needs attention", + tone: "error" + }); + } + } else if ((event.type === "background_task_started" || event.type === "background_task_updated") + && taskId + && terminalTaskStatus(event.taskStatus)) { + changed = true; + } + + const started = event.type === "turn_started" || event.type === "turn.started"; + if (started + && event.turnId + && autonomousInputSource(event.inputSource)) { + const pending = event.inputSource === "background_task" + ? this.pendingBackgroundTasks + : this.pendingSubagentMessages; + const taskIds = [...new Set([...(event.taskIds ?? []), ...pending])]; + pending.clear(); + this.handoffTurns.set(event.turnId, { source: event.inputSource, taskIds }); + changed = taskIds.length > 0 || changed; + return { changed, handoffSettled: false, notices }; + } + + const handoff = event.turnId ? this.handoffTurns.get(event.turnId) : undefined; + if (handoff && event.kind === "text_delta" && event.delta) { + for (const id of handoff.taskIds) this.appendHandoffDelta(id, event.turnId!, event.delta); + changed = handoff.taskIds.length > 0 || changed; + } + + const completed = event.type === "turn_complete" || event.type === "turn.completed"; + const failed = event.type === "turn_error" || event.type === "turn.failed"; + if (handoff && (completed || failed)) { + this.handoffTurns.delete(event.turnId!); + handoffSettled = true; + if (failed) { + const detail = event.message ?? "The task finished, but ZCode could not process its result."; + for (const id of handoff.taskIds) this.record(id, "error", detail, event.turnId); + notices.push({ + notification: "failed", + summary: handoff.taskIds.length > 0 + ? `${handoff.taskIds.join(", ")} · /tasks` + : "/tasks", + title: "Background result processing failed", + tone: "error" + }); + changed = true; + } + } + + return { changed, handoffSettled, notices }; + } + + entries(taskId: string): readonly TaskActivityEntry[] { + return this.activities.get(taskId) ?? []; + } + + hasActiveHandoffs(): boolean { + return this.handoffTurns.size > 0; + } + + settleActiveHandoffs(): number { + const count = this.handoffTurns.size; + this.handoffTurns.clear(); + return count; + } + + isTaskScoped(event: StreamEvent): boolean { + return autonomousInputSource(event.inputSource) + || Boolean(event.turnId && this.scopedTurnIds.has(event.turnId)) + || this.isBackgroundToolScoped(event); + } + + isBackgroundToolScoped(event: StreamEvent): boolean { + const scope = toolScope(event); + const part = event.part?.type === "tool" ? event.part : undefined; + return Boolean(backgroundAgentToolName(scope.name) + && (explicitlyBackgroundAgentInput(event.input ?? part?.input) + || asyncAgentResult(event.result ?? part?.output))) + || Boolean(scope.id && this.scopedTool(scope.id)) + || Boolean(scope.parentId && this.scopedTool(scope.parentId)); + } + + recordUserMessage(taskId: string, message: string): void { + this.record(taskId, "user", message); + } + + recordSystemMessage(taskId: string, message: string, failed = false): void { + this.record(taskId, failed ? "error" : "system", message); + } + + private appendHandoffDelta(taskId: string, turnId: string, delta: string): void { + const entries = this.activities.get(taskId) ?? []; + const id = `handoff:${turnId}`; + const existing = entries.find((entry) => entry.id === id); + if (existing) { + existing.text = bounded(existing.text + delta); + existing.timestamp = Date.now(); + } else { + entries.push({ + id, + kind: "assistant", + text: bounded(delta), + timestamp: Date.now(), + turnId + }); + } + this.retain(taskId, entries); + } + + private record(taskId: string, kind: TaskActivityKind, text: string, turnId?: string): void { + const value = text.trim(); + if (!value) return; + const entries = this.activities.get(taskId) ?? []; + entries.push({ + id: `task-event:${crypto.randomUUID()}`, + kind, + text: bounded(value), + timestamp: Date.now(), + ...(turnId ? { turnId } : {}) + }); + this.retain(taskId, entries); + } + + private retain(taskId: string, entries: TaskActivityEntry[]): void { + while (entries.length > maximumEntriesPerTask + || entries.reduce((total, entry) => total + entry.text.length, 0) > maximumCharactersPerTask) { + entries.shift(); + } + this.activities.set(taskId, entries); + } + + private claim(eventId: string): boolean { + if (this.seenEventIds.has(eventId)) return false; + this.seenEventIds.add(eventId); + if (this.seenEventIds.size > 4_096) { + const oldest = this.seenEventIds.values().next().value; + if (oldest) this.seenEventIds.delete(oldest); + } + return true; + } + + private rememberScopedTurn(turnId: string): void { + if (this.scopedTurnIds.has(turnId)) return; + this.scopedTurnIds.add(turnId); + if (this.scopedTurnIds.size > maximumScopedTurnIds) { + const oldest = this.scopedTurnIds.values().next().value; + if (oldest) this.scopedTurnIds.delete(oldest); + } + } + + private rememberScopedTool(event: StreamEvent): void { + const scope = toolScope(event); + if (scope.id && scope.parentId) this.rememberToolParent(scope.id, scope.parentId); + const part = event.part?.type === "tool" ? event.part : undefined; + const backgroundLifecycleParent = (event.type === "background_task_started" + || event.type === "background_task_updated") + && (event.taskKind === "local_agent" || event.toolName === "Agent") + ? event.progress?.parentToolCallId + : undefined; + if (backgroundLifecycleParent) this.rememberScopedToolCall(backgroundLifecycleParent); + if (!scope.id) return; + if ((backgroundAgentToolName(scope.name) + && (explicitlyBackgroundAgentInput(event.input ?? part?.input) + || asyncAgentResult(event.result ?? part?.output))) + || Boolean(scope.parentId && this.scopedTool(scope.parentId))) { + this.rememberScopedToolCall(scope.id); + } + } + + private scopedTool(toolCallId: string): boolean { + let current: string | undefined = toolCallId; + const visited = new Set(); + while (current && !visited.has(current)) { + if (this.scopedToolCallIds.has(current)) return true; + visited.add(current); + current = this.toolParents.get(current); + } + return false; + } + + private rememberToolParent(toolCallId: string, parentToolCallId: string): void { + this.toolParents.delete(toolCallId); + this.toolParents.set(toolCallId, parentToolCallId); + if (this.toolParents.size > maximumScopedToolCallIds) { + const oldest = this.toolParents.keys().next().value; + if (oldest) this.toolParents.delete(oldest); + } + } + + private rememberScopedToolCall(toolCallId: string): void { + if (this.scopedToolCallIds.has(toolCallId)) return; + this.scopedToolCallIds.add(toolCallId); + if (this.scopedToolCallIds.size > maximumScopedToolCallIds) { + const oldest = this.scopedToolCallIds.values().next().value; + if (oldest) this.scopedToolCallIds.delete(oldest); + } + } + + private claimTerminalNotice(taskId: string, status: string): boolean { + const now = Date.now(); + const recent = this.recentTerminalNotices.get(taskId); + this.recentTerminalNotices.set(taskId, { status, timestamp: now }); + return !recent || recent.status !== status || now - recent.timestamp > 5_000; + } +} diff --git a/packages/zcode-tui/src/background-task-output.ts b/packages/zcode-tui/src/background-task-output.ts new file mode 100644 index 0000000..64d0178 --- /dev/null +++ b/packages/zcode-tui/src/background-task-output.ts @@ -0,0 +1,38 @@ +import { closeSync, fstatSync, openSync, readSync } from "node:fs"; + +export interface BackgroundTaskOutput { + text: string; + truncated: boolean; +} + +const defaultMaximumBytes = 64 * 1024; + +export function readBackgroundTaskOutput( + path: string, + maximumBytes = defaultMaximumBytes +): BackgroundTaskOutput | undefined { + if (!path || !Number.isSafeInteger(maximumBytes) || maximumBytes <= 0) return undefined; + let descriptor: number | undefined; + try { + descriptor = openSync(path, "r"); + const metadata = fstatSync(descriptor); + if (!metadata.isFile() || metadata.size <= 0) return undefined; + const byteCount = Math.min(metadata.size, maximumBytes); + const position = metadata.size - byteCount; + const buffer = Buffer.allocUnsafe(byteCount); + let bytesRead = 0; + while (bytesRead < byteCount) { + const count = readSync(descriptor, buffer, bytesRead, byteCount - bytesRead, position + bytesRead); + if (count === 0) break; + bytesRead += count; + } + let start = 0; + while (start < Math.min(bytesRead, 3) && (buffer[start]! & 0xc0) === 0x80) start += 1; + const text = buffer.subarray(start, bytesRead).toString("utf8").trim(); + return text ? { text, truncated: position > 0 } : undefined; + } catch { + return undefined; + } finally { + if (descriptor !== undefined) closeSync(descriptor); + } +} diff --git a/packages/zcode-tui/src/events.ts b/packages/zcode-tui/src/events.ts index e801bdf..93a71ff 100644 --- a/packages/zcode-tui/src/events.ts +++ b/packages/zcode-tui/src/events.ts @@ -9,6 +9,15 @@ export interface StreamEvent { messageId?: string; partId?: string; turnId?: string; + eventId?: string; + inputSource?: string; + taskId?: string; + taskIds?: string[]; + taskKind?: string; + taskStatus?: string; + agentId?: string; + agentType?: string; + childSessionId?: string; targetTurnId?: string; inputId?: string; pendingInputId?: string; @@ -82,6 +91,8 @@ export function normalizeEvent(value: unknown): StreamEvent | null { const part = normalizeRestoredPart(partValue); const error = body.error; const errorRecord = isRecord(error) ? error : undefined; + const originMeta = nestedRecord(body, "originMeta"); + const subagentMessage = nestedRecord(body, "subagentMessage"); const streamRecovery = nestedRecord(body, "streamRecovery"); const recordNumber = (record: UnknownRecord | undefined, key: string): number | undefined => { const field = record?.[key]; @@ -113,6 +124,31 @@ export function normalizeEvent(value: unknown): StreamEvent | null { ?? part?.messageId, partId: asString(body.partId) ?? asString(body.partID) ?? part?.partId, turnId: envelopeString("turnId") ?? envelopeString("turnID"), + eventId: asString(value.eventId) + ?? (params && asString(params.eventId)) + ?? asString(value.id) + ?? (params && asString(params.id)) + ?? asString(body.eventId) + ?? asString(body.id), + inputSource: envelopeString("inputSource"), + taskId: envelopeString("taskId") + ?? envelopeString("taskID") + ?? (originMeta && asString(originMeta.workId)), + taskIds: strings("taskIds") + ?? strings("taskIDs") + ?? (originMeta && ( + Array.isArray(originMeta.workIds) + ? originMeta.workIds.filter((item): item is string => typeof item === "string") + : asString(originMeta.workId) ? [asString(originMeta.workId)!] : undefined + )), + taskKind: envelopeString("taskKind") ?? envelopeString("taskType"), + taskStatus: envelopeString("status"), + agentId: envelopeString("agentId") + ?? (subagentMessage && asString(subagentMessage.agentId)), + agentType: envelopeString("agentType") + ?? (subagentMessage && asString(subagentMessage.agentType)), + childSessionId: envelopeString("childSessionId") + ?? (subagentMessage && asString(subagentMessage.childSessionId)), targetTurnId: envelopeString("targetTurnId") ?? envelopeString("targetTurnID"), inputId: asString(body.inputId) ?? asString(body.inputID), pendingInputId: asString(body.pendingInputId) ?? asString(body.pendingInputID), @@ -132,7 +168,11 @@ export function normalizeEvent(value: unknown): StreamEvent | null { errorCode: asString(body.errorCode), errorPhase: asString(body.errorPhase), exceptionType: asString(body.exceptionType), - message: asString(body.message) ?? (errorRecord && asString(errorRecord.message)), + message: asString(body.message) + ?? asString(body.summaryText) + ?? (type === "subagent_message" ? asString(body.text) : undefined) + ?? asString(error) + ?? (errorRecord && asString(errorRecord.message)), progress: { elapsedMs: number("elapsedMs"), durationMs: number("durationMs") ?? number("duration"), diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index 1f0ec41..4f70407 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -31,6 +31,8 @@ import { } from "./attachments.ts"; import { AttachmentBar } from "./attachment-bar.ts"; import { AssistantStream } from "./assistant-stream.ts"; +import { BackgroundTaskEventStore } from "./background-task-events.ts"; +import { readBackgroundTaskOutput } from "./background-task-output.ts"; import { BoundedToolText, toolTextValue } from "./bounded-tool-text.ts"; import { choose, promptText, type ChoiceItem } from "./choice-dialog.ts"; import { @@ -121,6 +123,7 @@ import { InputQueue, type QueuedSubmission } from "./input-queue.ts"; import { QueuedInputView } from "./queued-input-view.ts"; import { RuntimeActivityView } from "./runtime-activity-view.ts"; import { + runtimeActivityActive, runtimePollInterval, runtimeRefreshNeeded, runtimePollStateChanged, @@ -188,11 +191,13 @@ import { turnTimerAnimationEnabled } from "./turn-status.ts"; import { TurnPresentationRegistry } from "./turn-presentation-registry.ts"; +import { TurnWorkTracker } from "./turn-work-tracker.ts"; import { asString, isRecord, type PromptCallOptions, type TuiOptions } from "./types.ts"; import { UpdateAvailableView, updateCommand } from "./update-available-view.ts"; import { Divider, WelcomeBanner } from "./welcome-banner.ts"; import { WorkspaceAutocompleteProvider } from "./workspace-autocomplete.ts"; import { readWorkspaceDiff } from "./workspace-diff.ts"; +import { workedDurationLabel, WorkDurationView } from "./work-duration-view.ts"; interface ToolViewState { id: string; @@ -233,6 +238,62 @@ const terminalThemeQueryTimeoutMs = 100; const exitUsageQueryTimeoutMs = 250; const updateAvailableBlockId = "update_available"; const modelRetryBlockIdPrefix = "model_retry_status"; +const backgroundTaskAttentionStatuses = new Set(["failed", "timed_out", "spawn_error", "lost"]); + +function backgroundTaskKindLabel(job: RuntimeBackgroundJob): string { + switch (job.taskKind) { + case "local_agent": return job.agentType ? `Agent (${job.agentType})` : "Agent"; + case "local_bash": return "Bash"; + case "local_workflow": return "Workflow"; + case "monitor_mcp": return "Monitor"; + default: return job.toolName ?? "Task"; + } +} + +function backgroundTaskSortRank(job: RuntimeBackgroundJob): number { + if (isActiveBackgroundJob(job)) return 0; + if (backgroundTaskAttentionStatuses.has(job.status)) return 1; + return 2; +} + +function backgroundAgentPart(part: RestoredPart): boolean { + if (part.type !== "tool") return false; + const name = part.toolName.trim().toLowerCase(); + if (name !== "agent" && name !== "subagent") return false; + const input = isRecord(part.input) ? part.input : undefined; + const output = isRecord(part.output) ? part.output : undefined; + const nestedOutput = isRecord(output?.output) ? output.output : undefined; + return input?.run_in_background === true + || output?.isAsync === true + || output?.status === "async_launched" + || output?.status === "backgrounded" + || typeof output?.backgroundTaskId === "string" + || nestedOutput?.isAsync === true + || nestedOutput?.status === "async_launched" + || nestedOutput?.status === "backgrounded" + || typeof nestedOutput?.backgroundTaskId === "string"; +} + +function backgroundToolPartIds(parts: readonly RestoredPart[]): Set { + const hidden = new Set(); + for (const part of parts) { + if (!backgroundAgentPart(part) || part.type !== "tool") continue; + const id = part.toolCallId ?? part.partId; + if (id) hidden.add(id); + } + let changed = true; + while (changed) { + changed = false; + for (const part of parts) { + if (part.type !== "tool" || !part.parentToolCallId || !hidden.has(part.parentToolCallId)) continue; + const id = part.toolCallId ?? part.partId; + if (!id || hidden.has(id)) continue; + hidden.add(id); + changed = true; + } + } + return hidden; +} function modelRetryProgress(event: StreamEvent, phase: "scheduled" | "started"): string { const retryNumber = phase === "started" @@ -309,6 +370,16 @@ function restoredToolState(status: string): string { } } +class ConditionalContainer extends Container { + constructor(private readonly visible: () => boolean) { + super(); + } + + override render(width: number): string[] { + return this.visible() ? super.render(width) : []; + } +} + class ZCodeTui { private readonly animateTurnTimer: boolean; private readonly colorsEnabled: boolean; @@ -318,6 +389,7 @@ class ZCodeTui { private readonly ui: TUI; private readonly transcript: Transcript; private readonly choiceHost = new Container(); + private readonly composerHost = new ConditionalContainer(() => this.choiceDepth === 0); private readonly runtimeActivity: RuntimeActivityView; private readonly status: StatusLine; private readonly turnStatus: FooterBar; @@ -334,6 +406,7 @@ class ZCodeTui { private queuedSelectionCommand?: QueuedSubmission; private readonly inputQueue: InputQueue; private turnAbortController?: AbortController; + private foregroundTurnInterrupt?: AbortController; private readonly steerAbortControllers = new Set(); private primaryTurnActive = false; private primaryTurnInputId?: string; @@ -367,6 +440,10 @@ class ZCodeTui { private lastAssistantText = ""; private turnAssistantText = ""; private unsubscribeWorkflow?: () => void; + private unsubscribeSession?: () => void; + private readonly backgroundTaskEvents = new BackgroundTaskEventStore(); + private readonly turnWork = new TurnWorkTracker(); + private readonly backgroundCoordinatorMessageIds = new Set(); private workflowPanel?: Record; private workflowView?: Markdown; private workflowRefreshInFlight = false; @@ -379,6 +456,7 @@ class ZCodeTui { private turnStartedAt?: number; private turnElapsedMilliseconds = 0; private turnTimingVisible = false; + private turnHadWorkActivity = false; private turnTimer?: ReturnType; private pendingTurnNotification?: TurnNotificationKind; private pendingTurnNotificationDetail = ""; @@ -396,6 +474,8 @@ class ZCodeTui { private runtimeRefreshPending = false; private runtimeRefreshTimer?: ReturnType; private runtimePollTimer?: ReturnType; + private backgroundDrainScheduled = false; + private backgroundHandoffInterruptInFlight = false; private updateCheckAbortController?: AbortController; private loginRequired: boolean; private readonly loginWarning = new Text("", 1, 0); @@ -498,6 +578,11 @@ class ZCodeTui { if (!this.loginRequired) void this.refreshSessionUsage(); this.scheduleRuntimePoll(0); void this.loadHistory(); + if (this.options.subscribeSessionEvents) { + this.unsubscribeSession = this.options.subscribeSessionEvents((event) => { + this.onSessionEvent(event); + }) ?? undefined; + } if (this.options.subscribeWorkflowEvents) { this.unsubscribeWorkflow = this.options.subscribeWorkflowEvents((event) => { this.debugEvent("workflow", event); @@ -538,11 +623,12 @@ class ZCodeTui { this.ui.addChild(this.transcript); this.ui.addChild(this.runtimeActivity); this.ui.addChild(this.choiceHost); - this.ui.addChild(this.turnStatus); - this.ui.addChild(this.queuedInputView); - this.ui.addChild(this.attachmentBar); - this.ui.addChild(this.editor); - this.ui.addChild(this.status); + this.composerHost.addChild(this.turnStatus); + this.composerHost.addChild(this.queuedInputView); + this.composerHost.addChild(this.attachmentBar); + this.composerHost.addChild(this.editor); + this.composerHost.addChild(this.status); + this.ui.addChild(this.composerHost); const commands = this.autocompleteCommands(); const workspaceDirectory = this.options.workspaceDirectory ?? process.cwd(); @@ -682,7 +768,11 @@ 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: "tasks", description: "Inspect or stop background tasks", argumentHint: "[stop ]" }, + { + name: "tasks", + description: "Inspect, message or recover background tasks", + argumentHint: "[message|resume|stop ]" + }, { name: "diff", description: "Browse current and per-turn file changes" }, { name: "context", description: "Inspect context usage and prompt composition" }, { name: "status", description: "Inspect detailed runtime and session status" }, @@ -808,6 +898,10 @@ class ZCodeTui { return { consume: true }; } if (matchesKey(data, "escape")) { + if (this.backgroundTaskEvents.hasActiveHandoffs()) { + this.clearRewindEscape(); + return { consume: true }; + } if (this.turnAbortController) { this.clearRewindEscape(); const pendingSteer = this.inputQueue.hasPendingSteers(); @@ -818,9 +912,7 @@ class ZCodeTui { if (pendingSteer) { this.requestPendingSteerInterrupt(); } else { - this.pendingSteerInterrupt = undefined; - this.turnAbortController.abort(); - this.updateActivity("cancelling…"); + this.requestForegroundTurnInterrupt(); } return { consume: true }; } @@ -897,6 +989,14 @@ class ZCodeTui { await this.stopBackgroundTask(input.slice("/tasks stop ".length).trim()); return; } + if (input.startsWith("/tasks message ")) { + await this.sendTaskCommand(input.slice("/tasks message ".length), false); + return; + } + if (input.startsWith("/tasks resume ")) { + await this.sendTaskCommand(input.slice("/tasks resume ".length), true); + return; + } if (input === "/diff") { await this.showDiffBrowser(); return; @@ -960,6 +1060,11 @@ class ZCodeTui { this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); return; } + if (!steering && this.backgroundTaskEvents.hasActiveHandoffs()) { + this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); + this.interruptBackgroundHandoffForInput(); + return; + } const turnEpoch = steering ? this.activeTurnEpoch : ++this.turnEpoch; if (turnEpoch === undefined) { this.inputQueue.queueFollowUp({ ...submission, recordHistory: false }); @@ -995,7 +1100,10 @@ class ZCodeTui { ) : undefined; if (steering) this.recentSteerCommit = undefined; - if (!steering) this.addUserMessage(submission.displayInput, attachments.length); + if (!steering) { + this.backgroundCoordinatorMessageIds.clear(); + this.addUserMessage(submission.displayInput, attachments.length); + } if (submission.pending) { this.addNotice([ submission.pending.primary, @@ -1071,7 +1179,8 @@ class ZCodeTui { if (steering && this.activeTurnEpoch !== turnEpoch) return; const interruptedForSteer = !steering && this.isPendingSteerInterrupt(turnEpoch, abortController); - if (abortController.signal.aborted || interruptedForSteer) { + const interruptedForeground = !steering && this.foregroundTurnInterrupt === abortController; + if (abortController.signal.aborted || interruptedForSteer || interruptedForeground) { unfinishedToolState = "cancelled"; if (!steering) { this.inputQueue.autoSend = false; @@ -1134,6 +1243,7 @@ class ZCodeTui { ? "turn_failed" : "turn_ended"; const targetTurnId = this.activeTurnId; + this.turnWork.bindTurn(targetTurnId); const pendingSteerInterrupt = this.isPendingSteerInterrupt(turnEpoch, abortController) ? this.pendingSteerInterrupt : undefined; @@ -1145,6 +1255,7 @@ class ZCodeTui { this.activeTurnId = undefined; this.activeTurnEpoch = undefined; this.pendingSteerInterrupt = undefined; + if (this.foregroundTurnInterrupt === abortController) this.foregroundTurnInterrupt = undefined; this.recentSteerCommit = undefined; if (this.turnAbortController === abortController) this.turnAbortController = undefined; for (const controller of this.steerAbortControllers) controller.abort(); @@ -1207,6 +1318,28 @@ class ZCodeTui { }); } + private requestForegroundTurnInterrupt(): void { + const abortController = this.turnAbortController; + if (!abortController || abortController.signal.aborted) return; + if (this.foregroundTurnInterrupt === abortController) return; + this.pendingSteerInterrupt = undefined; + this.foregroundTurnInterrupt = abortController; + this.updateActivity("cancelling…"); + const interruptTurn = this.options.interruptTurn; + if (!interruptTurn) { + abortController.abort(); + return; + } + void interruptTurn({ reason: "TUI interrupted the active foreground turn." }).then((outcome) => { + if (this.turnAbortController !== abortController || abortController.signal.aborted) return; + if (!isRecord(outcome) || asString(outcome.kind) !== "stopped") abortController.abort(); + }).catch(() => { + if (this.turnAbortController === abortController && !abortController.signal.aborted) { + abortController.abort(); + } + }); + } + private isPendingSteerInterrupt( turnEpoch: number, abortController: AbortController @@ -1310,6 +1443,19 @@ class ZCodeTui { if (turnEpoch !== undefined && turnEpoch !== this.activeTurnEpoch) return; const event = normalizeEvent(value); if (!event) return; + const taskScoped = this.backgroundTaskEvents.isTaskScoped(event); + this.applyBackgroundTaskEvent(event); + if (!taskScoped && event.kind && toolLifecycleEventKinds.has(event.kind)) this.turnHadWorkActivity = true; + const backgroundToolScoped = this.backgroundTaskEvents.isBackgroundToolScoped(event); + if (backgroundToolScoped) { + this.suppressBackgroundToolTranscript(event); + this.suppressBackgroundCoordinatorMessage(event.messageId); + } + if (taskScoped || this.backgroundTaskEvents.isTaskScoped(event)) { + if (runtimeRefreshNeeded(event)) this.scheduleRuntimeRefresh(); + return; + } + if (this.isBackgroundCoordinatorReasoning(event)) return; const steerQueued = event.type === "turn_steer_queued" || event.type === "turn.steerQueued"; if (this.turnAbortController && steerQueued @@ -1322,6 +1468,7 @@ class ZCodeTui { if ((event.type === "turn_started" || event.type === "turn.started") && event.inputId === this.primaryTurnInputId) { this.activeTurnId = event.turnId ?? event.targetTurnId ?? this.activeTurnId; + this.turnWork.bindTurn(this.activeTurnId); } if (runtimeRefreshNeeded(event)) this.scheduleRuntimeRefresh(); if (this.inputQueue.handleLifecycleEvent(event)) { @@ -1409,7 +1556,7 @@ class ZCodeTui { : "waiting for model…", false ); - } else if (event.type === "turn.failed") { + } else if (event.type === "turn.failed" || event.type === "turn_error") { this.finalizeUnresolvedTools("failed", event.message ?? "Turn failed."); this.addSystemEvent({ tone: "error", title: "Turn failed", detail: event.message }); } else if (event.type === "model_retry_scheduled" || event.type === "streamRecovery.updated") { @@ -1458,6 +1605,131 @@ class ZCodeTui { this.requestStreamRender(); } + private onSessionEvent(value: unknown): void { + this.debugEvent("session-subscription", value); + const event = normalizeEvent(value); + if (!event) return; + this.applyBackgroundTaskEvent(event); + } + + private isBackgroundCoordinatorReasoning(event: StreamEvent): boolean { + if (!event.messageId || !this.backgroundCoordinatorMessageIds.has(event.messageId)) return false; + return event.kind === "reasoning_start" + || event.kind === "reasoning_delta" + || event.kind === "reasoning_end" + || event.part?.type === "thought" + || event.field === "reasoning"; + } + + private suppressBackgroundCoordinatorMessage(messageId: string | undefined): void { + if (!messageId) return; + this.backgroundCoordinatorMessageIds.add(messageId); + let changed = false; + for (const [partId, thinking] of [...this.thinkingParts]) { + if (this.protocolPartMessages.get(partId) !== messageId) continue; + this.thinkingParts.delete(partId); + this.protocolPartKinds.delete(partId); + this.protocolPartMessages.delete(partId); + changed = this.transcript.removeBlock(partId) || changed; + if (this.currentThinking === thinking) { + this.currentThinking = undefined; + this.currentThinkingPartId = undefined; + } + } + if (changed) this.ui.requestRender(); + } + + private suppressBackgroundToolTranscript(event: StreamEvent): void { + const part = event.part?.type === "tool" ? event.part : undefined; + const toolId = event.toolCallId ?? part?.toolCallId ?? part?.partId; + let tool = toolId ? this.toolViews.get(toolId) : undefined; + const visited = new Set(); + while (tool?.parentToolCallId && !visited.has(tool.id)) { + visited.add(tool.id); + tool = this.toolViews.get(tool.parentToolCallId) ?? tool; + if (visited.has(tool.id)) break; + } + if (tool && this.transcript.removeBlock(tool.blockId)) this.ui.requestRender(); + } + + private applyBackgroundTaskEvent(event: StreamEvent): void { + const taskId = event.taskId ?? event.agentId ?? event.progress?.agentId; + const startsTask = event.type === "background_task_started" + || event.type === "background_task_updated" + || event.type === "subagent_spawned"; + if (this.turnStartedAt !== undefined) { + const wasOwned = this.turnWork.ownsTask(taskId); + const remainsActive = this.turnWork.handle(event); + if (startsTask && !wasOwned && this.turnWork.ownsTask(taskId)) this.turnHadWorkActivity = true; + if (!remainsActive) this.settleTurnTiming(); + } + const update = this.backgroundTaskEvents.handle(event); + if (update.changed) { + this.scheduleRuntimeRefresh(0); + this.updateRuntimeActivity(); + } + for (const notice of update.notices) { + this.addSystemEvent({ + tone: notice.tone, + title: notice.title, + summary: notice.summary, + detail: notice.detail + }); + void this.notifications.notify(notice.notification, notice.detail ?? `${notice.title} · ${notice.summary}`); + } + if (update.handoffSettled) this.drainInputAfterBackgroundHandoff(); + if (update.changed || update.notices.length > 0) this.requestStreamRender(); + } + + private drainInputAfterBackgroundHandoff(): void { + if (this.backgroundDrainScheduled) return; + this.backgroundDrainScheduled = true; + queueMicrotask(() => { + this.backgroundDrainScheduled = false; + if (this.stopped + || this.activeSubmissions > 0 + || this.backgroundHandoffInterruptInFlight + || this.backgroundTaskEvents.hasActiveHandoffs() + || !this.inputQueue.autoSend) return; + const next = this.inputQueue.takeNextFollowUp(); + if (next) void this.submit(next.input, next); + }); + } + + private interruptBackgroundHandoffForInput(): void { + if (this.backgroundHandoffInterruptInFlight) return; + if (!this.options.interruptTurn) { + this.addNotice("The background result turn cannot be interrupted in this runtime.", "warning"); + return; + } + + this.backgroundHandoffInterruptInFlight = true; + this.updateActivity("interrupting background result processing…"); + void this.options.interruptTurn({ + pendingInputIds: [], + reason: "User input preempted background result processing.", + reservationId: `background_handoff_${crypto.randomUUID()}`, + waitForIdle: true + }).then((outcome) => { + const kind = isRecord(outcome) ? asString(outcome.kind) : undefined; + if (kind !== "stopped" && kind !== "idle") { + throw new Error(`Runtime returned ${kind ?? "an unsupported response"}.`); + } + const settled = this.backgroundTaskEvents.settleActiveHandoffs(); + this.inputQueue.resetAutoSend(); + if (settled > 0) { + this.addNotice("Background result processing was interrupted; starting your queued input.", "muted"); + } + }).catch((error) => { + const detail = error instanceof Error ? error.message : String(error); + this.addNotice(`Unable to interrupt background result processing: ${detail}`, "error"); + }).finally(() => { + this.backgroundHandoffInterruptInFlight = false; + this.updateActivity(undefined); + this.drainInputAfterBackgroundHandoff(); + }); + } + private handleProtocolPartEvent(event: StreamEvent): boolean { if ((event.type === "part.started" || event.type === "part.upserted") && event.part) { this.upsertProtocolPart(event.part); @@ -1642,9 +1914,11 @@ class ZCodeTui { this.pendingTurnNotificationDetail = ""; this.currentToolGroup = undefined; this.turnDiffs.beginTurn(prompt); - this.turnStartedAt = Date.now(); + this.turnStartedAt = performance.now(); this.turnElapsedMilliseconds = 0; this.turnTimingVisible = true; + this.turnHadWorkActivity = false; + this.turnWork.begin(); if (this.turnTimer) clearInterval(this.turnTimer); this.turnTimer = setInterval( () => this.updateTurnStatus(), @@ -1660,6 +1934,7 @@ class ZCodeTui { this.assistantStream.clear(); this.currentThinking = undefined; this.currentThinkingPartId = undefined; + this.backgroundCoordinatorMessageIds.clear(); this.presentationRegistry.clear(); this.turnDiffs.clear(); this.currentToolGroup = undefined; @@ -2088,7 +2363,18 @@ class ZCodeTui { if (text) this.addUserMessage(text, 0, message.messageId); continue; } - for (const part of message.parts) this.restorePart(part, message.role, message.messageId); + const hiddenToolIds = message.role === "assistant" + ? backgroundToolPartIds(message.parts) + : new Set(); + const coordinatesBackgroundAgents = message.parts.some(backgroundAgentPart); + for (const part of message.parts) { + if (coordinatesBackgroundAgents && part.type === "thought") continue; + if (part.type === "tool") { + const toolId = part.toolCallId ?? part.partId; + if (backgroundAgentPart(part) || Boolean(toolId && hiddenToolIds.has(toolId))) continue; + } + this.restorePart(part, message.role, message.messageId); + } } this.currentToolGroup = undefined; this.currentToolGroupBlockId = undefined; @@ -3165,39 +3451,128 @@ class ZCodeTui { } private async showBackgroundTasks(): Promise { - await this.refreshRuntimeState(); - const jobs = [...(this.runtimeProjection?.backgroundJobs ?? [])] - .sort((left, right) => Number(isActiveBackgroundJob(right)) - Number(isActiveBackgroundJob(left)) - || (right.startedAt ?? 0) - (left.startedAt ?? 0)); - if (jobs.length === 0) { - this.addNotice("No background tasks.", "muted"); - return; + while (true) { + await this.refreshRuntimeState(); + const jobs = [...(this.runtimeProjection?.backgroundJobs ?? [])] + .sort((left, right) => backgroundTaskSortRank(left) - backgroundTaskSortRank(right) + || (right.startedAt ?? 0) - (left.startedAt ?? 0)); + if (jobs.length === 0) { + this.addNotice("No background tasks yet. New background work will appear in /tasks.", "muted"); + return; + } + const active = jobs.filter(isActiveBackgroundJob).length; + const attention = jobs.filter((job) => backgroundTaskAttentionStatuses.has(job.status)).length; + const selected = await this.showChoice({ + title: "Background tasks", + prompt: [ + active > 0 ? `${active} active` : undefined, + attention > 0 ? `${attention} need attention` : undefined, + `${jobs.length} total` + ].filter(Boolean).join(" · "), + help: "Type to filter · Up/Down select · Ctrl+O expand activity · Enter manage · Esc return", + items: jobs.map((job) => ({ + value: job.taskId, + label: job.description ?? job.command ?? job.toolName ?? job.taskId, + description: [ + job.status.replaceAll("_", " "), + backgroundTaskKindLabel(job), + job.taskId, + job.pid ? `pid ${job.pid}` : undefined + ].filter(Boolean).join(" · "), + preview: new Text(this.backgroundTaskDetail(job), 1, 0), + payload: job + })) + }); + if (!selected) return; + const outcome = await this.showBackgroundTaskDetail(selected.value); + if (outcome === "close") return; } - const selected = await this.showChoice({ - title: "Background tasks", - prompt: "Select a task to inspect or stop.", - items: jobs.map((job) => ({ - value: job.taskId, - label: job.description ?? job.command ?? job.toolName ?? job.taskId, - description: [job.status, job.taskId, job.pid ? `pid ${job.pid}` : undefined].filter(Boolean).join(" · "), - payload: job - })) - }); - if (!selected || !isRecord(selected.payload)) return; - const job = jobs.find((candidate) => candidate.taskId === selected.value); - if (!job) return; - - const canStop = isActiveBackgroundJob(job) && job.cancellable !== false && Boolean(this.options.cancelBackgroundTask); - const action = await this.showChoice({ - title: `Background task · ${job.taskId}`, - prompt: job.blocked ? job.blockedReason ?? "This task is blocked." : `Status: ${job.status}.`, - content: new Text(this.backgroundTaskDetail(job), 1, 0), - items: [ + } + + private async showBackgroundTaskDetail(taskId: string): Promise<"back" | "close"> { + while (true) { + await this.refreshRuntimeState(); + const job = this.runtimeProjection?.backgroundJobs.find((candidate) => candidate.taskId === taskId); + if (!job) { + this.addNotice(`Background task ${taskId} is no longer available.`, "warning"); + return "back"; + } + const active = isActiveBackgroundJob(job); + const canMessage = job.taskKind === "local_agent" && Boolean(this.options.sendBackgroundTaskMessage); + const canStop = active && job.cancellable !== false && Boolean(this.options.cancelBackgroundTask); + const items: ChoiceItem[] = [ + ...(canMessage ? [{ + value: "message", + label: active ? "Message agent" : "Resume agent", + description: active ? "Send guidance to this task" : "Continue from the saved child session" + }] : []), + ...(canMessage && active ? [{ + value: "restart", + label: "Restart agent", + description: "Stop this run and resume from the saved child session" + }] : []), ...(canStop ? [{ value: "stop", label: "Stop task", description: "Request cancellation" }] : []), - { value: "close", label: "Close", description: "Return to the prompt" } - ] - }); - if (action?.value === "stop") await this.stopBackgroundTask(job.taskId); + ...(!active && job.taskKind === "local_bash" && job.command ? [{ + value: "prepare-rerun", + label: "Prepare rerun", + description: "Place a reviewed rerun request in the editor" + }] : []), + ...(job.taskKind === "local_workflow" && this.options.refreshWorkflowPanel ? [{ + value: "workflow", + label: "Open workflow run", + description: "Inspect phases, events and workflow controls" + }] : []), + { value: "refresh", label: "Refresh task", description: "Read the latest status and output" }, + { value: "back", label: "Back to tasks", description: "Choose another background task" }, + { value: "close", label: "Close task center", description: "Return to the prompt" } + ]; + const action = await this.showChoice({ + title: `${backgroundTaskKindLabel(job)} task · ${job.taskId}`, + prompt: job.blocked + ? job.blockedReason ?? "This task is blocked." + : `Status: ${job.status.replaceAll("_", " ")}.`, + contentLabel: "Task activity", + content: new Text(this.backgroundTaskDetail(job), 1, 0), + items + }); + if (!action || action.value === "back") return "back"; + if (action.value === "close") return "close"; + if (action.value === "refresh") continue; + if (action.value === "stop") { + await this.stopBackgroundTask(job.taskId); + continue; + } + if (action.value === "message" || action.value === "restart") { + const restart = action.value === "restart"; + const message = await this.showTextPrompt({ + title: restart ? "Restart background agent" : active ? "Message background agent" : "Resume background agent", + prompt: restart + ? `Describe what ${job.agentId ?? job.taskId} should continue or repair after restart.` + : active + ? `Send guidance to ${job.agentId ?? job.taskId}.` + : `Describe what ${job.agentId ?? job.taskId} should continue or repair.` + }); + if (message?.trim()) await this.messageBackgroundTask(job, message.trim(), restart); + continue; + } + if (action.value === "prepare-rerun" && job.command) { + this.editor.setText([ + "Run this command again in the background and report when it finishes:", + "", + job.command + ].join("\n")); + this.addNotice(`Prepared a rerun request for ${job.taskId}. Review it, then press Enter.`, "muted"); + return "close"; + } + if (action.value === "workflow" && this.options.refreshWorkflowPanel) { + try { + const panel = await this.options.refreshWorkflowPanel({ runId: job.taskId }); + if (isRecord(panel)) await this.showWorkflowPanel(panel); + } catch (error) { + this.addNotice(error instanceof Error ? error.message : String(error), "error"); + } + } + } } private async showActivityDetails(): Promise { @@ -3229,24 +3604,119 @@ class ZCodeTui { const stderr = safe(job.stderrTail); const terminalId = safe(job.terminalId); const outputPath = safe(job.outputPath); + const taskEntries = this.backgroundTaskEvents.entries(job.taskId); + const persistedOutput = job.outputPath + && !job.outputTail + && !job.stdoutTail + && !taskEntries.some((entry) => entry.kind === "assistant") + ? readBackgroundTaskOutput(job.outputPath) + : undefined; + const conversation = taskEntries.flatMap((entry): string[] => { + const label = entry.kind === "user" ? "You" + : entry.kind === "assistant" ? "Agent" + : entry.kind === "error" ? "Error" + : "Update"; + const text = safe(entry.text); + if (!text) return []; + const line = `${label}: ${text}`; + return [entry.kind === "error" ? this.theme.error(line) : line]; + }); const lines = [ safe(job.description), + safe(job.prompt), safe(job.command), [ - safe(job.toolName), + backgroundTaskKindLabel(job), + job.agentId && job.agentId !== job.taskId ? `agent ${safe(job.agentId)}` : undefined, + job.childSessionId ? `session ${safe(job.childSessionId)}` : undefined, job.pid ? `pid ${job.pid}` : undefined, terminalId ? `terminal ${terminalId}` : undefined, job.outputBytes !== undefined ? `${job.outputBytes.toLocaleString()} output bytes` : undefined ].filter(Boolean).join(" · "), outputPath ? `Output: ${outputPath}` : undefined, job.outputTruncated ? "Output is truncated" : undefined, + job.error ? this.theme.error(`Error: ${safe(job.error)}`) : undefined, safe(job.stdoutTail), stderr ? this.theme.error(stderr) : undefined, - safe(job.outputTail) + safe(job.outputTail), + ...(persistedOutput ? [ + "", + this.theme.bold("Saved task result"), + persistedOutput.truncated ? this.theme.muted("Showing the latest 64 KiB") : undefined, + safe(persistedOutput.text) + ] : []), + ...(conversation.length > 0 ? ["", this.theme.bold("Task activity"), ...conversation] : []) ].filter((line): line is string => Boolean(line)); return sanitizeTerminalText(lines.join("\n"), { preserveSgr: true }); } + private async sendTaskCommand(command: string, resume: boolean): Promise { + const value = command.trim(); + const separator = value.search(/\s/u); + const taskId = separator < 0 ? value : value.slice(0, separator); + const suppliedMessage = separator < 0 ? "" : value.slice(separator).trim(); + if (!taskId) { + this.addNotice(`Usage: /tasks ${resume ? "resume" : "message"} ${resume ? "[instructions]" : ""}`, "muted"); + return; + } + await this.refreshRuntimeState(); + const job = this.runtimeProjection?.backgroundJobs.find((candidate) => candidate.taskId === taskId); + if (!job) { + this.addNotice(`No background task found with ID ${taskId}.`, "warning"); + return; + } + const message = suppliedMessage || (resume + ? "Continue the assigned task from the last completed step. Re-check the current workspace state and finish the remaining work." + : ""); + if (!message) { + this.addNotice(`Usage: /tasks message ${taskId} `, "muted"); + return; + } + await this.messageBackgroundTask(job, message, resume && isActiveBackgroundJob(job)); + } + + private async messageBackgroundTask( + job: RuntimeBackgroundJob, + message: string, + restart = false + ): Promise { + if (job.taskKind !== "local_agent") { + this.addNotice(`${backgroundTaskKindLabel(job)} tasks cannot receive messages.`, "warning"); + return; + } + if (!this.options.sendBackgroundTaskMessage) { + this.addNotice("Background agent messaging is unavailable in this runtime.", "warning"); + return; + } + this.backgroundTaskEvents.recordUserMessage(job.taskId, message); + this.updateActivity(restart && isActiveBackgroundJob(job) + ? "restarting background agent…" + : isActiveBackgroundJob(job) ? "sending task message…" : "resuming background agent…"); + try { + const result = await this.options.sendBackgroundTaskMessage({ + taskId: job.taskId, + message, + summary: message.replace(/\s+/gu, " ").trim().slice(0, 200), + restart + }); + const record = isRecord(result) ? result : undefined; + const status = asString(record?.status); + const detail = asString(record?.message) + ?? asString(record?.delivery)?.replaceAll("_", " ") + ?? "Message delivered."; + if (status === "failed") throw new Error(asString(record?.error) ?? detail); + this.backgroundTaskEvents.recordSystemMessage(job.taskId, detail); + this.addNotice(detail, "muted"); + await this.refreshRuntimeState(); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + this.backgroundTaskEvents.recordSystemMessage(job.taskId, detail, true); + this.addNotice(`${restart ? "Restart" : "Message"} for ${job.taskId} failed: ${detail}`, "error"); + } finally { + this.updateActivity(undefined); + } + } + private async stopBackgroundTask(taskId: string): Promise { if (!taskId) { this.addNotice("Usage: /tasks stop ", "muted"); @@ -3434,14 +3904,15 @@ class ZCodeTui { private updateTurnStatus(requestRender = true): void { if (this.turnStartedAt !== undefined) { - this.turnElapsedMilliseconds = Math.max(0, Date.now() - this.turnStartedAt); + this.turnElapsedMilliseconds = Math.max(0, performance.now() - this.turnStartedAt); } const showElapsed = this.turnStartedAt !== undefined || (!this.activity && this.turnTimingVisible); const text = turnStatusText( this.activity, this.turnElapsedMilliseconds, showElapsed, - this.turnStartedAt !== undefined && this.animateTurnTimer + this.turnStartedAt !== undefined && this.animateTurnTimer, + this.turnStartedAt === undefined && this.turnTimingVisible ) ?? ""; const left = text ? this.activity ? this.theme.accent(text) : this.theme.muted(text) @@ -3474,7 +3945,9 @@ class ZCodeTui { this.runtimeRefreshTimer.unref?.(); } - private scheduleRuntimePoll(delay = runtimePollInterval(this.turnStartedAt !== undefined)): void { + private scheduleRuntimePoll( + delay = runtimePollInterval(this.turnStartedAt !== undefined || runtimeActivityActive(this.runtimeProjection)) + ): void { if (this.stopped || (!this.options.readRuntimeProjection && !this.options.readTodos)) return; if (this.runtimePollTimer) return; this.runtimePollTimer = setTimeout(() => { @@ -3495,6 +3968,7 @@ class ZCodeTui { private applyRuntimeProjection(projection: RuntimeProjectionSnapshot | undefined): void { if (!projection) return; this.runtimeProjection = projection; + this.reconcileTurnTiming(projection); if (projection.sessionId) this.sessionId = projection.sessionId; this.sessionMetrics = mergeMetrics(this.sessionMetrics, { contextUsed: projection.contextUsage?.used, @@ -3542,6 +4016,7 @@ class ZCodeTui { if (todosResult.status === "fulfilled" && todosResult.value !== undefined) { next.todos = normalizeTodos(todosResult.value); } + if (next.projection) this.reconcileTurnTiming(next.projection); const current: RuntimePollState = { projection: this.runtimeProjection, todos: this.todos, @@ -3553,6 +4028,8 @@ class ZCodeTui { if (next.projection) this.applyRuntimeProjection(next.projection); else this.updateRuntimeActivity(false); this.updateMetadata(); + } else if (runtimeActivityActive(next.projection)) { + this.updateRuntimeActivity(); } } while (this.runtimeRefreshPending); } finally { @@ -3637,19 +4114,44 @@ class ZCodeTui { this.finalizeUnresolvedTools(unfinishedToolState); this.turnDiffs.finishTurn(); this.currentToolGroup = undefined; + if (!this.turnWork.finishForeground(Boolean(this.options.readRuntimeProjection))) { + this.settleTurnTiming(); + } + this.activity = undefined; + this.updateTurnStatus(); + this.scheduleRuntimeRefresh(0); + this.rescheduleRuntimePoll(); + if (notification) void this.notifications.notify(notification, notificationDetail); + } + + private reconcileTurnTiming(projection: RuntimeProjectionSnapshot): void { + if (this.turnStartedAt !== undefined + && !this.turnWork.reconcile(projection.backgroundJobs)) this.settleTurnTiming(); + } + + private settleTurnTiming(): void { + const wasRunning = this.turnStartedAt !== undefined || this.turnTimer !== undefined; + if (!wasRunning) return; if (this.turnStartedAt !== undefined) { - this.turnElapsedMilliseconds = Math.max(0, Date.now() - this.turnStartedAt); + this.turnElapsedMilliseconds = Math.max(0, performance.now() - this.turnStartedAt); this.turnStartedAt = undefined; } if (this.turnTimer) { clearInterval(this.turnTimer); this.turnTimer = undefined; } - this.activity = undefined; + const workedLabel = this.turnHadWorkActivity + ? workedDurationLabel(this.turnElapsedMilliseconds) + : undefined; + if (workedLabel) { + this.transcript.addBlock(new WorkDurationView(this.turnElapsedMilliseconds, this.theme), { + kind: "work-duration" + }); + } + // Short turns retain a compact completion marker; longer work follows Codex and + // moves the final duration into the transcript divider. + this.turnTimingVisible = workedLabel === undefined; this.updateTurnStatus(); - this.scheduleRuntimeRefresh(0); - this.rescheduleRuntimePoll(); - if (notification) void this.notifications.notify(notification, notificationDetail); } private debugEvent(channel: string, value: unknown): void { @@ -3674,10 +4176,11 @@ class ZCodeTui { if (this.rewindEscapeTimer) clearTimeout(this.rewindEscapeTimer); if (this.runtimeRefreshTimer) clearTimeout(this.runtimeRefreshTimer); if (this.runtimePollTimer) clearTimeout(this.runtimePollTimer); + this.unsubscribeSession?.(); this.unsubscribeWorkflow?.(); const elapsedMilliseconds = this.turnStartedAt === undefined ? this.turnElapsedMilliseconds - : Math.max(0, Date.now() - this.turnStartedAt); + : Math.max(0, performance.now() - this.turnStartedAt); this.notifications.stop(); this.ui.stop(); void this.finishStop(elapsedMilliseconds); diff --git a/packages/zcode-tui/src/runtime-poll.ts b/packages/zcode-tui/src/runtime-poll.ts index 8fd71bc..b6909e7 100644 --- a/packages/zcode-tui/src/runtime-poll.ts +++ b/packages/zcode-tui/src/runtime-poll.ts @@ -1,10 +1,12 @@ import { isDeepStrictEqual } from "node:util"; import type { StreamEvent } from "./events.ts"; -import type { - RuntimeProjectionSnapshot, - RuntimeTodo, - RuntimeTodoGroup +import { + isActiveBackgroundJob, + isActiveRuntimeTool, + type RuntimeProjectionSnapshot, + type RuntimeTodo, + type RuntimeTodoGroup } from "./runtime-projection.ts"; export const ACTIVE_RUNTIME_POLL_INTERVAL_MS = 1_000; @@ -16,6 +18,11 @@ export interface RuntimePollState { todoGroups: RuntimeTodoGroup[]; } +export function runtimeActivityActive(projection: RuntimeProjectionSnapshot | undefined): boolean { + return Boolean(projection?.activeToolCalls.some(isActiveRuntimeTool) + || projection?.backgroundJobs.some(isActiveBackgroundJob)); +} + export function runtimePollInterval(active: boolean): number { return active ? ACTIVE_RUNTIME_POLL_INTERVAL_MS : IDLE_RUNTIME_POLL_INTERVAL_MS; } diff --git a/packages/zcode-tui/src/runtime-projection.ts b/packages/zcode-tui/src/runtime-projection.ts index 0e001dd..58db876 100644 --- a/packages/zcode-tui/src/runtime-projection.ts +++ b/packages/zcode-tui/src/runtime-projection.ts @@ -9,6 +9,7 @@ export type RuntimeBackgroundStatus = | "cancelled" | "spawn_error" | "lost"; +export type RuntimeTaskKind = "local_agent" | "local_bash" | "local_workflow" | "monitor_mcp" | "unknown"; export interface RuntimeTodo { content: string; @@ -35,14 +36,23 @@ export interface RuntimeActiveToolCall { export interface RuntimeBackgroundJob { taskId: string; + taskKind: RuntimeTaskKind; toolCallId?: string; toolName?: string; + agentId?: string; + agentType?: string; + childSessionId?: string; + parentSessionId?: string; + parentToolCallId?: string; + turnId?: string; blocked?: boolean; blockedReason?: string; cancellable?: boolean; cancelRequestedAt?: number; command?: string; description?: string; + prompt?: string; + error?: string; status: RuntimeBackgroundStatus; pid?: number; startedAt?: number; @@ -216,16 +226,37 @@ function backgroundJobFrom(value: unknown): RuntimeBackgroundJob | undefined { const taskId = stringField(value, "taskId", "taskID", "id"); const status = stringField(value, "status") as RuntimeBackgroundStatus | undefined; if (!taskId || !status || !backgroundStatuses.has(status)) return undefined; + const rawKind = stringField(value, "taskKind", "taskType", "type")?.toLowerCase(); + const toolName = stringField(value, "toolName"); + const taskKind: RuntimeTaskKind = rawKind === "agent" || rawKind === "local_agent" || toolName === "Agent" + ? "local_agent" + : rawKind === "bash" || rawKind === "local_bash" || toolName === "Bash" + ? "local_bash" + : rawKind === "workflow" || rawKind === "local_workflow" || toolName === "Workflow" + ? "local_workflow" + : rawKind === "monitor_mcp" || toolName === "Monitor" + ? "monitor_mcp" + : "unknown"; + const rawError = isRecord(value.error) ? value.error : undefined; return { taskId, + taskKind, toolCallId: stringField(value, "toolCallId", "toolCallID"), - toolName: stringField(value, "toolName"), + toolName, + agentId: stringField(value, "agentId"), + agentType: stringField(value, "agentType", "subagentType"), + childSessionId: stringField(value, "childSessionId", "childSessionID"), + parentSessionId: stringField(value, "parentSessionId", "parentSessionID"), + parentToolCallId: stringField(value, "parentToolCallId", "parentToolCallID"), + turnId: stringField(value, "turnId", "turnID"), blocked: typeof value.blocked === "boolean" ? value.blocked : undefined, blockedReason: stringField(value, "blockedReason"), cancellable: typeof value.cancellable === "boolean" ? value.cancellable : undefined, cancelRequestedAt: timestamp(value.cancelRequestedAt), command: stringField(value, "command"), description: stringField(value, "description"), + prompt: stringField(value, "prompt"), + error: stringField(value, "error") ?? (rawError && stringField(rawError, "message")), status, pid: positiveInteger(value.pid), startedAt: timestamp(value.startedAt), @@ -287,6 +318,12 @@ export function normalizeRuntimeProjection(value: unknown): RuntimeProjectionSna const runtime = isRecord(value.runtime) ? value.runtime : undefined; const rawActiveTools = projection.activeToolCalls; const rawBackgroundJobs = projection.backgroundJobs ?? projection.backgroundTasks; + const taskDetails = new Map(records( + projection.backgroundTaskDetails ?? value.backgroundTaskDetails + ).flatMap((item): [string, Record][] => { + const taskId = stringField(item, "taskId", "taskID", "id"); + return taskId ? [[taskId, item]] : []; + })); const rawLastError = isRecord(projection.lastError) ? projection.lastError : undefined; const lastErrorType = rawLastError && stringField(rawLastError, "type"); const lastErrorMessage = rawLastError && stringField(rawLastError, "message"); @@ -302,7 +339,10 @@ export function normalizeRuntimeProjection(value: unknown): RuntimeProjectionSna return tool ? [tool] : []; }), backgroundJobs: records(rawBackgroundJobs).flatMap((item): RuntimeBackgroundJob[] => { - const job = backgroundJobFrom(item); + const taskId = stringField(item, "taskId", "taskID", "id"); + const job = backgroundJobFrom(taskId && taskDetails.has(taskId) + ? { ...taskDetails.get(taskId), ...item } + : item); return job ? [job] : []; }), contextUsage: contextUsageFrom(runtime?.contextUsage ?? value.contextUsage, projection), diff --git a/packages/zcode-tui/src/turn-status.ts b/packages/zcode-tui/src/turn-status.ts index d303bb8..3535a52 100644 --- a/packages/zcode-tui/src/turn-status.ts +++ b/packages/zcode-tui/src/turn-status.ts @@ -3,6 +3,7 @@ export const TURN_TIMER_FRAME_DURATION_MS = 1_000; // Unicode clock faces form a complete, same-style rotation with stable terminal width. const turnTimerFrames = ["🕛", "🕐", "🕑", "🕒", "🕓", "🕔", "🕕", "🕖", "🕗", "🕘", "🕙", "🕚"] as const; const settledTurnTimerFrame = "🕛"; +const completedTurnTimerFrame = "✓"; const reducedMotionValues = new Set(["1", "true", "yes", "on"]); export function turnTimerAnimationEnabled( @@ -36,9 +37,11 @@ export function turnStatusText( activity: string | undefined, elapsedMilliseconds: number, showElapsed = true, - animateTimer = false + animateTimer = false, + completed = false ): string | undefined { if (!showElapsed) return activity; - const elapsed = `[ ${turnTimerFrame(elapsedMilliseconds, animateTimer)} ${formatElapsed(elapsedMilliseconds)} ]`; + const frame = completed ? completedTurnTimerFrame : turnTimerFrame(elapsedMilliseconds, animateTimer); + const elapsed = `[ ${frame} ${formatElapsed(elapsedMilliseconds)} ]`; return activity ? `${activity} ── ${elapsed}` : elapsed; } diff --git a/packages/zcode-tui/src/turn-work-tracker.ts b/packages/zcode-tui/src/turn-work-tracker.ts new file mode 100644 index 0000000..24196bc --- /dev/null +++ b/packages/zcode-tui/src/turn-work-tracker.ts @@ -0,0 +1,88 @@ +import type { StreamEvent } from "./events.ts"; +import type { RuntimeBackgroundJob } from "./runtime-projection.ts"; + +const terminalStatuses = new Set([ + "completed", + "failed", + "timed_out", + "cancelled", + "spawn_error", + "lost", + "stopped" +]); + +function eventTaskId(event: StreamEvent): string | undefined { + return event.taskId ?? event.agentId ?? event.progress?.agentId; +} + +function startsTask(event: StreamEvent): boolean { + return event.type === "background_task_started" + || event.type === "subagent_spawned" + || (event.type === "background_task_updated" && !terminalStatuses.has(event.taskStatus ?? "running")); +} + +function settlesTask(event: StreamEvent): boolean { + return event.type === "background_task_completed" + || event.type === "subagent_stopped" + || terminalStatuses.has(event.taskStatus ?? ""); +} + +export class TurnWorkTracker { + private foregroundActive = false; + private awaitingProjection = false; + private turnId?: string; + private readonly taskIds = new Set(); + + begin(): void { + this.foregroundActive = true; + this.awaitingProjection = false; + this.turnId = undefined; + this.taskIds.clear(); + } + + bindTurn(turnId: string | undefined): void { + if (turnId) this.turnId = turnId; + } + + handle(event: StreamEvent): boolean { + const taskId = eventTaskId(event); + if (!taskId) return this.isActive(); + if (settlesTask(event)) { + this.taskIds.delete(taskId); + return this.isActive(); + } + if (startsTask(event) && this.accepts(event.turnId)) this.taskIds.add(taskId); + return this.isActive(); + } + + finishForeground(awaitProjection: boolean): boolean { + this.foregroundActive = false; + this.awaitingProjection = awaitProjection; + return this.isActive(); + } + + reconcile(jobs: readonly RuntimeBackgroundJob[]): boolean { + for (const job of jobs) { + const related = this.taskIds.has(job.taskId) + || Boolean(this.turnId && job.turnId === this.turnId); + if (!related) continue; + if (job.status === "running") this.taskIds.add(job.taskId); + else this.taskIds.delete(job.taskId); + } + if (!this.foregroundActive) this.awaitingProjection = false; + return this.isActive(); + } + + isActive(): boolean { + return this.foregroundActive || this.awaitingProjection || this.taskIds.size > 0; + } + + ownsTask(taskId: string | undefined): boolean { + return Boolean(taskId && this.taskIds.has(taskId)); + } + + private accepts(turnId: string | undefined): boolean { + if (turnId && this.turnId) return turnId === this.turnId; + return this.foregroundActive || this.awaitingProjection; + } +} diff --git a/packages/zcode-tui/src/types.ts b/packages/zcode-tui/src/types.ts index d786581..18b68fa 100644 --- a/packages/zcode-tui/src/types.ts +++ b/packages/zcode-tui/src/types.ts @@ -25,6 +25,7 @@ export interface InterruptTurnOptions { pendingInputIds?: string[]; reason?: string; reservationId?: string; + waitForIdle?: boolean; } export interface WorkspacePathSuggestionRequest { @@ -94,6 +95,12 @@ export interface TuiOptions { readRuntimeProjection?: () => Promise; readSessionUsage?: () => Promise; cancelBackgroundTask?: (taskId: string) => Promise; + sendBackgroundTaskMessage?: (options: { + taskId: string; + message: string; + summary: string; + restart?: boolean; + }) => Promise; previewFileRewind?: (targetMessageIds: string[]) => Promise; applyFileRewind?: (targetMessageIds: string[]) => Promise; interruptTurn?: (options: InterruptTurnOptions) => Promise; @@ -111,6 +118,7 @@ export interface TuiOptions { refreshWorkflowPanel?: (options: { runId?: string }) => Promise; stopWorkflow?: (options: { runId: string }) => Promise; subscribeWorkflowEvents?: (listener: (event: unknown) => void) => (() => void) | void; + subscribeSessionEvents?: (listener: (event: unknown) => void | Promise) => (() => void) | void; } export function isRecord(value: unknown): value is UnknownRecord { diff --git a/packages/zcode-tui/src/work-duration-view.ts b/packages/zcode-tui/src/work-duration-view.ts new file mode 100644 index 0000000..05aaee2 --- /dev/null +++ b/packages/zcode-tui/src/work-duration-view.ts @@ -0,0 +1,33 @@ +import { truncateToWidth, type Component } from "@earendil-works/pi-tui"; + +import { formatElapsed } from "./turn-status.ts"; +import type { ZCodeTheme } from "./theme.ts"; + +export function workedDurationLabel(elapsedMilliseconds: number): string | undefined { + if (Math.floor(elapsedMilliseconds / 1_000) <= 60) return undefined; + return `Worked for ${formatElapsed(elapsedMilliseconds)}`; +} + +/** Codex-style settled-work divider retained in the transcript after the task group ends. */ +export class WorkDurationView implements Component { + private readonly label?: string; + + constructor( + elapsedMilliseconds: number, + private readonly theme: ZCodeTheme + ) { + this.label = workedDurationLabel(elapsedMilliseconds); + } + + render(width: number): string[] { + if (!this.label || width <= 0) return []; + const available = Math.max(1, width - 1); + const content = `─ ${this.label} ─`; + const line = content.length >= available + ? truncateToWidth(content, available) + : `${content}${"─".repeat(available - content.length)}`; + return [` ${this.theme.muted(line)}`]; + } + + invalidate(): void {} +} diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 48bbdcc..edcf99e 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -46,6 +46,18 @@ if (!runtimeSource.includes('"plugin://"') || !runtimeSource.includes(".previewFileRewind=async e=>") || !runtimeSource.includes(".applyFileRewind=async e=>") || !runtimeSource.includes(".listSkills=async()=>await") + || !runtimeSource.includes(".subscribeSessionEvents=") + || !runtimeSource.includes(".sendBackgroundTaskMessage=async") + || !runtimeSource.includes("backgroundTaskDetails") + || !runtimeSource.includes('i.includes("AI SDK Warning")&&i.includes("cacheControl breakpoint limit")') + || !runtimeSource.includes('if(n==="model_not_found")return{code:lr.ModelNotFound') + || !runtimeSource.includes("autoBackgroundMs:this.config.subagents?.autoBackgroundMs??1e3,outputRootDir:") + || runtimeSource.split("Detached background agent lifecycle failed").length < 3 + || !runtimeSource.includes('if(e?.restart===!0&&o.status==="running")') + || !runtimeSource.includes('e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId') + || !runtimeSource.includes('status:"idle",currentTurnId:void 0,activeToolCalls:[],totalTokenCount:') + || !runtimeSource.includes('status:"error",currentTurnId:void 0,activeToolCalls:[],lastError:') + || !/runtimeTaskRegistry\?\.all\?\.\(\)\?\?\{\}\)\.filter\(([A-Za-z_$][\w$]*)=>\1\.isBackgrounded===!0\)\.map\(/u.test(runtimeSource) || !supportsMultiMessageFileRewind(runtimeSource) || !/messageId:[A-Za-z_$][\w$]*\.info\.id,role:"user"/u.test(runtimeSource) || !/messageId:[A-Za-z_$][\w$]*\.info\.id,role:"agent"/u.test(runtimeSource) @@ -58,7 +70,9 @@ if (!runtimeSource.includes('"plugin://"') || !/cancelBackgroundTask:[A-Za-z_$][\w$]*\.cancelBackgroundTask/u.test(runtimeSource) || !/previewFileRewind:[A-Za-z_$][\w$]*\.previewFileRewind/u.test(runtimeSource) || !/applyFileRewind:[A-Za-z_$][\w$]*\.applyFileRewind/u.test(runtimeSource) - || !/listSkills:[A-Za-z_$][\w$]*\.listSkills/u.test(runtimeSource)) { + || !/listSkills:[A-Za-z_$][\w$]*\.listSkills/u.test(runtimeSource) + || !/subscribeSessionEvents:[A-Za-z_$][\w$]*\.subscribeSessionEvents/u.test(runtimeSource) + || !/sendBackgroundTaskMessage:[A-Za-z_$][\w$]*\.sendBackgroundTaskMessage/u.test(runtimeSource)) { throw new Error("The runtime compatibility patches are missing; run `bun run sync` again."); } diff --git a/scripts/smoke-tui-features.ts b/scripts/smoke-tui-features.ts index 4f36d0d..d25b880 100644 --- a/scripts/smoke-tui-features.ts +++ b/scripts/smoke-tui-features.ts @@ -235,10 +235,46 @@ try { /Queued next turn · 1 input[\s\S]*Run this after the active turn\./i ); await waitFor("feature turn", /Feature prompt complete\./i, featureTurnStart, 12_000); + await waitFor( + "compact background agent reply", + /Background agent replied[\s\S]*agent_feature · \/tasks/i, + featureTurnStart, + 4_000 + ); + await waitFor( + "compact background task failure", + /Background task needs attention[\s\S]*agent_feature · \/tasks/i, + featureTurnStart, + 4_000 + ); + terminal.write("\x1b"); + await Bun.sleep(75); + if (child.exitCode !== null) throw new Error("Esc exited ZCode during a background handoff."); + await waitFor( + "compact handoff failure", + /Background result processing failed[\s\S]*agent_feature · \/tasks/i, + featureTurnStart, + 4_000 + ); await waitFor("queued follow-up turn", /Queued follow-up started after the active turn\./i, featureTurnStart, 4_000); await waitFor("feature turn completion", /Feature background audit · turn complete/i, 0, 4_000); - await sendAndWait("\x0f", "expanded Agent transcript", /Response:\s*Nested rendering inspected\./i); - + await sendAndWait( + "Continue after the stuck background handoff.\r", + "background handoff preemption", + /Background result processing was interrupted; starting your queued input\.[\s\S]*Queued input started after interrupting the stuck background handoff\./i + ); + const foregroundBeforeTaskCenter = plainText(output.slice(featureTurnStart)); + if (/Task-scoped agent handoff completed|Coordinator began processing the failed task result|Background-only reasoning|background_fetch|Coordinator dispatching background research|background-research|Inspect nested rendering/i.test(foregroundBeforeTaskCenter)) { + throw new Error("Task-scoped background output leaked into the foreground transcript."); + } + if (/Turn cancelled\./i.test(foregroundBeforeTaskCenter)) { + throw new Error("Esc cancelled the foreground submission while a background handoff was active."); + } + const expandedForegroundStart = await sendAndWait("\x0f", "expanded foreground tool transcript", /source text/i); + const expandedForeground = plainText(output.slice(expandedForegroundStart)); + if (/Coordinator dispatching background research|background-research|Inspect nested rendering|Nested rendering inspected/i.test(expandedForeground)) { + throw new Error("Expanding foreground tools exposed a background Agent tree."); + } await sendAndWait("/diff\r", "diff source picker", /Select current workspace changes or a completed turn/i); await sendAndWait("\x1b[B\r", "turn diff file list", /Diff · Turn \d+/i); await sendAndWait("\r", "turn diff detail", /Page 1\/\d+/i); @@ -269,8 +305,50 @@ try { await sendAndWait("/activity\r", "complete activity view", /Current activity[\s\S]*Verify the TUI/i); await sendAndSettle("\r"); await sendAndWait("/tasks\r", "background task picker", /Background tasks/i); - await sendAndWait("\r", "background task detail", /Background task · bg_feature/i); - await sendAndSettle("\r"); + await sendAndWait("\r", "Bash task detail", /Bash task · bg_feature/i); + await sendAndWait("\x1b", "return to background task list", /Background tasks/i); + await sendAndWait( + "\x1b[B\r", + "agent task detail", + /Agent \(reviewer\) task · agent_feature[\s\S]*Background agent reply stored in task activity\.[\s\S]*Background result failed visibly\./i + ); + const resumePromptStart = await sendAndWait("\r", "resume agent prompt", /Resume background agent/i); + if (/alpha\/model/i.test(plainText(output.slice(resumePromptStart)))) { + throw new Error("The main composer remained visible beneath the background agent prompt."); + } + await sendAndWait( + "Fix the recovery issue and rerun the focused test.\r", + "resumed agent task", + /Status: running[\s\S]*You: Fix the recovery issue and rerun the focused test\.[\s\S]*Agent "agent_feature" resumed in the background\./i + ); + await sendAndWait("\x1b[B\r", "restart active agent prompt", /Restart background agent/i); + await sendAndWait( + "Restart from the saved state and finish the remaining verification.\r", + "restarted active agent task", + /Status: running[\s\S]*Agent "agent_feature" restarted in the background\./i + ); + await sendAndWait("\x1b", "return to task list after resume", /Background tasks/i); + await sendAndSettle("\x1b"); + const timerTurnStart = output.length; + terminal.write("verify aggregate timer\r"); + await waitFor( + "aggregate timer foreground completion", + /Timer foreground complete; background still running\./i, + timerTurnStart + ); + await waitFor( + "aggregate timer continuing after foreground", + /Timer foreground complete; background still running\.[\s\S]*[🕐-🕛] [1-9]\d*s/u, + timerTurnStart, + 4_000 + ); + const timerForegroundSettled = output.length; + await waitFor( + "aggregate timer settling after background failure", + /✓ [1-9]\d*s/u, + timerForegroundSettled, + 5_000 + ); await sendAndWait("/goal pause\r", "paused goal", /Goal: Paused \(\/goal resume\)/i); await sendAndWait("/resume\r", "resume picker", /Resume Session/i); await sendAndWait("\r", "selected session transcript", /Restored selected response\./i); @@ -294,12 +372,17 @@ if (process.env.ZCODE_TUI_SMOKE_DEBUG === "1") console.log(plain); if (code !== 0) throw new Error(`Feature TUI smoke exited with ${code}.\n${plain.slice(-6_000)}`); const turnNotifications = output.match(/\x1b\]9;ZCode ·/gu) ?? []; -if (turnNotifications.length !== 3 +if (turnNotifications.length !== 8 || !output.includes("\x1b]9;ZCode · Plan approval fixture complete: allow.") || !output.includes("\x1b]9;ZCode · Plan approval fixture complete: deny · plan_approval_feedback.") || !output.includes("\x1b]9;ZCode · Queued follow-up started after the active turn.") + || !output.includes("\x1b]9;ZCode · Queued input started after interrupting the stuck background handoff.") + || !output.includes("\x1b]9;ZCode · Timer foreground complete; background still running.") + || !output.includes("\x1b]9;ZCode · Background agent replied · agent_feature · /tasks") + || !output.includes("\x1b]9;ZCode · Background task needs attention · agent_feature · /tasks") + || !output.includes("\x1b]9;ZCode · Background result processing failed · agent_feature · /tasks") || output.includes("\x1b]9;ZCode · Feature prompt complete.")) { - throw new Error(`Expected three idle-boundary agent-turn notifications, received ${turnNotifications.length}.`); + throw new Error(`Expected eight idle-boundary and task notifications, received ${turnNotifications.length}.`); } for (const [label, pattern] of [ @@ -328,7 +411,12 @@ for (const [label, pattern] of [ ["active goal footer", /Goal: Active \(40K \/ 50K\)/i], ["persistent runtime activity", /Activity · 1 in background · 1 open task · \/tasks/i], ["background task summary", /Feature background audit · bg_feature/i], - ["background task dialog", /Background task · bg_feature/i], + ["Bash task dialog", /Bash task · bg_feature/i], + ["agent task dialog", /Agent \(reviewer\) task · agent_feature/i], + ["task-scoped agent output", /Task activity[\s\S]*Background agent reply stored in task activity\./i], + ["task-scoped handoff failure", /Task activity[\s\S]*Background result failed visibly\./i], + ["resumed agent conversation", /You: Fix the recovery issue and rerun the focused test\.[\s\S]*Agent "agent_feature" resumed in the background\./i], + ["restarted active agent", /Agent "agent_feature" restarted in the background\./i], ["paused goal footer", /Goal: Paused \(\/goal resume\)/i], ["model picker", /Select model/i], ["effort picker", /Select reasoning effort/i], @@ -344,17 +432,18 @@ for (const [label, pattern] of [ ["rejected steer fallback", /Steer was not accepted \(turn not steerable\); queued for the next turn\./i], ["editable follow-up queue", /Queued next turn · 1 input[\s\S]*Revise this queued follow-up\./i], ["automatic queued follow-up", /Queued follow-up started after the active turn\./i], + ["background handoff preemption", /Queued input started after interrupting the stuck background handoff\./i], + ["compact background agent reply", /Background agent replied[\s\S]*agent_feature · \/tasks/i], + ["compact background task failure", /Background task needs attention[\s\S]*agent_feature · \/tasks/i], + ["compact handoff failure", /Background result processing failed[\s\S]*agent_feature · \/tasks/i], ["completed thinking card", /◇ Thought/i], - ["reasoning content", /Inspecting the repository before using tools\./i], ["updated plan", /● Updated Plan/i], ["plan summary", /2 completed · 1 in progress · 0 pending/i], ["active plan item", /□ Verify the TUI/i], ["pre-tool assistant commentary", /I will inspect the repository first\./i], - ["tool execution", /✓ Read demo\.ts/i], + ["tool execution", /Read 1 file · ⎿ demo\.ts/i], ["tool result", /source text/i], ["file diff header", /✓ Edit demo\.ts \+1 -1/i], - ["nested Agent tree", /child tool/i], - ["expanded Agent response", /Response:\s*Nested rendering inspected\./i], ["diff browser", /Diff · Turn \d+/i], ["diff detail paging", /Page 1\/\d+/i], ["context detail", /Estimated prompt composition by characters/i], diff --git a/scripts/smoke-tui-pressure.ts b/scripts/smoke-tui-pressure.ts index 9d756d2..d26a90c 100644 --- a/scripts/smoke-tui-pressure.ts +++ b/scripts/smoke-tui-pressure.ts @@ -126,6 +126,12 @@ try { cancelTurnStart, 2_000 ); + const foregroundCancelStart = output.length; + terminal.write("cancel stress\r"); + await waitFor("foreground Esc cancellation turn", /Bash cancel-pressure/i, foregroundCancelStart); + terminal.write("\x1b"); + await waitFor("foreground Esc cancellation", /Turn cancelled\./i, foregroundCancelStart, 2_000); + if (child.exitCode !== null) throw new Error("Esc exited ZCode while cancelling a foreground turn."); terminal.write("\x03"); } catch (error) { interactionError = error; diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 91dee27..e83608d 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -16,6 +16,7 @@ const updateServiceRoot = "https://zcode.z.ai"; const updateServiceManifestPath = "/api/v1/releases/electron/manifest"; const stableReleaseChannel = "1"; const updateManifestAccept = "application/x-yaml,text/yaml,text/plain,*/*"; +export const defaultAgentAutoBackgroundMs = 1_000; export interface SyncOptions { platform: "darwin" | "linux" | "win32"; @@ -246,6 +247,87 @@ export function supportsMultiMessageFileRewind(runtime: string): boolean { .test(runtime); } +/** Suppress known cache-control warnings before they reach the TUI stderr stream. */ +export function patchRuntimeTuiWarnings(runtime: string): string { + const suppression = 'i.includes("AI SDK Warning")&&i.includes("cacheControl breakpoint limit")'; + if (runtime.includes(suppression)) return runtime; + const anchor = "if(LKn.test(i)||BKn.test(i)){"; + if (!runtime.includes(anchor)) return runtime; + return runtime.replace(anchor, "if(LKn.test(i)||BKn.test(i)||" + suppression + "){"); +} + +/** Prevent provider model lookup failures from being retried as generic 5xx errors. */ +export function patchRuntimeProviderRetryClassification(runtime: string): string { + const classification = 'if(n==="model_not_found")return{code:lr.ModelNotFound,message:o,reason:Ht.InvalidRequest,retryReason:fn.NetworkError,retryable:!1,retryAfterMs:r,statusCode:i};'; + if (runtime.includes(classification)) return runtime; + const anchor = 'function QBr(e,t,r){if(!CS(e))return;let o=XBr(e),n=eUr(e),i=AWo(e,t),a=n?ZBr(n):void 0;'; + if (!runtime.includes(anchor)) return runtime; + return runtime.replace(anchor, anchor + classification); +} + +/** Keep short Agent calls inline, but detach long-running agents from the foreground turn. */ +export function patchRuntimeAgentAutoBackground(runtime: string): string { + const marker = "autoBackgroundMs:this.config.subagents?.autoBackgroundMs??1e3,outputRootDir:"; + if (runtime.includes(marker)) return runtime; + const anchor = "autoBackgroundMs:this.config.subagents?.autoBackgroundMs,outputRootDir:"; + if (!runtime.includes(anchor)) { + throw new Error("ZCode runtime is incompatible with the Agent auto-background patch."); + } + return runtime.replace(anchor, marker); +} + +/** Keep a detached Agent lifecycle failure from terminating the entire CLI process. */ +export function patchRuntimeDetachedAgentLifecycle(runtime: string): string { + const marker = 'Detached background agent lifecycle failed'; + const detachedRunnerPattern = /(onSessionStartFailed:([A-Za-z_$][\w$]*)\.reject\},[A-Za-z_$][\w$]*\.dispose)\);try\{await \2\.promise\}/gu; + const patched = runtime.replace( + detachedRunnerPattern, + '$1).catch(e=>{console.error("Detached background agent lifecycle failed",e??"unknown rejection")});try{await $2.promise}' + ); + if (patched !== runtime) return patched; + if (!runtime.includes(marker)) { + throw new Error("ZCode runtime is incompatible with the detached Agent lifecycle patch."); + } + return runtime; +} + +/** Clear tool projection entries when their owning turn reaches a terminal state. */ +export function patchRuntimeTerminalToolProjection(runtime: string): string { + const completedMarker = 'status:"idle",currentTurnId:void 0,activeToolCalls:[],totalTokenCount:'; + const failedMarker = 'status:"error",currentTurnId:void 0,activeToolCalls:[],lastError:'; + let patched = runtime; + if (!patched.includes(completedMarker)) { + const anchor = 'status:"idle",totalTokenCount:'; + if (!patched.includes(anchor)) { + throw new Error("ZCode runtime is incompatible with the terminal tool projection patch."); + } + patched = patched.replace(anchor, completedMarker); + } + if (!patched.includes(failedMarker)) { + const anchor = 'status:"error",lastError:'; + if (!patched.includes(anchor)) { + throw new Error("ZCode runtime is incompatible with the terminal tool projection patch."); + } + patched = patched.replace(anchor, failedMarker); + } + return patched; +} + +/** Exclude foreground Agent calls from the TUI background task projection. */ +export function patchRuntimeBackgroundTaskProjection(runtime: string): string { + const filteredProjectionPattern = /runtimeTaskRegistry\?\.all\?\.\(\)\?\?\{\}\)\.filter\(([A-Za-z_$][\w$]*)=>\1\.isBackgrounded===!0\)\.map\(/u; + if (filteredProjectionPattern.test(runtime)) return runtime; + const projectionPattern = /Object\.values\(([A-Za-z_$][\w$]*)\.runtime\?\.runtimeTaskRegistry\?\.all\?\.\(\)\?\?\{\}\)\.map\(([A-Za-z_$][\w$]*)=>/u; + const projection = projectionPattern.exec(runtime); + if (!projection) { + throw new Error("ZCode runtime is incompatible with the background task projection patch."); + } + return runtime.replace( + projectionPattern, + `Object.values($1.runtime?.runtimeTaskRegistry?.all?.()??{}).filter($2=>$2.isBackgrounded===!0).map($2=>` + ); +} + export function patchRuntimeTuiBridge(runtime: string): string { const transcriptMessageIdPattern = /\.push\(\{content:[A-Za-z_$][\w$]*,messageId:[A-Za-z_$][\w$]*\.info\.id,role:"user"\}\)/u; const transcriptAgentMessageIdPattern = /messageId:[A-Za-z_$][\w$]*\.info\.id,role:"agent"/u; @@ -254,17 +336,25 @@ export function patchRuntimeTuiBridge(runtime: string): string { const activeTurnGuidePattern = /\.steerTurn\(\{commandKind:([A-Za-z_$][\w$]*)\?\.commandKind,inputId:\1\?\.inputId,queryId:\1\?\.queryId,expectedTurnId:\1\?\.expectedTurnId,delivery:"guide",pendingInputId:\1\?\.pendingInputId,input:/u; const listSkillsBridgePattern = /\.listSkills=async\(\)=>await [A-Za-z_$][\w$]*\([A-Za-z_$][\w$]*\)/u; const listSkillsOptionPattern = /listSkills:[A-Za-z_$][\w$]*\.listSkills/u; + const sessionEventsBridgePattern = /\.subscribeSessionEvents=[A-Za-z_$][\w$]*=>/u; + const sessionEventsOptionPattern = /subscribeSessionEvents:[A-Za-z_$][\w$]*\.subscribeSessionEvents/u; + const taskMessageBridgePattern = /\.sendBackgroundTaskMessage=async [A-Za-z_$][\w$]*=>/u; + const taskMessageOptionPattern = /sendBackgroundTaskMessage:[A-Za-z_$][\w$]*\.sendBackgroundTaskMessage/u; + const taskMessageRestartMarker = "e?.restart===!0"; const interruptTurnMarker = ".interruptTurn=async e=>"; + const interruptWaitForIdleMarker = "e?.waitForIdle===!0"; const queuedInputPromotionMarker = "r?.pendingInputReservationId??r?.queryId??"; const alreadyPatched = runtime.includes(".loadSessionTranscript=async()=>await(await") && runtime.includes(".readGoal=async()=>await(await") && runtime.includes(".readTodos=async()=>await(await") && runtime.includes(".readRuntimeProjection=async()=>") + && runtime.includes("backgroundTaskDetails") && runtime.includes(".readSessionUsage=async()=>await(await") && runtime.includes(".cancelBackgroundTask=async") && runtime.includes(".previewFileRewind=async e=>") && runtime.includes(".applyFileRewind=async e=>") && runtime.includes(interruptTurnMarker) + && runtime.includes(interruptWaitForIdleMarker) && runtime.includes(".promoteQueuedInput=async(") && runtime.includes(queuedInputPromotionMarker) && activeTurnGuidePattern.test(runtime) @@ -283,7 +373,12 @@ export function patchRuntimeTuiBridge(runtime: string): string { && /promoteQueuedInput:[A-Za-z_$][\w$]*\.promoteQueuedInput/u.test(runtime) && /readSessionUsage:[A-Za-z_$][\w$]*\.readSessionUsage/u.test(runtime) && listSkillsBridgePattern.test(runtime) - && listSkillsOptionPattern.test(runtime); + && listSkillsOptionPattern.test(runtime) + && sessionEventsBridgePattern.test(runtime) + && sessionEventsOptionPattern.test(runtime) + && taskMessageBridgePattern.test(runtime) + && taskMessageOptionPattern.test(runtime) + && runtime.includes(taskMessageRestartMarker); if (alreadyPatched) return runtime; let patched = runtime; @@ -364,6 +459,8 @@ export function patchRuntimeTuiBridge(runtime: string): string { const [recallAssignment, bridge, , getApp] = assignment; const assignments: string[] = []; + const projectionAssignment = `${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}(),t=await e.runtime?.getProjection?.();if(!t)return null;let r=Object.values(e.runtime?.runtimeTaskRegistry?.all?.()??{}).filter(o=>o.isBackgrounded===!0).map(o=>({taskId:o.taskId,taskKind:o.taskType??o.type,agentId:o.agentId,agentType:o.agentType,childSessionId:o.childSessionId,parentSessionId:o.parentSessionId,parentToolCallId:o.parentToolCallId,turnId:o.turnId,prompt:o.prompt,error:o.error instanceof Error?o.error.message:typeof o.error==="string"?o.error:void 0,outputPath:o.outputFile,status:o.status,description:o.description,startedAt:o.startedAt,completedAt:o.completedAt}));return{...t,backgroundTaskDetails:r}}`; + const taskMessageAssignment = `${bridge}.sendBackgroundTaskMessage=async e=>{let t=await ${getApp}(),r=t.runtime,o=r?.runtimeTaskRegistry?.get?.(e?.taskId);if(!r?.subagentPort?.sendMessage)throw new Error("Background agent messaging is unavailable in this runtime.");if(!o||(o.type??o.taskType)!=="local_agent")throw new Error("The selected task is not a local agent.");if(typeof e?.message!=="string"||!e.message.trim())throw new Error("Enter a message for the background agent.");let n=e.message.trim().slice(0,2e4),i=(typeof e.summary==="string"?e.summary:n).replace(/\\s+/g," ").trim().slice(0,200);if(e?.restart===!0&&o.status==="running"){if(!r.subagentPort.stopTask)throw new Error("Background agent restart is unavailable in this runtime.");await r.subagentPort.stopTask(e.taskId),o=r.runtimeTaskRegistry?.get?.(e.taskId);if(!o)throw new Error("The background agent stopped but could not be restored.")}return await r.subagentPort.sendMessage({sessionId:o.parentSessionId??r.getSessionId?.(),turnId:o.turnId??"tui-task-message",parentToolCallId:o.parentToolCallId??"tui-task-message",to:o.agentId??e.taskId,summary:i,message:n,workingDirectory:o.workingDirectory??r.workingDirectory,workspaceRoot:o.workspaceRoot??r.workingDirectory,trace:o.traceContext??r.rootTraceContext})}`; if (!listSkillsBridgePattern.test(patched)) { const listSkillsFactory = /listSkills:[A-Za-z_$][\w$]*\(\(\)=>([A-Za-z_$][\w$]*)\(([A-Za-z_$][\w$]*)\),"listSkills"\)/u .exec(patched); @@ -372,7 +469,7 @@ export function patchRuntimeTuiBridge(runtime: string): string { } assignments.push(`${bridge}.listSkills=async()=>await ${listSkillsFactory[1]}(${listSkillsFactory[2]})`); } - const interruptAssignment = `${bridge}.interruptTurn=async e=>{let t=await ${getApp}(),r=e?.reservationId??"tui-steer-interrupt",o=(Array.isArray(e?.pendingInputIds)?e.pendingInputIds:[]).filter(Boolean),n=[],i=async()=>{for(let a of n)await t.releaseQueueItemReservation?.(a,r);n=[]};try{if(t.reserveQueueItem&&t.releaseQueueItemReservation)for(let a of o)if(await t.reserveQueueItem(a,r))n.push(a);else{await i();break}let a=t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:o.length>0&&n.length===o.length,reason:e?.reason??"TUI steer interrupt"})??{kind:"unsupported"};return a.kind!=="stopped"&&await i(),a}catch(a){await i();throw a}}`; + const interruptAssignment = `${bridge}.interruptTurn=async e=>{let t=await ${getApp}(),r=e?.reservationId??"tui-steer-interrupt",o=(Array.isArray(e?.pendingInputIds)?e.pendingInputIds:[]).filter(Boolean),n=[],i=async()=>{for(let a of n)await t.releaseQueueItemReservation?.(a,r);n=[]};try{if(t.reserveQueueItem&&t.releaseQueueItemReservation)for(let a of o)if(await t.reserveQueueItem(a,r))n.push(a);else{await i();break}let a=t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:o.length>0&&n.length===o.length,reason:e?.reason??"TUI steer interrupt"})??{kind:"unsupported"};if(a.kind==="stopped"&&e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId){let u=Date.now()+5e3;for(;t.runtime.getActiveForegroundExecutionId()!==void 0;){if(Date.now()>=u)throw new Error("Timed out waiting for background result processing to stop.");await new Promise(l=>setTimeout(l,25))}}return a.kind!=="stopped"&&await i(),a}catch(a){await i();throw a}}`; const promotionAssignment = `${bridge}.promoteQueuedInput=async(e,t,r)=>{let o=await ${getApp}(),n=r?.pendingInputReservationId??r?.queryId??r?.inputId??"tui-promotion",i=(Array.isArray(t)?t:[t]).filter(Boolean);if(i.length===0||!o.reserveQueueItem||!o.markQueueItemPromoting||!o.releaseQueueItemReservation||!o.removeQueueItem)return ${bridge}.sendInput(e,{...r,delivery:"start_turn"});let a=[],u=!1;try{for(let l of i){if(await o.markQueueItemPromoting(l,n)){a.push(l);continue}if(!await o.reserveQueueItem(l,n))throw new Error("TUI queued input is already reserved: "+l);a.push(l);if(!await o.markQueueItemPromoting(l,n))throw new Error("TUI queued input promotion failed: "+l)}let c=await ${bridge}.sendInput(e,{...r,delivery:"start_turn"});if(c?.kind==="rejected")return c;u=!0;for(let l of a)if(!await o.removeQueueItem(l,{reason:"promoted",reservationId:n}))throw new Error("TUI queued input promotion commit failed: "+l);return c}finally{if(!u)for(let l of a)await o.releaseQueueItemReservation(l,n)}}`; if (!patched.includes(".loadSessionTranscript=async()=>await(await")) { assignments.push(`${bridge}.loadSessionTranscript=async()=>await(await ${getApp}()).loadSessionTranscript?.()??[]`); @@ -383,8 +480,10 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!patched.includes(".readTodos=async()=>await(await")) { assignments.push(`${bridge}.readTodos=async()=>await(await ${getApp}()).readTodos?.()??[]`); } - if (!patched.includes(".readRuntimeProjection=async()=>")) { - assignments.push(`${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}();return e.runtime?.getProjection?.()??null}`); + if (!patched.includes("backgroundTaskDetails")) { + const existingProjection = `${bridge}.readRuntimeProjection=async()=>{let e=await ${getApp}();return e.runtime?.getProjection?.()??null}`; + if (patched.includes(existingProjection)) patched = patched.replace(existingProjection, projectionAssignment); + else assignments.push(projectionAssignment); } if (!patched.includes(".readSessionUsage=async()=>await(await")) { assignments.push(`${bridge}.readSessionUsage=async()=>await(await ${getApp}()).readSessionUsage?.()??null`); @@ -398,8 +497,32 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!patched.includes(".applyFileRewind=async e=>")) { assignments.push(`${bridge}.applyFileRewind=async e=>{let t=await ${getApp}();return await t.runtime?.applyWorkspaceFileRewind?.({targetMessageIds:e})??null}`); } + if (!sessionEventsBridgePattern.test(patched)) { + assignments.push(`${bridge}.subscribeSessionEvents=e=>{let t=!1,r;${getApp}().then(o=>{t||(r=o.runtime?.subscribeEvents?.({onSessionEvent:e}))});return()=>{t=!0,r?.()}}`); + } + if (!taskMessageBridgePattern.test(patched)) { + assignments.push(taskMessageAssignment); + } else if (!patched.includes(taskMessageRestartMarker)) { + const existingTaskMessageStart = patched.indexOf(`${bridge}.sendBackgroundTaskMessage=async e=>`); + const existingTaskMessageEnd = existingTaskMessageStart < 0 + ? -1 + : patched.indexOf(`,${bridge}.`, existingTaskMessageStart); + if (existingTaskMessageStart < 0 || existingTaskMessageEnd < 0) { + throw new Error("ZCode runtime is incompatible with the TUI bridge (task-message boundary missing)."); + } + patched = `${patched.slice(0, existingTaskMessageStart)}${taskMessageAssignment}${patched.slice(existingTaskMessageEnd)}`; + } if (!patched.includes(interruptTurnMarker)) { assignments.push(interruptAssignment); + } else if (!patched.includes(interruptWaitForIdleMarker)) { + const existingInterruptStart = patched.indexOf(`${bridge}.interruptTurn=async e=>`); + const existingInterruptEnd = existingInterruptStart < 0 + ? -1 + : patched.indexOf(`,${bridge}.`, existingInterruptStart); + if (existingInterruptStart < 0 || existingInterruptEnd < 0) { + throw new Error("ZCode runtime is incompatible with the TUI bridge (interrupt boundary missing)."); + } + patched = `${patched.slice(0, existingInterruptStart)}${interruptAssignment}${patched.slice(existingInterruptEnd)}`; } if (!patched.includes(queuedInputPromotionMarker)) { const existingPromotionStart = patched.indexOf(`${bridge}.promoteQueuedInput=async(`); @@ -455,6 +578,12 @@ export function patchRuntimeTuiBridge(runtime: string): string { if (!listSkillsOptionPattern.test(patched)) { optionFields.push(`listSkills:${submitBridge}.listSkills`); } + if (!sessionEventsOptionPattern.test(patched)) { + optionFields.push(`subscribeSessionEvents:${submitBridge}.subscribeSessionEvents`); + } + if (!taskMessageOptionPattern.test(patched)) { + optionFields.push(`sendBackgroundTaskMessage:${submitBridge}.sendBackgroundTaskMessage`); + } if (optionFields.length > 0) { patched = patched.replace(optionsAssignment, `${optionFields.join(",")},${optionsAssignment}`); } @@ -609,7 +738,21 @@ async function installTuiBridge(nextVendor: string): Promise { const runtime = await readFile(runtimePath, "utf8"); await writeFile( runtimePath, - patchRuntimeZaiDesktopOAuth(patchRuntimeOAuthHttpErrors(patchRuntimeTuiBridge(runtime))) + patchRuntimeZaiDesktopOAuth( + patchRuntimeOAuthHttpErrors( + patchRuntimeAgentAutoBackground( + patchRuntimeDetachedAgentLifecycle( + patchRuntimeTerminalToolProjection( + patchRuntimeProviderRetryClassification( + patchRuntimeTuiWarnings( + patchRuntimeBackgroundTaskProjection(patchRuntimeTuiBridge(runtime)) + ) + ) + ) + ) + ) + ) + ) ); } diff --git a/src/launcher.ts b/src/launcher.ts index d8328b5..2d9d35d 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -1,6 +1,15 @@ import { spawn as spawnChild, type ChildProcess } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { constants as osConstants } from "node:os"; +import { + appendFileSync, + chmodSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + unlinkSync +} from "node:fs"; +import { constants as osConstants, homedir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -20,6 +29,7 @@ const runtimePath = join(packageRoot, "vendor", "zcode.cjs"); const launcherPath = join(packageRoot, "bin", "zcode.js"); const defaultModelRetryMaxRetries = "5"; const defaultBrowserUseArgument = "--browser-use=headless"; +const tuiRuntimeLogLimitBytes = 2 * 1024 * 1024; const versionArguments = new Set(["version", "--version", "-v"]); const runtimeBooleanOptions = new Set([ "--allow-main-worktree-yolo", @@ -103,10 +113,20 @@ function longOptionName(argument: string): string { return separator < 0 ? argument : argument.slice(0, separator); } -export function withDefaultBrowserUse(args: string[]): string[] { +interface RuntimeInvocationInspection { + agentInvocation: boolean; + command?: string; + explicitBrowserUse: boolean; + invalid: boolean; + passthrough: boolean; +} + +function inspectRuntimeInvocation(args: string[]): RuntimeInvocationInspection { let agentInvocation = false; let command: string | undefined; + let explicitBrowserUse = false; let invalid = false; + let passthrough = false; for (let index = 0; index < args.length; index += 1) { const argument = args[index]!; @@ -117,8 +137,18 @@ export function withDefaultBrowserUse(args: string[]): string[] { if (argument.startsWith("--")) { const option = longOptionName(argument); const inlineValue = option.length !== argument.length; - if (option === "--browser-use") return args; - if (option === "--help" || option === "--version") return args; + if (option === "--browser-use") { + explicitBrowserUse = true; + if (!inlineValue) { + if (index + 1 >= args.length || args[index + 1]!.startsWith("-")) invalid = true; + else index += 1; + } + continue; + } + if (option === "--help" || option === "--version") { + passthrough = true; + continue; + } if (option === "--print") { if (inlineValue) invalid = true; else agentInvocation = true; @@ -152,7 +182,10 @@ export function withDefaultBrowserUse(args: string[]): string[] { continue; } if (argument.startsWith("-")) { - if (argument === "-h" || argument === "-v") return args; + if (argument === "-h" || argument === "-v") { + passthrough = true; + continue; + } if (argument === "-p" || argument.startsWith("-p")) { agentInvocation = true; if (argument === "-p") { @@ -168,10 +201,28 @@ export function withDefaultBrowserUse(args: string[]): string[] { command ??= argument; } - if (invalid || (!agentInvocation && command !== undefined && command !== "tui")) return args; + return { agentInvocation, command, explicitBrowserUse, invalid, passthrough }; +} + +export function withDefaultBrowserUse(args: string[]): string[] { + const invocation = inspectRuntimeInvocation(args); + if (invocation.explicitBrowserUse + || invocation.passthrough + || invocation.invalid + || (!invocation.agentInvocation + && invocation.command !== undefined + && invocation.command !== "tui")) return args; return [defaultBrowserUseArgument, ...args]; } +export function isTuiRuntimeInvocation(args: string[]): boolean { + const invocation = inspectRuntimeInvocation(args); + return !invocation.agentInvocation + && !invocation.invalid + && !invocation.passthrough + && (invocation.command === undefined || invocation.command === "tui"); +} + function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}): Record { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN; @@ -198,7 +249,10 @@ function signalExitCode(signal: NodeJS.Signals | null): number { return typeof number === "number" ? 128 + number : 1; } -async function waitForChild(child: ChildProcess): Promise { +async function waitForChild( + child: ChildProcess, + onError: (error: Error) => void = (error) => console.error("Error: " + error.message) +): Promise { return await new Promise((resolveExit) => { let settled = false; const finish = (code: number) => { @@ -207,20 +261,74 @@ async function waitForChild(child: ChildProcess): Promise { resolveExit(code); }; child.once("error", (error) => { - console.error(`Error: ${error.message}`); + onError(error); finish(1); }); child.once("exit", (code, signal) => finish(code ?? signalExitCode(signal))); }); } +interface TuiRuntimeDiagnosticState { + bytes: number; + initialized: boolean; + path?: string; + writeFailed: boolean; +} + +function appendTuiRuntimeDiagnostic(chunk: Buffer | string, state: TuiRuntimeDiagnosticState): void { + if (state.bytes >= tuiRuntimeLogLimitBytes) return; + const text = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + try { + const path = state.path ?? (process.env.ZCODE_TUI_RUNTIME_LOG?.trim() + || join(homedir(), ".zcode", "cli", "tui-runtime.log")); + state.path = path; + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + if (!state.initialized) { + state.initialized = true; + const existingBytes = existsSync(path) ? statSync(path).size : 0; + if (existingBytes >= tuiRuntimeLogLimitBytes) { + const rotated = `${path}.1`; + if (existsSync(rotated)) unlinkSync(rotated); + renameSync(path, rotated); + chmodSync(rotated, 0o600); + } else { + state.bytes = existingBytes; + } + } + const bounded = text.subarray(0, tuiRuntimeLogLimitBytes - state.bytes); + if (bounded.byteLength === 0) return; + appendFileSync(path, bounded, { mode: 0o600 }); + chmodSync(path, 0o600); + state.bytes += bounded.byteLength; + } catch { + state.writeFailed = true; + } +} + +function tuiRuntimeFailureMessage(code: number, state: TuiRuntimeDiagnosticState): string { + const diagnostic = state.path && !state.writeFailed + ? ` Diagnostics: ${state.path}` + : " Runtime diagnostics could not be written."; + return `Error: ZCode runtime exited with status ${code}.${diagnostic}\n`; +} + async function runRuntime(node: string, args: string[]): Promise { + const tuiInvocation = isTuiRuntimeInvocation(args); const child = spawnChild(node, [runtimePath, ...args], { cwd: process.cwd(), env: runtimeEnvironment(), - stdio: "inherit" + stdio: tuiInvocation ? ["inherit", "inherit", "pipe"] : "inherit" }); + const diagnosticState: TuiRuntimeDiagnosticState = { + bytes: 0, + initialized: false, + writeFailed: false + }; + const onDiagnostic = (chunk: Buffer | string) => appendTuiRuntimeDiagnostic(chunk, diagnosticState); + child.stderr?.on("data", onDiagnostic); + let forwardedSignal = false; const forwardSignal = (signal: NodeJS.Signals) => { + forwardedSignal = true; if (!child.killed) child.kill(signal); }; const onSigint = () => forwardSignal("SIGINT"); @@ -230,8 +338,18 @@ async function runRuntime(node: string, args: string[]): Promise { process.once("SIGTERM", onSigterm); if (process.platform !== "win32") process.once("SIGHUP", onSighup); try { - return await waitForChild(child); + const code = await waitForChild( + child, + tuiInvocation + ? (error) => appendTuiRuntimeDiagnostic((error.stack ?? error.message) + "\n", diagnosticState) + : undefined + ); + if (tuiInvocation && code !== 0 && !forwardedSignal) { + process.stderr.write(tuiRuntimeFailureMessage(code, diagnosticState)); + } + return code; } finally { + child.stderr?.off("data", onDiagnostic); process.off("SIGINT", onSigint); process.off("SIGTERM", onSigterm); if (process.platform !== "win32") process.off("SIGHUP", onSighup); diff --git a/test/background-task-events.test.ts b/test/background-task-events.test.ts new file mode 100644 index 0000000..94126f3 --- /dev/null +++ b/test/background-task-events.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; + +import { BackgroundTaskEventStore } from "../packages/zcode-tui/src/background-task-events.ts"; +import { normalizeEvent } from "../packages/zcode-tui/src/events.ts"; + +function event(value: unknown) { + const normalized = normalizeEvent(value); + if (!normalized) throw new Error("Expected a normalized event."); + return normalized; +} + +describe("background task event store", () => { + test("routes autonomous output to its task without treating handoff completion as task completion", () => { + const store = new BackgroundTaskEventStore(); + const completed = store.handle(event({ + id: "task-completed", + type: "background_task_completed", + turnId: "parent-turn", + payload: { taskId: "bg-1", status: "completed", taskKind: "bash" } + })); + expect(completed.notices).toEqual([expect.objectContaining({ + title: "Background task completed", + summary: "bg-1 · /tasks" + })]); + + store.handle(event({ + id: "handoff-started", + type: "turn_started", + turnId: "handoff-turn", + payload: { + inputSource: "background_task", + originMeta: { workId: "bg-1" } + } + })); + expect(store.hasActiveHandoffs()).toBe(true); + store.handle(event({ + id: "handoff-text", + type: "model.streaming", + turnId: "handoff-turn", + payload: { kind: "text_delta", delta: "Task output was reviewed." } + })); + const settled = store.handle(event({ + id: "handoff-completed", + type: "turn_complete", + turnId: "handoff-turn", + payload: {} + })); + + expect(settled.handoffSettled).toBe(true); + expect(settled.notices).toEqual([]); + expect(store.hasActiveHandoffs()).toBe(false); + expect(store.entries("bg-1").map((entry) => entry.text)).toEqual([ + "Task completed.", + "Task output was reviewed." + ]); + }); + + test("keeps result-processing failure separate from the underlying task status", () => { + const store = new BackgroundTaskEventStore(); + store.handle(event({ + id: "task-completed", + type: "background_task_completed", + payload: { taskId: "bg-2", status: "completed" } + })); + store.handle(event({ + id: "handoff-started", + type: "turn_started", + turnId: "handoff-failed", + payload: { inputSource: "background_task" } + })); + const failed = store.handle(event({ + id: "handoff-error", + type: "turn_error", + turnId: "handoff-failed", + payload: { error: { message: "Provider unavailable during handoff." } } + })); + + expect(failed.notices).toEqual([expect.objectContaining({ + title: "Background result processing failed", + summary: "bg-2 · /tasks" + })]); + expect(store.entries("bg-2").at(-1)).toMatchObject({ + kind: "error", + text: "Provider unavailable during handoff." + }); + expect(store.isTaskScoped(event({ + id: "late-handoff-event", + type: "model.streaming", + turnId: "handoff-failed", + payload: { kind: "reasoning_delta", delta: "late autonomous output" } + }))).toBe(true); + }); + + test("settles a stuck autonomous handoff when the user preempts it", () => { + const store = new BackgroundTaskEventStore(); + store.handle(event({ + id: "stuck-handoff-started", + type: "turn_started", + turnId: "stuck-handoff", + payload: { inputSource: "background_task" } + })); + + expect(store.hasActiveHandoffs()).toBe(true); + expect(store.settleActiveHandoffs()).toBe(1); + expect(store.hasActiveHandoffs()).toBe(false); + expect(store.settleActiveHandoffs()).toBe(0); + }); + + test("records agent replies once and allows a task-scoped user follow-up", () => { + const store = new BackgroundTaskEventStore(); + const reply = event({ + id: "agent-reply", + type: "subagent_message", + payload: { + agentId: "agent-1", + agentType: "reviewer", + message: "I found the failing test." + } + }); + store.handle(reply); + store.handle(reply); + store.recordUserMessage("agent-1", "Fix it and rerun the focused test."); + + expect(store.entries("agent-1").map(({ kind, text }) => ({ kind, text }))).toEqual([ + { kind: "assistant", text: "I found the failing test." }, + { kind: "user", text: "Fix it and rerun the focused test." } + ]); + }); + + test("deduplicates paired agent terminal events without dropping the error detail", () => { + const store = new BackgroundTaskEventStore(); + const task = store.handle(event({ + id: "agent-task-failed", + type: "background_task_completed", + payload: { taskId: "agent-2", toolName: "Agent", status: "failed" } + })); + const lifecycle = store.handle(event({ + id: "agent-stopped", + type: "subagent_stopped", + payload: { + agentId: "agent-2", + status: "failed", + error: "The provider connection closed." + } + })); + + expect(task.notices).toHaveLength(1); + expect(lifecycle.notices).toEqual([]); + expect(store.entries("agent-2").at(-1)).toMatchObject({ + kind: "error", + text: "The provider connection closed." + }); + }); + + test("scopes Agent tool trees and their descendants to the task center", () => { + const store = new BackgroundTaskEventStore(); + const agent = event({ + id: "agent-part", + type: "part.started", + payload: { + part: { + type: "tool", + partId: "part-agent", + messageId: "message-coordinator", + callId: "call-agent", + tool: "Agent", + state: { + status: "running", + input: { description: "Research rendering", run_in_background: true } + } + } + } + }); + store.handle(agent); + + const child = event({ + id: "child-part", + type: "part.started", + payload: { + part: { + type: "tool", + partId: "part-fetch", + messageId: "message-coordinator", + callId: "call-fetch", + tool: "Fetch", + state: { + status: "running", + input: { url: "https://example.com" }, + metadata: { parentToolCallId: "call-agent" } + } + } + } + }); + store.handle(child); + + const unrelated = event({ + id: "foreground-fetch", + type: "tool_call_started", + payload: { toolCallId: "call-foreground", toolName: "Fetch" } + }); + store.handle(unrelated); + + expect(store.isBackgroundToolScoped(agent)).toBe(true); + expect(store.isTaskScoped(child)).toBe(true); + expect(store.isTaskScoped(unrelated)).toBe(false); + }); + + test("keeps synchronous agents in the foreground until the runtime backgrounds them", () => { + const store = new BackgroundTaskEventStore(); + const started = event({ + id: "foreground-agent-started", + type: "part.started", + payload: { + part: { + type: "tool", + partId: "part-agent-auto", + messageId: "message-auto", + callId: "call-agent-auto", + tool: "Agent", + state: { status: "running", input: { description: "Research runtime" } } + } + } + }); + store.handle(started); + expect(store.isTaskScoped(started)).toBe(false); + + const child = event({ + id: "foreground-agent-child", + type: "part.started", + payload: { + part: { + type: "tool", + partId: "part-agent-child", + callId: "call-agent-child", + tool: "Fetch", + state: { status: "running", metadata: { parentToolCallId: "call-agent-auto" } } + } + } + }); + store.handle(child); + expect(store.isTaskScoped(child)).toBe(false); + + const backgrounded = event({ + id: "foreground-agent-backgrounded", + type: "part.upserted", + payload: { + part: { + type: "tool", + partId: "part-agent-auto", + messageId: "message-auto", + callId: "call-agent-auto", + tool: "Agent", + state: { + status: "completed", + input: { description: "Research runtime" }, + output: { + status: "async_launched", + isAsync: true, + backgroundTaskId: "agent-auto" + } + } + } + } + }); + store.handle(backgrounded); + + expect(store.isTaskScoped(backgrounded)).toBe(true); + expect(store.isTaskScoped(child)).toBe(true); + }); +}); diff --git a/test/background-task-output.test.ts b/test/background-task-output.test.ts new file mode 100644 index 0000000..cc1259b --- /dev/null +++ b/test/background-task-output.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { readBackgroundTaskOutput } from "../packages/zcode-tui/src/background-task-output.ts"; + +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map(async (directory) => { + await rm(directory, { recursive: true, force: true }); + })); +}); + +describe("background task output", () => { + test("reads a saved task result", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-task-output-")); + directories.push(directory); + const path = join(directory, "output.txt"); + await writeFile(path, " completed result\n"); + + expect(readBackgroundTaskOutput(path)).toEqual({ + text: "completed result", + truncated: false + }); + }); + + test("bounds large results and ignores unavailable files", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-task-output-")); + directories.push(directory); + const path = join(directory, "output.txt"); + await writeFile(path, "older output\nlatest result"); + + expect(readBackgroundTaskOutput(path, 13)).toEqual({ + text: "latest result", + truncated: true + }); + expect(readBackgroundTaskOutput(join(directory, "missing.txt"))).toBeUndefined(); + }); +}); diff --git a/test/config-bootstrap.test.ts b/test/config-bootstrap.test.ts index 9c1e1c8..60cb4f6 100644 --- a/test/config-bootstrap.test.ts +++ b/test/config-bootstrap.test.ts @@ -48,6 +48,7 @@ describe("user config bootstrap", () => { model: { lite: string; main: string }; modelStream: { idleTimeoutMs: number }; provider: { zai: { options: { apiKey?: string }; models: Record } }; + subagents: { autoBackgroundMs: number }; }; expect(result).toEqual({ configPath: userConfigPath(env), created: true }); @@ -56,6 +57,7 @@ describe("user config bootstrap", () => { expect(config.provider.zai.models["glm-5.1"]).toBeDefined(); expect(config.model).toEqual({ main: "zai/glm-5.2", lite: "zai/glm-5.1" }); expect(config.modelStream.idleTimeoutMs).toBe(60_000); + expect(config.subagents.autoBackgroundMs).toBe(1_000); expect(await readConfiguredModelAccess(env)).toBeNull(); if (process.platform !== "win32") { diff --git a/test/config-template.test.ts b/test/config-template.test.ts index 2f6fd9d..4766d21 100644 --- a/test/config-template.test.ts +++ b/test/config-template.test.ts @@ -19,6 +19,9 @@ interface ConfigTemplate { modelStream: { idleTimeoutMs: number; }; + subagents: { + autoBackgroundMs: number; + }; ui: { theme: string; notifications: { @@ -42,6 +45,7 @@ test("custom-provider config template is internally consistent", async () => { expect(config.provider[providerId]?.options.apiKey).toBeUndefined(); expect(config.provider[providerId]?.options.baseURL).toBe("https://api.z.ai/api/anthropic"); expect(config.modelStream.idleTimeoutMs).toBe(60_000); + expect(config.subagents.autoBackgroundMs).toBe(1_000); expect(config.ui.theme).toBe("auto"); expect(config.ui.notifications).toEqual({ method: "auto", condition: "unfocused" }); }); diff --git a/test/events.test.ts b/test/events.test.ts index 0a8591e..eb8ded8 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -44,12 +44,15 @@ describe("ZCode event adapter", () => { test("normalizes protocol streaming events", () => { expect(normalizeEvent({ + id: "event_stream_delta", type: "model.streaming", payload: { + id: "message_entity", kind: "text_delta", delta: "你好" } })).toMatchObject({ + eventId: "event_stream_delta", type: "model.streaming", kind: "text_delta", delta: "你好" @@ -102,6 +105,55 @@ describe("ZCode event adapter", () => { }); }); + test("normalizes raw autonomous turn lifecycle metadata and failures", () => { + expect(normalizeEvent({ + id: "event_background_start", + type: "turn_started", + turnId: "turn_background", + payload: { + inputSource: "background_task", + originMeta: { workId: "task_background", workIds: ["task_background", "task_two"] } + } + })).toMatchObject({ + eventId: "event_background_start", + type: "turn_started", + turnId: "turn_background", + inputSource: "background_task", + taskId: "task_background", + taskIds: ["task_background", "task_two"] + }); + + expect(normalizeEvent({ + id: "event_background_failure", + type: "turn_error", + turnId: "turn_background", + payload: { error: { message: "Background result failed." } } + })).toMatchObject({ + eventId: "event_background_failure", + type: "turn_error", + turnId: "turn_background", + message: "Background result failed." + }); + + expect(normalizeEvent({ + id: "event_agent_reply", + type: "subagent_message", + payload: { + agentId: "agent_background", + agentType: "reviewer", + childSessionId: "child_background", + status: "failed", + summaryText: "Review failed after the final tool call." + } + })).toMatchObject({ + agentId: "agent_background", + agentType: "reviewer", + childSessionId: "child_background", + taskStatus: "failed", + message: "Review failed after the final tool call." + }); + }); + test("uses nested runtime model-network event types and retry metadata", () => { expect(normalizeEvent({ type: "model.network_status", diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts index fe73055..5516ba5 100644 --- a/test/fixtures/tui-features.ts +++ b/test/fixtures/tui-features.ts @@ -21,8 +21,13 @@ if (process.argv[2] === "login") { let model = "alpha/model"; let effort = "low"; let backgroundStatus = "running"; +let agentStatus = "failed"; +let agentMessageCount = 0; +let timerTaskStatus: "failed" | "running" | undefined; let turnCompleted = false; let featureTurnActive = false; +let stuckBackgroundHandoff = false; +let sessionEventListener: ((event: unknown) => void | Promise) | undefined; let featureSteerInput: string | undefined; let featurePendingInputId: string | undefined; let resolveFeatureSteer!: () => void; @@ -101,6 +106,90 @@ async function emitRuntime(options: PromptCallOptions, type: string, payload: un await options.onEvent?.({ type, payload }); } +async function emitSessionEvent( + event: unknown, + submissionListener?: (event: unknown) => void | Promise +): Promise { + await sessionEventListener?.(event); + await submissionListener?.(event); +} + +async function emitBackgroundResultTurn( + turnId: string, + eventPrefix: string, + text: string, + inputSource: "background_task" | "subagent_message", + duplicateText = false, + submissionListener?: (event: unknown) => void | Promise +): Promise { + await emitSessionEvent({ + id: `${eventPrefix}_start`, type: "turn_started", turnId, + payload: { inputSource } + }, submissionListener); + const textEvent = { + id: `${eventPrefix}_text`, type: "model.streaming", turnId, + payload: { + kind: "text_delta", + delta: text, + messageId: `${eventPrefix}_message`, + partId: `${eventPrefix}_part` + } + }; + await emitSessionEvent(textEvent, submissionListener); + if (duplicateText) await emitSessionEvent(textEvent, submissionListener); + await emitSessionEvent({ + id: `${eventPrefix}_complete`, type: "turn_complete", turnId, payload: {} + }, submissionListener); +} + +async function runBackgroundCompletionTurn( + submissionListener?: (event: unknown) => void | Promise +): Promise { + await emitSessionEvent({ + id: "event_agent_reply", + type: "subagent_message", + payload: { + agentId: "agent_feature", + agentType: "reviewer", + message: "Background agent reply stored in task activity." + } + }, submissionListener); + await emitBackgroundResultTurn( + "turn_agent_handoff", + "event_agent_handoff", + "Task-scoped agent handoff completed.", + "subagent_message", + true, + submissionListener + ); + await emitSessionEvent({ + id: "event_background_task_failed", + type: "background_task_completed", + payload: { taskId: "agent_feature", taskKind: "local_agent", status: "failed" } + }, submissionListener); + await emitSessionEvent({ + id: "event_background_failure_start", type: "turn_started", turnId: "turn_background_failure", + payload: { inputSource: "background_task", originMeta: { workId: "agent_feature" } } + }, submissionListener); + await emitSessionEvent({ + id: "event_background_failure_reasoning", type: "model.streaming", turnId: "turn_background_failure", + payload: { kind: "reasoning_delta", delta: "Background-only reasoning must stay in task activity." } + }, submissionListener); + await emitSessionEvent({ + id: "event_background_failure_tool", type: "model.streaming", turnId: "turn_background_failure", + payload: { kind: "tool_input_start", toolCallId: "background_fetch", toolName: "Fetch" } + }, submissionListener); + await emitSessionEvent({ + id: "event_background_failure_text", type: "model.streaming", turnId: "turn_background_failure", + payload: { kind: "text_delta", delta: "Coordinator began processing the failed task result." } + }, submissionListener); + await Bun.sleep(400); + await emitSessionEvent({ + id: "event_background_failure", type: "turn_error", turnId: "turn_background_failure", + payload: { error: { message: "Background result failed visibly." } } + }, submissionListener); +} + await runTui({ version: "feature-smoke", theme: process.env.ZCODE_TUI_TEST_THEME, @@ -166,24 +255,101 @@ await runTui({ ] }, activeToolCalls: [], - backgroundTasks: [{ - taskId: "bg_feature", - toolName: "Bash", - description: turnCompleted ? "Feature background audit · turn complete" : "Feature background audit", - command: "bun test", - status: backgroundStatus, - cancellable: true, - pid: 4242, - startedAt: Date.now() - 5_000, - stdoutBytes: 512, - stdoutTail: "Background audit running" - }] + backgroundTasks: [ + { + taskId: "bg_feature", + taskKind: "bash", + toolName: "Bash", + description: turnCompleted ? "Feature background audit · turn complete" : "Feature background audit", + command: "bun test", + status: backgroundStatus, + cancellable: true, + pid: 4242, + startedAt: Date.now() - 5_000, + stdoutBytes: 512, + stdoutTail: "Background audit running" + }, + { + taskId: "agent_feature", + toolName: "Agent", + description: "Review task recovery", + status: agentStatus, + cancellable: agentStatus === "running", + startedAt: Date.now() - 4_000 + }, + ...(timerTaskStatus ? [{ + taskId: "timer_feature", + toolName: "Agent", + description: "Verify aggregate task timing", + status: timerTaskStatus, + cancellable: timerTaskStatus === "running", + startedAt: Date.now() - 1_000 + }] : []) + ], + backgroundTaskDetails: [ + { + taskId: "agent_feature", + taskKind: "local_agent", + agentId: "agent_feature", + agentType: "reviewer", + childSessionId: "child_feature", + parentSessionId: "feature-session", + turnId: "turn_feature", + prompt: "Review the recovery path and report findings.", + error: agentStatus === "failed" ? "The first attempt lost its provider connection." : undefined, + status: agentStatus + }, + ...(timerTaskStatus ? [{ + taskId: "timer_feature", + taskKind: "local_agent", + agentId: "timer_feature", + agentType: "reviewer", + childSessionId: "child_timer_feature", + parentSessionId: "feature-session", + turnId: "turn_timer_feature", + prompt: "Verify aggregate task timing.", + error: timerTaskStatus === "failed" ? "Expected timer fixture failure." : undefined, + status: timerTaskStatus + }] : []) + ] }), cancelBackgroundTask: async (taskId) => { if (taskId !== "bg_feature") throw new Error(`Unexpected background task: ${taskId}`); backgroundStatus = "cancelled"; return { cancelled: true, status: backgroundStatus, taskId }; }, + sendBackgroundTaskMessage: async ({ taskId, message, summary, restart }) => { + if (taskId !== "agent_feature") throw new Error(`Unexpected agent task: ${taskId}`); + agentMessageCount += 1; + const expectedMessage = agentMessageCount === 1 + ? "Fix the recovery issue and rerun the focused test." + : "Restart from the saved state and finish the remaining verification."; + if (message !== expectedMessage) throw new Error(`Unexpected agent message: ${message}`); + if (summary !== message) throw new Error(`Unexpected agent summary: ${summary}`); + if (restart !== (agentMessageCount === 2)) { + throw new Error(`Unexpected restart flag for agent message ${agentMessageCount}.`); + } + agentStatus = "running"; + return { + status: "success", + delivery: "resumed_background", + taskId, + message: restart + ? `Agent "${taskId}" restarted in the background.` + : `Agent "${taskId}" resumed in the background.` + }; + }, + interruptTurn: async ({ pendingInputIds, reason, reservationId, waitForIdle }) => { + if (!stuckBackgroundHandoff) throw new Error("Unexpected background handoff interrupt."); + if (pendingInputIds?.length !== 0 + || reason !== "User input preempted background result processing." + || !reservationId?.startsWith("background_handoff_") + || waitForIdle !== true) { + throw new Error("Background handoff interrupt did not request an idle runtime boundary."); + } + stuckBackgroundHandoff = false; + return { kind: "stopped", foregroundExecutionId: "fixture-background-handoff" }; + }, previewFileRewind: async (targetMessageIds) => { if (!targetMessageIds.every((messageId) => sessionTranscript.some((message) => message.messageId === messageId))) { throw new Error(`Unexpected rewind preview targets: ${targetMessageIds.join(", ")}`); @@ -211,6 +377,12 @@ await runTui({ modelRequestCount: 3, modelErrorCount: 0 }), + subscribeSessionEvents: (listener) => { + sessionEventListener = listener; + return () => { + if (sessionEventListener === listener) sessionEventListener = undefined; + }; + }, sendInput: async (input, options) => { const prompt = typeof input === "object" && input !== null ? input as Record : {}; const promptText = typeof input === "string" ? input : prompt.text; @@ -257,7 +429,48 @@ await runTui({ if (options.delivery !== "start_turn") { throw new Error(`Idle input used unexpected delivery mode: ${String(options.delivery)}`); } + if (promptText === "verify aggregate timer") { + timerTaskStatus = "running"; + await emitRuntime(options, "turn_started", { + inputId: options.inputId, + turnId: "turn_timer_feature" + }); + await emitRuntime(options, "background_task_started", { + taskId: "timer_feature", + taskKind: "local_agent", + status: "running", + turnId: "turn_timer_feature" + }); + setTimeout(() => { + timerTaskStatus = "failed"; + void emitSessionEvent({ + id: "event_timer_task_failed", + type: "background_task_updated", + turnId: "turn_timer_feature", + payload: { + taskId: "timer_feature", + taskKind: "local_agent", + status: "failed" + } + }); + }, 3_500).unref?.(); + return { + kind: "started_turn", + result: { + response: "Timer foreground complete; background still running.", + model, + thoughtLevel: effort + } + }; + } if (promptText === "Run this after the active turn.") { + stuckBackgroundHandoff = true; + await emitSessionEvent({ + id: "event_stuck_background_handoff", + type: "turn_started", + turnId: "turn_stuck_background_handoff", + payload: { inputSource: "background_task" } + }, options.onEvent); return { kind: "started_turn", result: { @@ -267,6 +480,17 @@ await runTui({ } }; } + if (promptText === "Continue after the stuck background handoff.") { + if (stuckBackgroundHandoff) throw new Error("Stuck background handoff was not interrupted."); + return { + kind: "started_turn", + result: { + response: "Queued input started after interrupting the stuck background handoff.", + model, + thoughtLevel: effort + } + }; + } if (promptText === "review long plan" || promptText === "review plan feedback") { if (!options.requestPermission) throw new Error("Plan approval callback is unavailable."); const approval = await options.requestPermission({ @@ -404,10 +628,23 @@ await runTui({ tool: "Agent", state: { status: "running", - input: { agentType: "explore", description: "Inspect nested rendering", prompt: "Read child.ts" } + input: { + agentType: "explore", + description: "Inspect nested rendering", + prompt: "Read child.ts", + run_in_background: true + } } }; await emitRuntime(options, "part.started", { part: agentPart }); + await emitRuntime(options, "part.started", { + part: { + type: "reasoning", + partId: "part_agent_coordination", + messageId: "message_assistant", + text: "Coordinator dispatching background research; this belongs in Tasks." + } + }); await emitRuntime(options, "subagent_spawned", { parentToolCallId: "call_agent", agentId: "agent_feature", @@ -417,13 +654,13 @@ await runTui({ }); const childPart = { type: "tool", - partId: "part_child_bash", + partId: "part_child_fetch", messageId: "message_assistant", - callId: "call_child_bash", - tool: "Bash", + callId: "call_child_fetch", + tool: "Fetch", state: { status: "running", - input: { command: "sed -n '1,80p' child.ts" }, + input: { url: "https://github.com/example/background-research" }, metadata: { parentToolCallId: "call_agent" } } }; @@ -434,7 +671,7 @@ await runTui({ state: { ...childPart.state, status: "completed", - output: "export const child = true;" + output: "Background research result" } } }); @@ -527,6 +764,7 @@ await runTui({ featureTurnActive = false; turnCompleted = true; resolveFeatureTurnFinished(); + await runBackgroundCompletionTurn(options.onEvent); return { kind: "started_turn", result: { diff --git a/test/fixtures/tui-pressure.ts b/test/fixtures/tui-pressure.ts index b5f08df..6f2d6aa 100644 --- a/test/fixtures/tui-pressure.ts +++ b/test/fixtures/tui-pressure.ts @@ -168,6 +168,13 @@ await runTui({ readSessionUsage: async () => ({ totalTokens: 0 }), interruptTurn: async ({ pendingInputIds, reason, reservationId }) => { if (!active || !activeTurnInterrupt) return { kind: "idle" }; + if (reason?.includes("active foreground turn")) { + if (pendingInputIds?.length) { + throw new Error(`Foreground interrupt received pending inputs: ${pendingInputIds.join(", ")}`); + } + activeTurnInterrupt.abort(new Error(reason)); + return { kind: "stopped" }; + } if (!reason?.includes("steer instructions")) { throw new Error(`Unexpected semantic interrupt reason: ${String(reason)}`); } diff --git a/test/launcher-runtime.test.ts b/test/launcher-runtime.test.ts index 70f98b5..7b6033e 100644 --- a/test/launcher-runtime.test.ts +++ b/test/launcher-runtime.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -18,7 +18,7 @@ afterAll(async () => { if (home) await rm(home, { recursive: true, force: true }); }); -async function run(args: string[], input = "") { +async function run(args: string[], input = "", environment: Record = {}) { if (!node) throw new Error("Node.js is required for launcher/runtime integration tests."); const child = Bun.spawn([process.execPath, "bin/zcode.ts", ...args], { cwd: root, @@ -26,7 +26,8 @@ async function run(args: string[], input = "") { ...process.env, HOME: home, USERPROFILE: home, - ZCODE_NODE: node + ZCODE_NODE: node, + ...environment }, stdin: "pipe", stdout: "pipe", @@ -43,6 +44,79 @@ async function run(args: string[], input = "") { } describe("launcher/runtime integration", () => { + test("keeps TUI runtime diagnostics out of the interactive terminal", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-stderr-")); + const fakeNode = join(directory, "fake-node"); + const logPath = join(directory, "tui-runtime.log"); + await writeFile(fakeNode, [ + "#!/bin/sh", + "printf '%s\\n' 'AI SDK Warning: cacheControl breakpoint limit' >&2", + "printf '%s\\n' 'ProviderBusinessError: No available channel for model GLM-5.2' >&2", + "printf '\\033[2J' >&2", + "exit \"${FAKE_NODE_EXIT:-0}\"", + "" + ].join("\n")); + await chmod(fakeNode, 0o755); + try { + const tui = await run(["--cwd", directory, "tui"], "", { + ZCODE_NODE: fakeNode, + ZCODE_TUI_RUNTIME_LOG: logPath + }); + expect(tui.code).toBe(0); + expect(tui.stderr).not.toContain("ProviderBusinessError"); + expect(tui.stderr).not.toContain("cacheControl breakpoint limit"); + const tuiLog = await Bun.file(logPath).text(); + expect(tuiLog).toContain("ProviderBusinessError"); + expect(tuiLog).toContain("cacheControl breakpoint limit"); + + const failed = await run(["--cwd", directory, "tui"], "", { + FAKE_NODE_EXIT: "7", + ZCODE_NODE: fakeNode, + ZCODE_TUI_RUNTIME_LOG: logPath + }); + expect(failed.code).toBe(7); + expect(failed.stderr).toContain("ZCode runtime exited with status 7"); + expect(failed.stderr).toContain(`Diagnostics: ${logPath}`); + expect(failed.stderr).not.toContain("ProviderBusinessError"); + + const print = await run(["--print", "hello"], "", { ZCODE_NODE: fakeNode }); + expect(print.code).toBe(0); + expect(print.stderr).toContain("ProviderBusinessError"); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + test("rotates the bounded TUI diagnostic log between invocations", async () => { + const directory = await mkdtemp(join(tmpdir(), "zcode-launcher-log-rotation-")); + const fakeNode = join(directory, "fake-node"); + const logPath = join(directory, "tui-runtime.log"); + await writeFile(fakeNode, [ + "#!/bin/sh", + "printf '%s\\n' 'fresh diagnostic' >&2", + "exit 0", + "" + ].join("\n")); + await chmod(fakeNode, 0o755); + await writeFile(logPath, Buffer.alloc(2 * 1024 * 1024, "x")); + if (process.platform !== "win32") await chmod(logPath, 0o644); + try { + const result = await run(["tui"], "", { + ZCODE_NODE: fakeNode, + ZCODE_TUI_RUNTIME_LOG: logPath + }); + expect(result.code).toBe(0); + expect(await Bun.file(logPath).text()).toContain("fresh diagnostic"); + expect(Bun.file(`${logPath}.1`).size).toBe(2 * 1024 * 1024); + if (process.platform !== "win32") { + expect((await stat(logPath)).mode & 0o777).toBe(0o600); + expect((await stat(`${logPath}.1`)).mode & 0o777).toBe(0o600); + } + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + test("keeps non-agent runtime subcommands usable", async () => { const doctor = await run(["doctor", "--json"]); expect(doctor.code).toBe(0); diff --git a/test/launcher.test.ts b/test/launcher.test.ts index c186182..51ee26e 100644 --- a/test/launcher.test.ts +++ b/test/launcher.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { formatVersionOutput, + isTuiRuntimeInvocation, isVersionInvocation, normalizeLoginArgs, readDistributionVersion, @@ -145,6 +146,16 @@ describe("launcher routing", () => { } }); + test("recognizes TUI invocations after consuming global option values", () => { + expect(isTuiRuntimeInvocation([])).toBe(true); + expect(isTuiRuntimeInvocation(["--cwd", "/tmp/project", "--settings", "custom.json", "tui"])).toBe(true); + expect(isTuiRuntimeInvocation(["--browser-use", "headless", "--cwd", "/tmp/project", "tui"])).toBe(true); + expect(isTuiRuntimeInvocation(["--prompt", "inspect this page"])).toBe(false); + expect(isTuiRuntimeInvocation(["plugins", "list"])).toBe(false); + expect(isTuiRuntimeInvocation(["--help"])).toBe(false); + expect(isTuiRuntimeInvocation(["--unknown"])).toBe(false); + }); + test("routes only the plain Z.AI login command through the Desktop OAuth bridge", () => { expect(classifyZaiOAuthInvocation(["login"])).toEqual({ json: false, diff --git a/test/runtime-activity-view.test.ts b/test/runtime-activity-view.test.ts index 8859b69..34e23fe 100644 --- a/test/runtime-activity-view.test.ts +++ b/test/runtime-activity-view.test.ts @@ -17,6 +17,7 @@ describe("runtime activity view", () => { activeToolCalls: [{ toolCallId: "tool-1", toolName: "Bash", status: "running" }], backgroundJobs: [{ taskId: "bg-1", + taskKind: "local_bash", status: "running", description: "Run repository tests", cancellable: true diff --git a/test/runtime-poll.test.ts b/test/runtime-poll.test.ts index 290c504..9d5bcb6 100644 --- a/test/runtime-poll.test.ts +++ b/test/runtime-poll.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { ACTIVE_RUNTIME_POLL_INTERVAL_MS, IDLE_RUNTIME_POLL_INTERVAL_MS, + runtimeActivityActive, runtimePollInterval, runtimeRefreshNeeded, runtimePollStateChanged, @@ -32,6 +33,21 @@ describe("runtime polling", () => { expect(IDLE_RUNTIME_POLL_INTERVAL_MS).toBe(5_000); }); + test("keeps polling while tools or background jobs remain active", () => { + expect(runtimeActivityActive(normalizeRuntimeProjection({ + activeToolCalls: [{ toolCallId: "tool-1", toolName: "Bash", status: "running" }], + backgroundJobs: [] + }))).toBeTrue(); + expect(runtimeActivityActive(normalizeRuntimeProjection({ + activeToolCalls: [], + backgroundJobs: [{ taskId: "task-1", status: "running" }] + }))).toBeTrue(); + expect(runtimeActivityActive(normalizeRuntimeProjection({ + activeToolCalls: [], + backgroundJobs: [{ taskId: "task-1", status: "failed" }] + }))).toBeFalse(); + }); + test("does not report a change for equivalent normalized snapshots", () => { expect(runtimePollStateChanged(state(1_000), state(1_000))).toBeFalse(); expect(runtimePollStateChanged(state(1_000), state(1_001))).toBeTrue(); diff --git a/test/runtime-projection.test.ts b/test/runtime-projection.test.ts index 262ed00..89908ce 100644 --- a/test/runtime-projection.test.ts +++ b/test/runtime-projection.test.ts @@ -44,12 +44,47 @@ describe("runtime projection normalization", () => { expect(snapshot?.activeToolCalls[0]).toMatchObject({ toolCallId: "tool-1", toolName: "Bash" }); expect(snapshot?.backgroundJobs[0]).toMatchObject({ taskId: "bg-1", + taskKind: "local_bash", command: "bun test", status: "running", stdoutTail: "81 pass" }); }); + test("merges runtime task-registry metadata into background projections", () => { + const snapshot = normalizeRuntimeProjection({ + sessionId: "session-1", + activeToolCalls: [], + backgroundTasks: [{ + taskId: "agent-1", + toolName: "Agent", + status: "failed", + description: "Review task recovery" + }], + backgroundTaskDetails: [{ + taskId: "agent-1", + taskKind: "local_agent", + agentId: "agent-1", + agentType: "reviewer", + childSessionId: "child-1", + parentSessionId: "session-1", + turnId: "turn-1", + prompt: "Audit the task flow", + error: "Provider unavailable" + }] + }); + + expect(snapshot?.backgroundJobs[0]).toMatchObject({ + taskId: "agent-1", + taskKind: "local_agent", + agentId: "agent-1", + agentType: "reviewer", + childSessionId: "child-1", + prompt: "Audit the task flow", + error: "Provider unavailable" + }); + }); + test("preserves protocol context breakdown and cache usage", () => { const snapshot = normalizeRuntimeProjection({ projection: { diff --git a/test/sync-runtime.test.ts b/test/sync-runtime.test.ts index 30ae7fd..2881b17 100644 --- a/test/sync-runtime.test.ts +++ b/test/sync-runtime.test.ts @@ -5,8 +5,14 @@ import { manifestUrl, parseArgs, parseRuntimeLock, + patchRuntimeAgentAutoBackground, + patchRuntimeBackgroundTaskProjection, + patchRuntimeDetachedAgentLifecycle, + patchRuntimeProviderRetryClassification, + patchRuntimeTerminalToolProjection, patchRuntimeOAuthHttpErrors, patchRuntimeTuiBridge, + patchRuntimeTuiWarnings, patchRuntimeZaiDesktopOAuth, resolveArtifactUrl, resolveLatestRuntimeLock, @@ -338,13 +344,22 @@ describe("runtime synchronization", () => { expect(patched).toContain("E.listSkills=async()=>await H(e)"); expect(patched).toContain("E.readGoal=async()=>await(await S()).readTarget?.()??null"); expect(patched).toContain("E.readTodos=async()=>await(await S()).readTodos?.()??[]"); - expect(patched).toContain("E.readRuntimeProjection=async()=>{let e=await S();return e.runtime?.getProjection?.()??null}"); + expect(patched).toContain("E.readRuntimeProjection=async()=>{let e=await S(),t=await e.runtime?.getProjection?.();if(!t)return null;"); + expect(patched).toContain(".filter(o=>o.isBackgrounded===!0).map(o=>"); + expect(patched).toContain("backgroundTaskDetails:r"); expect(patched).toContain("E.readSessionUsage=async()=>await(await S()).readSessionUsage?.()??null"); expect(patched).toContain("E.cancelBackgroundTask=async e=>await(await S()).cancelBackgroundTask?.(e)??null"); + expect(patched).toContain("E.subscribeSessionEvents=e=>{let t=!1,r;S().then(o=>{t||(r=o.runtime?.subscribeEvents?.({onSessionEvent:e}))});return()=>{t=!0,r?.()}}"); + expect(patched).toContain("E.sendBackgroundTaskMessage=async e=>"); + expect(patched).toContain('if(e?.restart===!0&&o.status==="running")'); + expect(patched).toContain("await r.subagentPort.stopTask(e.taskId)"); + expect(patched).toContain("r.subagentPort.sendMessage({sessionId:o.parentSessionId??r.getSessionId?.()"); expect(patched).toContain("E.previewFileRewind=async e=>{let t=await S();return await t.runtime?.previewWorkspaceFileRewind?.({targetMessageIds:e})??null}"); expect(patched).toContain("E.applyFileRewind=async e=>{let t=await S();return await t.runtime?.applyWorkspaceFileRewind?.({targetMessageIds:e})??null}"); expect(patched).toContain("E.interruptTurn=async e=>"); expect(patched).toContain("t.runtime?.stopActiveForegroundExecution?.({preserveQueueAutoDrainOnCancel:"); + expect(patched).toContain('e?.waitForIdle===!0&&t.runtime?.getActiveForegroundExecutionId'); + expect(patched).toContain("t.runtime.getActiveForegroundExecutionId()!==void 0"); expect(patched).toContain("await t.reserveQueueItem(a,r)"); expect(patched).toContain( "expectedTurnId:$?.expectedTurnId,delivery:\"guide\",pendingInputId:$?.pendingInputId,input:A" @@ -373,8 +388,12 @@ describe("runtime synchronization", () => { expect(patched).toContain("interruptTurn:g.interruptTurn"); expect(patched).toContain("promoteQueuedInput:g.promoteQueuedInput"); expect(patched).toContain("listSkills:g.listSkills"); + expect(patched).toContain("subscribeSessionEvents:g.subscribeSessionEvents"); + expect(patched).toContain("sendBackgroundTaskMessage:g.sendBackgroundTaskMessage"); expect(patched).toContain("sessionStore.queryTaskUsage?.({sessionID:e.sessionId})"); expect(patchRuntimeTuiBridge(patched)).toBe(patched); + const previousInterruptPatch = patched.replace("e?.waitForIdle===!0", "e?.waitForIdle===!1"); + expect(patchRuntimeTuiBridge(previousInterruptPatch)).toContain("e?.waitForIdle===!0"); expect(() => patchRuntimeTuiBridge("incompatible runtime")).toThrow(/incompatible/); const modernRuntime = runtimeWithApp @@ -391,4 +410,129 @@ describe("runtime synchronization", () => { expect(modernPatched).toContain("targetMessageIds&&t.targetMessageIds.length>0"); expect(modernPatched).not.toContain("Array.isArray(t.targetMessageIds)"); }); + + test("filters cache-control warnings from the TUI stderr stream", () => { + const runtime = "function UKn(e,t){if(LKn.test(i)||BKn.test(i)){return i}}"; + const patched = patchRuntimeTuiWarnings(runtime); + expect(patched).toContain('i.includes("AI SDK Warning")&&i.includes("cacheControl breakpoint limit")'); + expect(patchRuntimeTuiWarnings(patched)).toBe(patched); + }); + + test("does not retry provider model lookup failures reported with HTTP 503", () => { + const runtime = [ + 'const CS=()=>true,XBr=e=>e.providerMessage,eUr=e=>e.providerCode,AWo=e=>e.responseStatus,ZBr=()=>undefined;', + 'const lr={ModelNotFound:"model_not_found"},Ht={InvalidRequest:"invalid_request"},fn={NetworkError:"network_error"};', + "function QBr(e,t,r){if(!CS(e))return;let o=XBr(e),n=eUr(e),i=AWo(e,t),a=n?ZBr(n):void 0;", + "if(a)return a;return i>=500?{retryable:!0,statusCode:i}:{retryable:!1,statusCode:i}}" + ].join(""); + const patched = patchRuntimeProviderRetryClassification(runtime); + expect(patched).toContain( + 'if(n==="model_not_found")return{code:lr.ModelNotFound,message:o,reason:Ht.InvalidRequest,retryReason:fn.NetworkError,retryable:!1' + ); + const classify = new Function(`${patched};return QBr;`)() as ( + error: { providerCode: string; providerMessage: string; responseStatus: number } + ) => { code?: string; retryable: boolean; statusCode: number }; + expect(classify({ + providerCode: "model_not_found", + providerMessage: "No available channel for model GLM-5.2", + responseStatus: 503 + })).toMatchObject({ code: "model_not_found", retryable: false, statusCode: 503 }); + expect(classify({ + providerCode: "server_error", + providerMessage: "Temporary upstream failure", + responseStatus: 503 + })).toMatchObject({ retryable: true, statusCode: 503 }); + expect(patchRuntimeProviderRetryClassification(patched)).toBe(patched); + expect(patchRuntimeProviderRetryClassification("runtime without the classifier anchor")).toBe( + "runtime without the classifier anchor" + ); + }); + + test("auto-backgrounds long Agent calls while preserving explicit configuration", () => { + const runtime = "function delay(){return{autoBackgroundMs:this.config.subagents?.autoBackgroundMs,outputRootDir:'tasks'}}"; + const patched = patchRuntimeAgentAutoBackground(runtime); + const delay = new Function(`${patched};return delay;`)() as () => { autoBackgroundMs?: number }; + + expect(delay.call({ config: {} }).autoBackgroundMs).toBe(1_000); + expect(delay.call({ config: { subagents: { autoBackgroundMs: 0 } } }).autoBackgroundMs).toBe(0); + expect(patchRuntimeAgentAutoBackground(patched)).toBe(patched); + expect(() => patchRuntimeAgentAutoBackground("incompatible runtime")).toThrow(/incompatible/); + }); + + test("contains failures from the detached background Agent lifecycle", async () => { + const runtime = [ + "async function run(){throw void 0}", + "async function start(){", + "let d={promise:Promise.resolve(),reject(){}},h={dispose(){}};", + "run({onSessionStartFailed:d.reject},h.dispose);try{await d.promise}catch{}", + "let q={promise:Promise.resolve(),reject(){}},x={dispose(){}};", + "run({onSessionStartFailed:q.reject},x.dispose);try{await q.promise}catch{}", + "await Promise.resolve();await Promise.resolve()", + "}" + ].join(""); + const patched = patchRuntimeDetachedAgentLifecycle(runtime); + const diagnostics: unknown[][] = []; + const start = new Function( + "console", + `${patched};return start;` + )({ error: (...values: unknown[]) => diagnostics.push(values) }) as () => Promise; + + await start(); + expect(diagnostics).toEqual([ + ["Detached background agent lifecycle failed", "unknown rejection"], + ["Detached background agent lifecycle failed", "unknown rejection"] + ]); + expect(patchRuntimeDetachedAgentLifecycle(patched)).toBe(patched); + expect(() => patchRuntimeDetachedAgentLifecycle("incompatible runtime")).toThrow(/incompatible/); + }); + + test("keeps foreground agents out of the background task projection", () => { + const runtime = "function project(e){return Object.values(e.runtime?.runtimeTaskRegistry?.all?.()??{}).map(o=>o.taskId)}"; + const patched = patchRuntimeBackgroundTaskProjection(runtime); + const project = new Function(`${patched};return project;`)() as (app: unknown) => string[]; + const app = { + runtime: { + runtimeTaskRegistry: { + all: () => ({ + foreground: { taskId: "foreground", isBackgrounded: false }, + background: { taskId: "background", isBackgrounded: true } + }) + } + } + }; + + expect(project(app)).toEqual(["background"]); + expect(patchRuntimeBackgroundTaskProjection(patched)).toBe(patched); + expect(() => patchRuntimeBackgroundTaskProjection("incompatible runtime")).toThrow(/incompatible/); + }); + + test("clears stale active tools when a runtime turn settles", () => { + const runtime = [ + 'function complete(e){return{...e,status:"idle",totalTokenCount:e.totalTokenCount+1}}', + 'function fail(e){return{...e,status:"error",lastError:{message:"failed"}}}' + ].join(""); + const patched = patchRuntimeTerminalToolProjection(runtime); + const load = new Function(`${patched};return {complete,fail};`)() as { + complete: (state: Record) => Record; + fail: (state: Record) => Record; + }; + const state = { + activeToolCalls: [{ toolCallId: "stale", status: "running" }], + currentTurnId: "turn-1", + totalTokenCount: 0 + }; + + expect(load.complete(state)).toMatchObject({ + activeToolCalls: [], + currentTurnId: undefined, + status: "idle" + }); + expect(load.fail(state)).toMatchObject({ + activeToolCalls: [], + currentTurnId: undefined, + status: "error" + }); + expect(patchRuntimeTerminalToolProjection(patched)).toBe(patched); + expect(() => patchRuntimeTerminalToolProjection("incompatible runtime")).toThrow(/incompatible/); + }); }); diff --git a/test/turn-status.test.ts b/test/turn-status.test.ts index 0bf7717..80cb376 100644 --- a/test/turn-status.test.ts +++ b/test/turn-status.test.ts @@ -23,6 +23,10 @@ describe("TUI turn status", () => { expect(turnStatusText("thinking…", 3_000)).toBe("thinking… ── [ 🕛 3s ]"); }); + test("marks a settled foreground turn as completed instead of frozen", () => { + expect(turnStatusText(undefined, 364_000, true, false, true)).toBe("[ ✓ 6m 04s ]"); + }); + test("animates the active timer with complete stable-width clock frames", () => { const frames = Array.from( { length: 12 }, diff --git a/test/turn-work-tracker.test.ts b/test/turn-work-tracker.test.ts new file mode 100644 index 0000000..dae464f --- /dev/null +++ b/test/turn-work-tracker.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, test } from "bun:test"; + +import type { StreamEvent } from "../packages/zcode-tui/src/events.ts"; +import type { RuntimeBackgroundJob } from "../packages/zcode-tui/src/runtime-projection.ts"; +import { TurnWorkTracker } from "../packages/zcode-tui/src/turn-work-tracker.ts"; + +function event(value: Partial): StreamEvent { + return { raw: {}, ...value }; +} + +function job( + taskId: string, + status: RuntimeBackgroundJob["status"], + turnId = "turn-current" +): RuntimeBackgroundJob { + return { taskId, taskKind: "local_agent", status, turnId }; +} + +describe("turn work tracker", () => { + test("keeps timing until every associated background task settles", () => { + const tracker = new TurnWorkTracker(); + tracker.begin(); + tracker.bindTurn("turn-current"); + tracker.handle(event({ type: "background_task_started", taskId: "task-a", turnId: "turn-current" })); + tracker.handle(event({ type: "background_task_started", taskId: "task-b", turnId: "turn-current" })); + + expect(tracker.finishForeground(true)).toBeTrue(); + expect(tracker.reconcile([job("task-a", "running"), job("task-b", "running")])).toBeTrue(); + expect(tracker.handle(event({ + type: "background_task_completed", + taskId: "task-a", + taskStatus: "failed" + }))).toBeTrue(); + expect(tracker.handle(event({ + type: "background_task_completed", + taskId: "task-b", + taskStatus: "completed" + }))).toBeFalse(); + }); + + test("treats failure terminal statuses as settled and ignores older running jobs", () => { + for (const status of ["failed", "timed_out", "cancelled", "spawn_error", "lost"] as const) { + const tracker = new TurnWorkTracker(); + tracker.begin(); + tracker.bindTurn("turn-current"); + tracker.handle(event({ type: "subagent_spawned", agentId: "agent-1" })); + tracker.finishForeground(true); + + expect(tracker.reconcile([ + job("agent-1", status), + job("older-task", "running", "turn-older") + ])).toBeFalse(); + } + }); + + test("waits for the post-foreground projection before settling an inline-only turn", () => { + const tracker = new TurnWorkTracker(); + tracker.begin(); + tracker.bindTurn("turn-current"); + + expect(tracker.finishForeground(true)).toBeTrue(); + expect(tracker.reconcile([])).toBeFalse(); + }); + + test("resets task ownership when a newer foreground turn begins", () => { + const tracker = new TurnWorkTracker(); + tracker.begin(); + tracker.bindTurn("turn-old"); + tracker.handle(event({ type: "background_task_started", taskId: "old-task", turnId: "turn-old" })); + tracker.finishForeground(true); + + tracker.begin(); + tracker.bindTurn("turn-current"); + expect(tracker.reconcile([job("old-task", "running", "turn-old")])).toBeTrue(); + expect(tracker.finishForeground(true)).toBeTrue(); + expect(tracker.reconcile([job("old-task", "running", "turn-old")])).toBeFalse(); + }); +}); diff --git a/test/work-duration-view.test.ts b/test/work-duration-view.test.ts new file mode 100644 index 0000000..48db619 --- /dev/null +++ b/test/work-duration-view.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test"; + +import { workedDurationLabel, WorkDurationView } from "../packages/zcode-tui/src/work-duration-view.ts"; +import { createTheme } from "../packages/zcode-tui/src/theme.ts"; + +describe("work duration view", () => { + test("uses the Codex threshold and compact duration format", () => { + expect(workedDurationLabel(60_000)).toBeUndefined(); + expect(workedDurationLabel(61_000)).toBe("Worked for 1m 01s"); + expect(workedDurationLabel(3_661_000)).toBe("Worked for 1h 01m 01s"); + }); + + test("renders a width-safe settled-work divider", () => { + const view = new WorkDurationView(125_000, createTheme(false)); + const lines = view.render(32); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("Worked for 2m 05s"); + expect(lines[0]!.length).toBeLessThanOrEqual(32); + }); +});