Skip to content

Commit 459283a

Browse files
dimakisclaude
andcommitted
fix(server): report idle sessions as not-running on reconnect
handleReconnect conflated "query loop alive" with "agent actively processing a turn". isActive() returns true for sessions whose query loop is alive but idle between turns (waiting in `for await`). This caused the reconnected message to report running=true, making the frontend queue the first user message behind a 5-second fallback timer instead of sending it immediately — the "two messages to get a response after reattach" bug. Fix: check lastSpeaker from EventStore. When lastSpeaker=assistant the agent completed its last turn and is idle, so report running=false. When lastSpeaker=user the agent hasn't answered yet, so running=true. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5709b06 commit 459283a

2 files changed

Lines changed: 114 additions & 0 deletions

File tree

server/__tests__/ws-handler-v2.test.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,104 @@ describe('handleReconnect reconnected summary (P1)', () => {
11141114
expect(reattachChat).not.toHaveBeenCalled();
11151115
});
11161116

1117+
it('reports running=false when session is active but idle (lastSpeaker=assistant)', () => {
1118+
const sessionReg = mockSessionRegistry();
1119+
sessionReg.findBySessionId.mockReturnValue({ clientId: 'driver-1' });
1120+
sessionReg.isActive.mockReturnValue(true);
1121+
sessionReg.isAttached.mockReturnValue(false);
1122+
1123+
const eventStore = mockEventStore();
1124+
eventStore.getSession.mockReturnValue({ isActive: true, lastSpeaker: 'assistant' });
1125+
1126+
const ctx = createContext({
1127+
sessionRegistry: sessionReg as unknown as V2HandlerContext['sessionRegistry'],
1128+
eventStore: eventStore as unknown as V2HandlerContext['eventStore'],
1129+
});
1130+
const transport = mockTransport();
1131+
ctx.connRegistry.register('c1', transport);
1132+
1133+
handleReconnect(
1134+
'c1',
1135+
{ type: 'reconnect', sessions: [{ sessionId: 'sess-idle', lastSeq: 0 }] },
1136+
ctx,
1137+
);
1138+
1139+
const summary = transport.sent.find((m) => m.type === 'reconnected') as {
1140+
sessions: Array<{ sessionId: string; running: boolean }>;
1141+
};
1142+
expect(summary.sessions[0].running).toBe(false);
1143+
});
1144+
1145+
it('reports running=true when session is active and mid-turn (lastSpeaker=user)', () => {
1146+
const sessionReg = mockSessionRegistry();
1147+
sessionReg.findBySessionId.mockReturnValue({ clientId: 'driver-1' });
1148+
sessionReg.isActive.mockReturnValue(true);
1149+
sessionReg.isAttached.mockReturnValue(false);
1150+
1151+
const eventStore = mockEventStore();
1152+
eventStore.getSession.mockReturnValue({ isActive: true, lastSpeaker: 'user' });
1153+
1154+
const ctx = createContext({
1155+
sessionRegistry: sessionReg as unknown as V2HandlerContext['sessionRegistry'],
1156+
eventStore: eventStore as unknown as V2HandlerContext['eventStore'],
1157+
});
1158+
const transport = mockTransport();
1159+
ctx.connRegistry.register('c1', transport);
1160+
1161+
handleReconnect(
1162+
'c1',
1163+
{ type: 'reconnect', sessions: [{ sessionId: 'sess-busy', lastSeq: 0 }] },
1164+
ctx,
1165+
);
1166+
1167+
const summary = transport.sent.find((m) => m.type === 'reconnected') as {
1168+
sessions: Array<{ sessionId: string; running: boolean }>;
1169+
};
1170+
expect(summary.sessions[0].running).toBe(true);
1171+
});
1172+
1173+
it('handles mixed running states across multiple sessions', () => {
1174+
const sessionReg = mockSessionRegistry();
1175+
sessionReg.findBySessionId
1176+
.mockReturnValueOnce({ clientId: 'driver-1' })
1177+
.mockReturnValueOnce(null)
1178+
.mockReturnValueOnce({ clientId: 'driver-3' });
1179+
sessionReg.isActive.mockReturnValueOnce(true).mockReturnValueOnce(false);
1180+
1181+
const ctx = createContext({
1182+
sessionRegistry: sessionReg as unknown as V2HandlerContext['sessionRegistry'],
1183+
});
1184+
const transport = mockTransport();
1185+
ctx.connRegistry.register('c1', transport);
1186+
1187+
handleReconnect(
1188+
'c1',
1189+
{
1190+
type: 'reconnect',
1191+
sessions: [
1192+
{ sessionId: 'sess-1', lastSeq: 0 },
1193+
{ sessionId: 'sess-2', lastSeq: 0 },
1194+
{ sessionId: 'sess-3', lastSeq: 0 },
1195+
],
1196+
},
1197+
ctx,
1198+
);
1199+
1200+
const summary = transport.sent.find((m) => m.type === 'reconnected') as {
1201+
sessions: Array<{ sessionId: string; running: boolean }>;
1202+
};
1203+
expect(summary.sessions).toHaveLength(3);
1204+
expect(summary.sessions[0]).toEqual(
1205+
expect.objectContaining({ sessionId: 'sess-1', running: true }),
1206+
);
1207+
expect(summary.sessions[1]).toEqual(
1208+
expect.objectContaining({ sessionId: 'sess-2', running: false }),
1209+
);
1210+
expect(summary.sessions[2]).toEqual(
1211+
expect.objectContaining({ sessionId: 'sess-3', running: false }),
1212+
);
1213+
});
1214+
11171215
it('replays multiple events in sequence order', () => {
11181216
const eventStore = mockEventStore();
11191217
eventStore.getEventsAfter.mockReturnValue([

server/ws-handler-v2.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,22 @@ export function handleReconnect(
267267
});
268268
ctx.sessionRegistry.remove(found!.clientId);
269269
}
270+
// Distinguish "query loop alive but idle" from "agent actively
271+
// processing a turn". lastSpeaker === 'assistant' means the agent
272+
// completed its last turn and is waiting for input — report as
273+
// not-running so the client sends messages immediately instead of
274+
// queuing them behind a 5-second fallback timer.
275+
if (running) {
276+
const storeMeta = ctx.eventStore.getSession(entry.sessionId);
277+
if (storeMeta?.lastSpeaker === 'assistant') {
278+
running = false;
279+
log.info('session alive but idle (last speaker: assistant)', {
280+
connectionId,
281+
sessionId: entry.sessionId,
282+
clientId: found!.clientId,
283+
});
284+
}
285+
}
270286
if (found && running && !ctx.sessionRegistry.isAttached(found.clientId)) {
271287
const ownerConnection = getOwnerConnection(found.clientId);
272288
const ownerGone = !ctx.connRegistry.get(ownerConnection);

0 commit comments

Comments
 (0)