diff --git a/app/shared/src/chatview/adapterMapBlock.test.ts b/app/shared/src/chatview/adapterMapBlock.test.ts new file mode 100644 index 000000000..63fd400df --- /dev/null +++ b/app/shared/src/chatview/adapterMapBlock.test.ts @@ -0,0 +1,898 @@ +// real_tested=true +import { describe, it, expect } from 'vitest' + +import { mapBlock } from './adapterMapBlock' +import { SEP } from './adapterShared' +import type { RowItem } from './types' +import type { TranscriptBlock } from '../transcript/types' +import { makeAuthor } from './adapter-test-helpers' + +/** + * Assert that `mapBlock` produces a row and return it typed for further checks. + * Using a dedicated helper keeps every test focused on the mapping semantics. + */ +function mapRow(block: TranscriptBlock): RowItem { + const row = mapBlock(block) + expect(row).not.toBeNull() + return row as RowItem +} + +describe('mapBlock — thinking blocks', () => { + it('maps an in-progress thinking block to a running think row', () => { + const row = mapRow({ + id: 'th1', kind: 'thinking', author: makeAuthor('a1'), content: 'Plan the fix', isThinking: true, + }) + expect(row).toEqual({ + id: 'th1', type: 'think', label: '', status: 'running', collapsible: true, content: 'Plan the fix', + }) + }) + + it('maps a finished thinking block to an ok think row', () => { + const row = mapRow({ + id: 'th2', kind: 'thinking', author: makeAuthor('a1'), content: 'Done', isThinking: false, + }) + expect(row.status).toBe('ok') + expect(row.type).toBe('think') + }) + + it('treats a missing isThinking flag as finished (ok)', () => { + const row = mapRow({ id: 'th3', kind: 'thinking', author: makeAuthor('a1'), content: 'Done' }) + expect(row.status).toBe('ok') + }) + + it('falls back to empty content when content is missing', () => { + const row = mapRow({ id: 'th4', kind: 'thinking', author: makeAuthor('a1'), isThinking: true }) + expect(row.content).toBe('') + }) +}) + +describe('mapBlock — tool_call blocks', () => { + it('maps a running tool call to a running tool row with lowercased toolName', () => { + const row = mapRow({ + id: 'tc1', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + }) + expect(row).toEqual({ + id: 'tc1', type: 'tool', label: 'Read', status: 'running', collapsible: true, + toolName: 'read', content: undefined, + }) + }) + + it('maps a completed tool call to ok', () => { + const row = mapRow({ + id: 'tc2', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'completed', + }) + expect(row.status).toBe('ok') + }) + + it('maps a failed tool call to fail', () => { + const row = mapRow({ + id: 'tc3', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'failed', + }) + expect(row.status).toBe('fail') + }) + + it('treats a running tool call with completed evidenceRefs as ok', () => { + const row = mapRow({ + id: 'tc4', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + evidenceRefs: [{ id: 'er1', kind: 'tool', label: 'Read', status: 'completed' }], + }) + expect(row.status).toBe('ok') + }) + + it('keeps fail status even when evidenceRefs show completion', () => { + const row = mapRow({ + id: 'tc5', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'failed', + evidenceRefs: [{ id: 'er1', kind: 'tool', label: 'Read', status: 'completed' }], + }) + expect(row.status).toBe('fail') + }) + + it('ignores non-completed evidenceRefs when deriving status', () => { + const row = mapRow({ + id: 'tc6', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + evidenceRefs: [ + { id: 'er1', kind: 'tool', label: 'Read', status: 'running' }, + { id: 'er2', kind: 'tool', label: 'Read', status: 'failed' }, + ], + }) + expect(row.status).toBe('running') + }) + + it('falls back to unknown toolName when toolName is missing', () => { + const row = mapRow({ + id: 'tc7', kind: 'tool_call', author: makeAuthor('a1'), + toolName: undefined as unknown as string, status: 'running', + }) + expect(row.toolName).toBe('unknown') + expect(row.label).toBe('unknown') + }) + + it('carries the callId as toolCallId when present', () => { + const row = mapRow({ + id: 'tc8', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + callId: 'call-42', + }) + expect(row.toolCallId).toBe('call-42') + }) + + it('omits toolCallId when callId is absent', () => { + const row = mapRow({ + id: 'tc9', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + }) + expect(row.toolCallId).toBeUndefined() + }) + + it('uses summary as content and drops the extra target when summary exists', () => { + const row = mapRow({ + id: 'tc10', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + target: 'src/a.ts', summary: 'Read src/a.ts', + }) + expect(row.content).toBe('Read src/a.ts') + expect(row.extra).toBeUndefined() + }) + + it('falls back to target for both content and extra when summary is absent', () => { + const row = mapRow({ + id: 'tc11', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + target: 'src/a.ts', + }) + expect(row.content).toBe('src/a.ts') + expect(row.extra).toBe('src/a.ts') + }) + + it('falls back to target when summary is an empty string', () => { + const row = mapRow({ + id: 'tc12', kind: 'tool_call', author: makeAuthor('a1'), toolName: 'Read', status: 'running', + target: 'src/a.ts', summary: '', + }) + expect(row.content).toBe('src/a.ts') + expect(row.extra).toBe('src/a.ts') + }) +}) + +describe('mapBlock — tool_result blocks', () => { + it('maps a completed tool result to an ok result tool row', () => { + const row = mapRow({ + id: 'tr1', kind: 'tool_result', author: makeAuthor('a1'), toolName: 'Read', status: 'completed', + summary: 'file contents', + }) + expect(row).toEqual({ + id: 'tr1', type: 'tool', label: 'Read', status: 'ok', collapsible: true, + toolName: 'read', content: 'file contents', isResult: true, + }) + }) + + it('maps a failed tool result to fail', () => { + const row = mapRow({ + id: 'tr2', kind: 'tool_result', author: makeAuthor('a1'), toolName: 'Bash', status: 'failed', + summary: 'exit 1', + }) + expect(row.status).toBe('fail') + }) + + it('maps a pending tool result to running', () => { + const row = mapRow({ + id: 'tr3', kind: 'tool_result', author: makeAuthor('a1'), toolName: 'Bash', status: 'pending', + }) + expect(row.status).toBe('running') + }) + + it('falls back to unknown toolName when toolName is missing', () => { + const row = mapRow({ + id: 'tr4', kind: 'tool_result', author: makeAuthor('a1'), + toolName: undefined as unknown as string, status: 'completed', summary: 'ok', + }) + expect(row.toolName).toBe('unknown') + expect(row.label).toBe('unknown') + }) + + it('carries the callId as toolCallId when present', () => { + const row = mapRow({ + id: 'tr5', kind: 'tool_result', author: makeAuthor('a1'), toolName: 'Read', status: 'completed', + callId: 'call-7', + }) + expect(row.toolCallId).toBe('call-7') + }) + + it('leaves content undefined when summary is absent', () => { + const row = mapRow({ + id: 'tr6', kind: 'tool_result', author: makeAuthor('a1'), toolName: 'Read', status: 'completed', + }) + expect(row.content).toBeUndefined() + }) +}) + +describe('mapBlock — file_change blocks', () => { + it('maps a created file with extension-derived content', () => { + const row = mapRow({ + id: 'fc1', kind: 'file_change', author: makeAuthor('a1'), path: 'src/new.ts', action: 'created', + }) + expect(row).toEqual({ + id: 'fc1', type: 'file', label: '', extra: 'src/new.ts', status: 'ok', collapsible: true, + fileOp: 'cr', content: 'TS', diffLines: undefined, + }) + }) + + it('maps a modified file to the mod fileOp', () => { + const row = mapRow({ + id: 'fc2', kind: 'file_change', author: makeAuthor('a1'), path: 'src/old.ts', action: 'modified', + }) + expect(row.fileOp).toBe('mod') + expect(row.content).toBe('TS') + }) + + it('maps a deleted file to the del fileOp', () => { + const row = mapRow({ + id: 'fc3', kind: 'file_change', author: makeAuthor('a1'), path: 'src/gone.ts', action: 'deleted', + }) + expect(row.fileOp).toBe('del') + }) + + it('converts a patch into typed diff lines', () => { + const row = mapRow({ + id: 'fc4', kind: 'file_change', author: makeAuthor('a1'), path: 'src/a.ts', action: 'modified', + patch: '@@ -1 +1 @@\n-old\n+new\n same', + }) + expect(row.diffLines).toEqual([ + { type: 'ctx', text: '@@ -1 +1 @@' }, + { type: 'del', text: '-old' }, + { type: 'add', text: '+new' }, + { type: 'ctx', text: ' same' }, + ]) + }) + + it('omits diffLines when there is no patch', () => { + const row = mapRow({ + id: 'fc5', kind: 'file_change', author: makeAuthor('a1'), path: 'src/a.ts', action: 'modified', + }) + expect(row.diffLines).toBeUndefined() + }) + + it('uppercases the whole name for an extensionless path', () => { + const row = mapRow({ + id: 'fc6', kind: 'file_change', author: makeAuthor('a1'), path: 'README', action: 'modified', + }) + expect(row.content).toBe('README') + }) + + it('produces empty content for a missing path', () => { + const row = mapRow({ + id: 'fc7', kind: 'file_change', author: makeAuthor('a1'), + path: undefined as unknown as string, action: 'modified', + }) + expect(row.content).toBe('') + expect(row.extra).toBeUndefined() + }) +}) + +describe('mapBlock — artifact blocks', () => { + it('joins path, uri and mimeType into extra with the display separator', () => { + const row = mapRow({ + id: 'a1', kind: 'artifact', author: makeAuthor('a1'), title: 'Report', path: 'out/report.pdf', + uri: 'https://example.com/report.pdf', mimeType: 'application/pdf', action: 'created', + }) + expect(row).toEqual({ + id: 'a1', type: 'file', label: '', + extra: `out/report.pdf${SEP}https://example.com/report.pdf${SEP}application/pdf`, + status: 'ok', collapsible: true, fileOp: 'cr', content: 'PDF', + }) + }) + + it('maps a deleted artifact to the del fileOp', () => { + const row = mapRow({ + id: 'a2', kind: 'artifact', author: makeAuthor('a1'), title: 'x.pdf', action: 'deleted', + }) + expect(row.fileOp).toBe('del') + }) + + it('maps a modified artifact to the mod fileOp', () => { + const row = mapRow({ + id: 'a3', kind: 'artifact', author: makeAuthor('a1'), title: 'x.pdf', action: 'modified', + }) + expect(row.fileOp).toBe('mod') + }) + + it('falls back to the title for extra and content when path is missing', () => { + const row = mapRow({ + id: 'a4', kind: 'artifact', author: makeAuthor('a1'), title: 'design.png', action: 'created', + }) + expect(row.extra).toBe('design.png') + expect(row.content).toBe('PNG') + }) + + it('filters absent uri and mimeType out of extra', () => { + const row = mapRow({ + id: 'a5', kind: 'artifact', author: makeAuthor('a1'), title: 'notes.md', action: 'created', + }) + expect(row.extra).toBe('notes.md') + }) + + it('falls back to artifactKind for content when the title is empty', () => { + const row = mapRow({ + id: 'a6', kind: 'artifact', author: makeAuthor('a1'), title: '', artifactKind: 'pdf', action: 'created', + }) + expect(row.content).toBe('pdf') + }) + + it('produces empty content when there is no name or artifactKind', () => { + const row = mapRow({ + id: 'a7', kind: 'artifact', author: makeAuthor('a1'), title: '', action: 'created', + }) + expect(row.content).toBe('') + expect(row.extra).toBe('') + }) +}) + +describe('mapBlock — diff blocks', () => { + it('maps a diff with stats derived from files and counts', () => { + const row = mapRow({ + id: 'd1', kind: 'diff', author: makeAuthor('a1'), title: 'PR #1', + files: ['src/x.ts'], additions: 12, deletions: 3, + }) + expect(row).toEqual({ + id: 'd1', type: 'file', label: 'PR #1', extra: 'src/x.ts', status: 'ok', collapsible: true, + fileOp: 'mod', content: 'TS +12 -3', + }) + }) + + it('omits addition and deletion stats when they are undefined', () => { + const row = mapRow({ + id: 'd2', kind: 'diff', author: makeAuthor('a1'), title: 'PR #2', files: ['src/y.ts'], + }) + expect(row.content).toBe('TS') + }) + + it('falls back to empty extra and content for a missing files array entry', () => { + const row = mapRow({ + id: 'd3', kind: 'diff', author: makeAuthor('a1'), title: 'PR #3', files: [] as string[], + }) + expect(row.extra).toBe('') + expect(row.content).toBe('') + }) + + it('uppercases an extensionless first file name', () => { + const row = mapRow({ + id: 'd4', kind: 'diff', author: makeAuthor('a1'), title: 'PR #4', files: ['Makefile'], + }) + expect(row.content).toBe('Makefile'.toUpperCase()) + }) + + it('converts a patch into typed diff lines', () => { + const row = mapRow({ + id: 'd5', kind: 'diff', author: makeAuthor('a1'), title: 'PR #5', files: ['src/x.ts'], + patch: '-old\n+new', + }) + expect(row.diffLines).toEqual([ + { type: 'del', text: '-old' }, + { type: 'add', text: '+new' }, + ]) + }) +}) + +describe('mapBlock — approval blocks', () => { + it('joins toolName, risk and reason into apReason and carries the risk level', () => { + const row = mapRow({ + id: 'ap1', kind: 'approval', author: makeAuthor('a1'), title: 'Run command', + status: 'pending', toolName: 'Bash', risk: 'high', reason: 'Deletes files', + }) + expect(row).toEqual({ + id: 'ap1', type: 'approval', label: 'Run command', status: 'running', + collapsible: true, standalone: true, + apReason: `Bash${SEP}high${SEP}Deletes files`, riskLevel: 'high', + }) + }) + + it('maps a completed approval to ok', () => { + const row = mapRow({ + id: 'ap2', kind: 'approval', author: makeAuthor('a1'), title: 'Confirm', status: 'completed', + }) + expect(row.status).toBe('ok') + }) + + it('maps a failed approval to fail', () => { + const row = mapRow({ + id: 'ap3', kind: 'approval', author: makeAuthor('a1'), title: 'Confirm', status: 'failed', + }) + expect(row.status).toBe('fail') + }) + + it('falls back to the title in apReason when reason is absent', () => { + const row = mapRow({ + id: 'ap4', kind: 'approval', author: makeAuthor('a1'), title: 'Deploy to production', status: 'pending', + }) + expect(row.apReason).toBe('Deploy to production') + expect(row.riskLevel).toBeUndefined() + }) + + it('omits the risk part when risk is absent', () => { + const row = mapRow({ + id: 'ap5', kind: 'approval', author: makeAuthor('a1'), title: 'Confirm', + status: 'pending', toolName: 'Write', reason: 'Needs approval', + }) + expect(row.apReason).toBe(`Write${SEP}Needs approval`) + expect(row.riskLevel).toBeUndefined() + }) +}) + +describe('mapBlock — permission_request blocks', () => { + it('always maps to waiting regardless of the pending status field', () => { + const row = mapRow({ + id: 'pr1', kind: 'permission_request', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Allow write', status: 'pending', + }) + expect(row.type).toBe('approval') + expect(row.status).toBe('waiting') + expect(row.standalone).toBe(true) + expect(row.label).toBe('Allow write') + }) + + it('joins toolName, risk and reason into apReason', () => { + const row = mapRow({ + id: 'pr2', kind: 'permission_request', author: makeAuthor('a1'), + requestId: 'req-2', title: 'Allow write', status: 'pending', + toolName: 'Write', risk: 'medium', reason: 'Sensitive file', + }) + expect(row.apReason).toBe(`Write${SEP}medium${SEP}Sensitive file`) + expect(row.riskLevel).toBe('medium') + }) + + it('falls back to the title in apReason when toolName, risk and reason are absent', () => { + const row = mapRow({ + id: 'pr3', kind: 'permission_request', author: makeAuthor('a1'), + requestId: 'req-3', title: 'Access request', status: 'pending', + }) + expect(row.apReason).toBe('Access request') + }) +}) + +describe('mapBlock — permission_result blocks', () => { + it('maps a completed permission result to ok', () => { + const row = mapRow({ + id: 'ps1', kind: 'permission_result', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Allowed', status: 'completed', decision: 'allow', + }) + expect(row.type).toBe('approval') + expect(row.status).toBe('ok') + expect(row.label).toBe('Allowed') + expect(row.standalone).toBe(true) + }) + + it('maps a failed permission result to fail', () => { + const row = mapRow({ + id: 'ps2', kind: 'permission_result', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Denied', status: 'failed', decision: 'deny', + }) + expect(row.status).toBe('fail') + }) + + it('maps a pending permission result to running', () => { + const row = mapRow({ + id: 'ps3', kind: 'permission_result', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Pending', status: 'pending', decision: 'allow', + }) + expect(row.status).toBe('running') + }) + + it('joins toolName and reason into apReason and ignores the decision field', () => { + const row = mapRow({ + id: 'ps4', kind: 'permission_result', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Allowed write', status: 'completed', + decision: 'allow', toolName: 'Write', reason: 'Approved by user', + }) + expect(row.apReason).toBe(`Write${SEP}Approved by user`) + }) + + it('falls back to the title in apReason when reason is absent', () => { + const row = mapRow({ + id: 'ps5', kind: 'permission_result', author: makeAuthor('a1'), + requestId: 'req-1', title: 'Allowed write', status: 'completed', + decision: 'allow', toolName: 'Write', + }) + expect(row.apReason).toBe(`Write${SEP}Allowed write`) + }) +}) + +describe('mapBlock — run_session blocks', () => { + it('maps a completed session with all tags', () => { + const row = mapRow({ + id: 'rs1', kind: 'run_session', author: makeAuthor('a1'), title: 'Nightly build', + status: 'completed', agentLabel: 'builder', runtimeLabel: 'docker', meta: 'cron', + }) + expect(row).toEqual({ + id: 'rs1', type: 'session', label: 'Nightly build', status: 'ok', + collapsible: true, standalone: true, + sessionTags: ['Agent: builder', 'Runtime: docker', 'cron'], + }) + }) + + it('defaults a missing status to completed and maps it to ok', () => { + const row = mapRow({ + id: 'rs2', kind: 'run_session', author: makeAuthor('a1'), title: 'Minimal session', + }) + expect(row.status).toBe('ok') + }) + + it('maps a failed session to fail', () => { + const row = mapRow({ + id: 'rs3', kind: 'run_session', author: makeAuthor('a1'), title: 'Broken', status: 'failed', + }) + expect(row.status).toBe('fail') + }) + + it('maps a running session to running', () => { + const row = mapRow({ + id: 'rs4', kind: 'run_session', author: makeAuthor('a1'), title: 'Active', status: 'running', + }) + expect(row.status).toBe('running') + }) + + it('filters out absent tags and empty meta', () => { + const row = mapRow({ + id: 'rs5', kind: 'run_session', author: makeAuthor('a1'), title: 'Bare', + status: 'completed', meta: '', + }) + expect(row.sessionTags).toEqual([]) + }) +}) + +describe('mapBlock — subagent blocks', () => { + it('maps a subagent with worker name and summary', () => { + const row = mapRow({ + id: 'sa1', kind: 'subagent', author: makeAuthor('a1'), title: 'Lint code', + worker: 'Linter', status: 'completed', summary: 'No issues', + }) + expect(row).toEqual({ + id: 'sa1', type: 'sub', label: `Agent${SEP}Linter`, status: 'ok', + collapsible: true, content: 'No issues', + }) + }) + + it('falls back to the title for content when summary is absent', () => { + const row = mapRow({ + id: 'sa2', kind: 'subagent', author: makeAuthor('a1'), title: 'Lint code', + worker: 'Linter', status: 'running', + }) + expect(row.content).toBe('Lint code') + }) + + it('falls back to the title in the name when worker is empty', () => { + const row = mapRow({ + id: 'sa3', kind: 'subagent', author: makeAuthor('a1'), title: 'Lint code', + worker: '', status: 'running', + }) + expect(row.label).toBe(`Agent${SEP}Lint code`) + }) + + it('falls back to the title for the label when both worker and title are empty', () => { + const row = mapRow({ + id: 'sa5', kind: 'subagent', author: makeAuthor('a1'), title: '', + worker: '', status: 'running', + }) + expect(row.label).toBe('') + expect(row.content).toBe('') + }) + + it('maps a pending subagent to running', () => { + const row = mapRow({ + id: 'sa4', kind: 'subagent', author: makeAuthor('a1'), title: 'T', worker: 'w', status: 'pending', + }) + expect(row.status).toBe('running') + }) +}) + +describe('mapBlock — subtask blocks', () => { + it('maps a subtask using its worker for the label', () => { + const row = mapRow({ + id: 'st1', kind: 'subtask', author: makeAuthor('a1'), title: 'Test app', + worker: 'Tester', status: 'completed', summary: 'All green', + }) + expect(row.type).toBe('sub') + expect(row.label).toBe(`Agent${SEP}Tester`) + expect(row.content).toBe('All green') + expect(row.status).toBe('ok') + }) + + it('falls back to the title for both label and content when worker and summary are absent', () => { + const row = mapRow({ + id: 'st2', kind: 'subtask', author: makeAuthor('a1'), title: 'Test app', status: 'running', + }) + expect(row.label).toBe(`Agent${SEP}Test app`) + expect(row.content).toBe('Test app') + }) +}) + +describe('mapBlock — child_agent blocks', () => { + it('maps a child agent using its agent field for the label', () => { + const row = mapRow({ + id: 'ca1', kind: 'child_agent', author: makeAuthor('a1'), title: 'Scout repo', + agent: 'Scout', status: 'completed', summary: 'Found 3 issues', + }) + expect(row.type).toBe('sub') + expect(row.label).toBe(`Agent${SEP}Scout`) + expect(row.content).toBe('Found 3 issues') + expect(row.status).toBe('ok') + expect(row.collapsible).toBe(true) + }) + + it('maps a failed child agent to fail', () => { + const row = mapRow({ + id: 'ca2', kind: 'child_agent', author: makeAuthor('a1'), title: 'Scout repo', + agent: 'Scout', status: 'failed', + }) + expect(row.status).toBe('fail') + }) +}) + +describe('mapBlock — route_decision blocks', () => { + it('maps a route decision to a non-collapsible standalone route row', () => { + const row = mapRow({ + id: 'rd1', kind: 'route_decision', author: makeAuthor('a1'), + action: 'Send to Reviewer', summary: 'Needs review', + }) + expect(row).toEqual({ + id: 'rd1', type: 'route', label: 'Send to Reviewer', status: 'ok', + collapsible: false, standalone: true, content: 'Needs review', + }) + }) + + it('leaves content undefined when summary is absent', () => { + const row = mapRow({ + id: 'rd2', kind: 'route_decision', author: makeAuthor('a1'), action: 'Finish', + }) + expect(row.content).toBeUndefined() + }) +}) + +describe('mapBlock — context_usage blocks', () => { + it('maps a full context usage block with all stats', () => { + const row = mapRow({ + id: 'cu1', kind: 'context_usage', author: makeAuthor('a1'), + inputTokens: 1234, outputTokens: 512, usagePercent: 42, + contextLimit: 200000, cachePercent: 25, cost: '2.35', modelLabel: 'gpt-4o', + }) + expect(row).toEqual({ + id: 'cu1', type: 'ctx', label: '', status: 'ok', + collapsible: true, standalone: true, ctxPct: 42, + ctxStats: [ + 'in: 1.2k', + 'out: 0.5k', + 'limit: 200k', + 'cache: 25%', + '2.35', + 'gpt-4o', + ], + }) + }) + + it('defaults ctxPct to 0 when usagePercent is missing', () => { + const row = mapRow({ + id: 'cu2', kind: 'context_usage', author: makeAuthor('a1'), + inputTokens: 1000, outputTokens: 500, + }) + expect(row.ctxPct).toBe(0) + }) + + it('filters out falsy limit, cache, cost and model stats', () => { + const row = mapRow({ + id: 'cu3', kind: 'context_usage', author: makeAuthor('a1'), + inputTokens: 1000, outputTokens: 500, usagePercent: 0, + contextLimit: 0, cachePercent: 0, cost: '', modelLabel: '', + }) + expect(row.ctxPct).toBe(0) + expect(row.ctxStats).toEqual(['in: 1.0k', 'out: 0.5k']) + }) + + it('rounds context limit to whole kilobytes without decimals', () => { + const row = mapRow({ + id: 'cu4', kind: 'context_usage', author: makeAuthor('a1'), + inputTokens: 0, outputTokens: 0, contextLimit: 128000, + }) + expect(row.ctxStats).toEqual(['in: 0.0k', 'out: 0.0k', 'limit: 128k']) + }) +}) + +describe('mapBlock — deploy blocks', () => { + it('maps a ready deploy with full metadata', () => { + const row = mapRow({ + id: 'dp1', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-1', + status: 'ready', deployType: 'static', path: '/out', artifactId: 'art-1', + url: 'https://site.example.com', + }) + expect(row).toEqual({ + id: 'dp1', type: 'deploy', label: '', status: 'ok', + collapsible: true, standalone: true, + url: 'https://site.example.com', + deployMeta: `ready${SEP}static${SEP}/out${SEP}art-1`, + }) + }) + + it('falls back to a Deployed meta when all meta parts are absent', () => { + const row = mapRow({ + id: 'dp2', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-2', + }) + expect(row.status).toBe('ok') + expect(row.deployMeta).toBe('Deployed') + expect(row.url).toBeUndefined() + }) + + it('maps a failed deploy to fail', () => { + const row = mapRow({ + id: 'dp3', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-3', status: 'failed', + }) + expect(row.status).toBe('fail') + expect(row.deployMeta).toBe('failed') + }) + + it('maps a pending deploy to running', () => { + const row = mapRow({ + id: 'dp4', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-4', status: 'pending', + }) + expect(row.status).toBe('running') + }) + + it('maps a deploying deploy to running', () => { + const row = mapRow({ + id: 'dp5', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-5', status: 'deploying', + }) + expect(row.status).toBe('running') + }) + + it('maps a deployed deploy to ok', () => { + const row = mapRow({ + id: 'dp6', kind: 'deploy', author: makeAuthor('a1'), runId: 'run-6', status: 'deployed', + }) + expect(row.status).toBe('ok') + }) +}) + +describe('mapBlock — attachment blocks', () => { + it('maps a file attachment with a rounded KB size', () => { + const row = mapRow({ + id: 'at1', kind: 'attachment', author: makeAuthor('a1'), + attachmentRef: { id: 'att-1', name: 'photo.png', size: 2048, mime_type: 'image/png' }, + contentType: 'image', + }) + expect(row).toEqual({ + id: 'at1', type: 'attachment', label: 'photo.png', extra: 'image', status: 'ok', + collapsible: false, standalone: true, + fileName: 'photo.png', fileSize: '2 KB', + }) + }) + + it('omits fileSize for a zero-size attachment', () => { + const row = mapRow({ + id: 'at2', kind: 'attachment', author: makeAuthor('a1'), + attachmentRef: { id: 'att-2', name: 'empty.txt', size: 0, mime_type: 'text/plain' }, + contentType: 'file', + }) + expect(row.fileSize).toBeUndefined() + expect(row.extra).toBe('file') + }) + + it('rounds a fractional KB size to the nearest integer', () => { + const row = mapRow({ + id: 'at3', kind: 'attachment', author: makeAuthor('a1'), + attachmentRef: { id: 'att-3', name: 'doc.md', size: 1536, mime_type: 'text/markdown' }, + contentType: 'file', + }) + expect(row.fileSize).toBe('2 KB') + }) +}) + +describe('mapBlock — failure blocks', () => { + it('uses the reason as content when present', () => { + const row = mapRow({ + id: 'fl1', kind: 'failure', author: makeAuthor('a1'), title: 'Error', reason: 'boom', + }) + expect(row).toEqual({ + id: 'fl1', type: 'think', label: '', status: 'fail', collapsible: true, content: 'boom', + }) + }) + + it('falls back to the title when reason is absent', () => { + const row = mapRow({ + id: 'fl2', kind: 'failure', author: makeAuthor('a1'), title: 'Timed out', + }) + expect(row.content).toBe('Timed out') + }) + + it('falls back to the default failure text when both are empty', () => { + const row = mapRow({ + id: 'fl3', kind: 'failure', author: makeAuthor('a1'), title: '', reason: '', + }) + expect(row.content).toBe('运行失败') + }) +}) + +describe('mapBlock — preview blocks', () => { + it('extracts the domain and derives the title from the URL', () => { + const row = mapRow({ + id: 'pv1', kind: 'preview', author: makeAuthor('a1'), + previewId: 'pv-1', status: 'completed', url: 'https://www.github.com/user/repo', + }) + expect(row).toEqual({ + id: 'pv1', type: 'preview', label: '', status: 'ok', + collapsible: false, standalone: true, + url: 'https://www.github.com/user/repo', + previewDomain: 'github.com', previewTitle: 'repo', + }) + }) + + it('falls back to the previewId as title when the URL is absent', () => { + const row = mapRow({ + id: 'pv2', kind: 'preview', author: makeAuthor('a1'), + previewId: 'pv-2', status: 'pending', + }) + expect(row.previewDomain).toBe('') + expect(row.previewTitle).toBe('pv-2') + expect(row.url).toBeUndefined() + expect(row.status).toBe('running') + }) + + it('maps a failed preview to fail', () => { + const row = mapRow({ + id: 'pv3', kind: 'preview', author: makeAuthor('a1'), + previewId: 'pv-3', status: 'failed', url: 'https://example.com/', + }) + expect(row.status).toBe('fail') + }) + + it('falls back to the domain when the URL path has no segments', () => { + const row = mapRow({ + id: 'pv4', kind: 'preview', author: makeAuthor('a1'), + previewId: 'pv-4', status: 'completed', url: 'https://example.com/', + }) + expect(row.previewDomain).toBe('example.com') + expect(row.previewTitle).toBe('example.com') + }) + + it('survives an invalid URL by echoing it into domain and title', () => { + const row = mapRow({ + id: 'pv5', kind: 'preview', author: makeAuthor('a1'), + previewId: 'pv-5', status: 'completed', url: 'not a valid url', + }) + expect(row.previewDomain).toBe('not a valid url') + expect(row.previewTitle).toBe('not a valid url') + }) +}) + +describe('mapBlock — skipped kinds', () => { + it('returns null for a result block', () => { + expect(mapBlock({ id: 'r1', kind: 'result', author: makeAuthor('a1'), success: true })).toBeNull() + }) + + it('returns null for a finished block', () => { + expect(mapBlock({ id: 'f1', kind: 'finished', author: makeAuthor('a1'), title: 'Done' })).toBeNull() + }) + + it('returns null for a replay_gap block', () => { + expect(mapBlock({ + id: 'rg1', kind: 'replay_gap', author: makeAuthor('a1'), replayedCount: 3, + })).toBeNull() + }) + + it('returns null for an agent_timeline block', () => { + expect(mapBlock({ + id: 'tl1', kind: 'agent_timeline', author: makeAuthor('a1'), + items: [{ label: 'Step', status: 'completed' }], + })).toBeNull() + }) + + it('returns null for a run_step_group block', () => { + expect(mapBlock({ + id: 'g1', kind: 'run_step_group', author: makeAuthor('a1'), + icon: '>', title: 'Group', status: 'completed', children: [], + })).toBeNull() + }) + + it('returns null for a compact_boundary block', () => { + expect(mapBlock({ id: 'cb1', kind: 'compact_boundary', author: makeAuthor('a1') })).toBeNull() + }) + + it('returns null for an unknown block kind', () => { + const unknown = { + id: 'x1', kind: '__unknown_kind__' as unknown as TranscriptBlock['kind'], author: makeAuthor('a1'), + } as TranscriptBlock + expect(mapBlock(unknown)).toBeNull() + }) +}) diff --git a/app/shared/src/hub/hubClientApiExtended.test.ts b/app/shared/src/hub/hubClientApiExtended.test.ts new file mode 100644 index 000000000..8b9ecd960 --- /dev/null +++ b/app/shared/src/hub/hubClientApiExtended.test.ts @@ -0,0 +1,788 @@ +// real_tested=true +import { describe, expect, it } from 'vitest'; + +import { createHubClientExtendedApi } from './hubClientApiExtended'; + +interface RecordedRequestCall { + path: string; + init?: RequestInit; +} + +interface RecordedUploadCall { + path: string; + formData: FormData; +} + +const DEFAULT_BASE_URL = 'https://hub.example.test'; + +function createHarness(overrides?: { + baseUrl?: string; + requestImpl?: (path: string, init?: RequestInit) => Promise; + uploadImpl?: (path: string, formData: FormData) => Promise; +}) { + const requestCalls: RecordedRequestCall[] = []; + const uploadCalls: RecordedUploadCall[] = []; + + async function fakeRequest(path: string, init?: RequestInit): Promise { + requestCalls.push({ path, init }); + if (overrides?.requestImpl) { + return (await overrides.requestImpl(path, init)) as T; + } + return {} as T; + } + + async function fakeUploadMultipart(path: string, formData: FormData): Promise { + uploadCalls.push({ path, formData }); + if (overrides?.uploadImpl) { + return (await overrides.uploadImpl(path, formData)) as T; + } + return {} as T; + } + + const api = createHubClientExtendedApi({ + request: fakeRequest, + uploadMultipart: fakeUploadMultipart, + baseUrl: overrides?.baseUrl ?? DEFAULT_BASE_URL, + }); + + return { api, requestCalls, uploadCalls }; +} + +describe('createHubClientExtendedApi', () => { + describe('workspace projects', () => { + it('lists workspace projects with optional query params', async () => { + const { api, requestCalls } = createHarness(); + + await api.listWorkspaceProjects(); + await api.listWorkspaceProjects(undefined); + await api.listWorkspaceProjects({}); + await api.listWorkspaceProjects({ + pageSize: 25, + pageCursor: 'cursor/1', + q: 'demo project', + }); + await api.listWorkspaceProjects({ pageSize: 0, q: '' }); + + expect(requestCalls).toHaveLength(5); + expect(requestCalls[0]?.path).toBe('/web/projects'); + expect(requestCalls[0]?.init).toBeUndefined(); + expect(requestCalls[1]?.path).toBe('/web/projects'); + expect(requestCalls[2]?.path).toBe('/web/projects'); + expect(requestCalls[3]?.path).toBe( + '/web/projects?pageSize=25&pageCursor=cursor%2F1&q=demo+project', + ); + expect(requestCalls[4]?.path).toBe('/web/projects?pageSize=0&q='); + }); + + it('gets a workspace project by encoded id, including empty ids', async () => { + const { api, requestCalls } = createHarness(); + + await api.getWorkspaceProject('p/1'); + await api.getWorkspaceProject(''); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1'); + expect(requestCalls[0]?.init).toBeUndefined(); + expect(requestCalls[1]?.path).toBe('/web/projects/'); + }); + + it('creates a workspace project via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.createWorkspaceProject({ name: 'Alpha', description: 'first project' }); + await api.createWorkspaceProject({ name: 'Only' }); + + expect(requestCalls[0]?.path).toBe('/web/projects'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ name: 'Alpha', description: 'first project' }), + }); + expect(requestCalls[1]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ name: 'Only' }), + }); + }); + + it('updates a workspace project via JSON PATCH', async () => { + const { api, requestCalls } = createHarness(); + + await api.updateWorkspaceProject('p/1', { name: 'Renamed' }); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PATCH', + body: JSON.stringify({ name: 'Renamed' }), + }); + }); + + it('lists workspace project threads', async () => { + const { api, requestCalls } = createHarness(); + + await api.listWorkspaceProjectThreads('p/1'); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1/threads'); + expect(requestCalls[0]?.init).toBeUndefined(); + }); + + it('creates a workspace project thread via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.createWorkspaceProjectThread('p/1', { name: 'Thread A' }); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1/threads'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ name: 'Thread A' }), + }); + }); + + it('lists thread messages with optional limit', async () => { + const { api, requestCalls } = createHarness(); + + await api.listWorkspaceProjectThreadMessages('p/1', 'th/2'); + await api.listWorkspaceProjectThreadMessages('p/1', 'th/2', { limit: 20 }); + await api.listWorkspaceProjectThreadMessages('p/1', 'th/2', { limit: undefined }); + await api.listWorkspaceProjectThreadMessages('p/1', 'th/2', { limit: 0 }); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1/threads/th%2F2/messages'); + expect(requestCalls[1]?.path).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages?limit=20', + ); + expect(requestCalls[2]?.path).toBe('/web/projects/p%2F1/threads/th%2F2/messages'); + expect(requestCalls[3]?.path).toBe( + '/web/projects/p%2F1/threads/th%2F2/messages?limit=0', + ); + }); + + it('sends a workspace project thread message via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.sendWorkspaceProjectThreadMessage('p/1', 'th/2', { + client_msg_id: 'client/9', + content_type: 'text', + content: 'hello team', + }); + + expect(requestCalls[0]?.path).toBe('/web/projects/p%2F1/threads/th%2F2/messages'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ + client_msg_id: 'client/9', + content_type: 'text', + content: 'hello team', + }), + }); + }); + }); + + describe('message extras (T3.2 parity)', () => { + it('edits a message via JSON PUT', async () => { + const { api, requestCalls } = createHarness(); + + await api.editMessage('msg/1', { content: 'edited content' }); + + expect(requestCalls[0]?.path).toBe('/client/messages/msg%2F1'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PUT', + body: JSON.stringify({ content: 'edited content' }), + }); + }); + + it('adds a message reaction via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.addMessageReaction('msg/1', 'sess/2', { emoji: '👍' }); + + expect(requestCalls[0]?.path).toBe('/client/messages/msg%2F1/reactions'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ session_id: 'sess/2', emoji: '👍' }), + }); + }); + + it('removes a message reaction via JSON DELETE', async () => { + const { api, requestCalls } = createHarness(); + + await api.removeMessageReaction('msg/1', 'sess/2', { emoji: '👍' }); + + expect(requestCalls[0]?.path).toBe('/client/messages/msg%2F1/reactions'); + expect(requestCalls[0]?.init).toEqual({ + method: 'DELETE', + body: JSON.stringify({ session_id: 'sess/2', emoji: '👍' }), + }); + }); + + it('lists message reactions with the session id query param', async () => { + const { api, requestCalls } = createHarness(); + + await api.listMessageReactions('msg/1', 'sess/2'); + + expect(requestCalls[0]?.path).toBe( + '/client/messages/msg%2F1/reactions?session_id=sess%2F2', + ); + expect(requestCalls[0]?.init).toBeUndefined(); + }); + + it('fetches task run summaries, full event lists, and gap-fill events', async () => { + const { api, requestCalls } = createHarness(); + + await api.getTaskRunEventSummary('task/1'); + await api.listTaskRunEvents('task/1'); + await api.listTaskRunEventsAfter('task/1', 7); + await api.listTaskRunEventsAfter('task/1', 0); + await api.listTaskRunEventsAfter('task/1', -3); + await api.listTaskRunEventsAfter('task/1', 9007199254740991); + + expect(requestCalls[0]?.path).toBe('/web/agent-tasks/task%2F1/events/summary'); + expect(requestCalls[1]?.path).toBe('/web/agent-tasks/task%2F1/events'); + expect(requestCalls[2]?.path).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=7&limit=500', + ); + expect(requestCalls[3]?.path).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=0&limit=500', + ); + expect(requestCalls[4]?.path).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=-3&limit=500', + ); + expect(requestCalls[5]?.path).toBe( + '/web/agent-tasks/task%2F1/events?after_seq=9007199254740991&limit=500', + ); + }); + }); + + describe('agent teams', () => { + it('creates an agent team via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.createAgentTeam({ name: 'Team X', description: 'ship it' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ name: 'Team X', description: 'ship it' }), + }); + }); + + it('lists all agent teams', async () => { + const { api, requestCalls } = createHarness(); + + await api.listAgentTeams(); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams'); + expect(requestCalls[0]?.init).toBeUndefined(); + }); + + it('gets an agent team by encoded id', async () => { + const { api, requestCalls } = createHarness(); + + await api.getAgentTeam('team/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1'); + }); + + it('updates an agent team via JSON PUT', async () => { + const { api, requestCalls } = createHarness(); + + await api.updateAgentTeam('team/1', { name: 'Renamed', description: 'new' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PUT', + body: JSON.stringify({ name: 'Renamed', description: 'new' }), + }); + }); + + it('deletes an agent team with a bodyless DELETE init', async () => { + const { api, requestCalls } = createHarness(); + + await api.deleteAgentTeam('team/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1'); + expect(requestCalls[0]?.init).toEqual({ method: 'DELETE' }); + }); + + it('adds an agent team member via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.addAgentTeamMember('team/1', { agent_profile_id: 'profile/9', role: 'executor' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1/members'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ agent_profile_id: 'profile/9', role: 'executor' }), + }); + }); + + it('starts a team run with and without an optional target', async () => { + const { api, requestCalls } = createHarness(); + + await api.startTeamRun('team/1', { trigger_message: 'go', target_id: 'doc/5' }); + await api.startTeamRun('team/1', { trigger_message: 'go' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1/runs'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ trigger_message: 'go', target_id: 'doc/5' }), + }); + expect(requestCalls[1]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ trigger_message: 'go' }), + }); + }); + + it('lists team runs', async () => { + const { api, requestCalls } = createHarness(); + + await api.listTeamRuns('team/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1/runs'); + }); + + it('gets team run details, state, events, and tasks', async () => { + const { api, requestCalls } = createHarness(); + + await api.getTeamRun('team/1', 'run/2'); + await api.getTeamRunState('team/1', 'run/2'); + await api.listTeamEvents('team/1', 'run/2'); + await api.listTeamTasks('team/1', 'run/2'); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1/runs/run%2F2'); + expect(requestCalls[1]?.path).toBe('/web/agent-teams/team%2F1/runs/run%2F2/state'); + expect(requestCalls[2]?.path).toBe('/web/agent-teams/team%2F1/runs/run%2F2/events'); + expect(requestCalls[3]?.path).toBe('/web/agent-teams/team%2F1/runs/run%2F2/tasks'); + }); + + it('decides a team approval via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.decideTeamApproval('team/1', 'run/2', 'approval/3', { + decision: 'allow', + reason: 'looks good', + }); + await api.decideTeamApproval('team/1', 'run/2', 'approval/3', { decision: 'deny' }); + + expect(requestCalls[0]?.path).toBe( + '/web/agent-teams/team%2F1/runs/run%2F2/approvals/approval%2F3/decide', + ); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ decision: 'allow', reason: 'looks good' }), + }); + expect(requestCalls[1]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ decision: 'deny' }), + }); + }); + + it('resolves a team conflict via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.resolveTeamConflict('team/1', 'run/2', 'conflict/3', { + resolution: 'pick-a', + path: 'a', + }); + + expect(requestCalls[0]?.path).toBe( + '/web/agent-teams/team%2F1/runs/run%2F2/conflicts/conflict%2F3/resolve', + ); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ resolution: 'pick-a', path: 'a' }), + }); + }); + + it('removes an agent team member via bodyless DELETE', async () => { + const { api, requestCalls } = createHarness(); + + await api.removeAgentTeamMember('team/1', 'member/9'); + + expect(requestCalls[0]?.path).toBe('/web/agent-teams/team%2F1/members/member%2F9'); + expect(requestCalls[0]?.init).toEqual({ method: 'DELETE' }); + }); + + it('posts a coordinator route decision via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.postTeamRouteDecision('team/1', 'run/2', { + action: 'approve', + next_worker: 'worker/3', + }); + + expect(requestCalls[0]?.path).toBe( + '/web/agent-teams/team%2F1/runs/run%2F2/route-decisions', + ); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ action: 'approve', next_worker: 'worker/3' }), + }); + }); + }); + + describe('agent profiles', () => { + it('lists agent profiles with optional filters', async () => { + const { api, requestCalls } = createHarness(); + + await api.listAgentProfiles(); + await api.listAgentProfiles({ + runtime_id: 'runtime/1', + q: 'search me', + pageCursor: 'cursor/2', + pageSize: 10, + }); + + expect(requestCalls[0]?.path).toBe('/web/agent-profiles'); + expect(requestCalls[1]?.path).toBe( + '/web/agent-profiles?runtime_id=runtime%2F1&q=search+me&pageCursor=cursor%2F2&pageSize=10', + ); + }); + + it('creates an agent profile via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.createAgentProfile({ name: 'Profile A', runtime_id: 'runtime/1' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-profiles'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ name: 'Profile A', runtime_id: 'runtime/1' }), + }); + }); + + it('updates an agent profile via JSON PATCH', async () => { + const { api, requestCalls } = createHarness(); + + await api.updateAgentProfile('profile/1', { name: 'Profile A2' }); + + expect(requestCalls[0]?.path).toBe('/web/agent-profiles/profile%2F1'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PATCH', + body: JSON.stringify({ name: 'Profile A2' }), + }); + }); + + it('deletes an agent profile via bodyless DELETE', async () => { + const { api, requestCalls } = createHarness(); + + await api.deleteAgentProfile('profile/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-profiles/profile%2F1'); + expect(requestCalls[0]?.init).toEqual({ method: 'DELETE' }); + }); + + it('gets an agent profile by encoded id', async () => { + const { api, requestCalls } = createHarness(); + + await api.getAgentProfile('profile/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-profiles/profile%2F1'); + }); + }); + + describe('settings and attachments', () => { + it('fetches the client settings', async () => { + const { api, requestCalls } = createHarness(); + + await api.fetchSettings(); + + expect(requestCalls[0]?.path).toBe('/client/settings'); + expect(requestCalls[0]?.init).toBeUndefined(); + }); + + it('patches settings values including an empty map', async () => { + const { api, requestCalls } = createHarness(); + + await api.patchSettings({ theme: 'dark' }); + await api.patchSettings({}); + + expect(requestCalls[0]?.path).toBe('/client/settings'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PATCH', + body: JSON.stringify({ values: { theme: 'dark' } }), + }); + expect(requestCalls[1]?.init).toEqual({ + method: 'PATCH', + body: JSON.stringify({ values: {} }), + }); + }); + + it('probes an attachment by hash', async () => { + const { api, requestCalls } = createHarness(); + + await api.probeAttachment('abc123'); + + expect(requestCalls[0]?.path).toBe('/client/attachments/probe'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ hash: 'abc123' }), + }); + }); + + it('uploads an attachment as multipart form data', async () => { + const { api, uploadCalls, requestCalls } = createHarness(); + const file = new File(['attachment-bytes'], 'notes.txt', { type: 'text/plain' }); + + await api.uploadAttachment(file, 'abc123'); + + expect(requestCalls).toHaveLength(0); + expect(uploadCalls).toHaveLength(1); + expect(uploadCalls[0]?.path).toBe('/client/attachments'); + const formData = uploadCalls[0]?.formData; + expect(formData?.get('file')).toBe(file); + expect(formData?.get('hash')).toBe('abc123'); + expect(formData?.get('original_name')).toBe('notes.txt'); + }); + + it('builds attachment download URLs from the injected base url', () => { + const defaultApi = createHarness().api; + expect(defaultApi.downloadAttachmentUrl('att/1')).toBe( + 'https://hub.example.test/client/attachments/att%2F1', + ); + expect(defaultApi.downloadAttachmentUrl('')).toBe( + 'https://hub.example.test/client/attachments/', + ); + + // The base url is concatenated verbatim, so a trailing slash is preserved. + const customApi = createHarness({ baseUrl: 'https://custom.example.test/' }).api; + expect(customApi.downloadAttachmentUrl('att/1')).toBe( + 'https://custom.example.test//client/attachments/att%2F1', + ); + }); + }); + + describe('documents', () => { + it('lists documents with optional filters', async () => { + const { api, requestCalls } = createHarness(); + + await api.listDocuments(); + await api.listDocuments({ + status: 'active', + source: 'upload', + tag: 'demo', + pageCursor: 'cursor/2', + pageSize: 50, + }); + + expect(requestCalls[0]?.path).toBe('/web/documents'); + expect(requestCalls[1]?.path).toBe( + '/web/documents?status=active&source=upload&tag=demo&pageCursor=cursor%2F2&pageSize=50', + ); + }); + + it('gets a document by encoded id', async () => { + const { api, requestCalls } = createHarness(); + + await api.getDocument('doc/1'); + + expect(requestCalls[0]?.path).toBe('/web/documents/doc%2F1'); + }); + + it('creates a document via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.createDocument({ title: 'Doc A', content: 'hello', location: '/tmp/doc-a' }); + + expect(requestCalls[0]?.path).toBe('/web/documents'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ title: 'Doc A', content: 'hello', location: '/tmp/doc-a' }), + }); + }); + + it('updates a document via JSON PATCH', async () => { + const { api, requestCalls } = createHarness(); + + await api.updateDocument('doc/1', { title: 'Doc A2', status: 'archived' }); + + expect(requestCalls[0]?.path).toBe('/web/documents/doc%2F1'); + expect(requestCalls[0]?.init).toEqual({ + method: 'PATCH', + body: JSON.stringify({ title: 'Doc A2', status: 'archived' }), + }); + }); + + it('deletes a document via bodyless DELETE', async () => { + const { api, requestCalls } = createHarness(); + + await api.deleteDocument('doc/1'); + + expect(requestCalls[0]?.path).toBe('/web/documents/doc%2F1'); + expect(requestCalls[0]?.init).toEqual({ method: 'DELETE' }); + }); + }); + + describe('task stream events', () => { + it('streams a task event with optional run and client message ids', async () => { + const { api, requestCalls } = createHarness(); + + await api.streamTaskEvent('task/1', 'progress', { pct: 50 }); + await api.streamTaskEvent('task/1', 'progress', { pct: 50 }, { + runId: 'run/9', + clientMsgId: 'client/8', + }); + // Falsy option values are omitted from the body; null payloads are kept. + await api.streamTaskEvent('task/1', 'progress', null, { runId: '', clientMsgId: '' }); + + expect(requestCalls[0]?.path).toBe('/edge/agent-tasks/task%2F1/stream'); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ event_type: 'progress', payload: { pct: 50 } }), + }); + expect(requestCalls[1]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ + event_type: 'progress', + payload: { pct: 50 }, + run_id: 'run/9', + client_msg_id: 'client/8', + }), + }); + expect(requestCalls[2]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ event_type: 'progress', payload: null }), + }); + }); + }); + + describe('task approvals and artifacts (T3.4)', () => { + it('lists task approvals', async () => { + const { api, requestCalls } = createHarness(); + + await api.listTaskApprovals('task/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-tasks/task%2F1/approvals'); + }); + + it('decides a task approval via JSON POST', async () => { + const { api, requestCalls } = createHarness(); + + await api.decideTaskApproval('task/1', 'approval/2', { decision: 'allow', reason: 'fine' }); + + expect(requestCalls[0]?.path).toBe( + '/web/agent-tasks/task%2F1/approvals/approval%2F2/decide', + ); + expect(requestCalls[0]?.init).toEqual({ + method: 'POST', + body: JSON.stringify({ decision: 'allow', reason: 'fine' }), + }); + }); + + it('lists task artifacts', async () => { + const { api, requestCalls } = createHarness(); + + await api.listTaskArtifacts('task/1'); + + expect(requestCalls[0]?.path).toBe('/web/agent-tasks/task%2F1/artifacts'); + }); + }); + + describe('transport wiring', () => { + it('exposes the complete extended API surface', () => { + const { api } = createHarness(); + + expect(Object.keys(api).sort()).toEqual([ + 'addAgentTeamMember', + 'addMessageReaction', + 'createAgentProfile', + 'createAgentTeam', + 'createDocument', + 'createWorkspaceProject', + 'createWorkspaceProjectThread', + 'decideTaskApproval', + 'decideTeamApproval', + 'deleteAgentProfile', + 'deleteAgentTeam', + 'deleteDocument', + 'downloadAttachmentUrl', + 'editMessage', + 'fetchSettings', + 'getAgentProfile', + 'getAgentTeam', + 'getDocument', + 'getTaskRunEventSummary', + 'getTeamRun', + 'getTeamRunState', + 'getWorkspaceProject', + 'listAgentProfiles', + 'listAgentTeams', + 'listDocuments', + 'listMessageReactions', + 'listTaskApprovals', + 'listTaskArtifacts', + 'listTaskRunEvents', + 'listTaskRunEventsAfter', + 'listTeamEvents', + 'listTeamRuns', + 'listTeamTasks', + 'listWorkspaceProjectThreadMessages', + 'listWorkspaceProjectThreads', + 'listWorkspaceProjects', + 'patchSettings', + 'postTeamRouteDecision', + 'probeAttachment', + 'removeAgentTeamMember', + 'removeMessageReaction', + 'resolveTeamConflict', + 'sendWorkspaceProjectThreadMessage', + 'startTeamRun', + 'streamTaskEvent', + 'updateAgentProfile', + 'updateAgentTeam', + 'updateDocument', + 'updateWorkspaceProject', + 'uploadAttachment', + ]); + }); + + it('resolves direct GET responses through the injected transport', async () => { + const marker = { id: 'p/1', name: 'Project' }; + const { api } = createHarness({ requestImpl: async () => marker }); + + await expect(api.getWorkspaceProject('p/1')).resolves.toBe(marker); + }); + + it('resolves path+init responses through the injected transport', async () => { + const marker = { id: 'team/1', name: 'Team' }; + const { api } = createHarness({ requestImpl: async () => marker }); + + await expect(api.createAgentTeam({ name: 'Team' })).resolves.toBe(marker); + }); + + it('resolves multipart upload responses through the injected upload transport', async () => { + const marker = { id: 'att/1', hash: 'abc123', size: 3, mime_type: 'text/plain' }; + const file = new File(['abc'], 'a.txt'); + const { api } = createHarness({ uploadImpl: async () => marker }); + + await expect(api.uploadAttachment(file, 'abc123')).resolves.toBe(marker); + }); + + it('propagates rejections from direct GET methods', async () => { + const { api } = createHarness({ + requestImpl: async () => { + throw new Error('network down'); + }, + }); + + await expect(api.listAgentTeams()).rejects.toThrow('network down'); + }); + + it('propagates rejections from path+init methods', async () => { + const { api } = createHarness({ + requestImpl: async () => { + throw new Error('teapot'); + }, + }); + + await expect(api.deleteAgentTeam('team/1')).rejects.toThrow('teapot'); + }); + + it('propagates rejections from multipart uploads', async () => { + const file = new File(['abc'], 'a.txt'); + const { api } = createHarness({ + uploadImpl: async () => { + throw new Error('upload failed'); + }, + }); + + await expect(api.uploadAttachment(file, 'abc123')).rejects.toThrow('upload failed'); + }); + }); +}); diff --git a/app/shared/src/hub/hubClientTransportBasics.test.ts b/app/shared/src/hub/hubClientTransportBasics.test.ts new file mode 100644 index 000000000..b5282da68 --- /dev/null +++ b/app/shared/src/hub/hubClientTransportBasics.test.ts @@ -0,0 +1,286 @@ +// real_tested=true — every export exercised directly; the only global mocked is +// `fetch`, stubbed per-test with vi.stubGlobal for the resolveHubFetch lookup path. +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AppError } from '../errors'; +import { + DEFAULT_HUB_TIMEOUT_MS, + applyBearerAuth, + applyDefaultJsonContentType, + applyRefreshedBearerAuth, + buildHubFetchInit, + buildHubUrl, + buildMultipartFetchInit, + createAuthOnlyHeaders, + createJsonAuthHeaders, + createNetworkAppError, + createTimeoutAppError, + isAbortError, + isNetworkFetchTypeError, + normalizeHubBaseUrl, + requestMethodOf, + resolveHubFetch, + resolveHubTimeoutMs, + shouldAttemptTokenRefresh, + toReportableError, +} from './hubClientTransportBasics'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('hubClientTransportBasics (#1102)', () => { + it('exports the shared hub default timeout', () => { + expect(DEFAULT_HUB_TIMEOUT_MS).toBe(30_000); + }); + + it('classifies abort errors strictly by DOMException instance and name', () => { + expect(isAbortError(new DOMException('Aborted', 'AbortError'))).toBe(true); + expect(isAbortError(new DOMException('Some other message', 'AbortError'))).toBe(true); + expect(isAbortError(new DOMException('Aborted', 'TimeoutError'))).toBe(false); + expect(isAbortError(new Error('AbortError'))).toBe(false); + expect(isAbortError({ name: 'AbortError' })).toBe(false); + expect(isAbortError('AbortError')).toBe(false); + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + }); + + it('classifies network fetch TypeErrors by case-sensitive message substring', () => { + expect(isNetworkFetchTypeError(new TypeError('Failed to fetch'))).toBe(true); + expect(isNetworkFetchTypeError(new TypeError('network fetch failed'))).toBe(true); + expect(isNetworkFetchTypeError(new TypeError('connection refused'))).toBe(false); + // Substring match is case-sensitive: capitalized "Fetch" does not match. + expect(isNetworkFetchTypeError(new TypeError('Failed to Fetch'))).toBe(false); + expect(isNetworkFetchTypeError(new Error('Failed to fetch'))).toBe(false); + expect(isNetworkFetchTypeError('Failed to fetch')).toBe(false); + expect(isNetworkFetchTypeError(null)).toBe(false); + }); + + it('builds TIMEOUT AppError with status 0 and stable message format', () => { + const error = createTimeoutAppError({ + timeoutMs: 12_000, + method: 'POST', + path: '/web/projects', + }); + expect(error).toBeInstanceOf(AppError); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe('AppError'); + expect(error.code).toBe('TIMEOUT'); + expect(error.status).toBe(0); + expect(error.message).toBe('Request timed out after 12000ms: POST /web/projects'); + expect(error.rawBody).toEqual({ + error: { + code: 'TIMEOUT', + message: 'Request timed out after 12000ms: POST /web/projects', + }, + }); + + const zeroTimeout = createTimeoutAppError({ timeoutMs: 0, method: 'GET', path: '/x' }); + expect(zeroTimeout.message).toBe('Request timed out after 0ms: GET /x'); + }); + + it('builds NETWORK_ERROR AppError with status 0 and message prefix', () => { + const error = createNetworkAppError('Failed to fetch'); + expect(error).toBeInstanceOf(AppError); + expect(error.code).toBe('NETWORK_ERROR'); + expect(error.status).toBe(0); + expect(error.message).toBe('Network request failed: Failed to fetch'); + + const empty = createNetworkAppError(''); + expect(empty.message).toBe('Network request failed: '); + }); + + it('normalizes hub base URLs by stripping trailing slash runs', () => { + expect(normalizeHubBaseUrl()).toBe(''); + expect(normalizeHubBaseUrl(undefined)).toBe(''); + expect(normalizeHubBaseUrl('')).toBe(''); + expect(normalizeHubBaseUrl('https://hub.example.com')).toBe('https://hub.example.com'); + expect(normalizeHubBaseUrl('https://hub.example.com/')).toBe('https://hub.example.com'); + expect(normalizeHubBaseUrl('https://hub.example.com///')).toBe('https://hub.example.com'); + expect(normalizeHubBaseUrl('https://hub.example.com/api/')).toBe('https://hub.example.com/api'); + expect(normalizeHubBaseUrl('/')).toBe(''); + expect(normalizeHubBaseUrl('///')).toBe(''); + }); + + it('resolves timeouts with nullish fallback, preserving 0 and negatives', () => { + expect(resolveHubTimeoutMs(undefined)).toBe(DEFAULT_HUB_TIMEOUT_MS); + expect(resolveHubTimeoutMs(0)).toBe(0); + expect(resolveHubTimeoutMs(5_000)).toBe(5_000); + expect(resolveHubTimeoutMs(-1)).toBe(-1); + }); + + it('resolves request methods with GET default and no case normalization', () => { + expect(requestMethodOf({})).toBe('GET'); + expect(requestMethodOf({ method: 'POST' })).toBe('POST'); + expect(requestMethodOf({ method: 'patch' })).toBe('patch'); + expect(requestMethodOf({ method: undefined })).toBe('GET'); + }); + + it('joins base URL and path by plain concatenation', () => { + expect(buildHubUrl('https://hub.example.com', '/web/projects')).toBe( + 'https://hub.example.com/web/projects', + ); + expect(buildHubUrl('', '/client/auth/me')).toBe('/client/auth/me'); + expect(buildHubUrl('', '')).toBe(''); + // Pure concat: a trailing-slash base yields a double slash — callers normalize first. + expect(buildHubUrl('https://hub.example.com/', '/x')).toBe('https://hub.example.com//x'); + }); + + it('resolves injected fetch or the current global binding', () => { + const injected = (async () => new Response()) as typeof globalThis.fetch; + expect(resolveHubFetch(injected)).toBe(injected); + + const stubbed = vi.fn(async () => new Response(null, { status: 200 })); + vi.stubGlobal('fetch', stubbed); + expect(resolveHubFetch(undefined)).toBe(stubbed); + expect(resolveHubFetch()).toBe(stubbed); + }); + + it('applies default JSON content-type only when missing, case-insensitively', () => { + const empty = new Headers(); + applyDefaultJsonContentType(empty); + expect(empty.get('Content-Type')).toBe('application/json'); + + const custom = new Headers({ 'Content-Type': 'text/plain' }); + applyDefaultJsonContentType(custom); + expect(custom.get('Content-Type')).toBe('text/plain'); + + const lowerCase = new Headers({ 'content-type': 'multipart/form-data' }); + applyDefaultJsonContentType(lowerCase); + expect(lowerCase.get('Content-Type')).toBe('multipart/form-data'); + }); + + it('applies Bearer auth only for truthy token when Authorization is unset', () => { + const headers = new Headers(); + applyBearerAuth(headers, undefined); + applyBearerAuth(headers, null); + applyBearerAuth(headers, ''); + expect(headers.has('Authorization')).toBe(false); + + applyBearerAuth(headers, 'tok-1'); + expect(headers.get('Authorization')).toBe('Bearer tok-1'); + + // Existing Authorization wins over the provided token. + applyBearerAuth(headers, 'tok-2'); + expect(headers.get('Authorization')).toBe('Bearer tok-1'); + + const prefilled = new Headers({ authorization: 'Bearer custom' }); + applyBearerAuth(prefilled, 'tok-3'); + expect(prefilled.get('Authorization')).toBe('Bearer custom'); + }); + + it('force-sets Authorization for a refreshed token', () => { + const headers = new Headers({ Authorization: 'Bearer stale' }); + applyRefreshedBearerAuth(headers, 'fresh'); + expect(headers.get('Authorization')).toBe('Bearer fresh'); + + const empty = new Headers(); + applyRefreshedBearerAuth(empty, 'fresh'); + expect(empty.get('Authorization')).toBe('Bearer fresh'); + }); + + it('creates JSON headers preserving caller headers, defaults, and auth', () => { + const none = createJsonAuthHeaders(); + expect(none.get('Content-Type')).toBe('application/json'); + expect(none.has('Authorization')).toBe(false); + + const headers = createJsonAuthHeaders({ 'X-Test': '1' }, 'tok'); + expect(headers.get('X-Test')).toBe('1'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(headers.get('Authorization')).toBe('Bearer tok'); + + // Caller-supplied Content-Type and Authorization win over defaults. + const custom = createJsonAuthHeaders( + { 'Content-Type': 'text/plain', Authorization: 'Bearer custom' }, + 'tok', + ); + expect(custom.get('Content-Type')).toBe('text/plain'); + expect(custom.get('Authorization')).toBe('Bearer custom'); + + // HeadersInit array and Headers instance forms are accepted. + const fromArray = createJsonAuthHeaders([['X-From-Array', 'yes']]); + expect(fromArray.get('X-From-Array')).toBe('yes'); + expect(fromArray.get('Content-Type')).toBe('application/json'); + + const fromHeaders = createJsonAuthHeaders(new Headers({ 'X-From-Headers': 'yes' }), 'tok'); + expect(fromHeaders.get('X-From-Headers')).toBe('yes'); + expect(fromHeaders.get('Authorization')).toBe('Bearer tok'); + }); + + it('creates auth-only headers with Bearer and no content-type', () => { + const headers = createAuthOnlyHeaders('tok-up'); + expect(headers.get('Authorization')).toBe('Bearer tok-up'); + expect(headers.has('Content-Type')).toBe(false); + + const none = createAuthOnlyHeaders(); + expect(Array.from(none.entries())).toHaveLength(0); + + const emptyToken = createAuthOnlyHeaders(''); + expect(emptyToken.has('Authorization')).toBe(false); + }); + + it('builds JSON fetch init by spreading options and overriding headers/signal', () => { + const headers = new Headers({ 'Content-Type': 'application/json' }); + const controller = new AbortController(); + const init = buildHubFetchInit( + { + method: 'PUT', + body: JSON.stringify({ x: 1 }), + credentials: 'include', + headers: { 'X-Old': 'old' }, + signal: new AbortController().signal, + }, + headers, + controller.signal, + ); + expect(init).toEqual({ + method: 'PUT', + body: JSON.stringify({ x: 1 }), + credentials: 'include', + headers, + signal: controller.signal, + }); + // Caller options' headers/signal are replaced, not merged. + expect(init.headers).toBe(headers); + expect(init.signal).toBe(controller.signal); + }); + + it('builds multipart POST fetch init with form body', () => { + const headers = createAuthOnlyHeaders('tok-up'); + const form = new FormData(); + form.append('file', new Blob(['hello'], { type: 'text/plain' }), 'hello.txt'); + const controller = new AbortController(); + const init = buildMultipartFetchInit(headers, form, controller.signal); + expect(init).toEqual({ + method: 'POST', + headers, + body: form, + signal: controller.signal, + }); + expect(init.body).toBe(form); + }); + + it('decides token-refresh recovery only on 401 with a handler', () => { + expect(shouldAttemptTokenRefresh(401, true)).toBe(true); + expect(shouldAttemptTokenRefresh(401, false)).toBe(false); + expect(shouldAttemptTokenRefresh(403, true)).toBe(false); + expect(shouldAttemptTokenRefresh(200, true)).toBe(false); + expect(shouldAttemptTokenRefresh(0, true)).toBe(false); + }); + + it('normalizes unknown catch values into Error instances', () => { + const error = new Error('boom'); + expect(toReportableError(error)).toBe(error); + + const appError = new AppError({ error: { code: 'X', message: 'm' } }, 403); + expect(toReportableError(appError)).toBe(appError); + + const fromString = toReportableError('boom'); + expect(fromString).toBeInstanceOf(Error); + expect(fromString).toMatchObject({ message: 'boom' }); + + expect(toReportableError(42)).toMatchObject({ message: '42' }); + expect(toReportableError(null)).toMatchObject({ message: 'null' }); + expect(toReportableError(undefined)).toMatchObject({ message: 'undefined' }); + expect(toReportableError({ code: 'X' })).toMatchObject({ message: '[object Object]' }); + }); +});