Files
platform/src/servers/sidecar/claude/exit-diagnosis.test.ts
T
pastilhasandClaude Opus 5 9203472be8 the recovery could never have fired — fix its condition
Shipped e6422052 an hour ago claiming a chat that cannot be resumed would recover on
the next message. It would not have. Caught before it was pulled, by tracing the two
error strings in a screenshot back to their sources instead of trusting the change.

`sawOutput` was set on ANY SDK message. But a turn that cannot load its conversation
still emits a terminal `result` with `is_error` and no text — that is exactly what
reaches the user as "Claude Code returned an error", one of the two lines in the
report. So the single event that proves the resume failed was being counted as proof
it had worked, and `clearResume` could never be true in the one situation it exists
for.

The flag now ignores `error` and counts everything else, as `countsAsOutput` — a named
function with tests rather than an inline `!==`, because this is the second time this
rule has been wrong and both times it was wrong silently.

`stopped` deliberately counts: an interrupted turn was running, so its conversation
loaded, and discarding a transcript because somebody pressed stop quickly would be a
new bug of the same shape.

Five more tests, including the reported failure end to end (`['error']` -> give up on
the resume) and its opposite (`['assistant:text', 'error']` -> keep it).

Still does not explain WHY the resume fails. Also worth recording: my evidence that
"resume works by hand" may be worth nothing — `claude --resume` with an id it cannot
find may simply start a fresh session and exit 0, which would look identical to
success in both tests I ran. The new spawn log is what will settle it.

tsgo clean, 950 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 20:41:47 +00:00

94 lines
4.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { countsAsOutput, diagnoseExit, stderrReason } from './exit-diagnosis';
// The bug these encode: a member's chat answered "Claude Code process exited with code 1" four times in a
// row and could never recover, because every message retried the same `--resume` id and nothing ever
// reconsidered it. A fresh chat worked throughout, which is what identifies the resume as the difference.
describe('diagnoseExit', () => {
test('a resuming turn that died before saying anything gives up on the resume', () => {
const d = diagnoseExit({ sawOutput: false, resumeId: '49527b33', stderrTail: '' });
expect(d.clearResume).toBe(true);
expect(d.message).toContain('could not reopen');
// The cost has to be stated: the next turn genuinely will not have the history.
expect(d.message).toContain('will not remember');
});
// The important negative. A turn that spoke and then broke has a working transcript, so discarding the
// resume would turn one failed turn into a permanently amnesiac conversation.
test('a turn that produced output first keeps its conversation', () => {
const d = diagnoseExit({ sawOutput: true, resumeId: '49527b33', stderrTail: 'boom' });
expect(d.clearResume).toBe(false);
expect(d.message).toContain('conversation is safe');
});
test('a NEW session that dies has no resume to blame, so nothing is cleared', () => {
const d = diagnoseExit({ sawOutput: false, resumeId: undefined, stderrTail: '' });
expect(d.clearResume).toBe(false);
expect(d.message).toContain('exited unexpectedly');
});
test("the child's own words are carried to the person, not just to the log", () => {
const d = diagnoseExit({ sawOutput: false, resumeId: 'abc', stderrTail: 'No conversation found with session ID' });
expect(d.message).toContain('No conversation found with session ID');
});
// A silent exit is the case that started this: code 1, nothing on stderr. The sentence still has to read.
test('a silent death produces a clean sentence rather than a dangling colon', () => {
const d = diagnoseExit({ sawOutput: false, resumeId: 'abc', stderrTail: ' \n \n' });
expect(d.message).not.toContain(':');
expect(d.message).toContain('could not reopen this conversation.');
});
});
describe('stderrReason', () => {
test('keeps the last lines, where the actual error is', () => {
expect(stderrReason('one\ntwo\nthree\nfour')).toBe('two three four');
});
test('is empty for whitespace, so callers can test it as a condition', () => {
expect(stderrReason('')).toBe('');
expect(stderrReason(' \n \t ')).toBe('');
});
test('stays short enough to sit inside a sentence', () => {
expect(stderrReason('x'.repeat(5000)).length).toBeLessThanOrEqual(300);
});
});
// ── What counts as the harness having worked ──
//
// These exist because the first version of this rule was wrong in the only case it was written for, and
// nothing failed: a broken resume emits a terminal error result, that was counted as output, and the
// recovery it was supposed to trigger could never fire.
describe('countsAsOutput', () => {
test('an error does NOT count — it is what a failed resume emits', () => {
expect(countsAsOutput('error')).toBe(false);
});
test('real work counts', () => {
for (const type of ['assistant:text', 'assistant:delta', 'tool:start', 'tool:result', 'result']) {
expect(countsAsOutput(type)).toBe(true);
}
});
test('a stopped turn counts — it was running, so the conversation loaded', () => {
// Clearing someone's transcript because they pressed stop quickly would be its own bug.
expect(countsAsOutput('stopped')).toBe(true);
});
// The end-to-end shape of the reported failure: no real output, an error result, a resuming turn.
test('the reported failure now resolves to "give up on the resume"', () => {
const events = ['error'];
const sawOutput = events.some(countsAsOutput);
expect(diagnoseExit({ sawOutput, resumeId: '49527b33', stderrTail: '' }).clearResume).toBe(true);
});
test('and a turn that spoke before failing does not', () => {
const events = ['assistant:text', 'error'];
const sawOutput = events.some(countsAsOutput);
expect(diagnoseExit({ sawOutput, resumeId: '49527b33', stderrTail: '' }).clearResume).toBe(false);
});
});