import { beforeEach, describe, expect, mock, test } from 'bun:test' const apiRequest = mock(async (_endpoint: string, _options?: RequestInit): Promise => ({ data: {} })) const apiRequestRaw = mock(async (_endpoint: string, _options?: RequestInit): Promise => new Response(null, { status: 204 })) class ApiRequestError extends Error { constructor(public readonly status: number, message: string) { super(message) this.name = 'ApiRequestError' } } // Mock every export of common.ts — bun's mock.module leaks across test files, // so a partial mock would break other suites that import the barrel (index.ts) void mock.module('./common.ts', () => ({ apiRequest, apiRequestRaw, ApiRequestError, getApiBaseUrl: () => 'https://api.test', getAppBaseUrl: () => 'https://app.test', getAuthenticatedWorkspaceId: () => 'workspace-id', loadApiKey: () => 'test-token', loadFreshApiKey: async () => 'test-token', authHeaders: (token: string) => ({ 'x-data-token': token }), isApplicationToken: () => false, refreshSessionIfNeeded: async () => {}, getSessionPath: () => '/tmp/session.test', SESSION_PATH: '/tmp/session.test', getProjectUrl: (projectId: string) => `https://app.test/project/${projectId}`, getCanvasUrl: (canvasId: string) => `https://app.test/prompt/${canvasId}/craft`, isJwtExpired: () => false, getJwtWorkspace: () => null, parsePayload: (obj: unknown) => obj, })) describe('agents', () => { beforeEach(() => { apiRequest.mockClear() apiRequest.mockImplementation(async () => ({ data: {} })) apiRequestRaw.mockClear() apiRequestRaw.mockImplementation(async () => new Response(null, { status: 204 })) }) test('creates an agent and unwraps the nested { data: { agent } } envelope', async () => { const { createAgent } = await import('./agents.ts') // POST /agent nests the record: { data: { agent, repository, template? } } apiRequest.mockImplementation(async () => ({ data: { agent: { id: 'agent-1', title: 'My Agent' }, repository: { fullName: 'org/repo', sshUrl: 'git@...' }, }, })) const agent = await createAgent({ title: 'My Agent', projectId: 'proj-1' }) const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent') expect(options?.method).toBe('POST') expect(JSON.parse(String(options?.body))).toEqual({ title: 'My Agent', projectId: 'proj-1' }) expect(agent.id).toBe('agent-1') }) test('lists agents filtered by project', async () => { const { listAgents } = await import('./agents.ts') apiRequest.mockImplementation(async () => ({ data: [] })) await listAgents({ projectId: 'proj-1' }) expect(apiRequest.mock.calls[0]![0]).toBe('/agent?projectId=proj-1') }) test('updates the purpose with branch as a query param', async () => { const { updateAgentPurpose } = await import('./agents.ts') await updateAgentPurpose('agent-1', 'You extract contract data.', { branch: 'main', commitMessage: 'Update instructions', }) const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/purpose?branch=main') expect(options?.method).toBe('PUT') expect(JSON.parse(String(options?.body))).toEqual({ content: 'You extract contract data.', commitMessage: 'Update instructions', }) }) test('encodes file paths segment by segment in the wildcard', async () => { const { getAgentFile } = await import('./agents.ts') apiRequest.mockImplementation(async () => ({ data: { content: '' } })) await getAgentFile('agent-1', '.claude/skills/my skill/SKILL.md', { commitHash: 'abc' }) expect(apiRequest.mock.calls[0]![0]) .toBe('/agent/agent-1/files/.claude/skills/my%20skill/SKILL.md?commitHash=abc') }) test('refuses to write managed files directly', async () => { const { putAgentFile } = await import('./agents.ts') await expect(putAgentFile('agent-1', 'CLAUDE.md', 'hacked')).rejects.toThrow(/updateAgentPurpose/) await expect(putAgentFile('agent-1', './AGENTS.md', 'hacked')).rejects.toThrow(/updateAgentPurpose/) await expect(putAgentFile('agent-1', 'agent.config.json', '{}')).rejects.toThrow(/updateAgentConfig/) await expect(putAgentFile('agent-1', 'output-format.json', '{}')).rejects.toThrow(/updateAgentOutputSchema/) await expect(putAgentFile('agent-1', '.claude/settings.json', '{}')).rejects.toThrow(/system-managed/) expect(apiRequest).not.toHaveBeenCalled() }) test('dot-segment aliases cannot bypass the file guards', async () => { const { putAgentFile, deleteAgentFile, renameAgentFile, uploadAgentFiles } = await import('./agents.ts') await expect(putAgentFile('agent-1', 'subdir/../CLAUDE.md', 'hacked')).rejects.toThrow(/updateAgentPurpose/) await expect(putAgentFile('agent-1', 'a/b/../../agent.config.json', '{}')).rejects.toThrow(/updateAgentConfig/) await expect(deleteAgentFile('agent-1', 'references/../output-format.json')).rejects.toThrow(/requires it/) await expect(renameAgentFile('agent-1', 'notes.md', 'subdir/../CLAUDE.md')).rejects.toThrow(/overwriting/) await expect(uploadAgentFiles('agent-1', [{ path: 'x/../.claude/settings.json', content: '{}' }])) .rejects.toThrow(/managed file/) // escaping the repo root is refused outright await expect(putAgentFile('agent-1', '../outside.md', 'x')).rejects.toThrow(/escape the repository root/) expect(apiRequest).not.toHaveBeenCalled() // benign dot segments still resolve to valid paths apiRequest.mockImplementation(async () => ({ data: { content: '' } })) const { getAgentFile } = await import('./agents.ts') await getAgentFile('agent-1', 'references/./sub/../glossary.md') expect(apiRequest.mock.calls[0]![0]).toBe('/agent/agent-1/files/references/glossary.md') }) test('updateAgentConfig serializes concurrent writers for the same agent', async () => { const { updateAgentConfig } = await import('./agents.ts') // Simulated repo state: GET returns the stored config, PUT persists it. // Without per-agent serialization both writers would read the same // snapshot and the second write would drop the first writer's field. let stored: Record = {} apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (options?.method === 'PUT') { stored = JSON.parse(JSON.parse(String(options.body)).content) return { data: { success: true, branch: 'main', commitHash: 'c' } } } if (String(endpoint).includes('/history')) { return { data: { commits: [{ commitHash: 'c' }] } } } return { data: { content: JSON.stringify(stored), commitHash: 'c' } } }) await Promise.all([ updateAgentConfig('agent-1', { isMultiturn: true }), updateAgentConfig('agent-1', { thinking: true }), ]) expect(stored).toEqual({ isMultiturn: true, thinking: true }) }) test('writes regular files with commit options', async () => { const { putAgentFile } = await import('./agents.ts') await putAgentFile('agent-1', 'references/glossary.md', '# Glossary', { commitMessage: 'Add glossary' }) const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/files/references/glossary.md') expect(options?.method).toBe('PUT') expect(JSON.parse(String(options?.body))).toEqual({ content: '# Glossary', commitMessage: 'Add glossary' }) }) test('refuses to delete or move files the runtime requires', async () => { const { deleteAgentFile, renameAgentFile } = await import('./agents.ts') await expect(deleteAgentFile('agent-1', 'agent.config.json')).rejects.toThrow(/requires it/) await expect(deleteAgentFile('agent-1', '.claude/settings.json')).rejects.toThrow(/requires it/) await expect(deleteAgentFile('agent-1', 'output-format.json')).rejects.toThrow(/requires it/) await expect(renameAgentFile('agent-1', 'CLAUDE.md', 'notes.md')).rejects.toThrow(/exact path/) // directories containing protected files are guarded too await expect(deleteAgentFile('agent-1', '.claude')).rejects.toThrow(/file inside it/) await expect(deleteAgentFile('agent-1', '.claude/')).rejects.toThrow(/file inside it/) await expect(renameAgentFile('agent-1', '.claude', 'claude-backup')).rejects.toThrow(/file inside it/) await expect(renameAgentFile('agent-1', 'stuff', '.claude')).rejects.toThrow(/overwriting/) // destination is guarded too — moving onto a protected file overwrites it await expect(renameAgentFile('agent-1', 'notes.md', 'CLAUDE.md')).rejects.toThrow(/overwriting/) await expect(renameAgentFile('agent-1', 'notes.md', './agent.config.json')).rejects.toThrow(/overwriting/) expect(apiRequest).not.toHaveBeenCalled() }) test('refuses to upload over managed files', async () => { const { uploadAgentFiles } = await import('./agents.ts') await expect(uploadAgentFiles('agent-1', [ { path: 'references/ok.pdf', content: 'x' }, { path: 'CLAUDE.md', content: 'hack' }, ])).rejects.toThrow(/managed file/) await expect(uploadAgentFiles('agent-1', [{ path: './output-format.json', content: '{}' }])) .rejects.toThrow(/managed file/) expect(apiRequest).not.toHaveBeenCalled() }) test('validates skill and subagent names before touching repo paths', async () => { const { createAgentSubagent, updateAgentSubagent, createAgentCustomSkill } = await import('./agents.ts') await expect(updateAgentSubagent('agent-1', '../evil', 'content')).rejects.toThrow(/Invalid subagent name/) await expect(createAgentSubagent('agent-1', 'nested/name')).rejects.toThrow(/Invalid subagent name/) await expect(createAgentCustomSkill('agent-1', 'bad name', 'md')).rejects.toThrow(/Invalid skill name/) expect(apiRequest).not.toHaveBeenCalled() }) test('updateAgentConfig repairs a lost update from an external writer', async () => { const { updateAgentConfig } = await import('./agents.ts') // External writer W committed between our read (C1) and our write (C2): // history is [C2, W, C1] and the config at W differs from what we read const puts: string[] = [] apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { const url = String(endpoint) if (options?.method === 'PUT') { puts.push(JSON.parse(String(options.body)).content) return { data: { success: true, branch: 'main', commitHash: 'C2' } } } if (url.includes('/history')) { return { data: { commits: [ { commitHash: 'C2' }, { commitHash: 'W' }, { commitHash: 'C1' }, ] } } } if (url.includes('commitHash=W')) { return { data: { content: '{"a":1,"external":true}', commitHash: 'W' } } } return { data: { content: '{"a":1}', commitHash: 'C1' } } }) await updateAgentConfig('agent-1', { isMultiturn: true }) expect(puts).toHaveLength(2) // first write raced and dropped the external field... expect(JSON.parse(puts[0]!)).toEqual({ a: 1, isMultiturn: true }) // ...the repair write restores it while keeping our change expect(JSON.parse(puts[1]!)).toEqual({ a: 1, external: true, isMultiturn: true }) }) test('updateAgentConfig re-repairs when a writer lands between detection and the repair write', async () => { const { updateAgentConfig } = await import('./agents.ts') // W1 committed between our read (C1) and our write (C2); while we // repaired that (C3), a second writer W2 landed between C2 and C3. // Each repair round must re-merge on top of the newest external // snapshot so W2's fields are not silently dropped const puts: string[] = [] const histories = [ [{ commitHash: 'C2' }, { commitHash: 'W1' }, { commitHash: 'C1' }], [{ commitHash: 'C3' }, { commitHash: 'W2' }, { commitHash: 'C2' }, { commitHash: 'W1' }, { commitHash: 'C1' }], [{ commitHash: 'C4' }, { commitHash: 'C3' }, { commitHash: 'W2' }, { commitHash: 'C2' }], ] apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { const url = String(endpoint) if (options?.method === 'PUT') { puts.push(JSON.parse(String(options.body)).content) return { data: { success: true, branch: 'main', commitHash: `C${puts.length + 1}` } } } if (url.includes('/history')) { return { data: { commits: histories.shift() } } } if (url.includes('commitHash=W1')) { return { data: { content: '{"a":1,"external":true}', commitHash: 'W1' } } } if (url.includes('commitHash=W2')) { return { data: { content: '{"a":1,"external":true,"second":true}', commitHash: 'W2' } } } return { data: { content: '{"a":1}', commitHash: 'C1' } } }) await updateAgentConfig('agent-1', { isMultiturn: true }) expect(puts).toHaveLength(3) expect(JSON.parse(puts[1]!)).toEqual({ a: 1, external: true, isMultiturn: true }) // the second repair merges W2's snapshot, not the stale W1 one expect(JSON.parse(puts[2]!)).toEqual({ a: 1, external: true, second: true, isMultiturn: true }) }) test('updateAgentConfig fails loudly under sustained concurrent interleaving', async () => { const { updateAgentConfig } = await import('./agents.ts') // Every write is immediately interleaved by a fresh external commit // with different content — repair never converges and must throw // rather than silently drop the other writer's fields let putCount = 0 apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { const url = String(endpoint) if (options?.method === 'PUT') { putCount++ return { data: { success: true, branch: 'main', commitHash: `C${putCount + 1}` } } } if (url.includes('/history')) { return { data: { commits: [ { commitHash: `C${putCount + 1}` }, { commitHash: `W${putCount}` }, { commitHash: `C${putCount}` }, ] } } } const externalMatch = url.match(/commitHash=W(\d+)/) if (externalMatch) { return { data: { content: `{"a":1,"round":${externalMatch[1]}}`, commitHash: `W${externalMatch[1]}` } } } return { data: { content: '{"a":1}', commitHash: 'C1' } } }) await expect(updateAgentConfig('agent-1', { isMultiturn: true })) .rejects.toThrow(/kept committing concurrently/) // initial write + all repair rounds landed before giving up expect(putCount).toBe(4) }) test('updateAgentConfig fails loudly when the post-write conflict check cannot run', async () => { const { updateAgentConfig } = await import('./agents.ts') // A failed history lookup means it cannot be established whether the // write clobbered a concurrent update — that must never be reported // as success let historyCalls = 0 apiRequest.mockImplementation(async (endpoint: string, options?: RequestInit) => { if (options?.method === 'PUT') { return { data: { success: true, branch: 'main', commitHash: 'C2' } } } if (String(endpoint).includes('/history')) { historyCalls++ throw new Error('history endpoint unavailable') } return { data: { content: '{"a":1}', commitHash: 'C1' } } }) await expect(updateAgentConfig('agent-1', { isMultiturn: true })) .rejects.toThrow(/post-write conflict check failed/) // the check is retried once before giving up expect(historyCalls).toBe(2) }) test('updateAgentConfig enforces expectedCommitHash precondition', async () => { const { updateAgentConfig } = await import('./agents.ts') apiRequest.mockImplementation(async (endpoint: string) => { if (String(endpoint).includes('/history')) { return { data: { commits: [{ commitHash: 'newer-hash' }] } } } return { data: { content: '{"isMultiturn":false}', commitHash: 'newer-hash' } } }) await expect(updateAgentConfig('agent-1', { isMultiturn: true }, { expectedCommitHash: 'stale-hash' })) .rejects.toThrow(/changed since it was read/) // matching hash writes normally await updateAgentConfig('agent-1', { isMultiturn: true }, { expectedCommitHash: 'newer-hash' }) const putCall = apiRequest.mock.calls.find(([, options]) => (options as RequestInit)?.method === 'PUT')! expect(putCall[0]).toBe('/agent/agent-1/files/agent.config.json') }) test('deletes regular files', async () => { const { deleteAgentFile } = await import('./agents.ts') await deleteAgentFile('agent-1', 'references/old.md') const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/files/references/old.md') expect(options?.method).toBe('DELETE') }) test('updateAgentConfig merges into agent.config.json and rejects model/harness', async () => { const { updateAgentConfig } = await import('./agents.ts') await expect(updateAgentConfig('agent-1', { model: 'claude-sonnet-5' })).rejects.toThrow(/updateAgentModel/) apiRequest.mockImplementation(async (endpoint: string) => { if (String(endpoint).includes('agent.config.json') && !String(endpoint).includes('?')) { return { data: { content: JSON.stringify({ model: 'claude-sonnet-5', isMultiturn: false }), commitHash: 'abc' } } } if (String(endpoint).includes('/history')) { return { data: { commits: [{ commitHash: 'def' }, { commitHash: 'abc' }] } } } return { data: { success: true, branch: 'main', commitHash: 'def' } } }) await updateAgentConfig('agent-1', { isMultiturn: true }) const putCall = apiRequest.mock.calls.find(([, options]) => (options as RequestInit)?.method === 'PUT')! const body = JSON.parse(String((putCall[1] as RequestInit).body)) expect(JSON.parse(body.content)).toEqual({ model: 'claude-sonnet-5', isMultiturn: true }) }) test('validates input variable names client-side', async () => { const { createAgentInput } = await import('./agents.ts') await expect(createAgentInput('agent-1', { name: 'bad name!', type: 'text', required: true })) .rejects.toThrow(/Invalid input name/) await expect(createAgentInput('agent-1', { name: 'references', type: 'file', required: false })) .rejects.toThrow(/reserved/) expect(apiRequest).not.toHaveBeenCalled() await createAgentInput('agent-1', { name: 'contract_file', type: 'file', required: true }) expect(apiRequest.mock.calls[0]![0]).toBe('/agent/agent-1/input') }) test('reads and writes the output schema via output-format.json', async () => { const { getAgentOutputSchema, updateAgentOutputSchema } = await import('./agents.ts') apiRequest.mockImplementation(async () => ({ data: { content: '{"total":{"type":"number"}}' } })) expect(await getAgentOutputSchema('agent-1')).toEqual({ total: { type: 'number' } }) expect(apiRequest.mock.calls[0]![0]).toBe('/agent/agent-1/files/output-format.json') apiRequest.mockClear() apiRequest.mockImplementation(async () => ({ data: { success: true, branch: 'main', commitHash: 'abc' } })) await updateAgentOutputSchema('agent-1', { summary: { type: 'string' } }) const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/files/output-format.json') expect(options?.method).toBe('PUT') const body = JSON.parse(String(options?.body)) expect(JSON.parse(body.content)).toEqual({ summary: { type: 'string' } }) expect(body.commitMessage).toBe('Update output format') await expect(updateAgentOutputSchema('agent-1', [] as unknown as Record)) .rejects.toThrow(/plain object/) }) test('creates a custom skill under .claude/skills', async () => { const { createAgentCustomSkill } = await import('./agents.ts') await createAgentCustomSkill('agent-1', 'billing-rules', '---\nname: billing-rules\n---\n') const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/files/.claude/skills/billing-rules/SKILL.md') expect(JSON.parse(String(options?.body)).commitMessage).toBe('Add billing-rules skill') }) test('pre-serializes execution bodies so vault refs are not rewritten', async () => { const { testAgent } = await import('./agents.ts') apiRequest.mockImplementation(async () => ({ data: { sessionId: 's-1' } })) await testAgent('agent-1', { message: 'go', inputSchema: [{ type: 'file', name: 'doc', vaultRef: 'vault://abc', filename: 'doc.pdf' }], }) const [endpoint, options] = apiRequest.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1/test') expect(typeof options?.body).toBe('string') expect(JSON.parse(String(options?.body)).inputSchema[0].vaultRef).toBe('vault://abc') }) test('buildAgentExecutionInputs splits text and vault file values', async () => { const { buildAgentExecutionInputs } = await import('./agents.ts') const inputs = buildAgentExecutionInputs( { doc: 'vault://abc', instructions: 'Summarize' }, { fileNames: { doc: 'contract.pdf' } }, ) expect(inputs).toEqual([ { type: 'file', name: 'doc', vaultRef: 'vault://abc', filename: 'contract.pdf' }, { type: 'text', name: 'instructions', content: 'Summarize' }, ]) }) test('waitForAgentSession polls until a settled status and tolerates early 404s', async () => { const { waitForAgentSession } = await import('./agents.ts') let calls = 0 apiRequest.mockImplementation(async () => { calls++ if (calls === 1) throw new ApiRequestError(404, 'not found yet') if (calls === 2) return { data: { sessionId: 's-1', status: 'running', turnCount: 0, turns: [] } } return { data: { sessionId: 's-1', status: 'completed', turnCount: 1, turns: [] } } }) const { status } = await waitForAgentSession('s-1', { intervalMs: 1, maxAttempts: 10 }) expect(status).toBe('completed') expect(calls).toBe(3) }) test('runAgentAndWait returns the last assistant output (structured first)', async () => { const { runAgentAndWait } = await import('./agents.ts') apiRequest.mockImplementation(async (endpoint: string) => { if (String(endpoint).endsWith('/test')) return { data: { sessionId: 's-1' } } return { data: { sessionId: 's-1', status: 'completed', turnCount: 1, turns: [{ runId: 'r-1', index: 0, isContinuation: false, status: 'completed', createdAt: 1, finishedAt: 2, user: { text: 'go' }, assistant: { status: 'completed', text: 'raw', structuredContent: { total: 42 } }, }], }, } }) const result = await runAgentAndWait('agent-1', { message: 'go' }, { intervalMs: 1 }) expect(result.sessionId).toBe('s-1') expect(result.status).toBe('completed') expect(result.output).toEqual({ total: 42 }) }) test('publishes a commit and restores versions', async () => { const { publishAgent, restoreAgentVersion } = await import('./agents.ts') await publishAgent('agent-1', 'abc123') expect(apiRequest.mock.calls[0]![0]).toBe('/agent/agent-1/publish') expect(JSON.parse(String((apiRequest.mock.calls[0]![1] as RequestInit).body))).toEqual({ commitHash: 'abc123' }) await restoreAgentVersion('agent-1', 'abc123', { commitMessage: 'rollback' }) expect(apiRequest.mock.calls[1]![0]).toBe('/agent/agent-1/history/abc123/restore') }) test('deleteAgent uses the raw request and accepts 204', async () => { const { deleteAgent } = await import('./agents.ts') await deleteAgent('agent-1') const [endpoint, options] = apiRequestRaw.mock.calls[0]! expect(endpoint).toBe('/agent/agent-1') expect(options?.method).toBe('DELETE') }) test('deleteAgent throws ApiRequestError on failure', async () => { const { deleteAgent } = await import('./agents.ts') apiRequestRaw.mockImplementation(async () => new Response('nope', { status: 403, statusText: 'Forbidden' })) await expect(deleteAgent('agent-1')).rejects.toThrow(/403/) }) test('getAgentUrl points at the app agent page', async () => { const { getAgentUrl } = await import('./agents.ts') expect(getAgentUrl('agent-1')).toBe('https://app.test/agent/agent-1') }) })