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
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,11 @@ export class TuiTurnProjection {
changed = this.removePreviousTerminalDuration(turnId) || changed;
// One-time feedback survives history refreshes, so dismiss it when a new run starts.
for (const cell of this.transcript.snapshot()) {
// Runtime terminal errors survive history reconciliation, but belong only
// to the failed run. Otherwise a successful retry can reproject them again.
if (cell.kind === 'error' && cell.ephemeral && cell.turnId && cell.turnId !== turnId) {
changed = this.transcript.remove(cell.id) || changed;
}
if (
cell.kind === 'shell' &&
cell.ephemeral &&
Expand Down
60 changes: 60 additions & 0 deletions packages/tui/test/unit/tui-app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4455,6 +4455,66 @@ describe("createTuiApp", () => {
await app.stop();
});

it.each(["/retry", "Continue the task"])(
"dismisses a previous terminal failure when %s succeeds and history refreshes",
async (submission) => {
const terminal = new FakeTerminal();
const runtime = createRuntime();
let handled = false;
vi.mocked(runtime.watchEvents).mockImplementation(async function* (signal) {
yield runtimeEvent({
type: "session.error",
timestamp: Date.now(),
source: "runtime",
payload: {
sessionId: "session-1",
turnId: "failed-turn",
error: "provider unavailable",
errorCode: 50151,
},
});
handled = true;
await new Promise<void>((resolve) =>
signal.addEventListener("abort", () => resolve(), { once: true }),
);
});
const app = createTuiApp({
runtime,
terminal,
version: "0.1.0",
workspaceDir: "/workspace",
});
try {
await app.ready;
await app.openSession("session-1");
app.start();
await vi.waitFor(() => expect(handled).toBe(true));
expect(app.tui.render(100).join("\n")).toContain("Code: 50151");
// The failure must survive reconciliation until another run starts.
await app.controller.refreshCurrentSessionHistory();
expect(app.tui.render(100).join("\n")).toContain("Run /retry");

await app.submit(submission);
vi.mocked(runtime.getMessages).mockResolvedValue([
{
id: "recovered-answer",
role: "assistant",
content: "Hello from the Agent",
turnId: "recovered-turn",
},
]);
await app.controller.refreshCurrentSessionHistory();
const rendered = app.tui.render(100).join("\n");
expect(rendered).toContain("Hello from the Agent");
expect(rendered).not.toContain("Code: 50151");
expect(rendered).not.toContain("Run /retry");
expect(app.transcript.latestSettledFailure()).toBeUndefined();
} finally {
await app.stop();
}
},
);

it("uses Runtime retry-continuation when a terminal failure has no local submission snapshot", async () => {
const terminal = new FakeTerminal();
const runtime = createRuntime();
Expand Down
31 changes: 31 additions & 0 deletions packages/tui/test/unit/tui-chat-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,37 @@ describe('TuiChatController', () => {
await expect(sending).resolves.toBe('succeeded');
});

it('dismisses only older turn-scoped ephemeral errors when a Runtime turn starts', () => {
const transcript = new TranscriptStore();
for (const cell of [
{ id: 'old-terminal-error', turnId: 'old-turn', ephemeral: true },
{ id: 'current-terminal-error', turnId: 'current-turn', ephemeral: true },
{ id: 'local-action-error', ephemeral: true },
{ id: 'historical-error', turnId: 'old-turn' },
]) {
transcript.upsert({
...cell,
kind: 'error',
status: 'failed',
content: cell.id,
createdAtMs: 1,
});
}
const controller = new ProductionTuiChatController({
runtime: {} as never,
transcript,
workspaceDir: '/workspace',
});

controller.beginRuntimeTurn('current-turn', 2);

expect(transcript.snapshot().map((cell) => cell.id)).toEqual([
'current-terminal-error',
'local-action-error',
'historical-error',
]);
});

it('keeps the previous duration while projecting a steer for the current turn', () => {
const transcript = new TranscriptStore();
transcript.upsert({
Expand Down
Loading