diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 6f5cee57..d79a7f4c 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -1013,6 +1013,7 @@ export class ClaudeMultimodelBridgeService { memberName: string; limit?: number; laneId?: string; + sessionId?: string; timeoutMs?: number; } ): Promise { @@ -1035,6 +1036,9 @@ export class ClaudeMultimodelBridgeService { if (typeof params.laneId === 'string' && params.laneId.trim().length > 0) { args.push('--lane', params.laneId.trim()); } + if (typeof params.sessionId === 'string' && params.sessionId.trim().length > 0) { + args.push('--session-id', params.sessionId.trim()); + } const outputDir = await mkdtemp(path.join(tmpdir(), 'opencode-transcript-')); const outputPath = path.join(outputDir, 'transcript.json'); diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index b44bc2bc..7ae867d1 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -148,6 +148,11 @@ function buildParticipantKey(memberName: string): string { return `member:${normalizeMemberName(memberName)}`; } +function buildSessionSegmentKey(sessionId: string | undefined): string { + const normalized = sessionId?.trim(); + return normalized ? normalized.replace(/[^a-zA-Z0-9_.:-]/g, '_') : 'unknown-session'; +} + function buildParticipant(memberName: string): BoardTaskLogParticipant { return { key: buildParticipantKey(memberName), @@ -1109,7 +1114,9 @@ export class OpenCodeTaskLogStreamSource { const actor = buildActor(ownerName, transcript?.sessionId ?? firstMessage.sessionId); const participant = buildParticipant(ownerName); const segment: BoardTaskLogSegment = { - id: `opencode:${teamName}:${task.id}:${normalizeMemberName(ownerName)}`, + id: `opencode:${teamName}:${task.id}:${normalizeMemberName(ownerName)}:${buildSessionSegmentKey( + transcript?.sessionId ?? firstMessage.sessionId + )}`, participantKey: participant.key, actor, startTimestamp: firstMessage.timestamp.toISOString(), @@ -1162,18 +1169,21 @@ export class OpenCodeTaskLogStreamSource { } const memberKey = normalizeMemberName(memberName); - if (!transcriptCache.has(memberKey)) { + const sessionId = record.sessionId?.trim(); + const transcriptCacheKey = `${memberKey}::${sessionId ?? 'current'}`; + if (!transcriptCache.has(transcriptCacheKey)) { transcriptCache.set( - memberKey, + transcriptCacheKey, await this.runtimeBridge.getOpenCodeTranscript(binaryPath, { teamId: teamName, memberName, limit: ATTRIBUTED_TRANSCRIPT_LIMIT, + ...(sessionId ? { sessionId } : {}), }) ); } - const transcript = transcriptCache.get(memberKey); + const transcript = transcriptCache.get(transcriptCacheKey); if (!transcript) { continue; } @@ -1190,22 +1200,29 @@ export class OpenCodeTaskLogStreamSource { } const participantKey = buildParticipantKey(memberName); - const existing = projectedByParticipant.get(participantKey); + const projectedSessionId = transcript.sessionId ?? record.sessionId; + const segmentKey = `${participantKey}::${projectedSessionId ?? 'unknown-session'}`; + const existing = projectedByParticipant.get(segmentKey); if (existing) { - const seen = new Set(existing.messages.map((message) => message.uuid)); + const seen = new Set( + existing.messages.map( + (message) => `${message.sessionId || projectedSessionId || ''}::${message.uuid}` + ) + ); for (const message of filteredMessages) { - if (!seen.has(message.uuid)) { + const messageKey = `${message.sessionId || projectedSessionId || ''}::${message.uuid}`; + if (!seen.has(messageKey)) { existing.messages.push(message); - seen.add(message.uuid); + seen.add(messageKey); } } existing.messages.sort( (left, right) => left.timestamp.getTime() - right.timestamp.getTime() ); } else { - projectedByParticipant.set(participantKey, { + projectedByParticipant.set(segmentKey, { memberName, - sessionId: transcript.sessionId ?? record.sessionId, + sessionId: projectedSessionId, messages: filteredMessages, }); } @@ -1252,7 +1269,9 @@ export class OpenCodeTaskLogStreamSource { nativeToolCount += memberToolCounts.nativeToolCount; participants.push(participant); segments.push({ - id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`, + id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName( + member.memberName + )}:${buildSessionSegmentKey(member.sessionId ?? firstMessage.sessionId)}`, participantKey: participant.key, actor: buildActor(member.memberName, member.sessionId ?? firstMessage.sessionId), startTimestamp: firstMessage.timestamp.toISOString(), diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index a5be5c66..dd039aec 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -1103,6 +1103,65 @@ describe('ClaudeMultimodelBridgeService', () => { ); }); + it('passes exact OpenCode session id to the runtime transcript command', async () => { + execCliMock.mockImplementation(async (_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + + if ( + normalizedArgs.startsWith( + 'runtime transcript --json --provider opencode --team team-a --member alice --projection-only --limit 20 --session-id session-exact --output ' + ) + ) { + const outputIndex = Array.isArray(args) ? args.indexOf('--output') : -1; + const outputPath = + outputIndex >= 0 && Array.isArray(args) ? String(args[outputIndex + 1] ?? '') : ''; + await writeFile( + outputPath, + JSON.stringify({ + schemaVersion: 1, + providerId: 'opencode', + transcript: { + sessionId: 'session-exact', + durableState: 'idle', + messages: [], + diagnostics: [], + logProjection: { + sessionId: 'session-exact', + messages: [], + }, + }, + }), + 'utf8' + ); + return Promise.resolve({ + stdout: '', + stderr: '', + exitCode: 0, + }); + } + + return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + + const transcript = await service.getOpenCodeTranscript('/mock/agent_teams_orchestrator', { + teamId: 'team-a', + memberName: 'alice', + limit: 20, + sessionId: ' session-exact ', + }); + + expect(transcript?.sessionId).toBe('session-exact'); + expect(execCliMock).toHaveBeenCalledWith( + '/mock/agent_teams_orchestrator', + expect.arrayContaining(['--session-id', 'session-exact']), + expect.any(Object) + ); + }); + it('loads a large real OpenCode projection fixture through output-file transcript delivery', async () => { const fixturePath = path.resolve( process.cwd(), diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts index 52c2ac3c..f8772339 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts @@ -1227,6 +1227,7 @@ describe('OpenCodeTaskLogStreamSource', () => { teamId: 'team-a', memberName: 'bob', limit: 500, + sessionId: 'session-bob', }); }); @@ -1327,6 +1328,7 @@ describe('OpenCodeTaskLogStreamSource', () => { teamId: 'team-a', memberName: 'bob', limit: 500, + sessionId: 'session-bob', }); expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(2, '/tmp/claude', { teamId: 'team-a', @@ -1416,6 +1418,7 @@ describe('OpenCodeTaskLogStreamSource', () => { teamId: 'team-a', memberName: 'bob', limit: 500, + sessionId: 'session-bob', }); expect( chunkBuilder.buildBundleChunks.mock.calls @@ -1423,4 +1426,100 @@ describe('OpenCodeTaskLogStreamSource', () => { .map((message: { uuid: string }) => message.uuid) ).toEqual(['bob-new-attribution']); }); + + it('keeps same-member exact OpenCode session attributions in distinct segments', async () => { + const attributionRecords: OpenCodeTaskLogAttributionRecord[] = [ + { + taskId: 'task-a', + memberName: 'bob', + scope: 'member_session_window', + sessionId: 'session-bob-old', + since: '2026-04-21T10:00:00.000Z', + until: '2026-04-21T10:10:00.000Z', + }, + { + taskId: 'task-a', + memberName: 'bob', + scope: 'member_session_window', + sessionId: 'session-bob-new', + since: '2026-04-21T11:00:00.000Z', + until: '2026-04-21T11:10:00.000Z', + }, + ]; + const bridge = { + getOpenCodeTranscript: vi.fn( + async (_binaryPath, params: { memberName: string; sessionId?: string }) => { + if (params.memberName !== 'bob' || !params.sessionId) { + throw new Error(`unexpected transcript request ${JSON.stringify(params)}`); + } + const timestamp = + params.sessionId === 'session-bob-old' + ? '2026-04-21T10:05:00.000Z' + : '2026-04-21T11:05:00.000Z'; + return { + sessionId: params.sessionId, + logProjection: { + messages: [ + { + uuid: 'same-runtime-uuid', + parentUuid: undefined, + type: 'assistant', + timestamp, + role: 'assistant', + content: [{ type: 'text', text: params.sessionId }], + isMeta: false, + sessionId: params.sessionId, + toolCalls: [], + toolResults: [], + }, + ], + }, + }; + } + ), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: `chunk-${messages[0]?.sessionId ?? 'unknown'}`, + kind: 'assistant', + messages, + }, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [createTask()], + getDeletedTasks: async () => [], + } as never, + chunkBuilder as never, + { readTaskRecords: vi.fn(async () => attributionRecords) } + ); + + const response = await source.getTaskLogStream('team-a', 'task-a'); + + expect(response?.source).toBe('opencode_runtime_attribution'); + expect(response?.segments.map((segment) => segment.id)).toEqual([ + 'opencode-attributed:team-a:task-a:bob:session-bob-old', + 'opencode-attributed:team-a:task-a:bob:session-bob-new', + ]); + expect(response?.segments.map((segment) => segment.actor.sessionId)).toEqual([ + 'session-bob-old', + 'session-bob-new', + ]); + expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(1, '/tmp/claude', { + teamId: 'team-a', + memberName: 'bob', + limit: 500, + sessionId: 'session-bob-old', + }); + expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(2, '/tmp/claude', { + teamId: 'team-a', + memberName: 'bob', + limit: 500, + sessionId: 'session-bob-new', + }); + }); });