mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-08 06:32:44 +08:00
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.
283 lines
10 KiB
TypeScript
283 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import { closeConnection, initializeDatabase, sessionsDb } from '@/modules/database/index.js';
|
|
import { chatRunRegistry } from '@/modules/websocket/services/chat-run-registry.service.js';
|
|
import { connectedClients } from '@/modules/websocket/services/websocket-state.service.js';
|
|
|
|
/**
|
|
* Minimal stand-in for a websocket connection: collects every JSON frame the
|
|
* gateway writer forwards so assertions can inspect the outbound protocol.
|
|
*/
|
|
class FakeConnection {
|
|
readyState = 1; // WS_OPEN_STATE
|
|
frames: Array<Record<string, unknown>> = [];
|
|
|
|
send(data: string): void {
|
|
this.frames.push(JSON.parse(data) as Record<string, unknown>);
|
|
}
|
|
}
|
|
|
|
async function withIsolatedDatabase(runTest: () => void | Promise<void>): Promise<void> {
|
|
const previousDatabasePath = process.env.DATABASE_PATH;
|
|
const tempDirectory = await mkdtemp(path.join(tmpdir(), 'chat-run-registry-'));
|
|
const databasePath = path.join(tempDirectory, 'auth.db');
|
|
|
|
closeConnection();
|
|
process.env.DATABASE_PATH = databasePath;
|
|
await initializeDatabase();
|
|
|
|
try {
|
|
await runTest();
|
|
} finally {
|
|
connectedClients.clear();
|
|
chatRunRegistry.clearAll();
|
|
closeConnection();
|
|
if (previousDatabasePath === undefined) {
|
|
delete process.env.DATABASE_PATH;
|
|
} else {
|
|
process.env.DATABASE_PATH = previousDatabasePath;
|
|
}
|
|
await rm(tempDirectory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
test('live events are remapped to the app session id and sequenced', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-1', 'claude', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
const run = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-1',
|
|
provider: 'claude',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: 'user-1',
|
|
});
|
|
assert.ok(run);
|
|
|
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'provider-id-9', content: 'hello' });
|
|
run.writer.send({ kind: 'text', provider: 'claude', sessionId: 'provider-id-9', content: 'hello world' });
|
|
|
|
assert.equal(connection.frames.length, 2);
|
|
assert.equal(connection.frames[0]?.sessionId, 'app-run-1');
|
|
assert.equal(connection.frames[0]?.seq, 1);
|
|
assert.equal(connection.frames[1]?.sessionId, 'app-run-1');
|
|
assert.equal(connection.frames[1]?.seq, 2);
|
|
});
|
|
});
|
|
|
|
test('session_created is swallowed and persisted as the provider-id mapping', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-2', 'cursor', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
connectedClients.add(connection as never);
|
|
const run = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-2',
|
|
provider: 'cursor',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(run);
|
|
|
|
run.writer.send({
|
|
kind: 'session_created',
|
|
provider: 'cursor',
|
|
sessionId: 'cursor-native-7',
|
|
newSessionId: 'cursor-native-7',
|
|
});
|
|
|
|
// The provider-native event itself is never forwarded...
|
|
const sessionUpserts = connection.frames.filter((frame) => frame.kind === 'session_upserted');
|
|
assert.equal(sessionUpserts.length, 1);
|
|
assert.equal(sessionUpserts[0]?.sessionId, 'app-run-2');
|
|
assert.equal(sessionUpserts[0]?.providerSessionId, 'cursor-native-7');
|
|
// ...but the canonical mapping is recorded and persisted in the database.
|
|
assert.equal(run.providerSessionId, 'cursor-native-7');
|
|
assert.equal(sessionsDb.getSessionById('app-run-2')?.provider_session_id, 'cursor-native-7');
|
|
});
|
|
});
|
|
|
|
test('complete marks the run finished and duplicate completes are dropped', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-3', 'codex', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
const run = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-3',
|
|
provider: 'codex',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(run);
|
|
|
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 0 });
|
|
// Late duplicate from a killed runtime's exit handler.
|
|
run.writer.send({ kind: 'complete', provider: 'codex', sessionId: 'native-3', exitCode: 1 });
|
|
|
|
const completes = connection.frames.filter((frame) => frame.kind === 'complete');
|
|
assert.equal(completes.length, 1);
|
|
assert.equal(completes[0]?.actualSessionId, 'app-run-3');
|
|
assert.equal(chatRunRegistry.isProcessing('app-run-3'), false);
|
|
|
|
// completeRun is also a no-op once the run already completed.
|
|
chatRunRegistry.completeRun('app-run-3', { exitCode: 1 });
|
|
assert.equal(connection.frames.filter((frame) => frame.kind === 'complete').length, 1);
|
|
});
|
|
});
|
|
|
|
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');
|
|
sessionsDb.createAppSession('app-run-8', 'codex', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
|
|
const completedRun = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-7',
|
|
provider: 'claude',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(completedRun);
|
|
|
|
const runningRun = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-8',
|
|
provider: 'codex',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(runningRun);
|
|
|
|
chatRunRegistry.completeRun('app-run-7', { exitCode: 0 });
|
|
|
|
const runningSessions = chatRunRegistry.listRunningRuns();
|
|
assert.deepEqual(runningSessions.map((session) => session.sessionId), ['app-run-8']);
|
|
assert.equal(runningSessions[0]?.provider, 'codex');
|
|
});
|
|
});
|
|
|
|
test('replayEvents returns only events after the requested seq', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-4', 'claude', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
const run = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-4',
|
|
provider: 'claude',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(run);
|
|
|
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'a' });
|
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'b' });
|
|
run.writer.send({ kind: 'stream_delta', provider: 'claude', sessionId: 'x', content: 'c' });
|
|
|
|
const replayed = chatRunRegistry.replayEvents('app-run-4', 1);
|
|
assert.deepEqual(replayed.map((event) => event.content), ['b', 'c']);
|
|
assert.deepEqual(replayed.map((event) => event.seq), [2, 3]);
|
|
});
|
|
});
|
|
|
|
test('attachConnection reroutes the live stream to a new socket', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-5', 'opencode', '/workspace/demo');
|
|
const firstConnection = new FakeConnection();
|
|
const run = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-5',
|
|
provider: 'opencode',
|
|
providerSessionId: null,
|
|
connection: firstConnection,
|
|
userId: null,
|
|
});
|
|
assert.ok(run);
|
|
|
|
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'before' });
|
|
|
|
const secondConnection = new FakeConnection();
|
|
assert.equal(chatRunRegistry.attachConnection('app-run-5', secondConnection), true);
|
|
run.writer.send({ kind: 'stream_delta', provider: 'opencode', sessionId: 'o', content: 'after' });
|
|
|
|
assert.deepEqual(firstConnection.frames.map((frame) => frame.content), ['before']);
|
|
assert.deepEqual(secondConnection.frames.map((frame) => frame.content), ['after']);
|
|
});
|
|
});
|
|
|
|
test('startRun rejects a second concurrent run for the same session', async () => {
|
|
await withIsolatedDatabase(() => {
|
|
sessionsDb.createAppSession('app-run-6', 'opencode', '/workspace/demo');
|
|
const connection = new FakeConnection();
|
|
const first = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-6',
|
|
provider: 'opencode',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(first);
|
|
|
|
const second = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-6',
|
|
provider: 'opencode',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.equal(second, null);
|
|
|
|
// After the run finishes a new one is allowed again.
|
|
chatRunRegistry.completeRun('app-run-6', { exitCode: 0 });
|
|
const third = chatRunRegistry.startRun({
|
|
appSessionId: 'app-run-6',
|
|
provider: 'opencode',
|
|
providerSessionId: null,
|
|
connection,
|
|
userId: null,
|
|
});
|
|
assert.ok(third);
|
|
});
|
|
});
|