diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts index 0240999e..8fdb1e40 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogAttributionStore.ts @@ -13,12 +13,17 @@ const logger = createLogger('OpenCodeTaskLogAttributionStore'); const MAX_ATTRIBUTION_FILE_BYTES = 512 * 1024; export type OpenCodeTaskLogAttributionScope = 'task_session' | 'member_session_window'; -export type OpenCodeTaskLogAttributionSource = 'manual' | 'launch_runtime' | 'reconcile'; +export type OpenCodeTaskLogAttributionSource = + | 'manual' + | 'launch_runtime' + | 'reconcile' + | 'delivery_ledger'; export interface OpenCodeTaskLogAttributionRecord { taskId: string; memberName: string; scope: OpenCodeTaskLogAttributionScope; + laneId?: string; sessionId?: string; since?: string; until?: string; @@ -66,7 +71,10 @@ function normalizeScope(value: unknown): OpenCodeTaskLogAttributionScope { } function normalizeSource(value: unknown): OpenCodeTaskLogAttributionSource | undefined { - return value === 'manual' || value === 'launch_runtime' || value === 'reconcile' + return value === 'manual' || + value === 'launch_runtime' || + value === 'reconcile' || + value === 'delivery_ledger' ? value : undefined; } @@ -86,6 +94,7 @@ function normalizeRecord( return null; } const sessionId = trimString(raw.sessionId); + const laneId = trimString(raw.laneId); const startMessageUuid = trimString(raw.startMessageUuid); const endMessageUuid = trimString(raw.endMessageUuid); const source = normalizeSource(raw.source); @@ -96,6 +105,7 @@ function normalizeRecord( taskId, memberName, scope: normalizeScope(raw.scope), + ...(laneId ? { laneId } : {}), ...(sessionId ? { sessionId } : {}), ...(since ? { since } : {}), ...(until ? { until } : {}), diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index 7ae867d1..af101235 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -4,16 +4,19 @@ import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodel import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; import { ClaudeBinaryResolver } from '../../ClaudeBinaryResolver'; import { TeamTaskReader } from '../../TeamTaskReader'; +import { getTeamsBasePath } from '@main/utils/pathDecoder'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { mapOpenCodeRuntimeTranscriptLogMessageToParsedMessage } from './OpenCodeRuntimeProjectionMapper'; import { OpenCodeTaskLogAttributionStore } from './OpenCodeTaskLogAttributionStore'; +import { TaskLogOpenCodeSessionEvidenceSource } from './TaskLogOpenCodeSessionEvidenceSource'; import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../runtime/ClaudeMultimodelBridgeService'; import type { OpenCodeTaskLogAttributionReader, OpenCodeTaskLogAttributionRecord, } from './OpenCodeTaskLogAttributionStore'; +import type { OpenCodeTaskLogSessionEvidenceReader } from './TaskLogOpenCodeSessionEvidenceSource'; import type { ParsedMessage } from '@main/types'; import type { BoardTaskLogActor, @@ -191,6 +194,7 @@ function stableAttributionKey(records: OpenCodeTaskLogAttributionRecord[]): stri JSON.stringify([ normalizeMemberName(record.memberName), record.scope, + record.laneId ?? '', record.sessionId ?? '', record.since ?? '', record.until ?? '', @@ -202,6 +206,34 @@ function stableAttributionKey(records: OpenCodeTaskLogAttributionRecord[]): stri .join('|'); } +function mergeTaskLogAttributionRecords( + attributionRecords: OpenCodeTaskLogAttributionRecord[], + sessionEvidenceRecords: OpenCodeTaskLogAttributionRecord[] +): OpenCodeTaskLogAttributionRecord[] { + const merged: OpenCodeTaskLogAttributionRecord[] = []; + const seen = new Set(); + for (const record of [...attributionRecords, ...sessionEvidenceRecords]) { + const key = JSON.stringify([ + record.taskId, + normalizeMemberName(record.memberName), + record.scope, + record.laneId ?? '', + record.sessionId ?? '', + record.since ?? '', + record.until ?? '', + record.startMessageUuid ?? '', + record.endMessageUuid ?? '', + record.source ?? '', + ]); + if (seen.has(key)) { + continue; + } + seen.add(key); + merged.push(record); + } + return merged; +} + function normalizeTaskRef(value: unknown): string | null { if (typeof value !== 'string' && typeof value !== 'number') { return null; @@ -959,7 +991,12 @@ export class OpenCodeTaskLogStreamSource { private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver, private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), - private readonly attributionStore: OpenCodeTaskLogAttributionReader = new OpenCodeTaskLogAttributionStore() + private readonly attributionStore: OpenCodeTaskLogAttributionReader = new OpenCodeTaskLogAttributionStore(), + private readonly sessionEvidenceSource: OpenCodeTaskLogSessionEvidenceReader = new TaskLogOpenCodeSessionEvidenceSource( + { + teamsBasePath: getTeamsBasePath(), + } + ) ) {} private async resolveTask(teamName: string, taskId: string): Promise { @@ -979,12 +1016,26 @@ export class OpenCodeTaskLogStreamSource { return null; } - const attributionRecords = await this.attributionStore.readTaskRecords(teamName, taskId); - if (!task.owner?.trim() && attributionRecords.length === 0) { + const [attributionRecords, sessionEvidenceRecords] = await Promise.all([ + this.attributionStore.readTaskRecords(teamName, taskId), + this.sessionEvidenceSource.readTaskRecords(teamName, task).catch((error) => { + logger.warn( + `[${teamName}/${task.id}] OpenCode task-log session evidence lookup failed: ${ + error instanceof Error ? error.message : String(error) + }` + ); + return []; + }), + ]); + const taskLogRecords = mergeTaskLogAttributionRecords( + attributionRecords, + sessionEvidenceRecords + ); + if (!task.owner?.trim() && taskLogRecords.length === 0) { return null; } - const cacheKey = `${teamName}::${stableTaskWindowKey(task)}::${stableAttributionKey(attributionRecords)}`; + const cacheKey = `${teamName}::${stableTaskWindowKey(task)}::${stableAttributionKey(taskLogRecords)}`; const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return cached.response; @@ -995,7 +1046,7 @@ export class OpenCodeTaskLogStreamSource { return await existingPromise; } - const promise = this.buildTaskLogStream(teamName, task, attributionRecords) + const promise = this.buildTaskLogStream(teamName, task, taskLogRecords) .catch((error) => { logger.warn( `[${teamName}/${task.id}] OpenCode task-log fallback failed: ${ @@ -1169,8 +1220,11 @@ export class OpenCodeTaskLogStreamSource { } const memberKey = normalizeMemberName(memberName); + const laneId = record.laneId?.trim(); const sessionId = record.sessionId?.trim(); - const transcriptCacheKey = `${memberKey}::${sessionId ?? 'current'}`; + const transcriptCacheKey = `${memberKey}::${laneId ?? 'current-lane'}::${ + sessionId ?? 'current' + }`; if (!transcriptCache.has(transcriptCacheKey)) { transcriptCache.set( transcriptCacheKey, @@ -1178,6 +1232,7 @@ export class OpenCodeTaskLogStreamSource { teamId: teamName, memberName, limit: ATTRIBUTED_TRANSCRIPT_LIMIT, + ...(laneId ? { laneId } : {}), ...(sessionId ? { sessionId } : {}), }) ); @@ -1191,10 +1246,13 @@ export class OpenCodeTaskLogStreamSource { continue; } - const filteredMessages = filterMessagesForAttribution( - transcript.logProjection?.messages ?? [], - record - ); + const projectedMessages = transcript.logProjection?.messages ?? []; + const markerProjection = + record.source === 'delivery_ledger' + ? buildTaskMarkerProjection(projectedMessages, teamName, task) + : null; + const filteredMessages = + markerProjection?.messages ?? filterMessagesForAttribution(projectedMessages, record); if (filteredMessages.length === 0) { continue; } diff --git a/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts b/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts new file mode 100644 index 00000000..4e734563 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource.ts @@ -0,0 +1,305 @@ +import { readdir } from 'node:fs/promises'; +import * as path from 'node:path'; + +import { + createOpenCodePromptDeliveryLedgerStore, + type OpenCodePromptDeliveryLedgerRecord, +} from '../../opencode/delivery/OpenCodePromptDeliveryLedger'; +import { + getOpenCodeLaneScopedRuntimeFilePath, + getOpenCodeTeamRuntimeDirectory, + readOpenCodeRuntimeLaneIndex, +} from '../../opencode/store/OpenCodeRuntimeManifestEvidenceReader'; + +import type { OpenCodeTaskLogAttributionRecord } from './OpenCodeTaskLogAttributionStore'; +import type { TeamTask } from '@shared/types'; + +const OPENCODE_PROMPT_DELIVERY_LEDGER_FILE = 'opencode-prompt-delivery-ledger.json'; +const OPENCODE_TEAM_RUNTIME_LANES_DIR = 'lanes'; +const MAX_LEDGER_FILES_TO_SCAN = 48; +const MAX_RECORDS_PER_LEDGER = 96; +const MAX_EVIDENCE_RECORDS = 3; +const TERMINAL_EVIDENCE_GRACE_MS = 5 * 60_000; + +interface TaskLogOpenCodeSessionEvidenceSourceOptions { + teamsBasePath: string; + maxLedgerFilesToScan?: number; + maxRecordsPerLedger?: number; + maxEvidenceRecords?: number; +} + +export interface OpenCodeTaskLogSessionEvidenceReader { + readTaskRecords(teamName: string, task: TeamTask): Promise; +} + +function normalizeTaskRef(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') { + return null; + } + const normalized = String(value).trim().replace(/^#/, '').toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function buildTaskRefSet(task: TeamTask): Set { + return new Set( + [task.id, task.displayId, task.sourceMessageId] + .map(normalizeTaskRef) + .filter((value): value is string => value !== null) + ); +} + +function parseTimestampMs(value: string | null | undefined): number { + if (!value) { + return 0; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function minTimestampIso(values: Array): string | undefined { + const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0); + if (times.length === 0) { + return undefined; + } + return new Date(Math.min(...times)).toISOString(); +} + +function maxTimestampIso(values: Array): string | undefined { + const times = values.map(parseTimestampMs).filter((value) => Number.isFinite(value) && value > 0); + if (times.length === 0) { + return undefined; + } + return new Date(Math.max(...times)).toISOString(); +} + +function addMsToIso(value: string | undefined, deltaMs: number): string | undefined { + if (!value) { + return undefined; + } + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + return undefined; + } + return new Date(timestamp + deltaMs).toISOString(); +} + +function recordReferencesTask( + record: OpenCodePromptDeliveryLedgerRecord, + taskRefs: Set, + task: TeamTask +): boolean { + if (task.sourceMessageId && record.inboxMessageId === task.sourceMessageId) { + return true; + } + return record.taskRefs.some((ref) => { + const taskId = normalizeTaskRef(ref.taskId); + const displayId = normalizeTaskRef(ref.displayId); + return Boolean((taskId && taskRefs.has(taskId)) || (displayId && taskRefs.has(displayId))); + }); +} + +function isTerminalTask(task: TeamTask): boolean { + return task.status === 'completed' || task.status === 'pending' || task.status === 'deleted'; +} + +function shouldUseRecord( + record: OpenCodePromptDeliveryLedgerRecord, + teamName: string, + task: TeamTask, + taskRefs: Set +): boolean { + return ( + record.teamName === teamName && + Boolean(record.runtimeSessionId?.trim()) && + !(record.status === 'failed_terminal' && !record.acceptedAt) && + recordReferencesTask(record, taskRefs, task) + ); +} + +function recordSortTimestamp(record: OpenCodePromptDeliveryLedgerRecord): number { + return Math.max( + parseTimestampMs(record.respondedAt), + parseTimestampMs(record.lastObservedAt), + parseTimestampMs(record.acceptedAt), + parseTimestampMs(record.lastAttemptAt), + parseTimestampMs(record.inboxTimestamp), + parseTimestampMs(record.updatedAt), + parseTimestampMs(record.createdAt), + 0 + ); +} + +function toAttributionRecord( + record: OpenCodePromptDeliveryLedgerRecord, + task: TeamTask +): OpenCodeTaskLogAttributionRecord | null { + const sessionId = record.runtimeSessionId?.trim(); + const memberName = record.memberName.trim(); + if (!sessionId || !memberName) { + return null; + } + + const since = minTimestampIso([ + record.inboxTimestamp, + record.acceptedAt, + record.lastAttemptAt, + record.createdAt, + ]); + const terminalUntil = isTerminalTask(task) + ? maxTimestampIso([task.updatedAt, record.respondedAt, record.lastObservedAt, record.updatedAt]) + : undefined; + const fallbackUntil = + record.status === 'responded' || record.status === 'failed_terminal' + ? maxTimestampIso([ + record.respondedAt, + record.lastObservedAt, + record.failedAt, + record.updatedAt, + ]) + : undefined; + const until = addMsToIso(terminalUntil ?? fallbackUntil, TERMINAL_EVIDENCE_GRACE_MS); + const startMessageUuid = record.deliveredUserMessageId?.trim() || undefined; + + return { + taskId: task.id, + memberName, + scope: 'member_session_window', + laneId: record.laneId.trim(), + sessionId, + source: 'delivery_ledger', + ...(since ? { since } : {}), + ...(until ? { until } : {}), + ...(startMessageUuid ? { startMessageUuid } : {}), + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +async function mapWithConcurrency( + inputs: readonly TInput[], + concurrency: number, + mapper: (input: TInput) => Promise +): Promise { + const results: TOutput[] = []; + let index = 0; + const workerCount = Math.max(1, Math.min(concurrency, inputs.length)); + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (index < inputs.length) { + const currentIndex = index; + index += 1; + results[currentIndex] = await mapper(inputs[currentIndex] as TInput); + } + }) + ); + return results; +} + +export class TaskLogOpenCodeSessionEvidenceSource implements OpenCodeTaskLogSessionEvidenceReader { + private readonly teamsBasePath: string; + private readonly maxLedgerFilesToScan: number; + private readonly maxRecordsPerLedger: number; + private readonly maxEvidenceRecords: number; + + constructor(options: TaskLogOpenCodeSessionEvidenceSourceOptions) { + this.teamsBasePath = options.teamsBasePath; + this.maxLedgerFilesToScan = options.maxLedgerFilesToScan ?? MAX_LEDGER_FILES_TO_SCAN; + this.maxRecordsPerLedger = options.maxRecordsPerLedger ?? MAX_RECORDS_PER_LEDGER; + this.maxEvidenceRecords = options.maxEvidenceRecords ?? MAX_EVIDENCE_RECORDS; + } + + async readTaskRecords( + teamName: string, + task: TeamTask + ): Promise { + const taskRefs = buildTaskRefSet(task); + if (taskRefs.size === 0) { + return []; + } + + const ledgerPaths = await this.discoverLedgerPaths(teamName); + if (ledgerPaths.length === 0) { + return []; + } + + const recordBatches = await mapWithConcurrency(ledgerPaths, 4, async (filePath) => + this.readLedgerRecords(filePath) + ); + const records = recordBatches + .flat() + .filter((record) => shouldUseRecord(record, teamName, task, taskRefs)) + .sort((left, right) => recordSortTimestamp(right) - recordSortTimestamp(left)); + + const seen = new Set(); + const result: OpenCodeTaskLogAttributionRecord[] = []; + for (const record of records) { + const sessionId = record.runtimeSessionId?.trim(); + if (!sessionId) { + continue; + } + const key = [ + record.memberName.trim().toLowerCase(), + record.laneId.trim(), + sessionId, + record.deliveredUserMessageId ?? record.inboxMessageId, + ].join('::'); + if (seen.has(key)) { + continue; + } + seen.add(key); + const attributionRecord = toAttributionRecord(record, task); + if (!attributionRecord) { + continue; + } + result.push(attributionRecord); + if (result.length >= this.maxEvidenceRecords) { + break; + } + } + + return result; + } + + private async discoverLedgerPaths(teamName: string): Promise { + const ledgerPaths = new Set(); + const runtimeDir = getOpenCodeTeamRuntimeDirectory(this.teamsBasePath, teamName); + const lanesDir = path.join(runtimeDir, OPENCODE_TEAM_RUNTIME_LANES_DIR); + const laneDirs = await readdir(lanesDir, { withFileTypes: true }).catch(() => []); + for (const entry of laneDirs) { + if (!entry.isDirectory()) { + continue; + } + ledgerPaths.add(path.join(lanesDir, entry.name, OPENCODE_PROMPT_DELIVERY_LEDGER_FILE)); + if (ledgerPaths.size >= this.maxLedgerFilesToScan) { + break; + } + } + + const laneIndex = await readOpenCodeRuntimeLaneIndex(this.teamsBasePath, teamName).catch( + () => null + ); + for (const laneId of Object.keys(laneIndex?.lanes ?? {})) { + if (ledgerPaths.size >= this.maxLedgerFilesToScan) { + break; + } + ledgerPaths.add( + getOpenCodeLaneScopedRuntimeFilePath({ + teamsBasePath: this.teamsBasePath, + teamName, + laneId, + fileName: OPENCODE_PROMPT_DELIVERY_LEDGER_FILE, + }) + ); + } + + return Array.from(ledgerPaths); + } + + private async readLedgerRecords(filePath: string): Promise { + const store = createOpenCodePromptDeliveryLedgerStore({ filePath }); + return await store + .list() + .then((records) => records.slice(-this.maxRecordsPerLedger)) + .catch(() => []); + } +} diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts index f8772339..bc04d902 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts @@ -264,6 +264,95 @@ describe('OpenCodeTaskLogStreamSource', () => { expect(second).toEqual(first); }); + it('uses exact OpenCode session evidence from delivery ledgers before current-lane fallback', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async (_binaryPath, request: { sessionId?: string }) => ({ + sessionId: request.sessionId ?? 'current-session', + logProjection: { + messages: [ + textLogMessage({ + uuid: 'runtime-user-evidence', + type: 'user', + role: 'user', + timestamp: '2026-04-21T10:01:00.000Z', + sessionId: 'session-from-ledger', + content: [{ type: 'text', text: 'Start task-a now' }], + }), + taskMarkerLogMessage({ + uuid: 'assistant-start-evidence', + parentUuid: 'runtime-user-evidence', + timestamp: '2026-04-21T10:02:00.000Z', + toolName: 'mcp__agent-teams__task_start', + input: { teamName: 'team-a', taskId: 'task-a' }, + }), + taskMarkerLogMessage({ + uuid: 'assistant-native-evidence', + parentUuid: 'assistant-start-evidence', + timestamp: '2026-04-21T10:03:00.000Z', + toolName: 'bash', + input: { command: 'pnpm test' }, + }), + ].map((message) => ({ + ...message, + sessionId: request.sessionId ?? message.sessionId, + })), + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-ledger-session', + kind: 'assistant', + messages, + }, + ]), + }; + const sessionEvidenceSource = { + readTaskRecords: vi.fn(async () => [ + { + taskId: 'task-a', + memberName: 'bob', + scope: 'member_session_window', + laneId: 'lane-from-ledger', + sessionId: 'session-from-ledger', + since: '2026-04-21T10:00:00.000Z', + source: 'delivery_ledger', + startMessageUuid: 'runtime-user-evidence', + } satisfies OpenCodeTaskLogAttributionRecord, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [createTask({ owner: undefined })], + getDeletedTasks: async () => [], + } as never, + chunkBuilder as never, + { readTaskRecords: vi.fn(async () => []) }, + sessionEvidenceSource + ); + + const response = await source.getTaskLogStream('team-a', 'task-a'); + + expect(response?.source).toBe('opencode_runtime_attribution'); + expect(response?.segments[0]?.actor.sessionId).toBe('session-from-ledger'); + expect(response?.segments[0]?.id).toContain('session-from-ledger'); + expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/claude', { + teamId: 'team-a', + memberName: 'bob', + limit: 500, + laneId: 'lane-from-ledger', + sessionId: 'session-from-ledger', + }); + expect( + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) + ).toEqual(['runtime-user-evidence', 'assistant-start-evidence', 'assistant-native-evidence']); + }); + it('sanitizes OpenCode delivery retry envelopes from projected task log text', async () => { const bridge = { getOpenCodeTranscript: vi.fn(async () => ({ diff --git a/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts b/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts new file mode 100644 index 00000000..2ff69418 --- /dev/null +++ b/test/main/services/team/TaskLogOpenCodeSessionEvidenceSource.test.ts @@ -0,0 +1,206 @@ +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { TaskLogOpenCodeSessionEvidenceSource } from '../../../../src/main/services/team/taskLogs/stream/TaskLogOpenCodeSessionEvidenceSource'; +import { + OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION, + type OpenCodePromptDeliveryLedgerRecord, +} from '../../../../src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger'; + +import type { TeamTask } from '../../../../src/shared/types'; + +const tempDirs: string[] = []; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: 'task-a', + displayId: 'task-a', + subject: 'Implement task', + owner: 'bob', + status: 'in_progress', + createdAt: '2026-04-21T09:00:00.000Z', + updatedAt: '2026-04-21T10:00:00.000Z', + ...overrides, + }; +} + +function createLedgerRecord( + overrides: Partial = {} +): OpenCodePromptDeliveryLedgerRecord { + return { + id: 'record-a', + teamName: 'team-a', + memberName: 'bob', + laneId: 'lane-a', + runId: 'run-a', + runtimeSessionId: 'session-a', + inboxMessageId: 'inbox-a', + inboxTimestamp: '2026-04-21T10:00:00.000Z', + source: 'watcher', + messageKind: 'default', + replyRecipient: 'user', + actionMode: 'do', + taskRefs: [ + { + taskId: 'task-a', + displayId: 'task-a', + teamName: 'team-a', + }, + ], + payloadHash: 'hash-a', + status: 'accepted', + responseState: 'pending', + attempts: 1, + maxAttempts: 3, + acceptanceUnknown: false, + nextAttemptAt: null, + lastAttemptAt: '2026-04-21T10:00:01.000Z', + lastObservedAt: null, + acceptedAt: '2026-04-21T10:00:02.000Z', + respondedAt: null, + failedAt: null, + inboxReadCommittedAt: null, + inboxReadCommitError: null, + prePromptCursor: null, + postPromptCursor: null, + deliveredUserMessageId: 'runtime-user-a', + observedAssistantMessageId: null, + observedAssistantPreview: null, + observedToolCallNames: [], + observedVisibleMessageId: null, + visibleReplyMessageId: null, + visibleReplyInbox: null, + visibleReplyCorrelation: null, + lastReason: null, + diagnostics: [], + createdAt: '2026-04-21T10:00:00.000Z', + updatedAt: '2026-04-21T10:00:02.000Z', + ...overrides, + }; +} + +async function writeLedger(input: { + teamsBasePath: string; + teamName: string; + laneId: string; + records: OpenCodePromptDeliveryLedgerRecord[]; +}): Promise { + const ledgerPath = path.join( + input.teamsBasePath, + input.teamName, + '.opencode-runtime', + 'lanes', + encodeURIComponent(input.laneId), + 'opencode-prompt-delivery-ledger.json' + ); + await mkdir(path.dirname(ledgerPath), { recursive: true }); + await writeFile( + ledgerPath, + `${JSON.stringify( + { + schemaVersion: OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION, + updatedAt: '2026-04-21T10:00:00.000Z', + data: input.records, + }, + null, + 2 + )}\n` + ); +} + +async function createTempTeamsBasePath(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-session-evidence-')); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe('TaskLogOpenCodeSessionEvidenceSource', () => { + it('returns bounded exact OpenCode session evidence from prompt delivery ledgers', async () => { + const teamsBasePath = await createTempTeamsBasePath(); + await writeLedger({ + teamsBasePath, + teamName: 'team-a', + laneId: 'lane-a', + records: [ + createLedgerRecord({ + id: 'record-old', + runtimeSessionId: 'session-old', + inboxTimestamp: '2026-04-21T09:00:00.000Z', + lastAttemptAt: '2026-04-21T09:00:01.000Z', + acceptedAt: '2026-04-21T09:00:01.000Z', + createdAt: '2026-04-21T09:00:00.000Z', + updatedAt: '2026-04-21T09:00:01.000Z', + }), + createLedgerRecord({ + id: 'record-new', + runtimeSessionId: 'session-new', + deliveredUserMessageId: 'runtime-user-new', + inboxTimestamp: '2026-04-21T10:00:00.000Z', + lastAttemptAt: '2026-04-21T10:00:01.000Z', + acceptedAt: '2026-04-21T10:00:01.000Z', + createdAt: '2026-04-21T10:00:00.000Z', + updatedAt: '2026-04-21T10:00:01.000Z', + }), + ], + }); + await writeLedger({ + teamsBasePath, + teamName: 'team-a', + laneId: 'lane-foreign', + records: [ + createLedgerRecord({ + id: 'record-foreign-task', + laneId: 'lane-foreign', + runtimeSessionId: 'session-foreign', + taskRefs: [ + { + taskId: 'task-foreign', + displayId: 'task-foreign', + teamName: 'team-a', + }, + ], + }), + createLedgerRecord({ + id: 'record-rejected-before-acceptance', + laneId: 'lane-foreign', + runtimeSessionId: 'session-rejected', + status: 'failed_terminal', + acceptedAt: null, + }), + ], + }); + + const source = new TaskLogOpenCodeSessionEvidenceSource({ + teamsBasePath, + maxEvidenceRecords: 1, + }); + + const records = await source.readTaskRecords('team-a', createTask()); + + expect(records).toEqual([ + expect.objectContaining({ + taskId: 'task-a', + memberName: 'bob', + scope: 'member_session_window', + laneId: 'lane-a', + sessionId: 'session-new', + source: 'delivery_ledger', + startMessageUuid: 'runtime-user-new', + }), + ]); + }); + + it('returns an empty candidate list when no matching ledger exists', async () => { + const teamsBasePath = await createTempTeamsBasePath(); + const source = new TaskLogOpenCodeSessionEvidenceSource({ teamsBasePath }); + + await expect(source.readTaskRecords('team-a', createTask())).resolves.toEqual([]); + }); +});