diff --git a/app/shared/src/composer/upload.test.ts b/app/shared/src/composer/upload.test.ts new file mode 100644 index 000000000..7ee4af274 --- /dev/null +++ b/app/shared/src/composer/upload.test.ts @@ -0,0 +1,472 @@ +// real_tested=true — hashing runs through the real computeFileHash (Node webcrypto SHA-256); +// only the transport layer (XMLHttpRequest / fetch) is replaced with in-memory fakes. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + uploadAttachmentWithProgress, + uploadPendingAttachmentsWithProgress, +} from './upload'; +import type { AttachmentUploadContext, AttachmentUploadProgress } from './upload'; +import type { AttachmentRef, ComposerAttachment } from './types'; + +const HUB_BASE_URL = 'http://hub.test:8080'; +const SHA256_EMPTY = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; +const SHA256_HELLO = '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'; + +interface FakeUploadProgressEvent { + lengthComputable: boolean; + loaded: number; + total: number; +} + +/** + * Minimal in-memory XMLHttpRequest stand-in. `upload.ts` only touches + * open/setRequestHeader/send, the `upload.onprogress` hook, and the + * onload/onerror callbacks, so the fake needs nothing else. + */ +class FakeXMLHttpRequest { + static instances: FakeXMLHttpRequest[] = []; + + status = 0; + responseText = ''; + method = ''; + url = ''; + requestHeaders: Record = {}; + sentBody: FormData | null = null; + upload: { onprogress: ((event: FakeUploadProgressEvent) => void) | null } = { + onprogress: null, + }; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + + open(method: string, url: string): void { + this.method = method; + this.url = url; + FakeXMLHttpRequest.instances.push(this); + } + + setRequestHeader(name: string, value: string): void { + this.requestHeaders[name] = value; + } + + send(body: FormData): void { + this.sentBody = body; + } + + respondWith(status: number, responseText: string): void { + this.status = status; + this.responseText = responseText; + this.onload?.(); + } + + failNetwork(): void { + this.onerror?.(); + } + + emitUploadProgress(loaded: number, total: number, lengthComputable = true): void { + this.upload.onprogress?.({ lengthComputable, loaded, total }); + } +} + +function makeContext(overrides: Partial = {}): AttachmentUploadContext { + return { + hubBaseUrl: HUB_BASE_URL, + getToken: () => null, + ...overrides, + }; +} + +function makeFile(contents = 'hello', name = 'hello.txt'): File { + return new File([contents], name, { type: 'text/plain' }); +} + +function latestXhr(): FakeXMLHttpRequest { + const xhr = FakeXMLHttpRequest.instances[FakeXMLHttpRequest.instances.length - 1]; + if (!xhr) throw new Error('Expected an XMLHttpRequest to have been created'); + return xhr; +} + +async function waitForXhr(): Promise { + await vi.waitFor(() => { + expect(FakeXMLHttpRequest.instances.length).toBeGreaterThan(0); + }); + return latestXhr(); +} + +function requireBody(xhr: FakeXMLHttpRequest): FormData { + if (!xhr.sentBody) throw new Error('Expected xhr.send() to be called with a FormData body'); + return xhr.sentBody; +} + +function hubEnvelope(data: unknown): string { + return JSON.stringify({ code: 'ok', data }); +} + +const STORED_ATTACHMENT: AttachmentRef = { + id: 'att-1', + name: 'stored.txt', + original_name: 'renamed.txt', + size: 1234, + mime_type: 'text/plain', + hash: 'server-hash', + metadata: '{"k":"v"}', + created_at: '2026-08-19T00:00:00Z', +}; + +beforeEach(() => { + FakeXMLHttpRequest.instances = []; + vi.stubGlobal('XMLHttpRequest', FakeXMLHttpRequest); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('uploadAttachmentWithProgress', () => { + it('hashes with the real SHA-256 and POSTs the multipart fields', async () => { + const file = makeFile('hello', 'hello.txt'); + const pending = uploadAttachmentWithProgress(file, makeContext()); + + const xhr = await waitForXhr(); + expect(xhr.method).toBe('POST'); + expect(xhr.url).toBe(`${HUB_BASE_URL}/client/attachments`); + expect(xhr.requestHeaders['Authorization']).toBeUndefined(); + + const body = requireBody(xhr); + expect(body.get('hash')).toBe(SHA256_HELLO); + expect(body.get('original_name')).toBe('hello.txt'); + expect(body.get('file')).toBe(file); + + xhr.respondWith(201, hubEnvelope(STORED_ATTACHMENT)); + const result = await pending; + + expect(result.downloadUrl).toBe(`${HUB_BASE_URL}/client/attachments/att-1`); + expect(result.attachmentRef).toEqual(expect.objectContaining({ + id: 'att-1', + name: 'renamed.txt', + original_name: 'renamed.txt', + size: 1234, + mime_type: 'text/plain', + hash: 'server-hash', + url: result.downloadUrl, + metadata: '{"k":"v"}', + created_at: '2026-08-19T00:00:00Z', + })); + }); + + it('adds an Authorization header only when a token is provided', async () => { + const pending = uploadAttachmentWithProgress( + makeFile(), + makeContext({ getToken: () => 'jwt-123' }), + ); + const xhr = await waitForXhr(); + expect(xhr.requestHeaders['Authorization']).toBe('Bearer jwt-123'); + + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + await expect(pending).resolves.toBeTruthy(); + }); + + it('reports hashing and upload progress mapped onto the 15-95% band', async () => { + const progress: AttachmentUploadProgress[] = []; + const pending = uploadAttachmentWithProgress(makeFile(), makeContext(), (p) => progress.push(p)); + + const xhr = await waitForXhr(); + xhr.emitUploadProgress(0, 100); + xhr.emitUploadProgress(25, 100); + xhr.emitUploadProgress(100, 100); + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + await pending; + + expect(progress).toEqual([ + { percent: 0, phase: 'hashing' }, + { percent: 10, phase: 'hashing' }, + { percent: 15, phase: 'uploading' }, + { percent: 35, phase: 'uploading' }, + { percent: 95, phase: 'uploading' }, + { percent: 100, phase: 'done' }, + ]); + }); + + it('ignores upload progress events that are not length-computable', async () => { + const progress: AttachmentUploadProgress[] = []; + const pending = uploadAttachmentWithProgress(makeFile(), makeContext(), (p) => progress.push(p)); + + const xhr = await waitForXhr(); + xhr.emitUploadProgress(50, 100, false); + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + await pending; + + expect(progress).toEqual([ + { percent: 0, phase: 'hashing' }, + { percent: 10, phase: 'hashing' }, + { percent: 100, phase: 'done' }, + ]); + }); + + it('parses a direct response without the Hub envelope', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith( + 200, + JSON.stringify({ id: 'att-2', size: 7, mime_type: 'application/octet-stream' }), + ); + const result = await pending; + + expect(result.attachmentRef).toEqual({ + id: 'att-2', + name: 'hello.txt', + size: 7, + mime_type: 'application/octet-stream', + hash: SHA256_HELLO, + url: `${HUB_BASE_URL}/client/attachments/att-2`, + }); + }); + + it('rejects with the HTTP status on non-2xx responses', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith(404, 'not found'); + await expect(pending).rejects.toThrow('Upload failed: HTTP 404'); + }); + + it('rejects on transport errors', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.failNetwork(); + await expect(pending).rejects.toThrow('Upload failed: network error'); + }); + + it('wraps JSON parse failures with a descriptive message', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith(200, 'not json{'); + await expect(pending).rejects.toThrow(/Failed to parse upload response/); + }); + + it('percent-encodes special characters in the attachment id in the download URL', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith(200, hubEnvelope({ id: 'att/odd id?', name: 'x', size: 1, mime_type: 'x/y' })); + const result = await pending; + + expect(result.downloadUrl).toBe(`${HUB_BASE_URL}/client/attachments/att%2Fodd%20id%3F`); + }); + + it('treats an envelope without a data field as the raw body', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith(200, JSON.stringify({ code: 'ok', note: 'no data here' })); + const result = await pending; + + expect(result.attachmentRef.name).toBe('hello.txt'); + expect(result.downloadUrl).toBe(`${HUB_BASE_URL}/client/attachments/undefined`); + }); + + it('keeps an empty original_name instead of falling back to the file name', async () => { + const pending = uploadAttachmentWithProgress(makeFile(), makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith( + 200, + hubEnvelope({ id: 'att-5', original_name: '', size: 3, mime_type: 'text/plain' }), + ); + const result = await pending; + + expect(result.attachmentRef.name).toBe(''); + expect('original_name' in result.attachmentRef).toBe(false); + }); + + it('hashes an empty file to the SHA-256 of the empty string', async () => { + const pending = uploadAttachmentWithProgress(makeFile('', 'empty.bin'), makeContext()); + const xhr = await waitForXhr(); + expect(requireBody(xhr).get('hash')).toBe(SHA256_EMPTY); + + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + await expect(pending).resolves.toBeTruthy(); + }); + + it('skips the upload when probeHash reports the file already stored', async () => { + const cachedRef: AttachmentRef = { id: 'att-3', name: 'cached.png', size: 9, mime_type: 'image/png' }; + const probeHash = vi.fn().mockResolvedValue({ exists: true, attachment: cachedRef }); + const progress: AttachmentUploadProgress[] = []; + + const result = await uploadAttachmentWithProgress( + makeFile(), + makeContext({ probeHash }), + (p) => progress.push(p), + ); + + expect(probeHash).toHaveBeenCalledWith(SHA256_HELLO); + expect(FakeXMLHttpRequest.instances).toHaveLength(0); + expect(result.attachmentRef).toBe(cachedRef); + expect(result.downloadUrl).toBe(`${HUB_BASE_URL}/client/attachments/att-3`); + expect(progress).toEqual([ + { percent: 0, phase: 'hashing' }, + { percent: 10, phase: 'hashing' }, + { percent: 100, phase: 'done' }, + ]); + }); + + it('proceeds to upload when probeHash reports the file missing', async () => { + const probeHash = vi.fn().mockResolvedValue({ exists: false }); + const pending = uploadAttachmentWithProgress(makeFile(), makeContext({ probeHash })); + + const xhr = await waitForXhr(); + expect(probeHash).toHaveBeenCalledWith(SHA256_HELLO); + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + + const result = await pending; + expect(result.attachmentRef.id).toBe('att-1'); + }); + + it('proceeds to upload when probeHash reports exists without an attachment', async () => { + const probeHash = vi.fn().mockResolvedValue({ exists: true }); + const pending = uploadAttachmentWithProgress(makeFile(), makeContext({ probeHash })); + + const xhr = await waitForXhr(); + xhr.respondWith(200, hubEnvelope(STORED_ATTACHMENT)); + const result = await pending; + + expect(result.attachmentRef.id).toBe('att-1'); + }); + + it('propagates probeHash failures', async () => { + const probeHash = vi.fn().mockRejectedValue(new Error('probe down')); + await expect( + uploadAttachmentWithProgress(makeFile(), makeContext({ probeHash })), + ).rejects.toThrow('probe down'); + }); + + it('falls back to fetch when XMLHttpRequest is unavailable', async () => { + vi.stubGlobal('XMLHttpRequest', undefined); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => hubEnvelope(STORED_ATTACHMENT), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await uploadAttachmentWithProgress( + makeFile(), + makeContext({ getToken: () => 'jwt-123' }), + ); + + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch was not called'); + const [url, init] = call; + expect(url).toBe(`${HUB_BASE_URL}/client/attachments`); + expect(init?.method).toBe('POST'); + expect((init?.headers as Record | undefined)?.['Authorization']).toBe( + 'Bearer jwt-123', + ); + + const body = init?.body as FormData | undefined; + expect(body?.get('hash')).toBe(SHA256_HELLO); + expect(body?.get('original_name')).toBe('hello.txt'); + + expect(result.attachmentRef).toEqual(expect.objectContaining({ + id: 'att-1', + name: 'renamed.txt', + hash: 'server-hash', + url: `${HUB_BASE_URL}/client/attachments/att-1`, + })); + }); + + it('rejects the fetch fallback on non-ok responses', async () => { + vi.stubGlobal('XMLHttpRequest', undefined); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 413, + text: async () => '', + })); + + await expect( + uploadAttachmentWithProgress(makeFile(), makeContext()), + ).rejects.toThrow('Upload failed: HTTP 413'); + }); + + it('omits the hash and uses an empty name in the fetch fallback when the server omits them', async () => { + vi.stubGlobal('XMLHttpRequest', undefined); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: async () => hubEnvelope({ id: 'att-6', size: 2, mime_type: 'text/plain' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await uploadAttachmentWithProgress(makeFile(), makeContext()); + + const call = fetchMock.mock.calls[0]; + if (!call) throw new Error('fetch was not called'); + const [, init] = call; + expect(Object.keys((init?.headers ?? {}) as Record)).toHaveLength(0); + + expect(result.attachmentRef.name).toBe(''); + expect('hash' in result.attachmentRef).toBe(false); + expect('original_name' in result.attachmentRef).toBe(false); + }); +}); + +describe('uploadPendingAttachmentsWithProgress', () => { + it('skips attachments that already have a ref or lack a file', async () => { + const existingRef: AttachmentRef = { id: 'done-1', name: 'done.png', size: 1, mime_type: 'image/png' }; + const attachments: ComposerAttachment[] = [ + { id: 'a1', name: 'done.png', attachmentRef: existingRef }, + { id: 'a2', name: 'no-file.txt' }, + { id: 'a3', name: 'todo.txt', file: makeFile('data', 'todo.txt') }, + ]; + + const pending = uploadPendingAttachmentsWithProgress(attachments, makeContext()); + const xhr = await waitForXhr(); + xhr.respondWith( + 200, + hubEnvelope({ id: 'att-7', name: 'todo.txt', size: 4, mime_type: 'text/plain' }), + ); + const result = await pending; + + expect(result[0]).toBe(attachments[0]); + expect(result[1]).toBe(attachments[1]); + expect(result[2]?.attachmentRef?.id).toBe('att-7'); + expect(attachments[2]?.attachmentRef).toBeUndefined(); + }); + + it('keeps a failed attachment as-is so its text content is still sent', async () => { + const attachments: ComposerAttachment[] = [ + { id: 'a1', name: 'flaky.txt', file: makeFile('data', 'flaky.txt') }, + ]; + + const pending = uploadPendingAttachmentsWithProgress(attachments, makeContext()); + const xhr = await waitForXhr(); + xhr.failNetwork(); + const result = await pending; + + expect(result[0]).toBe(attachments[0]); + expect(attachments[0]?.attachmentRef).toBeUndefined(); + }); + + it('reports progress keyed by attachment index', async () => { + const existingRef: AttachmentRef = { id: 'done-1', name: 'done.png', size: 1, mime_type: 'image/png' }; + const attachments: ComposerAttachment[] = [ + { id: 'a1', name: 'done.png', attachmentRef: existingRef }, + { id: 'a2', name: 'up.txt', file: makeFile('data', 'up.txt') }, + ]; + const events: Array<[number, AttachmentUploadProgress]> = []; + + const pending = uploadPendingAttachmentsWithProgress( + attachments, + makeContext(), + (index, progress) => events.push([index, progress]), + ); + const xhr = await waitForXhr(); + xhr.respondWith(200, hubEnvelope({ id: 'att-8', name: 'up.txt', size: 4, mime_type: 'text/plain' })); + await pending; + + expect(events[0]).toEqual([1, { percent: 0, phase: 'hashing' }]); + expect(events[events.length - 1]).toEqual([1, { percent: 100, phase: 'done' }]); + }); + + it('returns an empty array without touching the transport for empty input', async () => { + const result = await uploadPendingAttachmentsWithProgress([], makeContext()); + expect(result).toEqual([]); + expect(FakeXMLHttpRequest.instances).toHaveLength(0); + }); +}); diff --git a/app/shared/src/stores/queryKeys.test.ts b/app/shared/src/stores/queryKeys.test.ts new file mode 100644 index 000000000..0285848a5 --- /dev/null +++ b/app/shared/src/stores/queryKeys.test.ts @@ -0,0 +1,504 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; +import { edgeQueryKeys, hubQueryKeys, isQueryKeyPrefix, rootPrefix } from './queryKeys'; + +// ── hubQueryKeys: auth ────────────────────────────────────────────── + +describe('hubQueryKeys.auth', () => { + it('defines the stable user key', () => { + expect(hubQueryKeys.auth.user).toEqual(['hub', 'auth', 'user']); + }); + + it('builds a profile key from a user id', () => { + expect(hubQueryKeys.auth.profile('user-1')).toEqual(['hub', 'auth', 'profile', 'user-1']); + }); +}); + +// ── hubQueryKeys: threads ─────────────────────────────────────────── + +describe('hubQueryKeys.threads', () => { + it('defines the threads root', () => { + expect(hubQueryKeys.threads.root).toEqual(['hub', 'threads']); + }); + + it('collapses missing, undefined, and empty-string projectId to the root key', () => { + expect(hubQueryKeys.threads.all()).toEqual(hubQueryKeys.threads.root); + expect(hubQueryKeys.threads.all(undefined)).toEqual(hubQueryKeys.threads.root); + expect(hubQueryKeys.threads.all('')).toEqual(hubQueryKeys.threads.root); + }); + + it('builds an all() key scoped to a project', () => { + expect(hubQueryKeys.threads.all('project-1')).toEqual(['hub', 'threads', 'project-1']); + }); + + it('builds a detail key from a thread id', () => { + expect(hubQueryKeys.threads.detail('thread-1')).toEqual(['hub', 'threads', 'detail', 'thread-1']); + }); + + it('builds a messages key from a thread id', () => { + expect(hubQueryKeys.threads.messages('thread-1')).toEqual(['hub', 'threads', 'thread-1', 'messages']); + }); + + it('builds a pins key from a thread id', () => { + expect(hubQueryKeys.threads.pins('thread-1')).toEqual(['hub', 'threads', 'thread-1', 'pins']); + }); +}); + +// ── hubQueryKeys: agents ──────────────────────────────────────────── + +describe('hubQueryKeys.agents', () => { + it('defines the agents root', () => { + expect(hubQueryKeys.agents.root).toEqual(['hub', 'agents']); + }); + + it('defaults the list context to hub', () => { + expect(hubQueryKeys.agents.list()).toEqual(['hub', 'agents', 'hub']); + }); + + it('supports the signed-out list context', () => { + expect(hubQueryKeys.agents.list('signed-out')).toEqual(['hub', 'agents', 'signed-out']); + }); + + it('builds a detail key from an agent id', () => { + expect(hubQueryKeys.agents.detail('agent-1')).toEqual(['hub', 'agents', 'detail', 'agent-1']); + }); +}); + +// ── hubQueryKeys: agentTeams ──────────────────────────────────────── + +describe('hubQueryKeys.agentTeams', () => { + it('defines the agent-teams root', () => { + expect(hubQueryKeys.agentTeams.root).toEqual(['hub', 'agent-teams']); + }); + + it('defaults the list context to hub', () => { + expect(hubQueryKeys.agentTeams.list()).toEqual(['hub', 'agent-teams', 'hub']); + }); + + it('supports the signed-out list context', () => { + expect(hubQueryKeys.agentTeams.list('signed-out')).toEqual(['hub', 'agent-teams', 'signed-out']); + }); + + it('builds a detail key from a team id', () => { + expect(hubQueryKeys.agentTeams.detail('team-1')).toEqual(['hub', 'agent-teams', 'detail', 'team-1']); + }); + + it('builds a runs key from a team id', () => { + expect(hubQueryKeys.agentTeams.runs('team-1')).toEqual(['hub', 'agent-teams', 'team-1', 'runs']); + }); + + it('builds a runDetail key from a team id and run id', () => { + expect(hubQueryKeys.agentTeams.runDetail('team-1', 'run-1')).toEqual([ + 'hub', + 'agent-teams', + 'team-1', + 'runs', + 'run-1', + ]); + }); + + it('builds a runState key from a team id and run id', () => { + expect(hubQueryKeys.agentTeams.runState('team-1', 'run-1')).toEqual([ + 'hub', + 'agent-teams', + 'team-1', + 'runs', + 'run-1', + 'state', + ]); + }); + + it('builds a runEvents key from a team id and run id', () => { + expect(hubQueryKeys.agentTeams.runEvents('team-1', 'run-1')).toEqual([ + 'hub', + 'agent-teams', + 'team-1', + 'runs', + 'run-1', + 'events', + ]); + }); + + it('builds a runTasks key from a team id and run id', () => { + expect(hubQueryKeys.agentTeams.runTasks('team-1', 'run-1')).toEqual([ + 'hub', + 'agent-teams', + 'team-1', + 'runs', + 'run-1', + 'tasks', + ]); + }); +}); + +// ── hubQueryKeys: projects ────────────────────────────────────────── + +describe('hubQueryKeys.projects', () => { + it('defines the projects root', () => { + expect(hubQueryKeys.projects.root).toEqual(['hub', 'projects']); + }); + + it('defaults the list context to hub', () => { + expect(hubQueryKeys.projects.list()).toEqual(['hub', 'projects', 'hub']); + }); + + it('supports the signed-out list context', () => { + expect(hubQueryKeys.projects.list('signed-out')).toEqual(['hub', 'projects', 'signed-out']); + }); + + it('builds a detail key from a project id', () => { + expect(hubQueryKeys.projects.detail('project-1')).toEqual(['hub', 'projects', 'project-1']); + }); + + it('builds a threads key from a project id', () => { + expect(hubQueryKeys.projects.threads('project-1')).toEqual(['hub', 'projects', 'project-1', 'threads']); + }); + + it('builds a threadMessages key from a project id and thread id', () => { + expect(hubQueryKeys.projects.threadMessages('project-1', 'thread-1')).toEqual([ + 'hub', + 'projects', + 'project-1', + 'threads', + 'thread-1', + 'messages', + ]); + }); +}); + +// ── hubQueryKeys: executionTargets ────────────────────────────────── + +describe('hubQueryKeys.executionTargets', () => { + it('defines the execution-targets root', () => { + expect(hubQueryKeys.executionTargets.root).toEqual(['hub', 'execution-targets']); + }); + + it('defaults the list context to hub', () => { + expect(hubQueryKeys.executionTargets.list()).toEqual(['hub', 'execution-targets', 'hub']); + }); + + it('supports the signed-out list context', () => { + expect(hubQueryKeys.executionTargets.list('signed-out')).toEqual(['hub', 'execution-targets', 'signed-out']); + }); + + it('builds a detail key from a target id', () => { + expect(hubQueryKeys.executionTargets.detail('target-1')).toEqual([ + 'hub', + 'execution-targets', + 'detail', + 'target-1', + ]); + }); +}); + +// ── hubQueryKeys: contacts ────────────────────────────────────────── + +describe('hubQueryKeys.contacts', () => { + it('defines the contacts root', () => { + expect(hubQueryKeys.contacts.root).toEqual(['hub', 'contacts']); + }); + + it('defines the contacts list key', () => { + expect(hubQueryKeys.contacts.list).toEqual(['hub', 'contacts', 'list']); + }); + + it('defines the friend-requests key', () => { + expect(hubQueryKeys.contacts.friendRequests).toEqual(['hub', 'contacts', 'friend-requests']); + }); +}); + +// ── hubQueryKeys: notifications ───────────────────────────────────── + +describe('hubQueryKeys.notifications', () => { + it('defines the notifications root', () => { + expect(hubQueryKeys.notifications.root).toEqual(['hub', 'notifications']); + }); + + it('defaults the list key to all notifications', () => { + expect(hubQueryKeys.notifications.list()).toEqual(['hub', 'notifications', 'all']); + expect(hubQueryKeys.notifications.list(undefined)).toEqual(['hub', 'notifications', 'all']); + }); + + it('keeps the all key for explicit false', () => { + expect(hubQueryKeys.notifications.list(false)).toEqual(['hub', 'notifications', 'all']); + }); + + it('builds the unread key for explicit true', () => { + expect(hubQueryKeys.notifications.list(true)).toEqual(['hub', 'notifications', 'unread']); + }); +}); + +// ── hubQueryKeys: customAgents ────────────────────────────────────── + +describe('hubQueryKeys.customAgents', () => { + it('defines the custom-agents root', () => { + expect(hubQueryKeys.customAgents.root).toEqual(['hub', 'custom-agents']); + }); + + it('defines the custom-agents list key', () => { + expect(hubQueryKeys.customAgents.list).toEqual(['hub', 'custom-agents', 'list']); + }); +}); + +// ── hubQueryKeys: catalog ─────────────────────────────────────────── + +describe('hubQueryKeys.catalog', () => { + it('defines the skills catalog key', () => { + expect(hubQueryKeys.catalog.skills).toEqual(['hub', 'catalog', 'skills']); + }); + + it('defines the MCP servers catalog key', () => { + expect(hubQueryKeys.catalog.mcpServers).toEqual(['hub', 'catalog', 'mcp-servers']); + }); +}); + +// ── hubQueryKeys: auditEvents ─────────────────────────────────────── + +describe('hubQueryKeys.auditEvents', () => { + it('defines the audit-events root', () => { + expect(hubQueryKeys.auditEvents.root).toEqual(['hub', 'audit-events']); + }); +}); + +// ── hubQueryKeys: relayCommands ───────────────────────────────────── + +describe('hubQueryKeys.relayCommands', () => { + it('defines the relay-commands root', () => { + expect(hubQueryKeys.relayCommands.root).toEqual(['hub', 'relay-commands']); + }); + + it('builds a detail key from a command id', () => { + expect(hubQueryKeys.relayCommands.detail('command-1')).toEqual(['hub', 'relay-commands', 'command-1']); + }); +}); + +// ── hubQueryKeys: runs ────────────────────────────────────────────── + +describe('hubQueryKeys.runs', () => { + it('defines the runs root', () => { + expect(hubQueryKeys.runs.root).toEqual(['hub', 'runs']); + }); + + it('collapses the all() key to the root when no ids are given', () => { + expect(hubQueryKeys.runs.all()).toEqual(hubQueryKeys.runs.root); + expect(hubQueryKeys.runs.all(undefined, undefined)).toEqual(hubQueryKeys.runs.root); + }); + + it('pads the threadId slot with an empty string when only projectId is given', () => { + expect(hubQueryKeys.runs.all('project-1')).toEqual(['hub', 'runs', 'project-1', '']); + }); + + it('pads the projectId slot with an empty string when only threadId is given', () => { + expect(hubQueryKeys.runs.all(undefined, 'thread-1')).toEqual(['hub', 'runs', '', 'thread-1']); + }); + + it('builds the all() key for both projectId and threadId', () => { + expect(hubQueryKeys.runs.all('project-1', 'thread-1')).toEqual(['hub', 'runs', 'project-1', 'thread-1']); + }); + + it('treats empty-string ids as absent and falls back to the root', () => { + expect(hubQueryKeys.runs.all('', '')).toEqual(hubQueryKeys.runs.root); + expect(hubQueryKeys.runs.all('', undefined)).toEqual(hubQueryKeys.runs.root); + }); + + it('builds a detail key from a run id', () => { + expect(hubQueryKeys.runs.detail('run-1')).toEqual(['hub', 'runs', 'detail', 'run-1']); + }); +}); + +// ── edgeQueryKeys: threads ────────────────────────────────────────── + +describe('edgeQueryKeys.threads', () => { + it('defines the threads root', () => { + expect(edgeQueryKeys.threads.root).toEqual(['edge', 'threads']); + }); + + it('collapses missing, undefined, and empty-string projectId to the root key', () => { + expect(edgeQueryKeys.threads.all()).toEqual(edgeQueryKeys.threads.root); + expect(edgeQueryKeys.threads.all(undefined)).toEqual(edgeQueryKeys.threads.root); + expect(edgeQueryKeys.threads.all('')).toEqual(edgeQueryKeys.threads.root); + }); + + it('builds an all() key scoped to a project', () => { + expect(edgeQueryKeys.threads.all('project-1')).toEqual(['edge', 'threads', 'project-1']); + }); + + it('builds an items key with an undefined threadId when omitted', () => { + expect(edgeQueryKeys.threads.items()).toEqual(['edge', 'threadItems', undefined]); + }); + + it('builds an items key from a thread id', () => { + expect(edgeQueryKeys.threads.items('thread-1')).toEqual(['edge', 'threadItems', 'thread-1']); + }); + + it('builds a pins key from a thread id', () => { + expect(edgeQueryKeys.threads.pins('thread-1')).toEqual(['edge', 'threadPins', 'thread-1']); + }); + + it('builds a pins key for a null thread id', () => { + expect(edgeQueryKeys.threads.pins(null)).toEqual(['edge', 'threadPins', null]); + }); +}); + +// ── edgeQueryKeys: runs ───────────────────────────────────────────── + +describe('edgeQueryKeys.runs', () => { + it('defines the runs root', () => { + expect(edgeQueryKeys.runs.root).toEqual(['edge', 'runs']); + }); + + it('keeps undefined slots when no ids are given', () => { + expect(edgeQueryKeys.runs.all()).toEqual(['edge', 'runs', undefined, undefined]); + }); + + it('fills only the projectId slot when only projectId is given', () => { + expect(edgeQueryKeys.runs.all('project-1')).toEqual(['edge', 'runs', 'project-1', undefined]); + }); + + it('builds the all() key for both projectId and threadId', () => { + expect(edgeQueryKeys.runs.all('project-1', 'thread-1')).toEqual(['edge', 'runs', 'project-1', 'thread-1']); + }); +}); + +// ── edgeQueryKeys: agents ─────────────────────────────────────────── + +describe('edgeQueryKeys.agents', () => { + it('defines the agents root', () => { + expect(edgeQueryKeys.agents.root).toEqual(['edge', 'agents']); + }); + + it('defines the agents list key', () => { + expect(edgeQueryKeys.agents.list).toEqual(['edge', 'agents', 'list']); + }); +}); + +// ── edgeQueryKeys: runners ────────────────────────────────────────── + +describe('edgeQueryKeys.runners', () => { + it('defines the runners root', () => { + expect(edgeQueryKeys.runners.root).toEqual(['edge', 'runners']); + }); + + it('defines the runners list key', () => { + expect(edgeQueryKeys.runners.list).toEqual(['edge', 'runners', 'list']); + }); + + it('builds a detail key from a runner id', () => { + expect(edgeQueryKeys.runners.detail('runner-1')).toEqual(['edge', 'runners', 'runner-1']); + }); +}); + +// ── edgeQueryKeys: health / currentUser ───────────────────────────── + +describe('edgeQueryKeys.health', () => { + it('defines the health root', () => { + expect(edgeQueryKeys.health.root).toEqual(['edge', 'health']); + }); +}); + +describe('edgeQueryKeys.currentUser', () => { + it('defines the currentUser root', () => { + expect(edgeQueryKeys.currentUser.root).toEqual(['edge', 'currentUser']); + }); +}); + +// ── isQueryKeyPrefix ──────────────────────────────────────────────── + +describe('isQueryKeyPrefix', () => { + it('returns true when the candidate equals the prefix', () => { + expect(isQueryKeyPrefix(['hub', 'threads'], ['hub', 'threads'])).toBe(true); + }); + + it('returns true when the prefix matches the head of a longer candidate', () => { + expect(isQueryKeyPrefix(['hub', 'threads', 'thread-1', 'messages'], ['hub', 'threads'])).toBe(true); + }); + + it('returns false when the prefix is longer than the candidate', () => { + expect(isQueryKeyPrefix(['hub', 'threads'], ['hub', 'threads', 'detail'])).toBe(false); + }); + + it('returns false when a middle segment mismatches', () => { + expect(isQueryKeyPrefix(['hub', 'agents', 'agent-1'], ['hub', 'threads'])).toBe(false); + }); + + it('returns false when only the first segment matches', () => { + expect(isQueryKeyPrefix(['hub', 'agents'], ['hub', 'threads'])).toBe(false); + }); + + it('accepts an empty prefix for any candidate', () => { + expect(isQueryKeyPrefix(['anything'], [])).toBe(true); + expect(isQueryKeyPrefix([], [])).toBe(true); + }); + + it('rejects a non-empty prefix for an empty candidate', () => { + expect(isQueryKeyPrefix([], ['hub'])).toBe(false); + }); + + it('compares segments strictly, without type coercion', () => { + expect(isQueryKeyPrefix([1, 'a'], [1])).toBe(true); + expect(isQueryKeyPrefix([1, 'a'], ['1'])).toBe(false); + expect(isQueryKeyPrefix(['1', 'a'], [1])).toBe(false); + }); + + it('treats null and undefined as distinct segments', () => { + expect(isQueryKeyPrefix([null, 'x'], [null])).toBe(true); + expect(isQueryKeyPrefix([undefined, 'x'], [undefined])).toBe(true); + expect(isQueryKeyPrefix([null], [undefined])).toBe(false); + }); + + it('matches real keys against their hub roots for invalidation', () => { + expect(isQueryKeyPrefix(hubQueryKeys.threads.detail('thread-1'), hubQueryKeys.threads.root)).toBe(true); + expect(isQueryKeyPrefix(hubQueryKeys.threads.messages('thread-1'), hubQueryKeys.threads.root)).toBe(true); + expect(isQueryKeyPrefix(hubQueryKeys.agentTeams.runState('team-1', 'run-1'), hubQueryKeys.agentTeams.root)).toBe( + true, + ); + expect(isQueryKeyPrefix(hubQueryKeys.runs.detail('run-1'), hubQueryKeys.runs.root)).toBe(true); + expect(isQueryKeyPrefix(hubQueryKeys.projects.threadMessages('project-1', 'thread-1'), hubQueryKeys.projects.root)).toBe( + true, + ); + expect(isQueryKeyPrefix(hubQueryKeys.relayCommands.detail('command-1'), hubQueryKeys.relayCommands.root)).toBe(true); + }); + + it('does not cross-match hub and edge keys', () => { + expect(isQueryKeyPrefix(edgeQueryKeys.threads.root, hubQueryKeys.threads.root)).toBe(false); + expect(isQueryKeyPrefix(hubQueryKeys.threads.root, edgeQueryKeys.threads.root)).toBe(false); + }); +}); + +// ── rootPrefix ────────────────────────────────────────────────────── + +describe('rootPrefix', () => { + it('returns the same empty array for an empty key', () => { + const emptyKey: readonly unknown[] = []; + expect(rootPrefix(emptyKey)).toBe(emptyKey); + }); + + it('returns the same single-segment key unchanged', () => { + const singleKey = ['hub'] as const; + expect(rootPrefix(singleKey)).toBe(singleKey); + }); + + it('returns the same two-segment key unchanged', () => { + const twoSegmentKey = ['hub', 'threads'] as const; + expect(rootPrefix(twoSegmentKey)).toBe(twoSegmentKey); + }); + + it('returns a fresh array with the first two segments for longer keys', () => { + const longKey = ['hub', 'threads', 'thread-1', 'messages'] as const; + const prefix = rootPrefix(longKey); + expect(prefix).toEqual(['hub', 'threads']); + expect(prefix).not.toBe(longKey); + }); + + it('derives the broad invalidation prefix from hub detail keys', () => { + expect(rootPrefix(hubQueryKeys.threads.messages('thread-1'))).toEqual(['hub', 'threads']); + expect(rootPrefix(hubQueryKeys.agentTeams.runState('team-1', 'run-1'))).toEqual(['hub', 'agent-teams']); + expect(rootPrefix(hubQueryKeys.runs.detail('run-1'))).toEqual(['hub', 'runs']); + }); + + it('derives the broad invalidation prefix from edge keys', () => { + expect(rootPrefix(edgeQueryKeys.threads.pins('thread-1'))).toEqual(['edge', 'threadPins']); + expect(rootPrefix(edgeQueryKeys.threads.items())).toEqual(['edge', 'threadItems']); + expect(rootPrefix(edgeQueryKeys.runs.all('project-1'))).toEqual(['edge', 'runs']); + }); +}); diff --git a/app/shared/src/transcript/edgeEventMappersAgents.test.ts b/app/shared/src/transcript/edgeEventMappersAgents.test.ts new file mode 100644 index 000000000..ca89dbb87 --- /dev/null +++ b/app/shared/src/transcript/edgeEventMappersAgents.test.ts @@ -0,0 +1,635 @@ +// real_tested=true +import { describe, expect, it, vi } from 'vitest'; +import type { EventEnvelope, EventScope } from '../events'; +import { AGENT_AUTHOR } from './edgeEventEvidence'; +import { + agentResultBlock, + childAgentBlock, + compactBoundaryBlock, + contextUsageBlock, + routeDecisionBlock, + subagentBlock, + subtaskBlock, +} from './edgeEventMappersAgents'; +import type { + CompactBoundaryTranscriptBlock, + ContextUsageTranscriptBlock, + SubagentTranscriptBlock, + SubtaskTranscriptBlock, +} from './types'; + +function edgeEvent( + id: string, + seq: number, + type: string, + payload: Record, + sentAt = `2026-06-07T03:00:0${seq}Z`, + scopeOverrides: EventScope = {}, +): EventEnvelope { + return { + version: 'v1', + id, + seq, + type, + scope: { + threadId: 'thread-live', + runId: typeof payload.runId === 'string' ? payload.runId : undefined, + ...scopeOverrides, + }, + sentAt, + payload, + }; +} + +describe('subagentBlock', () => { + it('maps primary payload fields into a subagent block with run evidence', () => { + expect( + subagentBlock( + edgeEvent('evt-sub', 1, 'run.agent.subtask', { + runId: 'run-1', + taskRunId: 'task-1', + title: 'Research', + worker: 'w1', + status: 'queued', + summary: ' Done ', + agentId: 'agent-7', + agentName: 'Researcher', + }), + ), + ).toEqual({ + id: 'edge-event-evt-sub', + author: { id: 'agent-7', name: 'Researcher', role: 'agent' }, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'pending' }, + ], + kind: 'subagent', + title: 'Research', + worker: 'w1', + status: 'pending', + summary: 'Done', + runId: 'task-1', + }); + }); + + it('falls back to legacy field names for run id, title, worker, and summary', () => { + const raw = subagentBlock( + edgeEvent('evt-legacy', 2, 'run.agent.subtask', { + runId: 'run-2', + taskId: 'task-9', + task: 'Survey', + workerName: 'w2', + content: ' partial ', + id: 'payload-5', + }), + ); + expect(raw).not.toBeNull(); + const block = raw as SubagentTranscriptBlock; + expect(block.kind).toBe('subagent'); + expect(block.title).toBe('Survey'); + expect(block.worker).toBe('w2'); + expect(block.summary).toBe('partial'); + expect(block.runId).toBe('task-9'); + expect(block.status).toBe('running'); + }); + + it('falls back through agent and agentName for the worker and name for the title', () => { + expect( + subagentBlock( + edgeEvent('evt-a', 3, 'run.agent.subtask', { runId: 'run-3', title: 'T3', agent: 'agent-x', status: 'succeeded' }), + ), + ).toMatchObject({ kind: 'subagent', worker: 'agent-x', status: 'completed' }); + + expect( + subagentBlock( + edgeEvent('evt-b', 4, 'run.agent.subtask', { runId: 'run-4', title: 'T4', agentName: 'a-9' }), + ), + ).toMatchObject({ kind: 'subagent', worker: 'a-9' }); + + expect( + subagentBlock( + edgeEvent('evt-c', 5, 'run.agent.subtask', { runId: 'run-5', name: 'Named task', worker: 'w' }), + ), + ).toMatchObject({ kind: 'subagent', title: 'Named task' }); + }); + + it('returns null when the title is missing', () => { + expect( + subagentBlock(edgeEvent('evt-t', 6, 'run.agent.subtask', { runId: 'run-6', worker: 'w' })), + ).toBeNull(); + }); + + it('returns null when the worker is missing', () => { + expect( + subagentBlock(edgeEvent('evt-w', 7, 'run.agent.subtask', { runId: 'run-7', title: 'T' })), + ).toBeNull(); + }); + + it('treats whitespace-only title and worker as missing', () => { + expect( + subagentBlock( + edgeEvent('evt-ws', 8, 'run.agent.subtask', { runId: 'run-8', title: ' ', worker: '\t ' }), + ), + ).toBeNull(); + }); + + it('omits the summary and runId keys when no summary-like or task id fields are present', () => { + const raw = subagentBlock( + edgeEvent('evt-ns', 9, 'run.agent.subtask', { runId: 'run-9', title: 'T', worker: 'W' }), + ); + expect(raw).not.toBeNull(); + const block = raw as SubagentTranscriptBlock; + expect(block.summary).toBeUndefined(); + expect(block.runId).toBeUndefined(); + }); + + it('reads the run id from scope when the payload has none', () => { + const raw = subagentBlock( + edgeEvent('evt-sc', 10, 'run.agent.subtask', { title: 'T', worker: 'W' }, undefined, { + runId: 'scope-run', + }), + ); + expect(raw).not.toBeNull(); + const block = raw as SubagentTranscriptBlock; + expect(block.evidenceRefs).toEqual([ + { id: 'run-scope-run', kind: 'run', label: 'Run scope-run', status: 'running' }, + ]); + }); + + it('omits createdAt when the envelope has no sentAt', () => { + const raw = subagentBlock( + edgeEvent('evt-no-sent', 11, 'run.agent.subtask', { runId: 'run-11', title: 'T', worker: 'W' }, ''), + ); + expect(raw).not.toBeNull(); + const block = raw as SubagentTranscriptBlock; + expect(block.createdAt).toBeUndefined(); + }); +}); + +describe('subtaskBlock', () => { + it('maps primary fields into a subtask block', () => { + expect( + subtaskBlock( + edgeEvent('evt-st', 1, 'run.agent.subtask', { + runId: 'run-1', + taskRunId: 'st-1', + title: 'Do it', + worker: 'W', + status: 'running', + summary: 'S', + }), + ), + ).toEqual({ + id: 'edge-event-evt-st', + author: { id: 'w', name: 'W', role: 'agent' }, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'subtask', + title: 'Do it', + worker: 'W', + status: 'running', + summary: 'S', + runId: 'st-1', + }); + }); + + it('produces a minimal block from a bare title without worker, summary, or evidence', () => { + const raw = subtaskBlock( + edgeEvent('evt-min', 2, 'run.agent.subtask', { title: 'Only title' }), + ); + expect(raw).not.toBeNull(); + const block = raw as SubtaskTranscriptBlock; + expect(block).toMatchObject({ kind: 'subtask', title: 'Only title', status: 'running' }); + expect(block.worker).toBeUndefined(); + expect(block.summary).toBeUndefined(); + expect(block.runId).toBeUndefined(); + expect(block.evidenceRefs).toBeUndefined(); + }); + + it('maps edge-server task shapes via description and agentName', () => { + expect( + subtaskBlock( + edgeEvent('evt-edge', 3, 'run.agent.subtask', { + runId: 'run-3', + description: 'Do the thing', + agentName: 'bot-1', + status: 'streaming', + }), + ), + ).toMatchObject({ + kind: 'subtask', + title: 'Do the thing', + worker: 'bot-1', + status: 'running', + }); + }); + + it('falls back to content for the summary', () => { + expect( + subtaskBlock( + edgeEvent('evt-c', 4, 'run.agent.subtask', { runId: 'run-4', title: 'T', content: ' body ' }), + ), + ).toMatchObject({ kind: 'subtask', title: 'T', summary: 'body' }); + }); + + it('falls back to name/progress titles and agentId run ids', () => { + expect( + subtaskBlock( + edgeEvent('evt-n', 5, 'run.agent.subtask', { runId: 'run-5', name: 'Named', agentId: 'ag-2' }), + ), + ).toMatchObject({ kind: 'subtask', title: 'Named', runId: 'ag-2' }); + + expect( + subtaskBlock( + edgeEvent('evt-p', 6, 'run.agent.subtask', { runId: 'run-6', progress: 'In progress', agentName: 'b2' }), + ), + ).toMatchObject({ kind: 'subtask', title: 'In progress', worker: 'b2' }); + }); + + it('returns null when no title-like field is present', () => { + expect( + subtaskBlock(edgeEvent('evt-none', 7, 'run.agent.subtask', { runId: 'run-7', worker: 'W' })), + ).toBeNull(); + }); +}); + +describe('childAgentBlock', () => { + it('maps primary fields into a child agent block', () => { + expect( + childAgentBlock( + edgeEvent('evt-ca', 1, 'run.agent.child', { + runId: 'run-1', + childRunId: 'c-1', + parentRunId: 'p-1', + title: 'Child', + agent: 'a-1', + status: 'completed', + summary: 'ok', + }), + ), + ).toEqual({ + id: 'edge-event-evt-ca', + author: { id: 'a-1', name: 'a-1', role: 'agent' }, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'completed' }, + ], + kind: 'child_agent', + title: 'Child', + agent: 'a-1', + status: 'completed', + summary: 'ok', + runId: 'c-1', + parentRunId: 'p-1', + }); + }); + + it('falls back to childId, task, agentName, error summary, and the parent run id', () => { + expect( + childAgentBlock( + edgeEvent('evt-ca2', 2, 'run.agent.child', { + runId: 'run-2', + childId: 'c-2', + task: 'Fallback task', + agentName: 'a-2', + error: 'oops', + }), + ), + ).toMatchObject({ + kind: 'child_agent', + title: 'Fallback task', + agent: 'a-2', + summary: 'oops', + runId: 'c-2', + parentRunId: 'run-2', + status: 'running', + }); + }); + + it('returns null without a title', () => { + expect( + childAgentBlock(edgeEvent('evt-ca3', 3, 'run.agent.child', { runId: 'run-3', agent: 'a' })), + ).toBeNull(); + }); + + it('returns null without an agent', () => { + expect( + childAgentBlock(edgeEvent('evt-ca4', 4, 'run.agent.child', { runId: 'run-4', title: 'T' })), + ).toBeNull(); + }); +}); + +describe('routeDecisionBlock', () => { + it('maps action, summary, and targetAgent', () => { + expect( + routeDecisionBlock( + edgeEvent('evt-rd', 1, 'run.agent.route_decision', { + runId: 'run-1', + action: 'route', + summary: 'Route summary', + targetAgent: 'next', + }), + ), + ).toEqual({ + id: 'edge-event-evt-rd', + author: AGENT_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'route_decision', + action: 'route', + summary: 'Route summary', + targetAgent: 'next', + }); + }); + + it('falls back to kind, instructions, and nextWorker', () => { + expect( + routeDecisionBlock( + edgeEvent('evt-rd2', 2, 'run.agent.route_decision', { + runId: 'run-2', + kind: 'approve', + instructions: 'do it', + nextWorker: 'w-9', + }), + ), + ).toMatchObject({ + kind: 'route_decision', + action: 'approve', + summary: 'do it', + targetAgent: 'w-9', + }); + }); + + it('falls back to reasoning for the summary', () => { + expect( + routeDecisionBlock( + edgeEvent('evt-rd3', 3, 'run.agent.route_decision', { + runId: 'run-3', + action: 'escalate', + reasoning: 'because', + }), + ), + ).toMatchObject({ kind: 'route_decision', action: 'escalate', summary: 'because' }); + }); + + it('returns null when neither action nor kind is present', () => { + expect( + routeDecisionBlock( + edgeEvent('evt-rd4', 4, 'run.agent.route_decision', { runId: 'run-4', summary: 'no action' }), + ), + ).toBeNull(); + }); +}); + +describe('contextUsageBlock', () => { + it('maps all primary fields and formats a numeric cost', () => { + expect( + contextUsageBlock( + edgeEvent('evt-cu', 1, 'run.agent.context_usage', { + runId: 'run-1', + inputTokens: 1000, + outputTokens: 500, + contextLimit: 6000, + totalTokens: 1500, + usagePercent: 25, + cachePercent: 10, + cost: 0.42, + modelLabel: 'gpt-5', + }), + ), + ).toEqual({ + id: 'edge-event-evt-cu', + author: AGENT_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'context_usage', + inputTokens: 1000, + outputTokens: 500, + usagePercent: 25, + contextLimit: 6000, + cachePercent: 10, + cost: '$0.42', + modelLabel: 'gpt-5', + }); + }); + + it('computes usagePercent from totals when not provided', () => { + const raw = contextUsageBlock( + edgeEvent('evt-cu2', 2, 'run.agent.context_usage', { + runId: 'run-2', + inputTokens: 100, + outputTokens: 50, + contextLimit: 300, + }), + ); + expect(raw).not.toBeNull(); + const block = raw as ContextUsageTranscriptBlock; + expect(block.usagePercent).toBeCloseTo(50); + expect(block.cost).toBeUndefined(); + expect(block.cachePercent).toBeUndefined(); + }); + + it('falls back to short field names and coerces numeric strings', () => { + const raw = contextUsageBlock( + edgeEvent('evt-cu3', 3, 'run.agent.context_usage', { + runId: 'run-3', + input: '200', + output: '100', + limit: 1000, + total: 300, + cacheHitPercent: 5, + totalCost: 1.5, + provider: 'anthropic', + }), + ); + expect(raw).not.toBeNull(); + const block = raw as ContextUsageTranscriptBlock; + expect(block.inputTokens).toBe(200); + expect(block.outputTokens).toBe(100); + expect(block.contextLimit).toBe(1000); + expect(block.usagePercent).toBeCloseTo(30); + expect(block.cachePercent).toBe(5); + expect(block.cost).toBe('$1.50'); + expect(block.modelLabel).toBe('anthropic'); + }); + + it('defaults missing output tokens to zero and skips usagePercent without a limit', () => { + const raw = contextUsageBlock( + edgeEvent('evt-cu4', 4, 'run.agent.context_usage', { runId: 'run-4', inputTokens: 10 }), + ); + expect(raw).not.toBeNull(); + const block = raw as ContextUsageTranscriptBlock; + expect(block.inputTokens).toBe(10); + expect(block.outputTokens).toBe(0); + expect(block.usagePercent).toBeUndefined(); + }); + + it('accepts a zero token count as present', () => { + const raw = contextUsageBlock( + edgeEvent('evt-cu5', 5, 'run.agent.context_usage', { runId: 'run-5', inputTokens: 0 }), + ); + expect(raw).not.toBeNull(); + const block = raw as ContextUsageTranscriptBlock; + expect(block.inputTokens).toBe(0); + expect(block.outputTokens).toBe(0); + }); + + it('keeps string costs verbatim', () => { + expect( + contextUsageBlock( + edgeEvent('evt-cu6', 6, 'run.agent.context_usage', { runId: 'run-6', inputTokens: 1, cost: 'free tier' }), + ), + ).toMatchObject({ kind: 'context_usage', cost: 'free tier' }); + }); + + it('returns null when no token counts are present or they are not finite', () => { + expect( + contextUsageBlock( + edgeEvent('evt-cu7', 7, 'run.agent.context_usage', { runId: 'run-7', modelLabel: 'x' }), + ), + ).toBeNull(); + expect( + contextUsageBlock( + edgeEvent('evt-cu8', 8, 'run.agent.context_usage', { + runId: 'run-8', + inputTokens: Number.NaN, + outputTokens: Number.NaN, + }), + ), + ).toBeNull(); + }); +}); + +describe('agentResultBlock', () => { + it('maps a successful result with summary, duration, and turns', () => { + expect( + agentResultBlock( + edgeEvent('evt-res', 1, 'run.agent.result', { + runId: 'run-1', + success: true, + summary: 'All good', + duration: '3s', + turns: 4, + }), + ), + ).toEqual({ + id: 'edge-event-evt-res', + author: AGENT_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'completed' }, + ], + kind: 'result', + success: true, + summary: 'All good', + duration: '3s', + turns: 4, + }); + }); + + it('generates a default summary for a bare success', () => { + expect( + agentResultBlock(edgeEvent('evt-res2', 2, 'run.agent.result', { runId: 'run-2', success: true })), + ).toMatchObject({ kind: 'result', success: true, summary: 'Run run-2 result received' }); + }); + + it('marks failure and appends the error to the default summary', () => { + expect( + agentResultBlock( + edgeEvent('evt-res3', 3, 'run.agent.result', { runId: 'run-3', success: false, error: 'boom' }), + ), + ).toMatchObject({ + kind: 'result', + success: false, + summary: 'Run run-3 result failed: boom', + evidenceRefs: [ + { id: 'run-run-3', kind: 'run', label: 'Run run-3', status: 'failed' }, + ], + }); + }); + + it('generates a default summary for a bare failure', () => { + expect( + agentResultBlock(edgeEvent('evt-res4', 4, 'run.agent.result', { runId: 'run-4', success: false })), + ).toMatchObject({ kind: 'result', success: false, summary: 'Run run-4 result failed' }); + }); + + it('falls back to content for the summary', () => { + expect( + agentResultBlock( + edgeEvent('evt-res5', 5, 'run.agent.result', { + runId: 'run-5', + success: true, + content: ' content result ', + }), + ), + ).toMatchObject({ kind: 'result', summary: 'content result' }); + }); + + it('derives a duration label from durationMs', () => { + expect( + agentResultBlock( + edgeEvent('evt-res6', 6, 'run.agent.result', { runId: 'run-6', success: true, durationMs: 65000 }), + ), + ).toMatchObject({ kind: 'result', duration: '1m5s' }); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + agentResultBlock(edgeEvent('evt-res7', 7, 'run.agent.result', { success: true, summary: 'x' })), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'normalizeEdgeEvents: run.agent.result missing runId', + { eventId: 'evt-res7' }, + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('compactBoundaryBlock', () => { + it('maps trigger and preTokens', () => { + expect( + compactBoundaryBlock( + edgeEvent('evt-cb', 1, 'run.agent.compact_boundary', { runId: 'run-1', trigger: 'auto', preTokens: 8000 }), + ), + ).toEqual({ + id: 'edge-event-evt-cb', + author: AGENT_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'compact_boundary', + trigger: 'auto', + preTokens: 8000, + }); + }); + + it('falls back to pre_tokens', () => { + const raw = compactBoundaryBlock( + edgeEvent('evt-cb2', 2, 'run.agent.compact_boundary', { runId: 'run-2', pre_tokens: '4000' }), + ); + expect(raw).toMatchObject({ kind: 'compact_boundary', preTokens: 4000 }); + const block = raw as CompactBoundaryTranscriptBlock; + expect(block.trigger).toBeUndefined(); + }); + + it('produces a minimal block from an empty payload', () => { + expect(compactBoundaryBlock(edgeEvent('evt-cb3', 3, 'run.agent.compact_boundary', {}))).toEqual({ + id: 'edge-event-evt-cb3', + author: AGENT_AUTHOR, + createdAt: '2026-06-07T03:00:03Z', + kind: 'compact_boundary', + }); + }); +}); diff --git a/app/shared/src/transcript/edgeEventMappersRun.test.ts b/app/shared/src/transcript/edgeEventMappersRun.test.ts new file mode 100644 index 000000000..62746fe8f --- /dev/null +++ b/app/shared/src/transcript/edgeEventMappersRun.test.ts @@ -0,0 +1,574 @@ +// real_tested=true +import { describe, expect, it, vi } from 'vitest'; +import type { EventEnvelope, EventScope } from '../events'; +import { EDGE_AUTHOR } from './edgeEventEvidence'; +import { + agentTextBlock, + outputBatchTextBlock, + outputTextBlock, + runCancelledBlock, + runFailedBlock, + runFinishedBlock, + runStatusBlock, + runTextBlock, + thinkingBlock, +} from './edgeEventMappersRun'; +import type { + FailureTranscriptBlock, + FinishedTranscriptBlock, + ThinkingTranscriptBlock, +} from './types'; + +const STDIN_WARNING = + 'Warning: no stdin data received in 3s, proceeding without it. If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.'; + +function edgeEvent( + id: string, + seq: number, + type: string, + payload: Record, + sentAt = `2026-06-07T03:00:0${seq}Z`, + scopeOverrides: EventScope = {}, +): EventEnvelope { + return { + version: 'v1', + id, + seq, + type, + scope: { + threadId: 'thread-live', + runId: typeof payload.runId === 'string' ? payload.runId : undefined, + ...scopeOverrides, + }, + sentAt, + payload, + }; +} + +describe('runTextBlock', () => { + it('builds a text block with the given action and status', () => { + expect( + runTextBlock( + edgeEvent('evt-rt', 1, 'run.started', { runId: 'run-1', startedAt: '2026-06-07T03:00:01Z' }), + 'started', + 'running', + ), + ).toEqual({ + id: 'edge-event-evt-rt', + author: EDGE_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'text', + text: 'Run run-1 started', + }); + }); + + it('honours a custom status passed by the caller', () => { + expect( + runTextBlock(edgeEvent('evt-rt2', 2, 'run.queued', { runId: 'run-2' }), 'queued', 'pending'), + ).toMatchObject({ + kind: 'text', + text: 'Run run-2 queued', + evidenceRefs: [ + { id: 'run-run-2', kind: 'run', label: 'Run run-2', status: 'pending' }, + ], + }); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(runTextBlock(edgeEvent('evt-rt3', 3, 'run.started', {}), 'started', 'running')).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + 'normalizeEdgeEvents: run lifecycle event missing runId', + { type: 'run.started', eventId: 'evt-rt3' }, + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('runStatusBlock', () => { + it('maps a status change into a text block with normalized evidence', () => { + expect( + runStatusBlock( + edgeEvent('evt-rs', 1, 'run.status.changed', { runId: 'run-1', status: 'completed' }), + ), + ).toEqual({ + id: 'edge-event-evt-rs', + author: EDGE_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'completed' }, + ], + kind: 'text', + text: 'Run run-1 completed', + }); + }); + + it('keeps unknown status text verbatim and normalizes evidence to running', () => { + expect( + runStatusBlock( + edgeEvent('evt-rs2', 2, 'run.status.changed', { runId: 'run-2', status: 'throttled' }), + ), + ).toMatchObject({ + kind: 'text', + text: 'Run run-2 throttled', + evidenceRefs: [ + { id: 'run-run-2', kind: 'run', label: 'Run run-2', status: 'running' }, + ], + }); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + runStatusBlock(edgeEvent('evt-rs3', 3, 'run.status.changed', { status: 'running' })), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it('returns null and warns when the status is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + runStatusBlock(edgeEvent('evt-rs4', 4, 'run.status.changed', { runId: 'run-4' })), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); + + it('returns null and warns when the status is whitespace-only', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + runStatusBlock(edgeEvent('evt-rs5', 5, 'run.status.changed', { runId: 'run-5', status: ' ' })), + ).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('runFailedBlock', () => { + it('maps a run failure with a reason', () => { + expect( + runFailedBlock(edgeEvent('evt-rf', 1, 'run.failed', { runId: 'run-1', reason: 'crashed' })), + ).toEqual({ + id: 'edge-event-evt-rf', + author: EDGE_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'failed' }, + ], + kind: 'failure', + title: 'Run run-1 failed', + runId: 'run-1', + reason: 'crashed', + }); + }); + + it('falls back to a string error for the reason', () => { + expect( + runFailedBlock(edgeEvent('evt-rf2', 2, 'run.failed', { runId: 'run-2', error: 'ERR-2' })), + ).toMatchObject({ kind: 'failure', title: 'Run run-2 failed', reason: 'ERR-2' }); + }); + + it('extracts the message from an error object payload', () => { + expect( + runFailedBlock( + edgeEvent('evt-rf3', 3, 'run.failed', { runId: 'run-3', error: { message: 'inner failure' } }), + ), + ).toMatchObject({ kind: 'failure', reason: 'inner failure' }); + }); + + it('falls back to a top-level message for the reason', () => { + expect( + runFailedBlock(edgeEvent('evt-rf4', 4, 'run.failed', { runId: 'run-4', message: 'plain message' })), + ).toMatchObject({ kind: 'failure', reason: 'plain message' }); + }); + + it('omits the reason key when no reason-like field is present', () => { + const raw = runFailedBlock(edgeEvent('evt-rf5', 5, 'run.failed', { runId: 'run-5' })); + expect(raw).not.toBeNull(); + const block = raw as FailureTranscriptBlock; + expect(block.reason).toBeUndefined(); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(runFailedBlock(edgeEvent('evt-rf6', 6, 'run.failed', { reason: 'x' }))).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('runCancelledBlock', () => { + it('maps a run cancellation with a reason', () => { + expect( + runCancelledBlock( + edgeEvent('evt-rc', 1, 'run.cancelled', { runId: 'run-1', reason: 'user aborted' }), + ), + ).toMatchObject({ + kind: 'failure', + title: 'Run run-1 cancelled', + runId: 'run-1', + reason: 'user aborted', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'failed' }, + ], + }); + }); + + it('extracts the reason from an error object payload', () => { + expect( + runCancelledBlock( + edgeEvent('evt-rc2', 2, 'run.cancelled', { runId: 'run-2', error: { reason: 'inner reason' } }), + ), + ).toMatchObject({ kind: 'failure', title: 'Run run-2 cancelled', reason: 'inner reason' }); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(runCancelledBlock(edgeEvent('evt-rc3', 3, 'run.cancelled', { reason: 'x' }))).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('runFinishedBlock', () => { + it('maps a finished run with a duration string', () => { + expect( + runFinishedBlock(edgeEvent('evt-fin', 1, 'run.finished', { runId: 'run-1', duration: '10s' })), + ).toEqual({ + id: 'edge-event-evt-fin', + author: EDGE_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'completed' }, + ], + kind: 'finished', + title: 'Run run-1 finished', + runId: 'run-1', + duration: '10s', + }); + }); + + it('derives a duration label from durationMs', () => { + expect( + runFinishedBlock(edgeEvent('evt-fin2', 2, 'run.finished', { runId: 'run-2', durationMs: 1500 })), + ).toMatchObject({ kind: 'finished', title: 'Run run-2 finished', duration: '1.5s' }); + }); + + it('omits the duration key when neither duration field is present', () => { + const raw = runFinishedBlock(edgeEvent('evt-fin3', 3, 'run.finished', { runId: 'run-3' })); + expect(raw).not.toBeNull(); + const block = raw as FinishedTranscriptBlock; + expect(block.duration).toBeUndefined(); + }); + + it('returns null and warns when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect(runFinishedBlock(edgeEvent('evt-fin4', 4, 'run.finished', { duration: '1s' }))).toBeNull(); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('outputTextBlock', () => { + it('trims and maps run output text', () => { + expect( + outputTextBlock( + edgeEvent('evt-out', 1, 'run.output', { runId: 'run-1', stream: 'stdout', text: ' hello ' }), + ), + ).toEqual({ + id: 'edge-event-evt-out', + author: EDGE_AUTHOR, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'text', + text: 'hello', + }); + }); + + it('falls back to the event id for evidence when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + outputTextBlock(edgeEvent('evt-fb', 2, 'run.output', { text: 'orphan output' })), + ).toMatchObject({ + kind: 'text', + text: 'orphan output', + evidenceRefs: [ + { id: 'run-evt-fb', kind: 'run', label: 'Run evt-fb', status: 'running' }, + ], + }); + expect(warnSpy).toHaveBeenCalledWith( + 'normalizeEdgeEvents: run.output missing runId, using event.id as fallback evidenceRef', + { eventId: 'evt-fb' }, + ); + } finally { + warnSpy.mockRestore(); + } + }); + + it('returns null when no text is present', () => { + expect(outputTextBlock(edgeEvent('evt-out3', 3, 'run.output', { runId: 'run-3' }))).toBeNull(); + }); + + it('returns null for whitespace-only text', () => { + expect( + outputTextBlock(edgeEvent('evt-out4', 4, 'run.output', { runId: 'run-4', text: ' ' })), + ).toBeNull(); + }); + + it('returns null for runtime diagnostic text', () => { + expect( + outputTextBlock( + edgeEvent('evt-out5', 5, 'run.output', { runId: 'run-5', stream: 'stderr', text: STDIN_WARNING }), + ), + ).toBeNull(); + }); +}); + +describe('outputBatchTextBlock', () => { + it('joins chunk texts into one text block', () => { + const raw = outputBatchTextBlock( + edgeEvent('evt-ob', 1, 'run.output.batch', { + runId: 'run-1', + chunks: [{ offset: 0, text: 'a' }, { offset: 1, text: 'b' }], + }), + ); + expect(raw).toMatchObject({ kind: 'text', text: 'ab' }); + expect(raw?.author).toEqual(EDGE_AUTHOR); + }); + + it('skips non-record chunks', () => { + expect( + outputBatchTextBlock( + edgeEvent('evt-ob2', 2, 'run.output.batch', { + runId: 'run-2', + chunks: ['junk', { text: 'x' }, null, 42, { other: 'y' }], + }), + ), + ).toMatchObject({ kind: 'text', text: 'x' }); + }); + + it('returns null when chunks is not an array', () => { + expect( + outputBatchTextBlock(edgeEvent('evt-ob3', 3, 'run.output.batch', { runId: 'run-3', chunks: 'oops' })), + ).toBeNull(); + }); + + it('returns null when the joined text is empty', () => { + expect( + outputBatchTextBlock( + edgeEvent('evt-ob4', 4, 'run.output.batch', { + runId: 'run-4', + chunks: [{ text: ' ' }, { text: '' }], + }), + ), + ).toBeNull(); + }); + + it('returns null when the joined text is a runtime diagnostic', () => { + expect( + outputBatchTextBlock( + edgeEvent('evt-ob5', 5, 'run.output.batch', { + runId: 'run-5', + chunks: [ + { text: 'Warning: no stdin data received in 3s, ' }, + { text: 'proceeding without it.' }, + ], + }), + ), + ).toBeNull(); + }); + + it('falls back to the event id for evidence when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + outputBatchTextBlock( + edgeEvent('evt-ob6', 6, 'run.output.batch', { chunks: [{ text: 'batch orphan' }] }), + ), + ).toMatchObject({ + kind: 'text', + text: 'batch orphan', + evidenceRefs: [ + { id: 'run-evt-ob6', kind: 'run', label: 'Run evt-ob6', status: 'running' }, + ], + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('agentTextBlock', () => { + it('maps content into a text block with the default agent author', () => { + expect( + agentTextBlock( + edgeEvent('evt-at', 1, 'run.agent.text_block', { runId: 'run-1', content: ' hi ' }), + ), + ).toEqual({ + id: 'edge-event-evt-at', + author: { id: 'agent', name: 'Agent', role: 'agent' }, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'text', + text: 'hi', + }); + }); + + it('falls back to the text field for content', () => { + expect( + agentTextBlock( + edgeEvent('evt-at2', 2, 'run.agent.text_block', { runId: 'run-2', text: 'fallback text' }), + ), + ).toMatchObject({ kind: 'text', text: 'fallback text' }); + }); + + it('returns null for empty content', () => { + expect( + agentTextBlock(edgeEvent('evt-at3', 3, 'run.agent.text_block', { runId: 'run-3', content: '' })), + ).toBeNull(); + }); + + it('returns null for runtime diagnostic content', () => { + expect( + agentTextBlock( + edgeEvent('evt-at4', 4, 'run.agent.text_block', { runId: 'run-4', content: STDIN_WARNING }), + ), + ).toBeNull(); + }); + + it('derives the author from payload agent identity fields', () => { + expect( + agentTextBlock( + edgeEvent('evt-at5', 5, 'run.agent.text_block', { + runId: 'run-5', + content: 'msg', + agentId: 'ag-1', + agentName: 'Nova', + }), + ), + ).toMatchObject({ + kind: 'text', + author: { id: 'ag-1', name: 'Nova', role: 'agent' }, + }); + }); + + it('falls back to the event id for evidence when the run id is missing', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + expect( + agentTextBlock(edgeEvent('evt-at6', 6, 'run.agent.text_block', { content: 'no run' })), + ).toMatchObject({ + kind: 'text', + text: 'no run', + evidenceRefs: [ + { id: 'run-evt-at6', kind: 'run', label: 'Run evt-at6', status: 'running' }, + ], + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('thinkingBlock', () => { + it('maps thinking content with a default running status', () => { + expect( + thinkingBlock(edgeEvent('evt-th', 1, 'run.agent.thinking', { runId: 'run-1', content: 'hmm' })), + ).toEqual({ + id: 'edge-event-evt-th', + author: { id: 'agent', name: 'Agent', role: 'agent' }, + createdAt: '2026-06-07T03:00:01Z', + evidenceRefs: [ + { id: 'run-run-1', kind: 'run', label: 'Run run-1', status: 'running' }, + ], + kind: 'thinking', + content: 'hmm', + isThinking: true, + }); + }); + + it('flags completed thinking as no longer active', () => { + const raw = thinkingBlock( + edgeEvent('evt-th2', 2, 'run.agent.thinking', { runId: 'run-2', content: 'done', status: 'completed' }), + ); + expect(raw).not.toBeNull(); + const block = raw as ThinkingTranscriptBlock; + expect(block.isThinking).toBe(false); + expect(block.evidenceRefs).toEqual([ + { id: 'run-run-2', kind: 'run', label: 'Run run-2', status: 'completed' }, + ]); + }); + + it('normalizes failure statuses', () => { + const raw = thinkingBlock( + edgeEvent('evt-th3', 3, 'run.agent.thinking', { runId: 'run-3', content: 'x', status: 'failed' }), + ); + expect(raw).not.toBeNull(); + const block = raw as ThinkingTranscriptBlock; + expect(block.isThinking).toBe(false); + expect(block.evidenceRefs).toEqual([ + { id: 'run-run-3', kind: 'run', label: 'Run run-3', status: 'failed' }, + ]); + }); + + it('returns null when the content is empty', () => { + expect(thinkingBlock(edgeEvent('evt-th4', 4, 'run.agent.thinking', { runId: 'run-4' }))).toBeNull(); + expect( + thinkingBlock(edgeEvent('evt-th5', 5, 'run.agent.thinking', { runId: 'run-5', content: ' ' })), + ).toBeNull(); + }); + + it('omits evidence when there is no run id', () => { + const raw = thinkingBlock( + edgeEvent('evt-th6', 6, 'run.agent.thinking', { content: 'scope-less' }), + ); + expect(raw).not.toBeNull(); + const block = raw as ThinkingTranscriptBlock; + expect(block.evidenceRefs).toBeUndefined(); + expect(block.isThinking).toBe(true); + }); + + it('maps queued status to pending and flags thinking as inactive', () => { + const raw = thinkingBlock( + edgeEvent('evt-th7', 7, 'run.agent.thinking', { runId: 'run-7', content: 'y', status: 'queued' }), + ); + expect(raw).not.toBeNull(); + const block = raw as ThinkingTranscriptBlock; + expect(block.isThinking).toBe(false); + expect(block.evidenceRefs).toEqual([ + { id: 'run-run-7', kind: 'run', label: 'Run run-7', status: 'pending' }, + ]); + }); +});