import { describe, expect, it } from 'vitest' import { MAX_ACP_MESSAGE_IMAGES_PER_MESSAGE, type AcpRuntimeEvent } from './acp' import type { ArtifactFile } from './artifacts' import { activateConversationBranch, createLinearConversationGraph, forkEditedConversationMessage } from './conversation-graph' import type { PersistedChatMessage, PersistedChatSession } from './session-persistence' import { applyRuntimeSessionEvents, attachRuntimeSessionArtifacts, type RuntimeSessionScope } from './runtime-session-projection' const prompt = (id = 'prompt-1', timestamp = 1): PersistedChatMessage => ({ id, role: 'user', content: 'Do the work', status: 'complete', eventIds: [], createdAt: timestamp, updatedAt: timestamp }) const fixture = (): { session: PersistedChatSession; scope: RuntimeSessionScope } => { const message = prompt() const conversationGraph = createLinearConversationGraph({ sessionId: 'session-1', messages: [message], frameworkId: 'claude-code', createdAt: 1, updatedAt: 1 }) const scope = { promptMessageId: message.id, agentFrameId: conversationGraph.rootFrameId, messageBranchId: conversationGraph.branches[0].id, runtimeSegmentId: conversationGraph.runtimeSegments[0].id } return { scope, session: { id: 'session-1', projectId: 'project-1', title: 'Session', cwd: '/workspace', status: 'running', permissionProfile: 'ask', messages: [message], conversationGraph, activeRun: { promptMessageId: message.id, startedAt: 1 }, createdAt: 1, updatedAt: 1 } } } const event = ( kind: Kind, fields: Omit, 'kind' | 'level' | 'sessionId'> ): Extract => ({ kind, level: 'info', sessionId: 'session-1', ...fields }) as Extract< AcpRuntimeEvent, { kind: Kind } > describe('runtime Session projection', () => { it('appends partial chunks by stream, deduplicates events, and separates streams', () => { const { session, scope } = fixture() const events = [ event('message', { id: 'e1', timestamp: 2, role: 'assistant', messageId: 'stream-a', text: 'a' }), event('message', { id: 'e2', timestamp: 3, role: 'assistant', messageId: 'stream-a', text: 'b' }), event('message', { id: 'e2', timestamp: 3, role: 'assistant', messageId: 'stream-a', text: 'b' }), event('message', { id: 'e3', timestamp: 4, role: 'assistant', messageId: 'stream-a', text: 'c' }), event('message', { id: 'e4', timestamp: 5, role: 'assistant', messageId: 'stream-b', text: 'other', image: { mimeType: 'image/png', data: 'aGVsbG8=', byteLength: 5 } }) ] const projected = applyRuntimeSessionEvents(session, scope, events) expect(projected.messages.slice(1).map(({ content }) => content)).toEqual(['abc', 'other']) expect(projected.messages[1].eventIds).toEqual(['e1', 'e2', 'e3']) expect(projected.messages[2].images).toEqual([ { id: 'e4', mimeType: 'image/png', data: 'aGVsbG8=', byteLength: 5 } ]) }) it('routes events to an inactive branch without changing branch selection', () => { const { session, scope } = fixture() const originalBranchId = scope.messageBranchId const forked = forkEditedConversationMessage( session.conversationGraph!, scope.promptMessageId, 'edited-branch', 2 ) session.conversationGraph = activateConversationBranch(forked, originalBranchId) const editedPrompt = prompt('edited-prompt', 3) editedPrompt.content = 'Edited' const branch = session.conversationGraph.branches.find(({ id }) => id === 'edited-branch')! session.conversationGraph.messages.push({ ...editedPrompt, agentFrameId: scope.agentFrameId, introducedOnBranchId: branch.id, parentMessageId: branch.headMessageId, runtimeSegmentId: scope.runtimeSegmentId, revisionRootMessageId: scope.promptMessageId, supersedesMessageId: scope.promptMessageId }) branch.headMessageId = editedPrompt.id const offBranchScope = { ...scope, promptMessageId: editedPrompt.id, messageBranchId: branch.id } const projected = applyRuntimeSessionEvents(session, offBranchScope, [ event('message', { id: 'off-1', timestamp: 4, role: 'assistant', text: 'hidden' }) ]) expect(projected.conversationGraph!.frames[0].activeBranchId).toBe(originalBranchId) expect(projected.messages.some(({ content }) => content === 'hidden')).toBe(false) expect(projected.conversationGraph!.messages.some(({ content }) => content === 'hidden')).toBe( true ) }) it('preserves tool metadata, elicitation, and activity group membership', () => { const { session, scope } = fixture() const projected = applyRuntimeSessionEvents(session, scope, [ event('tool', { id: 'group-start', timestamp: 2, toolCallId: 'group-1', providerToolName: 'mcp__open-science-activity__begin_activity_group', rawInput: { title: 'Research phase' } }), event('tool', { id: 'tool-start', timestamp: 3, toolCallId: 'tool-1', title: 'Ask', status: 'in_progress', providerToolName: 'ask_user', rawInput: { question: '?' }, toolLocations: [{ path: '/tmp/a', line: 4 }], elicitation: { state: 'pending', message: 'Choose an approach', fields: [], durable: { kind: 'agent-user-choice', requestId: 'request-1', promptMessageId: scope.promptMessageId } } }), event('tool', { id: 'tool-end', timestamp: 4, toolCallId: 'tool-1', status: 'completed', rawOutput: { answer: 'yes' } }), event('compaction', { id: 'compact', timestamp: 4.5, toolCallId: 'compaction-tool', title: 'Compact context', status: 'completed' }), event('message', { id: 'answer', timestamp: 5, role: 'assistant', text: 'Done' }) ]) expect(projected.activities?.[0]).toMatchObject({ id: 'tool-1', status: 'completed', activityGroupId: 'group-1', rawInput: { question: '?' }, rawOutput: { answer: 'yes' } }) expect(projected.activities?.[0].elicitation?.state).toBe('pending') expect(projected.activities?.[1]).toMatchObject({ id: 'compaction-tool', status: 'completed', providerToolName: 'ContextCompaction', toolKind: 'other' }) expect(projected.activityGroups?.[0]).toMatchObject({ id: 'group-1', activityIds: ['tool-1'], completedAt: 4.5 }) }) it('synthesizes an artifact-only owner and records terminal usage on it', () => { const { session, scope } = fixture() const artifact: ArtifactFile = { id: 'version-1', versionId: 'version-1', projectId: 'project-1', sessionId: session.id, name: 'result.csv', path: '/managed/result.csv', fileUrl: 'file:///managed/result.csv', size: 3, mtimeMs: 10, isPublished: true, artifactId: 'artifact-1', versionNumber: 2, checksum: 'sha256-value', createdAt: '1970-01-01T00:00:01.000Z' } const attached = attachRuntimeSessionArtifacts(session, scope, { eventId: 'artifact-1', runId: 'run-1', artifacts: [artifact], timestamp: 2 }) const projected = applyRuntimeSessionEvents(attached.session, scope, [ event('stop', { id: 'stop-1', timestamp: 3, turnUsage: { inputTokens: 2, cacheTokens: 0, outputTokens: 3, turnCount: 1 } }) ]) expect(projected.messages[1]).toMatchObject({ id: attached.messageId, artifactIds: ['version-1'], turnUsage: { inputTokens: 2, cacheTokens: 0, outputTokens: 3 } }) expect(projected.artifacts?.[0]).toEqual({ id: 'version-1', kind: 'managed-file', path: '/managed/result.csv', fileUrl: 'file:///managed/result.csv', name: 'result.csv', mimeType: undefined, size: 3, createdAt: 1_000, mtimeMs: 10, artifactId: 'artifact-1', versionId: 'version-1', versionNumber: 2, sha256: 'sha256-value' }) const replay = attachRuntimeSessionArtifacts(attached.session, scope, { eventId: 'artifact-1', runId: 'run-1', artifacts: [artifact], timestamp: 9 }) expect(replay.session.filesRevision).toBe(attached.session.filesRevision) const changed = attachRuntimeSessionArtifacts(attached.session, scope, { eventId: 'artifact-2', runId: 'run-1', artifacts: [{ ...artifact, checksum: 'changed' }], timestamp: 10 }) expect(changed.session.filesRevision).toBe((attached.session.filesRevision ?? 0) + 1) }) it('rejects an explicitly incompatible Artifact owner', () => { const { session, scope } = fixture() expect(() => attachRuntimeSessionArtifacts(session, scope, { messageId: 'missing-owner', eventId: 'artifact', runId: 'run', artifacts: [], timestamp: 2 }) ).toThrow('outside the supplied Session scope') }) it('adds image identities, enforces per-message limits, and normalizes Claude refusals', () => { const { session, scope } = fixture() session.agentFrameworkId = 'claude-code' const events: AcpRuntimeEvent[] = Array.from( { length: MAX_ACP_MESSAGE_IMAGES_PER_MESSAGE + 1 }, (_, index) => event('message', { id: `image-${index}`, timestamp: index + 2, role: 'assistant', messageId: 'image-stream', text: index === 0 ? 'API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup).' : '', image: { mimeType: 'image/png', data: 'YQ==', byteLength: 1 } }) ) const projected = applyRuntimeSessionEvents(session, scope, events) expect(projected.messages[1].images).toHaveLength(MAX_ACP_MESSAGE_IMAGES_PER_MESSAGE) expect(projected.messages[1].images?.[0].id).toBe('image-0') expect(projected.messages[1].content).toContain('selected model declined') }) it('keeps pending elicitation waiting and does not clear an unrelated active run', () => { const { session, scope } = fixture() const waiting = applyRuntimeSessionEvents(session, scope, [ event('tool', { id: 'question', timestamp: 2, toolCallId: 'question-tool', status: 'in_progress', elicitation: { state: 'pending', message: 'Choose an approach', fields: [], durable: { kind: 'agent-user-choice', requestId: 'r', promptMessageId: scope.promptMessageId } } }), event('stop', { id: 'stop', timestamp: 3 }) ]) expect(waiting.status).toBe('waiting-for-user') expect(waiting.activeRun).toBeUndefined() const unrelated = fixture() unrelated.session.activeRun = { promptMessageId: 'different-prompt', startedAt: 8 } const late = applyRuntimeSessionEvents(unrelated.session, unrelated.scope, [ event('stop', { id: 'late-stop', timestamp: 9 }) ]) expect(late.activeRun).toEqual({ promptMessageId: 'different-prompt', startedAt: 8 }) expect(late.status).toBe('running') }) it('does not duplicate a retained-away replay after a successful terminal message', () => { const { session, scope } = fixture() const completed = applyRuntimeSessionEvents(session, scope, [ event('message', { id: 'chunk', timestamp: 2, role: 'assistant', messageId: 'stream', text: 'once' }), event('stop', { id: 'stop', timestamp: 3 }) ]) const withLateSuccess = applyRuntimeSessionEvents(completed, scope, [ event('message', { id: 'late-success', timestamp: 4, role: 'assistant', messageId: 'stream', text: ' late' }) ]) expect(withLateSuccess.conversationGraph!.messages[1]).toMatchObject({ content: 'once late', status: 'complete' }) withLateSuccess.conversationGraph!.messages[1].eventIds = [] const retainedAwayLateReplay = applyRuntimeSessionEvents(withLateSuccess, scope, [ event('message', { id: 'late-success', timestamp: 4, role: 'assistant', messageId: 'stream', text: ' late' }) ]) expect(retainedAwayLateReplay.conversationGraph!.messages[1].content).toBe('once late') completed.conversationGraph!.messages[1].eventIds = [] const replayed = applyRuntimeSessionEvents(completed, scope, [ event('message', { id: 'chunk', timestamp: 2, role: 'assistant', messageId: 'stream', text: 'once' }) ]) expect(replayed.conversationGraph!.messages[1].content).toBe('once') expect(replayed.conversationGraph!.messages[1].status).toBe('complete') }) it('marks cancellation terminal and ignores a duplicate late terminal event', () => { const { session, scope } = fixture() const streamed = applyRuntimeSessionEvents(session, scope, [ event('message', { id: 'chunk', timestamp: 2, role: 'assistant', text: 'partial' }), event('stop', { id: 'cancel', timestamp: 3, text: 'cancelled' }) ]) const replayed = applyRuntimeSessionEvents(streamed, scope, [ event('stop', { id: 'cancel', timestamp: 3, text: 'cancelled' }), event('thought', { id: 'late-thought', timestamp: 4, text: 'private' }), event('message', { id: 'late-message', timestamp: 5, role: 'assistant', text: 'must not revive the turn' }) ]) expect(replayed.status).toBe('error') expect(replayed.messages[0]).toMatchObject({ interrupted: true }) expect(replayed.messages[1]).toMatchObject({ status: 'error', content: 'partial' }) expect(replayed.messages).toHaveLength(2) }) })