mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 14:52:42 +08:00
fix(chat): make message queuing reliable across sessions and turn boundaries
Queued messages had four related defects: - A queued message flashed and then vanished at flush time: concurrent transcript refreshes (the `complete` handler racing the watcher-triggered update) could resolve out of order, letting a stale response overwrite newer server messages after the optimistic row was pruned. Session slots now carry a monotonic fetch ticket and discard stale fetch/refresh/ fetchMore responses. - Switching sessions flushed the previous session's queued draft into the newly viewed one (sending it with the wrong provider's settings, e.g. a Claude model into a Codex session). The composer flush is now scoped to its session, and a draft restored into an idle session sends after a short grace period so a live-run ack can cancel it. - The thinking banner never appeared for a queued turn: the chat handler's session-keyed completeRun safety net could fire after a queued message had already started the session's next run, emitting a spurious `complete` that killed it. The safety net is now scoped to its own run via completeRunIfCurrent (with a regression test). - Queued messages only sent while their session was being viewed. Drafts now persist their send options (model, effort, permissions) at queue time, and a new app-level useQueuedMessageAutoSend hook dispatches a non-viewed session's queued message as soon as its run completes, using the storage key as the claim ticket to prevent double sends.
This commit is contained in:
@@ -318,6 +318,22 @@ export const chatRunRegistry = {
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Safety-net variant of `completeRun` scoped to one specific run: a no-op
|
||||
* unless `run` is still the session's current, running run. A runtime
|
||||
* promise can resolve after its own `complete` already streamed AND a new
|
||||
* run has replaced it in the registry (a queued message sends within
|
||||
* milliseconds of the previous turn ending) — the session-keyed
|
||||
* `completeRun` would terminate that newer run.
|
||||
*/
|
||||
completeRunIfCurrent(run: ChatRun, opts: { exitCode: number; aborted?: boolean }): void {
|
||||
if (runs.get(run.appSessionId) !== run || run.status !== 'running') {
|
||||
return;
|
||||
}
|
||||
|
||||
run.writer.sendComplete(opts);
|
||||
},
|
||||
|
||||
/**
|
||||
* Test-only escape hatch: clears every tracked run.
|
||||
*/
|
||||
|
||||
@@ -212,8 +212,10 @@ async function handleChatSend(
|
||||
} finally {
|
||||
// Safety net: a runtime that crashed (or resolved) without emitting its
|
||||
// terminal `complete` would otherwise leave the session stuck in
|
||||
// "processing" forever on every connected client.
|
||||
chatRunRegistry.completeRun(sessionId, { exitCode: 1 });
|
||||
// "processing" forever on every connected client. Scoped to THIS run —
|
||||
// a queued message can start the session's next run before this promise
|
||||
// settles, and the session-keyed completeRun would kill that new run.
|
||||
chatRunRegistry.completeRunIfCurrent(run, { exitCode: 1 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,44 @@ test('complete marks the run finished and duplicate completes are dropped', asyn
|
||||
});
|
||||
});
|
||||
|
||||
test('a finished run\'s safety net cannot complete the session\'s next run', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-9', 'codex', '/workspace/demo');
|
||||
const connection = new FakeConnection();
|
||||
|
||||
const firstRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(firstRun);
|
||||
firstRun.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-9', exitCode: 0 });
|
||||
|
||||
// A queued message starts the next run before the first run's runtime
|
||||
// promise settles (the chat handler's `finally` hasn't executed yet).
|
||||
const secondRun = chatRunRegistry.startRun({
|
||||
appSessionId: 'app-run-9',
|
||||
provider: 'codex',
|
||||
providerSessionId: null,
|
||||
connection,
|
||||
userId: null,
|
||||
});
|
||||
assert.ok(secondRun);
|
||||
|
||||
// First run's safety net fires late: it must not touch the new run.
|
||||
chatRunRegistry.completeRunIfCurrent(firstRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), true);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
||||
|
||||
// The second run's own safety net still works while it is current.
|
||||
chatRunRegistry.completeRunIfCurrent(secondRun, { exitCode: 1 });
|
||||
assert.equal(chatRunRegistry.isProcessing('app-run-9'), false);
|
||||
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test('listRunningRuns returns only currently running app sessions', async () => {
|
||||
await withIsolatedDatabase(() => {
|
||||
sessionsDb.createAppSession('app-run-7', 'claude', '/workspace/demo');
|
||||
|
||||
Reference in New Issue
Block a user