reattach a refreshed browser to a running turn

Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.

So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.

`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:59:26 +00:00
co-authored by Claude Opus 5
parent dc5ad28aa2
commit 6b4339052a
12 changed files with 316 additions and 19 deletions
+22
View File
@@ -105,6 +105,28 @@ export function getClaudeSession(sessionKey: string): string | undefined {
return currentState.claudeSessions[sessionKey];
}
/**
* The same map read backwards: Claude's transcript uuid → the key officer made up for the session.
*
* A browser that has refreshed holds only the uuid, because that is what is in the URL; officer's own
* key lived in page state and is gone. This is the only record anywhere that relates the two, which is
* why re-binding a socket to a running turn has to come through the sidecar rather than being answerable
* on the platform side.
*
* A linear scan over a handful of live sessions. If that ever stops being true, add the inverse map —
* but a second copy of a mapping is a second thing to keep honest, and this one is written on every turn.
* Newest wins: a transcript resumed under a fresh key leaves the old entry in place, and the caller wants
* the session generating now, not the one that produced the same file yesterday.
*/
export function findSessionKeyByClaudeSession(claudeSessionId: string): string | undefined {
const keys = Object.keys(currentState.claudeSessions);
for (let i = keys.length - 1; i >= 0; i--) {
const key = keys[i]!;
if (currentState.claudeSessions[key] === claudeSessionId) return key;
}
return undefined;
}
function scheduleSave() {
if (saveTimer) return;
saveTimer = setTimeout(async () => {
@@ -338,7 +338,10 @@ describe('parseStream', () => {
expect(state.gotResult).toBe(true);
expect(sessionIds).toEqual(['sess_1', 'sess_1']);
expect(events.map((e) => e.type)).toEqual(['delta', 'text', 'result']);
// `session` leads: the transcript id goes out at `system.init` so the URL is a permalink from the
// start of the turn, which is what makes a mid-turn refresh reattachable.
expect(events.map((e) => e.type)).toEqual(['session', 'delta', 'text', 'result']);
expect(events[0]).toEqual({ type: 'session', claudeSessionId: 'sess_1' });
});
test('handles chunked delivery (split mid-line)', async () => {
+7 -1
View File
@@ -148,7 +148,13 @@ function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallb
const subtype = msg.subtype as string | undefined;
if (subtype === 'init') {
const sessionId = msg.session_id as string | undefined;
if (sessionId) callbacks.onSessionId(sessionId);
if (sessionId) {
callbacks.onSessionId(sessionId);
// Also out to the browser, and at the *start* of the turn. The same id used to travel only on
// `result`, so a chat had no address until its first turn had finished — refresh before that and
// there was nothing to reconnect by, which is exactly when a long turn is worth reconnecting to.
callbacks.onEvent({ type: 'session', claudeSessionId: sessionId });
}
} else if (subtype === 'task_started') {
callbacks.onEvent({
type: 'task:started',
@@ -28,6 +28,27 @@ describe('createTurnStream', () => {
expect(durable).toHaveLength(0);
});
test('the transcript id goes out live but is never persisted', () => {
// Durable would put a second copy of the answer inside the question: the only way to replay this log
// is to ask for it by the very id the event carries.
const { all, durable, types } = run([{ type: 'session', claudeSessionId: 'claude-uuid-1' }]);
expect(types).toEqual(['session:claude']);
expect(all[0]!.msg).toEqual({ type: 'session:claude', claudeSessionId: 'claude-uuid-1' });
expect(durable).toHaveLength(0);
});
test('the transcript id does not disturb an open delta buffer', () => {
// It arrives at `system.init`, but a resumed turn can re-announce it mid-flight, and flushing there
// would split one paragraph into two messages.
const { durable } = run([
{ type: 'delta', text: 'half ' },
{ type: 'session', claudeSessionId: 'claude-uuid-1' },
{ type: 'delta', text: 'a sentence' },
{ type: 'result', cost: COST },
]);
expect(durable[0]).toEqual({ type: 'assistant:text', text: 'half a sentence' });
});
test('an explicit text event wins over the deltas that produced it', () => {
const { durable } = run([
{ type: 'delta', text: 'par' },
@@ -46,6 +46,12 @@ export function createTurnStream(sessionId: string): TurnStream {
const parent = ('parentToolUseId' in event ? event.parentToolUseId : undefined) ?? '';
switch (event.type) {
case 'session':
// Not durable: the id names the log rather than belonging in it, and a client replaying the log
// had to know the id to ask for it. Persisting it would put a second copy of the answer inside
// the question.
return [{ msg: { type: 'session:claude', claudeSessionId: event.claudeSessionId }, durable: false }];
case 'delta':
buffers.set(parent, (buffers.get(parent) ?? '') + event.text);
return [{ msg: { type: 'assistant:delta', text: event.text, ...parented(parent) }, durable: false }];
+17 -1
View File
@@ -2,7 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { SidecarCommand, SidecarEvent } from '../protocol';
import { initPaths, loadState, flushAndSave, acquireLock, releaseLock, readProxySecretFromDisk } from './state';
import {
initPaths,
loadState,
flushAndSave,
acquireLock,
releaseLock,
readProxySecretFromDisk,
findSessionKeyByClaudeSession,
} from './state';
import { createSessionLogStore } from './session-log';
import { setMcpConfigPath } from './claude-manager';
import * as claudeManager from './claude-manager';
@@ -194,6 +202,14 @@ async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
reply({ type: 'claude:generating', id: cmd.id, generating: claudeManager.isSessionGenerating(cmd.sessionKey) });
break;
case 'claude:find-session':
reply({
type: 'claude:session-key',
id: cmd.id,
sessionKey: findSessionKeyByClaudeSession(cmd.claudeSessionId) ?? null,
});
break;
case 'claude:clear-session':
claudeManager.clearSession(cmd.sessionKey);
sessionLog.drop(cmd.sessionKey);