diff --git a/electron/runtime/cc-connect-bridge-adapter.ts b/electron/runtime/cc-connect-bridge-adapter.ts index ee7dde7e..527e743c 100644 --- a/electron/runtime/cc-connect-bridge-adapter.ts +++ b/electron/runtime/cc-connect-bridge-adapter.ts @@ -527,7 +527,7 @@ export class CcConnectBridgeAdapter { this.shouldReconnect = false; this.clearHeartbeat(); this.clearReconnectTimer(); - await this.failPendingRuns('cc-connect runtime stopped before the run completed'); + this.failPendingRuns('cc-connect runtime stopped before the run completed'); const sockets = new Set([ ...this.connectingSockets, ...(this.socket ? [this.socket] : []), @@ -841,13 +841,14 @@ export class CcConnectBridgeAdapter { } return; } - this.handleServerMessage(parsed); + this.handleServerMessage(parsed, socket); }); socket.once('error', (error) => finish(error)); socket.once('close', () => { if (this.socket === socket) { this.socket = null; this.clearHeartbeat(); + this.failPendingRuns('cc-connect bridge disconnected before the run completed'); this.scheduleReconnect(); } finish(new Error('cc-connect bridge connection closed')); @@ -904,7 +905,8 @@ export class CcConnectBridgeAdapter { } } - private handleServerMessage(message: Record): void { + private handleServerMessage(message: Record, sourceSocket: WebSocket): void { + if (sourceSocket !== this.socket) return; const progressItems = parseBridgeProgressItems(message.content); if (progressItems) { logger.debug( @@ -1587,7 +1589,7 @@ export class CcConnectBridgeAdapter { this.terminalCardRuns.delete(pending.runId); } - private async failPendingRuns(error: string): Promise { + private failPendingRuns(error: string): void { const failedAt = Date.now(); for (const pending of Array.from(this.pendingRuns.values())) { if (this.pendingRuns.get(pending.runId) !== pending) continue; @@ -1595,7 +1597,7 @@ export class CcConnectBridgeAdapter { sessionKey: pending.sessionKey, abortedAt: failedAt, }); - await this.finishPendingRun(pending, { + void this.finishPendingRun(pending, { text: error, isError: true, appendMessage: false, @@ -1641,7 +1643,10 @@ export class CcConnectBridgeAdapter { pending.sessionKey === sessionKey || toCcConnectBridgeSessionKey(pending.sessionKey) === bridgeSessionKey )); if (hasPendingForSession) return false; - return Array.from(this.abortedRuns.values()).some((aborted) => aborted.sessionKey === sessionKey); + return Array.from(this.abortedRuns.values()).some((aborted) => ( + aborted.sessionKey === sessionKey + || toCcConnectBridgeSessionKey(aborted.sessionKey) === bridgeSessionKey + )); } private pruneAbortedRuns(): void { diff --git a/electron/runtime/cc-connect-codex-transcript.ts b/electron/runtime/cc-connect-codex-transcript.ts index 91de3dba..6f37d235 100644 --- a/electron/runtime/cc-connect-codex-transcript.ts +++ b/electron/runtime/cc-connect-codex-transcript.ts @@ -14,7 +14,10 @@ type CachedTranscriptFile = { turnMetadata?: { sessionTimestamp?: number; sessionWorkDir?: string; - userMessages: string[]; + userTurns: Array<{ + content: string; + timestamp?: number; + }>; }; toolMessages?: RawMessage[]; }; @@ -166,7 +169,7 @@ function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable['userTurns'] = []; for (const line of file.jsonl.split(/\r?\n/)) { if (!line.trim()) continue; let record: Record; @@ -185,29 +188,37 @@ function transcriptTurnMetadata(file: CachedTranscriptFile): NonNullable ( - Math.abs(sessionTimestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS - && userMessages.includes(hint.content.trim()) - )); + const { userTurns } = transcriptTurnMetadata(file); + if (userTurns.length === 0 || !transcriptMatchesWorkDir(file, expectedWorkDir)) return false; + return hints.some((hint) => userTurns.some((turn) => ( + turn.timestamp !== undefined + && Math.abs(turn.timestamp - hint.timestamp) <= TRANSCRIPT_TURN_MATCH_WINDOW_MS + && turn.content === hint.content.trim() + ))); } async function findTurnTranscriptFiles( @@ -374,21 +385,24 @@ export async function loadCcConnectCodexTranscriptTools( ? [codexHomeDirs] : Array.from(codexHomeDirs); const transcriptPaths = new Set(); - const cachedSessionPath = hasValidAgentSessionId - ? transcriptPathBySessionId.get(agentSessionId) - : undefined; - if (cachedSessionPath) transcriptPaths.add(cachedSessionPath); for (const codexHomeDir of new Set(homes.filter(Boolean))) { - if (hasValidAgentSessionId && !transcriptPathBySessionId.has(agentSessionId)) { - const transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId); + if (hasValidAgentSessionId) { + const sessionPathCacheKey = `${resolve(codexHomeDir)}\0${agentSessionId}`; + let transcriptPath = transcriptPathBySessionId.get(sessionPathCacheKey); + if (!transcriptPath) { + transcriptPath = await findTranscriptFile(join(codexHomeDir, 'sessions'), agentSessionId); + } if (transcriptPath) { setBoundedCache( transcriptPathBySessionId, - agentSessionId, + sessionPathCacheKey, transcriptPath, MAX_TRANSCRIPT_PATH_CACHE_ENTRIES, ); - transcriptPaths.add(transcriptPath); + const file = await readTranscriptFile(transcriptPath); + if (file?.jsonl && transcriptMatchesWorkDir(file, expectedWorkDir)) { + transcriptPaths.add(transcriptPath); + } } } for (const turnTranscriptPath of await findTurnTranscriptFiles(codexHomeDir, turnHints, expectedWorkDir)) { diff --git a/tests/unit/cc-connect-bridge-adapter.test.ts b/tests/unit/cc-connect-bridge-adapter.test.ts index aab569a6..f12e823a 100644 --- a/tests/unit/cc-connect-bridge-adapter.test.ts +++ b/tests/unit/cc-connect-bridge-adapter.test.ts @@ -118,8 +118,9 @@ describe('cc-connect BridgePlatform adapter', () => { }); }); + let adapter: CcConnectBridgeAdapter | undefined; try { - const adapter = new CcConnectBridgeAdapter({ + adapter = new CcConnectBridgeAdapter({ port, token: 'token', project: 'clawx-main', @@ -132,11 +133,12 @@ describe('cc-connect BridgePlatform adapter', () => { message: 'ping', idempotencyKey: 'idem-1', })).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) })); - await expect(adapter.send({ + const channelRun = await adapter.send({ sessionKey: 'agent:coder:feishu:chat-1:user-1', message: 'channel ping', idempotencyKey: 'idem-2', - })).resolves.toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) })); + }); + expect(channelRun).toEqual(expect.objectContaining({ runId: expect.stringMatching(/^cc-connect-/) })); await vi.waitFor(() => { expect(emitted).toEqual(expect.arrayContaining([ @@ -154,6 +156,12 @@ describe('cc-connect BridgePlatform adapter', () => { sessionKey: 'agent:research:desk', status: 'completed', })], + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: channelRun.runId, + sessionKey: 'agent:coder:feishu:chat-1:user-1', + status: 'completed', + })], ])); }); @@ -185,6 +193,7 @@ describe('cc-connect BridgePlatform adapter', () => { ]); await adapter.close(); } finally { + await adapter?.close(); await new Promise((resolve) => server.close(() => resolve())); } }); @@ -932,6 +941,7 @@ describe('cc-connect BridgePlatform adapter', () => { }); const sockets: WebSocket[] = []; const received: Record[] = []; + const emitted: Array<[string, unknown]> = []; server.on('connection', (socket) => { sockets.push(socket); @@ -940,6 +950,14 @@ describe('cc-connect BridgePlatform adapter', () => { received.push(parsed); if (parsed.type === 'register') { socket.send(JSON.stringify({ type: 'register_ack', ok: true })); + return; + } + if (parsed.type === 'message' && sockets.indexOf(socket) === 1) { + socket.send(JSON.stringify({ + type: 'reply', + session_key: parsed.session_key, + content: 'reply after dropped connection', + })); } }); }); @@ -948,7 +966,7 @@ describe('cc-connect BridgePlatform adapter', () => { port, token: 'token', project: 'clawx-main', - emit: vi.fn(), + emit: ((event: string, payload: unknown) => emitted.push([event, payload])) as never, heartbeatIntervalMs: 20, reconnectDelayMs: 20, }); @@ -961,10 +979,40 @@ describe('cc-connect BridgePlatform adapter', () => { ])); }); + const droppedRun = await adapter.send({ + sessionKey: 'agent:coder:feishu:chat-1:user-1', + message: 'drop this run', + idempotencyKey: 'idem-before-drop', + }); sockets[0]?.close(); await vi.waitFor(() => { expect(sockets).toHaveLength(2); expect(adapter.isConnected()).toBe(true); + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: droppedRun.runId, + sessionKey: 'agent:coder:feishu:chat-1:user-1', + status: 'error', + error: 'cc-connect bridge disconnected before the run completed', + })], + ])); + }); + + const replacementRun = await adapter.send({ + sessionKey: 'agent:coder:feishu:chat-1:user-1', + message: 'answer after reconnect', + idempotencyKey: 'idem-after-drop', + }); + await vi.waitFor(() => { + expect(emitted).toEqual(expect.arrayContaining([ + ['chat:runtime-event', expect.objectContaining({ + type: 'run.ended', + runId: replacementRun.runId, + sessionKey: 'agent:coder:feishu:chat-1:user-1', + status: 'completed', + })], + ])); }); await adapter.close(); @@ -1017,7 +1065,7 @@ describe('cc-connect BridgePlatform adapter', () => { try { const first = await adapter.send({ - sessionKey: 'agent:main:first', + sessionKey: 'agent:coder:feishu:chat-1:user-1', message: 'never answered', idempotencyKey: 'idem-before-close', }); @@ -1027,14 +1075,14 @@ describe('cc-connect BridgePlatform adapter', () => { ['chat:runtime-event', expect.objectContaining({ type: 'run.ended', runId: first.runId, - sessionKey: 'agent:main:first', + sessionKey: 'agent:coder:feishu:chat-1:user-1', status: 'error', error: 'cc-connect runtime stopped before the run completed', })], ])); const second = await adapter.send({ - sessionKey: 'agent:main:second', + sessionKey: 'agent:coder:feishu:chat-1:user-1', message: 'answer this', idempotencyKey: 'idem-after-close', }); @@ -1043,7 +1091,7 @@ describe('cc-connect BridgePlatform adapter', () => { ['chat:runtime-event', expect.objectContaining({ type: 'run.ended', runId: second.runId, - sessionKey: 'agent:main:second', + sessionKey: 'agent:coder:feishu:chat-1:user-1', status: 'completed', })], ])); diff --git a/tests/unit/cc-connect-runtime-provider.test.ts b/tests/unit/cc-connect-runtime-provider.test.ts index e0530c0a..85343f6e 100644 --- a/tests/unit/cc-connect-runtime-provider.test.ts +++ b/tests/unit/cc-connect-runtime-provider.test.ts @@ -2609,7 +2609,7 @@ describe('CcConnectRuntimeProvider', () => { }); }); - it('scopes transcript turn matching by workspace and parses direct Web Search and MCP tools', async () => { + it('rejects stale cross-workspace session IDs and matches later Web Search and MCP turns', async () => { const transcriptDir = join( tempDir, 'runtimes', @@ -2623,11 +2623,11 @@ describe('CcConnectRuntimeProvider', () => { await mkdir(transcriptDir, { recursive: true }); const timestamp = Date.parse('2026-07-27T06:30:10.000Z'); const sessionMeta = (id: string, cwd: string) => JSON.stringify({ - timestamp: '2026-07-27T06:30:10.000Z', + timestamp: '2026-07-27T06:00:10.000Z', type: 'session_meta', payload: { id, - timestamp: '2026-07-27T06:30:10.000Z', + timestamp: '2026-07-27T06:00:10.000Z', cwd, }, }); @@ -2665,7 +2665,7 @@ describe('CcConnectRuntimeProvider', () => { }, }), ].join('\n'), 'utf8'); - await writeFile(join(transcriptDir, 'rollout-main.jsonl'), [ + await writeFile(join(transcriptDir, 'rollout-main-session.jsonl'), [ sessionMeta('main-session', '/workspace/main'), userMessage, JSON.stringify({ @@ -2683,7 +2683,7 @@ describe('CcConnectRuntimeProvider', () => { const { loadCcConnectCodexTranscriptTools } = await import('@electron/runtime/cc-connect-codex-transcript'); const messages = await loadCcConnectCodexTranscriptTools( join(tempDir, 'runtimes', 'cc-connect', 'codex-home'), - '', + 'main-session', [{ content: '查一下状态', timestamp }], '/workspace/coder', );