diff --git a/package.json b/package.json index 7fa57a2..5a23fd8 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:node": "node --test test/node/*.test.cjs", "test:tui": "bun run test:tui:component && bun run test:tui:e2e", "test:tui:component": "bun test test/tui/scenario-http.test.ts test/tui/scenario-runtime.test.ts test/tui/scenario-shell.test.ts test/tui/scenario-workspace.test.ts test/tui/terminal-screen.test.ts", - "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", + "test:tui:e2e": "bun test test/tui/allowlisted-shell.test.ts test/tui/dynamic-workflows.test.ts test/tui/http-mock.test.ts test/tui/input-actions.test.ts test/tui/model-resume.test.ts test/tui/model-status.test.ts test/tui/permission-request-queue.test.ts test/tui/run-scenario.test.ts test/tui/runtime-refresh.test.ts test/tui/session-rename.test.ts test/tui/startup.test.ts test/tui/terminal-session.test.ts test/tui/write-and-diff.test.ts", "test:tui:host": "bun test test/tui/scenario-mountx.test.ts", "test:tui:manual": "bun scripts/tui-scenario.ts --manual", "test:tui-scenario": "bun scripts/tui-scenario.ts", diff --git a/packages/zcode-tui/src/index.ts b/packages/zcode-tui/src/index.ts index d6648f0..16bb8a6 100644 --- a/packages/zcode-tui/src/index.ts +++ b/packages/zcode-tui/src/index.ts @@ -319,7 +319,7 @@ const exitUsageQueryTimeoutMs = 250; const fullscreenWelcomeTransitionMs = 180; const terminalFocusIn = "\x1b[I"; const updateAvailableBlockId = "update_available"; -const modelRetryBlockIdPrefix = "model_retry_status"; +const modelStatusBlockIdPrefix = "model_status"; const questionBackValue = "__back__"; const questionOtherValue = "__other__"; const questionDoneValue = "__done__"; @@ -662,7 +662,7 @@ class ZCodeTui { private activeTurnId?: string; private turnEpoch = 0; private activeTurnEpoch?: number; - private modelRetryBlockId?: string; + private modelStatusBlockId?: string; private pendingSteerInterrupt?: PendingSteerInterrupt; private recentSteerCommit?: { at: number; turnEpoch: number }; private currentThinking?: ThinkingView; @@ -2450,15 +2450,22 @@ class ZCodeTui { title: event.type === "streamRecovery.updated" ? "Recovering model stream" : "Retrying model request", summary: [retry, delay].filter(Boolean).join(" · "), detail: event.message - }, this.modelRetryBlockId); + }, this.modelStatusBlockId); } else if (event.type === "model_request_failed") { if (!isModelCancellationEvent(event)) { this.updateActivity( event.retryable === true ? "model request failed · waiting to retry…" : "model request failed…", false ); + // A failed attempt is turn status, not a transcript record: the runtime + // can still recover it (stream recovery, model fallback), so it updates the + // per-turn status row instead of appending one error row per attempt. if (event.retryable !== true) { - this.addSystemEvent({ tone: "error", title: "Model request failed", detail: event.message }); + this.addSystemEvent({ + tone: "error", + title: "Model request failed", + detail: event.message + }, this.modelStatusBlockId); } } } else if (event.type === "model_stream_stalled") { @@ -2469,9 +2476,10 @@ class ZCodeTui { title: "Model stream stalled", summary: idle, detail: event.message - }); + }, this.modelStatusBlockId); } else if (event.type === "model_request_completed") { this.updateActivity("processing model response…", false); + this.clearModelStatus(); } else if (event.type === "compact_boundary" || event.type === "session_compacted") { this.addSystemEvent({ tone: "muted", @@ -2873,7 +2881,7 @@ class ZCodeTui { private beginTurn(prompt?: string): void { this.completeThinking(); this.presentationRegistry.beginTurn(); - this.modelRetryBlockId = `${modelRetryBlockIdPrefix}_${this.turnEpoch}`; + this.modelStatusBlockId = `${modelStatusBlockIdPrefix}_${this.turnEpoch}`; this.currentThinking = undefined; this.currentThinkingPartId = undefined; this.currentToolGroup = undefined; @@ -3007,6 +3015,15 @@ class ZCodeTui { this.ui.requestRender(); } + /** + * The model status row reports the current attempt (stalled, failed, retrying). + * A completed request proves the connection is back, so the row must not outlive it. + */ + private clearModelStatus(): void { + if (!this.modelStatusBlockId || !this.transcript.removeBlock(this.modelStatusBlockId)) return; + this.ui.requestRender(); + } + private addUpdateAvailable(currentVersion: string, latestVersion: string): void { this.transcript.addBlock(new UpdateAvailableView(this.theme, currentVersion, latestVersion), { id: updateAvailableBlockId, diff --git a/test/fixtures/tui-features.ts b/test/fixtures/tui-features.ts index 9499d1e..2258d26 100644 --- a/test/fixtures/tui-features.ts +++ b/test/fixtures/tui-features.ts @@ -52,6 +52,10 @@ let goal = { timeUsedSeconds: 120 }; +// Model status rows are replaced in place and cleared once a request completes, +// so the smoke test needs a window to observe each step. +const statusObservationMilliseconds = 400; + const assistantResponse = [ "Feature prompt complete.", "", @@ -751,6 +755,9 @@ await runTui({ timeoutMs: 60_000, message: "Model stream stalled: no event received for 60000ms." }); + // The status row is replaced in place and cleared once a request completes, + // so keep each step observable for the smoke assertions. + await Bun.sleep(statusObservationMilliseconds); await emitRuntime(options, "model.network_status", { type: "model_request_failed", attempt: 1, @@ -767,6 +774,7 @@ await runTui({ retryable: true, message: "Retrying after model stream idle timeout." }); + await Bun.sleep(statusObservationMilliseconds); await emitRuntime(options, "model.network_status", { type: "model_request_started", attempt: 2, diff --git a/test/tui/fixtures/model-status.ts b/test/tui/fixtures/model-status.ts new file mode 100644 index 0000000..628c673 --- /dev/null +++ b/test/tui/fixtures/model-status.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun + +import { runTui } from "../../../packages/zcode-tui/src/index.ts"; +import type { PromptCallOptions } from "../../../packages/zcode-tui/src/types.ts"; + +async function emit(options: PromptCallOptions, type: string, payload: unknown): Promise { + await options.onEvent?.({ type, payload }); + // Let the TUI render each status before the next one supersedes it. + await Bun.sleep(200); +} + +await runTui({ + initialModel: "scenario/model", + workspaceDirectory: process.cwd(), + loadSessionTranscript: async () => [{ + messageId: "model_status_instruction", + role: "agent", + content: "Type “recover stream” to recover a model request that failed after streaming output. Type “fail hard” to end a turn on an unrecoverable request." + }], + submitPrompt: async (input, options) => { + if (String(input) === "fail hard") { + await emit(options, "model.network_status", { type: "model_request_started", attempt: 1, maxAttempts: 6 }); + await emit(options, "model.network_status", { + type: "model_request_failed", + attempt: 1, + maxAttempts: 6, + message: "Provider rejected the request.", + reason: "invalid_request", + retryable: false + }); + throw new Error("Provider rejected the request."); + } + await emit(options, "model.network_status", { type: "model_request_started", attempt: 1, maxAttempts: 6 }); + for (const [index, attempt] of [1, 2].entries()) { + // The runtime reports a stalled stream, then a non-retryable failure once streamed + // output is committed, and finally recovers that same request at the turn level. + await emit(options, "model.network_status", { + type: "model_stream_stalled", + attempt, + maxAttempts: 6, + idleMs: 60_000, + timeoutMs: 60_000, + message: "Model stream stalled: no event received for 60000ms." + }); + await emit(options, "model.network_status", { + type: "model_request_failed", + attempt, + maxAttempts: 6, + message: `Attempt ${attempt} lost the provider stream.`, + reason: "network_error", + retryable: false, + streamOutputCommitted: true + }); + await emit(options, "streamRecovery.updated", { + attemptId: `attempt_${attempt}`, + failureKind: "sse_disconnected", + message: `Attempt ${attempt} lost the provider stream.`, + retryNumber: index + 1, + maxRetries: 5 + }); + await emit(options, "model.network_status", { + type: "model_request_started", + attempt: attempt + 1, + maxAttempts: 6, + streamRecovery: { retryNumber: index + 1, maxRetries: 5, message: "Model stream recovery retry started" } + }); + } + await emit(options, "model.network_status", { type: "model_request_completed", attempt: 3, maxAttempts: 6 }); + return { response: "Recovered after reconnecting the model stream." }; + } +}); diff --git a/test/tui/model-status.test.ts b/test/tui/model-status.test.ts new file mode 100644 index 0000000..339ad97 --- /dev/null +++ b/test/tui/model-status.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; + +import { ScenarioWorkspace } from "./harness/scenario-workspace.ts"; +import { TerminalSession } from "./harness/terminal-session.ts"; + +test.skipIf(process.platform === "win32")( + "a recovered model failure leaves one status row instead of one error row per attempt", + async () => { + await using workspace = await ScenarioWorkspace.create({ prefix: "zcode-model-status-" }); + await using session = TerminalSession.start({ + command: [process.execPath, join(import.meta.dir, "fixtures/model-status.ts")], + workspace + }); + await session.waitForScreen("scenario instructions", /recover a model request that failed/iu); + session.send("recover stream\r"); + // The status row still reports the attempt while it is unresolved. + await session.waitForScreen("stalled model status", /Model stream stalled\s+·\s+idle 60s/u); + await session.waitForHistory( + "recovered turn", + /Recovered after reconnecting the model stream\./u + ); + await session.settle(); + const recovered = session.screenText(); + // Two stalled and failed attempts were recovered, so no status row about them + // may survive the successful request, and none may be appended per attempt. + for (const statusRow of ["Model request failed", "Model stream stalled", "Retrying model request", "Recovering model stream"]) { + expect(recovered).not.toContain(statusRow); + } + + // An unrecoverable failure still has to be visible in the transcript. + session.send("fail hard\r"); + await session.waitForHistory("terminal model failure", /Provider rejected the request\./u); + await session.settle(); + expect(session.screenText().match(/Model request failed/gu) ?? []).toHaveLength(1); + await session.exit(); + }, + 20_000 +);