Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 23 additions & 6 deletions packages/zcode-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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") {
Expand All @@ -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",
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions test/fixtures/tui-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"",
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions test/tui/fixtures/model-status.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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." };
}
});
39 changes: 39 additions & 0 deletions test/tui/model-status.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
Loading