stop resume-cursor guessing that a session is claude

B7. `msg.model || DEFAULT_MODEL` declared every session without an explicit model to be
claude-code, and the parity doc recorded only the visible half of what that cost.

The durable false cut-off is real: endTurnIfAgentIsGone asked the claude sidecar about a
key it had never held, was told false, and wrote "the agent went away" into a turn that
was running fine. It survives reload, because surviving reload is what that row is for.

The same default also handed the session to adoptOrphanedSession as a claude one, which
subscribes it to that sidecar bus and pins session.model — so an opencode turn output
never arrived, and stopping it called killClaude on a key that sidecar never had. A stop
button that silently does nothing.

decideResume makes both rules explicit: the server record beats the client claim, and an
unknown harness stays unknown — no adoption, no cut-off check, just the replay. Silence
is the safe failure when the wrong answer is written durably.

DEFAULT_MODEL stays in handleAttach and is now commented as to why: that path reached its
sessionId by asking the claude sidecar to resolve a claudeSessionId, so only claude could
have answered.

First test in api/chat, which had none. websocket.ts has no seam to drive the handler
through, so the decision is extracted and tested; the wiring around it is not covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:16:30 +01:00
co-authored by Claude Opus 5
parent be259f813a
commit d9a3513cb5
2 changed files with 99 additions and 5 deletions
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'bun:test';
import { decideResume } from './websocket';
// The first test in api/chat, which had none.
//
// It covers the harness decision on reconnect — the one place where guessing wrong writes something
// permanent. `endTurnIfAgentIsGone` appends a durable `cut-off` row, so a session misidentified here
// gets "the agent went away" written into a turn that is running normally, and it survives every reload
// because surviving reloads is what that row is for.
//
// The old code was `msg.model || DEFAULT_MODEL` with `DEFAULT_MODEL = 'claude-code'`, and it was masked
// only by the client always happening to send `model`. These tests exist so the default cannot come back.
describe('decideResume', () => {
it("takes the server's own record over the client's claim", () => {
// The socket does not get to re-declare a session's harness. This is the direction that matters:
// believing the claim is how an OpenCode session ends up subscribed to the Claude sidecar.
expect(decideResume('opencode/big-pickle', 'claude-code')).toEqual({
kind: 'known',
model: 'opencode/big-pickle',
});
});
it('adopts on the claim only when this process has no record of the session', () => {
// An officer restart drops the in-memory session while the agent carries on, so the client is the
// only one left who knows. Trusting it here is not the same as trusting it above.
expect(decideResume(undefined, 'opencode/big-pickle')).toEqual({ kind: 'adopt', model: 'opencode/big-pickle' });
expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' });
});
it('stays unknown rather than defaulting to claude, which is the whole bug', () => {
// No record and no claim. Previously this became `claude-code` and was indistinguishable from a real
// Claude session; now it declines to adopt and declines to run the cut-off check.
expect(decideResume(undefined, undefined)).toEqual({ kind: 'replay-only' });
expect(decideResume(undefined, '')).toEqual({ kind: 'replay-only' });
});
it('never reports replay-only once anything is known, so a live turn is always re-bound', () => {
// The failure mode in the other direction: declining to adopt a session we could have identified
// would silently drop the reconnect that makes turn output resume.
expect(decideResume('claude-code', undefined).kind).toBe('known');
expect(decideResume('', 'claude-code').kind).toBe('adopt');
});
});