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
326 changes: 326 additions & 0 deletions src/tui/runtime-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,332 @@ describe("committed inference retry", () => {
});
});

describe("same-turn failover after inference.error", () => {
const errorRows = (shell: {
streamLog: readonly { role: string; meta?: string; text: string }[];
}) => shell.streamLog.filter((r) => r.meta === "error").map((r) => r.text);

test("a recovered quota error does not stay in the transcript", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
for (const event of [
{ type: "inference.start", data: {} },
{
type: "inference.error",
data: {
error: {
category: "quota_exhausted",
message: "The usage limit has been reached",
statusCode: 429,
},
},
},
{ type: "inference.start", data: {} },
{ type: "inference.text.delta", data: { token: "recovered" } },
{ type: "inference.done", data: {} },
{ type: "reactor.done", data: {} },
] as const) {
bridge.handle(event);
}

expect(errorRows(shell)).toEqual([]);
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("a recovered credential error does not stay in the transcript", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
for (const event of [
{ type: "inference.start", data: {} },
{
type: "inference.error",
data: {
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
},
},
{ type: "inference.start", data: {} },
{ type: "inference.text.delta", data: { token: "recovered" } },
{ type: "inference.done", data: {} },
{ type: "reactor.done", data: {} },
] as const) {
bridge.handle(event);
}

expect(errorRows(shell)).toEqual([]);
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("an echoed auto-retry prompt does not expire recovery", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const port = createRecordingPort();
const bridge = attachSessionBridge(shell, port);
try {
bridge.submit("retry this", "immediate");
bridge.handle({ type: "message.received", data: { message: { content: "retry this" } } });
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({
type: "inference.error",
data: {
error: {
category: "quota_exhausted",
message: "The usage limit has been reached",
statusCode: 429,
},
},
});
bridge.submit("retry this", "immediate");
bridge.handle({ type: "message.received", data: { message: { content: "retry this" } } });
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } });
bridge.handle({ type: "inference.done", data: {} });
bridge.handle({ type: "reactor.done", data: {} });

expect(errorRows(shell)).toEqual([]);
expect(shell.streamLog.map((r) => r.text).join("\n")).toContain("recovered");
// The replay duplicates the operator's prompt; rollback drops the copy.
expect(shell.streamLog.filter((r) => r.role === "user").map((r) => r.text)).toEqual([
"retry this",
]);
// Same-turn failover, not an operator stop — recovery must not borrow interrupt.
expect(port.calls.some((c) => c.op === "interrupt")).toBe(false);
expect(shell.streamLog.some((r) => r.meta === "stop")).toBe(false);
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("a steer echo at a tool boundary opens a new thinking row", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
bridge.submit("first prompt", "immediate");
bridge.handle({
type: "message.received",
data: { message: { content: "first prompt" } },
});
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({ type: "inference.thinking.delta", data: { token: "planning" } });
bridge.handle({
type: "inference.tool_call.end",
data: { name: "run_shell", callId: "c1", arguments: "{}" },
});
bridge.handle({ type: "inference.done", data: {} });
bridge.submit("steer this", "steer");
bridge.handle({
type: "tool.done",
data: { result: { callId: "c1", content: "ok", isError: false } },
});
bridge.handle({ type: "message.received", data: { message: { content: "steer this" } } });
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({ type: "inference.thinking.delta", data: { token: "after steer" } });
bridge.handle({ type: "inference.text.delta", data: { token: "done" } });

const rows = shell.streamLog.map((r) => `${r.meta ?? r.role}:${r.text}`);
expect(rows.indexOf("thinking:planning")).toBeGreaterThan(-1);
expect(rows.indexOf("steering:steer this")).toBeGreaterThan(
rows.indexOf("thinking:planning"),
);
expect(rows.indexOf("thinking:after steer")).toBeGreaterThan(
rows.indexOf("steering:steer this"),
);
expect(shell.streamLog.filter((r) => r.meta === "thinking")).toHaveLength(2);
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("interrupt then a new prompt keeps the prompt and the classified error", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const port = createRecordingPort();
const bridge = attachSessionBridge(shell, port);
try {
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({
type: "inference.error",
data: {
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
},
});
bridge.interrupt();
bridge.submit("next prompt", "immediate");
bridge.handle({
type: "message.received",
data: { message: { content: "next prompt" } },
});
bridge.handle({ type: "inference.start", data: {} });

const text = shell.streamLog.map((r) => r.text).join("\n");
expect(text).toContain("next prompt");
expect(errorRows(shell)).toContain("Session expired — re-authenticating…");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("a queued steer row survives failover rollback", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({
type: "inference.error",
data: {
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
},
});
bridge.submit("steer this", "steer");
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } });
bridge.handle({ type: "inference.done", data: {} });
bridge.handle({ type: "reactor.done", data: {} });

const text = shell.streamLog.map((r) => r.text).join("\n");
expect(errorRows(shell)).toEqual([]);
expect(text).toContain("recovered");
expect(text).toContain("steer this");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("reinject interrupt keeps the prompt and the classified error", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({
type: "inference.error",
data: {
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
},
});
bridge.submit("restart from here", "reinject");
bridge.handle({ type: "inference.start", data: {} });
bridge.handle({ type: "inference.text.delta", data: { token: "recovered" } });
bridge.handle({ type: "inference.done", data: {} });
bridge.handle({ type: "reactor.done", data: {} });

const text = shell.streamLog.map((r) => r.text).join("\n");
expect(text).toContain("restart from here");
expect(text).toContain("stop — restarting from your message");
expect(errorRows(shell)).toContain("Session expired — re-authenticating…");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});

test("a terminal inference.error with no recovery still surfaces", async () => {
await withTestRenderer(
async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
});
const bridge = attachSessionBridge(shell, createRecordingPort());
try {
for (const event of [
{ type: "inference.start", data: {} },
{
type: "inference.error",
data: {
error: { category: "credential_failure", message: "Forbidden", statusCode: 403 },
},
},
{ type: "reactor.error", data: { error: "failed" } },
] as const) {
bridge.handle(event);
}

expect(errorRows(shell)).toContain("Session expired — re-authenticating…");
} finally {
bridge.dispose();
shell.dispose();
}
},
{ width: 80, height: 24 },
);
});
});

describe("parallel sub-agent dispatch on the live session bridge", () => {
// The live main-session path tracks a call's row by callId in its own map
// (applyToolCall/applyToolResult), independent of tool-rows.ts's name-based
Expand Down
Loading
Loading