mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 14:25:51 +00:00
feat: 添加云端管理命令(memory-stores、vault、schedule、skill-store、agents-platform)
- /memory-stores: 远程记忆存储管理 - /vault: 密钥保险库管理 - /schedule: 云端定时触发器管理(cron) - /skill-store: 技能商店浏览和安装 - /agents-platform: 远程 agent 调度管理 Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
96
src/commands/agents-platform/AgentsPlatformView.tsx
Normal file
96
src/commands/agents-platform/AgentsPlatformView.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react';
|
||||
import { Box, Text } from '@anthropic/ink';
|
||||
import type { Theme } from '@anthropic/ink';
|
||||
import type { AgentTrigger } from './agentsApi.js';
|
||||
import { cronToHuman } from '../../utils/cron.js';
|
||||
|
||||
type Props =
|
||||
| { mode: 'list'; agents: AgentTrigger[] }
|
||||
| { mode: 'created'; agent: AgentTrigger }
|
||||
| { mode: 'deleted'; id: string }
|
||||
| { mode: 'ran'; id: string; runId: string }
|
||||
| { mode: 'error'; message: string };
|
||||
|
||||
function AgentRow({ agent }: { agent: AgentTrigger }): React.ReactNode {
|
||||
const schedule = cronToHuman(agent.cron_expr, { utc: true });
|
||||
const nextRun = agent.next_run ? new Date(agent.next_run).toLocaleString() : '—';
|
||||
return (
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Box>
|
||||
<Text bold>{agent.id}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color={'suggestion' as keyof Theme}>{agent.status}</Text>
|
||||
</Box>
|
||||
<Text>Schedule: {schedule}</Text>
|
||||
<Text dimColor>Prompt: {agent.prompt}</Text>
|
||||
<Text dimColor>Next run: {nextRun}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsPlatformView(props: Props): React.ReactNode {
|
||||
if (props.mode === 'list') {
|
||||
if (props.agents.length === 0) {
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
No scheduled agents. Use /agents-platform create <cron> <prompt> to create one.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>Scheduled Agents ({props.agents.length})</Text>
|
||||
</Box>
|
||||
{props.agents.map(agent => (
|
||||
<AgentRow key={agent.id} agent={agent} />
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'created') {
|
||||
const schedule = cronToHuman(props.agent.cron_expr, { utc: true });
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text bold color={'success' as keyof Theme}>
|
||||
Agent created
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>ID: {props.agent.id}</Text>
|
||||
<Text>Schedule: {schedule}</Text>
|
||||
<Text>Prompt: {props.agent.prompt}</Text>
|
||||
<Text dimColor>Status: {props.agent.status}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'deleted') {
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Agent {props.id} deleted.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (props.mode === 'ran') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text color={'success' as keyof Theme}>Agent {props.id} triggered.</Text>
|
||||
</Box>
|
||||
<Text dimColor>Run ID: {props.runId}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// error mode
|
||||
return (
|
||||
<Box>
|
||||
<Text color={'error' as keyof Theme}>{props.message}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Tests for AgentsPlatformView.tsx
|
||||
* Covers all 5 modes: list (empty), list (with agents), created, deleted, ran, error
|
||||
*/
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import * as React from 'react';
|
||||
import { renderToString } from '../../../utils/staticRender.js';
|
||||
|
||||
// Mock cron utility before importing AgentsPlatformView
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
cronToHuman: (expr: string) => `HumanCron(${expr})`,
|
||||
parseCronExpression: () => null,
|
||||
computeNextCronRun: () => null,
|
||||
}));
|
||||
|
||||
const { AgentsPlatformView } = await import('../AgentsPlatformView.js');
|
||||
|
||||
const sampleAgent = {
|
||||
id: 'agt_abc123',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'Run standup report',
|
||||
status: 'active' as const,
|
||||
timezone: 'UTC',
|
||||
next_run: '2026-05-05T09:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('AgentsPlatformView list mode', () => {
|
||||
test('empty list shows placeholder message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[]} />);
|
||||
expect(out).toContain('No scheduled agents');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent count', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Scheduled Agents (1)');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('non-empty list shows agent status', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('active');
|
||||
});
|
||||
|
||||
test('non-empty list shows human-readable schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('list shows agent prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
|
||||
test('list shows next run date', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent]} />);
|
||||
// next_run is formatted via toLocaleString — just check it's rendered
|
||||
expect(out).toContain('Next run');
|
||||
});
|
||||
|
||||
test('list with null next_run shows em dash', async () => {
|
||||
const agentNoNextRun = { ...sampleAgent, next_run: null };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[agentNoNextRun]} />);
|
||||
expect(out).toContain('—');
|
||||
});
|
||||
|
||||
test('multiple agents rendered', async () => {
|
||||
const agent2 = { ...sampleAgent, id: 'agt_xyz', cron_expr: '0 10 * * 2' };
|
||||
const out = await renderToString(<AgentsPlatformView mode="list" agents={[sampleAgent, agent2]} />);
|
||||
expect(out).toContain('Scheduled Agents (2)');
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('agt_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView created mode', () => {
|
||||
test('shows Agent created', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Agent created');
|
||||
});
|
||||
|
||||
test('shows agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
});
|
||||
|
||||
test('shows schedule', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('HumanCron(0 9 * * 1)');
|
||||
});
|
||||
|
||||
test('shows prompt', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="created" agent={sampleAgent} />);
|
||||
expect(out).toContain('Run standup report');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView deleted mode', () => {
|
||||
test('shows deleted confirmation with id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="deleted" id="agt_abc123" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView ran mode', () => {
|
||||
test('shows triggered with agent id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('agt_abc123');
|
||||
expect(out).toContain('triggered');
|
||||
});
|
||||
|
||||
test('shows run id', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="ran" id="agt_abc123" runId="run_xyz" />);
|
||||
expect(out).toContain('run_xyz');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentsPlatformView error mode', () => {
|
||||
test('shows error message', async () => {
|
||||
const out = await renderToString(<AgentsPlatformView mode="error" message="Network failure" />);
|
||||
expect(out).toContain('Network failure');
|
||||
});
|
||||
});
|
||||
382
src/commands/agents-platform/__tests__/agentsApi.test.ts
Normal file
382
src/commands/agents-platform/__tests__/agentsApi.test.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import {
|
||||
afterAll,
|
||||
afterEach,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
mock,
|
||||
test,
|
||||
} from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
import { setupAxiosMock } from '../../../../tests/mocks/axios.js'
|
||||
|
||||
// Mock side-effect modules first
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
|
||||
// ── Workspace API key mock ──────────────────────────────────────────────────
|
||||
const mockApiKey = 'sk-ant-api03-test-agents-key'
|
||||
|
||||
mock.module('src/constants/oauth.js', () => ({
|
||||
getOauthConfig: () => ({ BASE_API_URL: 'https://api.anthropic.com' }),
|
||||
}))
|
||||
|
||||
const prepareWorkspaceApiRequestMock = mock(async () => ({
|
||||
apiKey: mockApiKey,
|
||||
}))
|
||||
|
||||
mock.module('src/utils/teleport/api.js', () => ({
|
||||
prepareWorkspaceApiRequest: prepareWorkspaceApiRequestMock,
|
||||
}))
|
||||
|
||||
// Note: we do NOT mock src/services/auth/hostGuard.js here.
|
||||
// The real assertWorkspaceHost() is called with the URL from getOauthConfig()
|
||||
// (mocked to https://api.anthropic.com), which passes the host guard.
|
||||
// Mocking hostGuard would pollute hostGuard's own test file via Bun process-level cache.
|
||||
|
||||
// ── Axios mock ──────────────────────────────────────────────────────────────
|
||||
const axiosGetMock = mock(async () => ({}))
|
||||
const axiosPostMock = mock(async () => ({}))
|
||||
const axiosDeleteMock = mock(async () => ({}))
|
||||
|
||||
const axiosIsAxiosError = mock((err: unknown) => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'isAxiosError' in err &&
|
||||
(err as { isAxiosError: boolean }).isAxiosError === true
|
||||
)
|
||||
})
|
||||
|
||||
const axiosHandle = setupAxiosMock()
|
||||
axiosHandle.stubs.get = axiosGetMock
|
||||
axiosHandle.stubs.post = axiosPostMock
|
||||
axiosHandle.stubs.delete = axiosDeleteMock
|
||||
axiosHandle.stubs.isAxiosError = axiosIsAxiosError
|
||||
|
||||
// Lazy import after mocks are in place
|
||||
let listAgents: typeof import('../agentsApi.js').listAgents
|
||||
let createAgent: typeof import('../agentsApi.js').createAgent
|
||||
let deleteAgent: typeof import('../agentsApi.js').deleteAgent
|
||||
let runAgent: typeof import('../agentsApi.js').runAgent
|
||||
|
||||
beforeAll(async () => {
|
||||
axiosHandle.useStubs = true
|
||||
const mod = await import('../agentsApi.js')
|
||||
listAgents = mod.listAgents
|
||||
createAgent = mod.createAgent
|
||||
deleteAgent = mod.deleteAgent
|
||||
runAgent = mod.runAgent
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
axiosHandle.useStubs = false
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
axiosGetMock.mockClear()
|
||||
axiosPostMock.mockClear()
|
||||
axiosDeleteMock.mockClear()
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
// Ensure ANTHROPIC_API_KEY is set for happy-path tests
|
||||
process.env['ANTHROPIC_API_KEY'] = mockApiKey
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up env var to avoid test pollution
|
||||
delete process.env['ANTHROPIC_API_KEY']
|
||||
})
|
||||
|
||||
// afterEach handled above
|
||||
|
||||
describe('listAgents', () => {
|
||||
test('returns agents on 200', async () => {
|
||||
const agents = [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
]
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: agents }, status: 200 })
|
||||
|
||||
const result = await listAgents()
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.id).toBe('agt_1')
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('returns empty array when data.data is empty', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
const result = await listAgents()
|
||||
expect(result).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('throws on 401 with friendly message', async () => {
|
||||
const err = Object.assign(new Error('Unauthorized'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 401, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow('re-authenticate')
|
||||
})
|
||||
|
||||
test('throws on 403 with subscription message', async () => {
|
||||
const err = Object.assign(new Error('Forbidden'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 403, data: {} },
|
||||
})
|
||||
axiosGetMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow('Subscription')
|
||||
})
|
||||
|
||||
test('retries on 5xx and eventually throws', async () => {
|
||||
const make5xxErr = () =>
|
||||
Object.assign(new Error('Server Error'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 500, data: {} },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
.mockRejectedValueOnce(make5xxErr())
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(listAgents()).rejects.toThrow()
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(3)
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('createAgent', () => {
|
||||
test('sends correct body and returns agent', async () => {
|
||||
const agent = {
|
||||
id: 'agt_new',
|
||||
cron_expr: '0 9 * * *',
|
||||
prompt: 'Test',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
}
|
||||
axiosPostMock.mockResolvedValueOnce({ data: agent, status: 201 })
|
||||
|
||||
const result = await createAgent('0 9 * * *', 'Test')
|
||||
expect(result.id).toBe('agt_new')
|
||||
const callArgs = (
|
||||
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
|
||||
)[0]
|
||||
const body = callArgs?.[1] as { cron_expr: string; timezone: string }
|
||||
expect(body.cron_expr).toBe('0 9 * * *')
|
||||
expect(body.timezone).toBe('UTC')
|
||||
})
|
||||
|
||||
test('throws on 404', async () => {
|
||||
const err = Object.assign(new Error('Not Found'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 404, data: {} },
|
||||
})
|
||||
axiosPostMock.mockRejectedValueOnce(err)
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
await expect(createAgent('0 9 * * *', 'Test')).rejects.toThrow(
|
||||
'Agent not found',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteAgent', () => {
|
||||
test('calls DELETE endpoint with agent id', async () => {
|
||||
axiosDeleteMock.mockResolvedValueOnce({ status: 204 })
|
||||
|
||||
await deleteAgent('agt_del')
|
||||
const url = (
|
||||
axiosDeleteMock.mock.calls as unknown as [string, unknown][]
|
||||
)[0]?.[0] as string
|
||||
expect(url).toContain('agt_del')
|
||||
})
|
||||
})
|
||||
|
||||
describe('runAgent', () => {
|
||||
test('calls POST /v1/agents/:id/run and returns run_id', async () => {
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: { run_id: 'run_abc' },
|
||||
status: 200,
|
||||
})
|
||||
|
||||
const result = await runAgent('agt_run')
|
||||
expect(result.run_id).toBe('run_abc')
|
||||
const url = (
|
||||
axiosPostMock.mock.calls as unknown as [string, unknown, unknown][]
|
||||
)[0]?.[0] as string
|
||||
expect(url).toContain('agt_run/run')
|
||||
})
|
||||
})
|
||||
|
||||
// ── M3 regression: createAgent must use system timezone, not hardcoded UTC ──
|
||||
describe('createAgent M3: timezone uses system TZ not hardcoded UTC', () => {
|
||||
test('createAgent passes system timezone to the API body', async () => {
|
||||
axiosPostMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
id: 'agt_tz',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello',
|
||||
status: 'active',
|
||||
timezone: 'America/New_York',
|
||||
},
|
||||
status: 200,
|
||||
})
|
||||
|
||||
await createAgent('0 9 * * 1', 'hello')
|
||||
|
||||
const calls = axiosPostMock.mock.calls as unknown as [
|
||||
string,
|
||||
Record<string, unknown>,
|
||||
unknown,
|
||||
][]
|
||||
const body = calls[0]?.[1]
|
||||
expect(body).toHaveProperty('timezone')
|
||||
// Must NOT be the hardcoded 'UTC' string — must be a real timezone string
|
||||
// In CI the system TZ may be UTC, but the field must still be present and a string.
|
||||
expect(typeof body?.timezone).toBe('string')
|
||||
expect((body?.timezone as string).length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── M5 regression: withRetry must honor Retry-After header ──
|
||||
describe('withRetry M5: honors Retry-After header on 5xx', () => {
|
||||
test('waits at least Retry-After seconds before retrying on 5xx', async () => {
|
||||
// First call: 503 with Retry-After: 0 (immediate, so test is fast)
|
||||
// Second call: success
|
||||
const serverErr = Object.assign(new Error('Service Unavailable'), {
|
||||
isAxiosError: true,
|
||||
response: { status: 503, data: {}, headers: { 'retry-after': '0' } },
|
||||
})
|
||||
axiosGetMock
|
||||
.mockRejectedValueOnce(serverErr)
|
||||
.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
|
||||
axiosIsAxiosError.mockImplementation(
|
||||
(e: unknown) =>
|
||||
typeof e === 'object' &&
|
||||
e !== null &&
|
||||
'isAxiosError' in e &&
|
||||
(e as { isAxiosError: boolean }).isAxiosError === true,
|
||||
)
|
||||
|
||||
const result = await listAgents()
|
||||
// Should have retried and succeeded on second attempt
|
||||
expect(result).toHaveLength(0)
|
||||
expect(axiosGetMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Regression: auth must use prepareWorkspaceApiRequest (not subscription OAuth) ──
|
||||
describe('regression: uses prepareWorkspaceApiRequest for auth', () => {
|
||||
test('listAgents calls prepareWorkspaceApiRequest to obtain workspace API key', async () => {
|
||||
prepareWorkspaceApiRequestMock.mockClear()
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
|
||||
await listAgents()
|
||||
|
||||
expect(prepareWorkspaceApiRequestMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Invariant: buildHeaders must return x-api-key, not Authorization ─────────
|
||||
describe('invariant: x-api-key present, no Authorization, no x-organization-uuid', () => {
|
||||
test('buildHeaders returns x-api-key header (workspace key)', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-api-key']).toBe(mockApiKey)
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include Authorization header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['Authorization']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildHeaders does NOT include x-organization-uuid header', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['x-organization-uuid']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('buildHeaders includes anthropic-beta header with managed-agents umbrella', async () => {
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [
|
||||
string,
|
||||
{ headers: Record<string, string> },
|
||||
][]
|
||||
const headers = calls[0]?.[1]?.headers ?? {}
|
||||
expect(headers['anthropic-beta']).toContain('managed-agents')
|
||||
})
|
||||
|
||||
test('throws 501 when ANTHROPIC_API_KEY is missing (all 3 retries fail)', async () => {
|
||||
// withRetry retries 5xx errors (statusCode >= 500 including 501).
|
||||
// buildHeaders throws AgentsApiError(msg, 501) for config errors.
|
||||
// All 3 retry attempts must fail for the error to propagate.
|
||||
const missingKeyError = new Error('ANTHROPIC_API_KEY is required')
|
||||
prepareWorkspaceApiRequestMock
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
.mockRejectedValueOnce(missingKeyError)
|
||||
await expect(listAgents()).rejects.toThrow(/ANTHROPIC_API_KEY|required/i)
|
||||
}, 5000)
|
||||
|
||||
test('request goes to api.anthropic.com (host guard passes for correct host)', async () => {
|
||||
// The real assertWorkspaceHost() runs and passes since BASE_API_URL is api.anthropic.com
|
||||
axiosGetMock.mockResolvedValueOnce({ data: { data: [] }, status: 200 })
|
||||
await listAgents()
|
||||
const calls = axiosGetMock.mock.calls as unknown as [string, unknown][]
|
||||
expect(calls[0]?.[0]).toContain('api.anthropic.com')
|
||||
})
|
||||
})
|
||||
66
src/commands/agents-platform/__tests__/index.test.ts
Normal file
66
src/commands/agents-platform/__tests__/index.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Tests for agents-platform/index.ts — command metadata only.
|
||||
* We verify load() resolves without error but do NOT mock launchAgentsPlatform,
|
||||
* to avoid polluting other test files via Bun's process-level mock.module cache.
|
||||
*/
|
||||
import { beforeAll, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
let cmd: {
|
||||
load?: () => Promise<{ call: unknown }>
|
||||
isEnabled?: () => boolean
|
||||
name?: string
|
||||
type?: string
|
||||
aliases?: string[]
|
||||
bridgeSafe?: boolean
|
||||
availability?: string[]
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../index.js')
|
||||
cmd = mod.default as typeof cmd
|
||||
})
|
||||
|
||||
describe('agentsPlatform index metadata', () => {
|
||||
test('command name is agents-platform', () => {
|
||||
expect(cmd.name).toBe('agents-platform')
|
||||
})
|
||||
|
||||
test('command type is local-jsx', () => {
|
||||
expect(cmd.type).toBe('local-jsx')
|
||||
})
|
||||
|
||||
test('isEnabled returns true', () => {
|
||||
expect(cmd.isEnabled?.()).toBe(true)
|
||||
})
|
||||
|
||||
test('aliases includes agents and schedule-agent', () => {
|
||||
expect(cmd.aliases).toContain('agents')
|
||||
expect(cmd.aliases).toContain('schedule-agent')
|
||||
})
|
||||
|
||||
test('bridgeSafe is false', () => {
|
||||
expect(cmd.bridgeSafe).toBe(false)
|
||||
})
|
||||
|
||||
test('availability includes claude-ai', () => {
|
||||
expect(cmd.availability).toContain('claude-ai')
|
||||
})
|
||||
|
||||
test('load() exists and is a function', () => {
|
||||
expect(typeof cmd.load).toBe('function')
|
||||
})
|
||||
|
||||
test('load() resolves to object with call function', async () => {
|
||||
const loaded = await cmd.load!()
|
||||
expect(typeof (loaded as { call?: unknown }).call).toBe('function')
|
||||
})
|
||||
|
||||
test('isHidden is boolean (dynamic: false when ANTHROPIC_API_KEY set, true when absent)', () => {
|
||||
// isHidden = !process.env['ANTHROPIC_API_KEY']
|
||||
expect(typeof (cmd as { isHidden?: unknown }).isHidden).toBe('boolean')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,262 @@
|
||||
import { beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { debugMock } from '../../../../tests/mocks/debug.js'
|
||||
import { logMock } from '../../../../tests/mocks/log.js'
|
||||
|
||||
mock.module('src/utils/log.ts', logMock)
|
||||
mock.module('src/utils/debug.ts', debugMock)
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => true,
|
||||
}))
|
||||
|
||||
// ── Analytics mock ──────────────────────────────────────────────────────────
|
||||
const logEventMock = mock(() => {})
|
||||
mock.module('src/services/analytics/index.js', () => ({
|
||||
logEvent: logEventMock,
|
||||
logEventAsync: mock(() => Promise.resolve()),
|
||||
_resetForTesting: mock(() => {}),
|
||||
attachAnalyticsSink: mock(() => {}),
|
||||
stripProtoFields: mock((v: unknown) => v),
|
||||
}))
|
||||
|
||||
// ── agentsApi mock ──────────────────────────────────────────────────────────
|
||||
const listMock = mock(async () => [
|
||||
{
|
||||
id: 'agt_1',
|
||||
cron_expr: '0 9 * * 1',
|
||||
prompt: 'hello world',
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
},
|
||||
])
|
||||
const createMock = mock(async (cron: string, prompt: string) => ({
|
||||
id: 'agt_new',
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
status: 'active',
|
||||
timezone: 'UTC',
|
||||
next_run: null,
|
||||
}))
|
||||
const deleteMock = mock(async () => undefined)
|
||||
const runMock = mock(async () => ({ run_id: 'run_123' }))
|
||||
|
||||
mock.module('src/commands/agents-platform/agentsApi.js', () => ({
|
||||
listAgents: listMock,
|
||||
createAgent: createMock,
|
||||
deleteAgent: deleteMock,
|
||||
runAgent: runMock,
|
||||
}))
|
||||
|
||||
// ── cron mock ───────────────────────────────────────────────────────────────
|
||||
mock.module('src/utils/cron.js', () => ({
|
||||
parseCronExpression: (expr: string) =>
|
||||
expr.includes('INVALID')
|
||||
? null
|
||||
: { minute: [0], hour: [9], dayOfMonth: [1], month: [1], dayOfWeek: [1] },
|
||||
cronToHuman: (expr: string) => `Human(${expr})`,
|
||||
computeNextCronRun: () => null,
|
||||
}))
|
||||
|
||||
let callAgentsPlatform: typeof import('../launchAgentsPlatform.js').callAgentsPlatform
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../launchAgentsPlatform.js')
|
||||
callAgentsPlatform = mod.callAgentsPlatform
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
logEventMock.mockClear()
|
||||
listMock.mockClear()
|
||||
createMock.mockClear()
|
||||
deleteMock.mockClear()
|
||||
runMock.mockClear()
|
||||
})
|
||||
|
||||
function makeContext() {
|
||||
return {} as Parameters<typeof callAgentsPlatform>[1]
|
||||
}
|
||||
|
||||
describe('callAgentsPlatform', () => {
|
||||
test('list (empty args) calls listAgents and returns element', async () => {
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_list',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('list sub-command calls listAgents', async () => {
|
||||
const onDone = mock(() => {})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(listMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
test('create with valid cron calls createAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 Run standup',
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
const [cron, prompt] = createMock.mock.calls[0] as [string, string]
|
||||
expect(cron).toBe('0 9 * * 1')
|
||||
expect(prompt).toBe('Run standup')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_create',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('create with INVALID cron does not call API', async () => {
|
||||
// parseCronExpression returns null for expressions containing 'INVALID'
|
||||
const onDone = mock(() => {})
|
||||
await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create INVALID INVALID * * * my prompt',
|
||||
)
|
||||
// cron = 'INVALID INVALID * * *', mock returns null → no API call
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('delete with id calls deleteAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(deleteMock).toHaveBeenCalledWith('agt_abc')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_delete',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('run with id calls runAgent', async () => {
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(runMock).toHaveBeenCalledWith('agt_xyz')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_run',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('invalid args logs failed and calls onDone', async () => {
|
||||
const onDone = mock(() => {})
|
||||
await callAgentsPlatform(onDone, makeContext(), 'unknown-cmd foo')
|
||||
expect(onDone).toHaveBeenCalledTimes(1)
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(listMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test('listAgents API error → error view returned', async () => {
|
||||
listMock.mockRejectedValueOnce(new Error('network error'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(onDone, makeContext(), 'list')
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('started event fires on every call', async () => {
|
||||
const onDone = mock(() => {})
|
||||
await callAgentsPlatform(onDone, makeContext(), '')
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_started',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
// ── Error-path branches (lines 77-86, 100-109, 128-136) ──────────────────
|
||||
|
||||
test('createAgent API error → error view returned', async () => {
|
||||
createMock.mockRejectedValueOnce(new Error('subscription required'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'create 0 9 * * 1 My prompt',
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(onDone).toHaveBeenCalledWith(
|
||||
expect.stringContaining('subscription required'),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('deleteAgent API error → error view returned', async () => {
|
||||
deleteMock.mockRejectedValueOnce(new Error('not found'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'delete agt_abc',
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(onDone).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not found'),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('runAgent API error → error view returned', async () => {
|
||||
runMock.mockRejectedValueOnce(new Error('run failed'))
|
||||
const onDone = mock(() => {})
|
||||
const result = await callAgentsPlatform(
|
||||
onDone,
|
||||
makeContext(),
|
||||
'run agt_xyz',
|
||||
)
|
||||
expect(result).not.toBeNull()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
expect(onDone).toHaveBeenCalledWith(
|
||||
expect.stringContaining('run failed'),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
|
||||
test('create with no prompt part → invalid action', async () => {
|
||||
const onDone = mock(() => {})
|
||||
// Only 4 cron fields — parseArgs returns invalid
|
||||
await callAgentsPlatform(onDone, makeContext(), 'create 0 9 * *')
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
expect(logEventMock).toHaveBeenCalledWith(
|
||||
'tengu_agents_platform_failed',
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
116
src/commands/agents-platform/__tests__/parseArgs.test.ts
Normal file
116
src/commands/agents-platform/__tests__/parseArgs.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseAgentsPlatformArgs, splitCronAndPrompt } from '../parseArgs.js'
|
||||
|
||||
describe('parseAgentsPlatformArgs', () => {
|
||||
test('empty string returns list', () => {
|
||||
const r = parseAgentsPlatformArgs('')
|
||||
expect(r.action).toBe('list')
|
||||
})
|
||||
|
||||
test('"list" returns list', () => {
|
||||
const r = parseAgentsPlatformArgs('list')
|
||||
expect(r.action).toBe('list')
|
||||
})
|
||||
|
||||
test('whitespace-only returns list', () => {
|
||||
const r = parseAgentsPlatformArgs(' ')
|
||||
expect(r.action).toBe('list')
|
||||
})
|
||||
|
||||
test('create with valid cron and prompt', () => {
|
||||
const r = parseAgentsPlatformArgs('create 0 9 * * 1 Run daily standup')
|
||||
expect(r.action).toBe('create')
|
||||
if (r.action === 'create') {
|
||||
expect(r.cron).toBe('0 9 * * 1')
|
||||
expect(r.prompt).toBe('Run daily standup')
|
||||
}
|
||||
})
|
||||
|
||||
test('create with multi-word prompt', () => {
|
||||
const r = parseAgentsPlatformArgs(
|
||||
'create 30 8 * * * Check emails and summarize',
|
||||
)
|
||||
expect(r.action).toBe('create')
|
||||
if (r.action === 'create') {
|
||||
expect(r.cron).toBe('30 8 * * *')
|
||||
expect(r.prompt).toBe('Check emails and summarize')
|
||||
}
|
||||
})
|
||||
|
||||
test('create with missing prompt is invalid', () => {
|
||||
const r = parseAgentsPlatformArgs('create 0 9 * * 1')
|
||||
expect(r.action).toBe('invalid')
|
||||
if (r.action === 'invalid') {
|
||||
expect(r.reason).toContain('5 cron fields')
|
||||
}
|
||||
})
|
||||
|
||||
test('create with no args is invalid', () => {
|
||||
const r = parseAgentsPlatformArgs('create')
|
||||
expect(r.action).toBe('invalid')
|
||||
if (r.action === 'invalid') {
|
||||
expect(r.reason).toContain('cron expression')
|
||||
}
|
||||
})
|
||||
|
||||
test('delete with id', () => {
|
||||
const r = parseAgentsPlatformArgs('delete agt_abc123')
|
||||
expect(r.action).toBe('delete')
|
||||
if (r.action === 'delete') {
|
||||
expect(r.id).toBe('agt_abc123')
|
||||
}
|
||||
})
|
||||
|
||||
test('delete without id is invalid', () => {
|
||||
const r = parseAgentsPlatformArgs('delete')
|
||||
expect(r.action).toBe('invalid')
|
||||
if (r.action === 'invalid') {
|
||||
expect(r.reason).toContain('agent id')
|
||||
}
|
||||
})
|
||||
|
||||
test('run with id', () => {
|
||||
const r = parseAgentsPlatformArgs('run agt_xyz789')
|
||||
expect(r.action).toBe('run')
|
||||
if (r.action === 'run') {
|
||||
expect(r.id).toBe('agt_xyz789')
|
||||
}
|
||||
})
|
||||
|
||||
test('run without id is invalid', () => {
|
||||
const r = parseAgentsPlatformArgs('run')
|
||||
expect(r.action).toBe('invalid')
|
||||
if (r.action === 'invalid') {
|
||||
expect(r.reason).toContain('agent id')
|
||||
}
|
||||
})
|
||||
|
||||
test('unknown sub-command is invalid', () => {
|
||||
const r = parseAgentsPlatformArgs('foobar something')
|
||||
expect(r.action).toBe('invalid')
|
||||
if (r.action === 'invalid') {
|
||||
expect(r.reason).toContain('Unknown sub-command')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('splitCronAndPrompt', () => {
|
||||
test('splits 5-field cron from prompt', () => {
|
||||
const r = splitCronAndPrompt('0 9 * * 1 My prompt here')
|
||||
expect(r).not.toBeNull()
|
||||
expect(r?.cron).toBe('0 9 * * 1')
|
||||
expect(r?.prompt).toBe('My prompt here')
|
||||
})
|
||||
|
||||
test('returns null if fewer than 6 tokens', () => {
|
||||
expect(splitCronAndPrompt('0 9 * * 1')).toBeNull()
|
||||
expect(splitCronAndPrompt('0 9 *')).toBeNull()
|
||||
})
|
||||
|
||||
test('handles extra spaces in input', () => {
|
||||
const r = splitCronAndPrompt(' 0 9 * * 1 hello world ')
|
||||
expect(r).not.toBeNull()
|
||||
expect(r?.cron).toBe('0 9 * * 1')
|
||||
expect(r?.prompt).toBe('hello world')
|
||||
})
|
||||
})
|
||||
206
src/commands/agents-platform/agentsApi.ts
Normal file
206
src/commands/agents-platform/agentsApi.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Thin HTTP client for the /v1/agents endpoint.
|
||||
*
|
||||
* Reuses the same base-URL + auth-header pattern as the rest of the codebase:
|
||||
* getOauthConfig().BASE_API_URL → base
|
||||
* getClaudeAIOAuthTokens()?.accessToken → Bearer token
|
||||
* getOAuthHeaders(token) → Authorization + anthropic-version headers
|
||||
* getOrganizationUUID() → x-organization-uuid header
|
||||
*/
|
||||
|
||||
import axios from 'axios'
|
||||
import { getOauthConfig } from '../../constants/oauth.js'
|
||||
import { assertWorkspaceHost } from '../../services/auth/hostGuard.js'
|
||||
import { prepareWorkspaceApiRequest } from '../../utils/teleport/api.js'
|
||||
|
||||
export type AgentTrigger = {
|
||||
id: string
|
||||
cron_expr: string
|
||||
prompt: string
|
||||
status: string
|
||||
timezone: string
|
||||
next_run?: string | null
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
type ListAgentsResponse = {
|
||||
data: AgentTrigger[]
|
||||
}
|
||||
|
||||
type AgentRunResponse = {
|
||||
run_id: string
|
||||
}
|
||||
|
||||
// Server requires the managed-agents umbrella beta header.
|
||||
const AGENTS_BETA_HEADER = 'managed-agents-2026-04-01'
|
||||
const MAX_RETRIES = 3
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
class AgentsApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode: number,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'AgentsApiError'
|
||||
}
|
||||
}
|
||||
|
||||
async function buildHeaders(): Promise<Record<string, string>> {
|
||||
// /v1/agents requires a workspace-scoped API key (sk-ant-api03-*).
|
||||
// Subscription OAuth bearer tokens always 401 here (server-enforced plane separation).
|
||||
// Guard the host before sending the key to prevent credential leakage.
|
||||
let apiKey: string
|
||||
try {
|
||||
const prepared = await prepareWorkspaceApiRequest()
|
||||
apiKey = prepared.apiKey
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
throw new AgentsApiError(msg, 501)
|
||||
}
|
||||
assertWorkspaceHost(agentsBaseUrl())
|
||||
return {
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-beta': AGENTS_BETA_HEADER,
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
}
|
||||
|
||||
function agentsBaseUrl(): string {
|
||||
return `${getOauthConfig().BASE_API_URL}/v1/agents`
|
||||
}
|
||||
|
||||
function classifyError(err: unknown): AgentsApiError {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status ?? 0
|
||||
if (status === 401) {
|
||||
return new AgentsApiError(
|
||||
'Authentication failed. Please run /login to re-authenticate.',
|
||||
401,
|
||||
)
|
||||
}
|
||||
if (status === 403) {
|
||||
return new AgentsApiError(
|
||||
'Subscription required. Scheduled agents require a Claude Pro/Max/Team subscription.',
|
||||
403,
|
||||
)
|
||||
}
|
||||
if (status === 404) {
|
||||
return new AgentsApiError('Agent not found.', 404)
|
||||
}
|
||||
// G2: add 429 handler (was missing; other P2 clients have it)
|
||||
if (status === 429) {
|
||||
const retryAfter =
|
||||
(err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
] ?? ''
|
||||
const detail = retryAfter ? ` Retry after ${retryAfter}s.` : ''
|
||||
return new AgentsApiError(`Rate limit exceeded.${detail}`, 429)
|
||||
}
|
||||
const msg =
|
||||
(err.response?.data as { error?: { message?: string } } | undefined)
|
||||
?.error?.message ?? err.message
|
||||
return new AgentsApiError(msg, status)
|
||||
}
|
||||
if (err instanceof AgentsApiError) return err
|
||||
return new AgentsApiError(err instanceof Error ? err.message : String(err), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the Retry-After header value into milliseconds.
|
||||
* Accepts both integer-seconds (e.g. "30") and HTTP-date strings.
|
||||
* Returns null when the header is absent or unparseable.
|
||||
*/
|
||||
function parseRetryAfterMs(header: string | undefined): number | null {
|
||||
if (!header) return null
|
||||
const seconds = Number(header)
|
||||
if (!Number.isNaN(seconds) && seconds >= 0) return seconds * 1000
|
||||
const date = Date.parse(header)
|
||||
if (!Number.isNaN(date)) return Math.max(0, date - Date.now())
|
||||
return null
|
||||
}
|
||||
|
||||
async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
|
||||
let lastErr: AgentsApiError | undefined
|
||||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err: unknown) {
|
||||
const classified = classifyError(err)
|
||||
// Only retry 5xx errors
|
||||
if (classified.statusCode >= 500) {
|
||||
lastErr = classified
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
// Honor Retry-After if present; fall back to exponential backoff.
|
||||
const retryAfterHeader = axios.isAxiosError(err)
|
||||
? (err.response?.headers as Record<string, string> | undefined)?.[
|
||||
'retry-after'
|
||||
]
|
||||
: undefined
|
||||
const waitMs =
|
||||
parseRetryAfterMs(retryAfterHeader) ?? 500 * 2 ** attempt
|
||||
await sleep(waitMs)
|
||||
}
|
||||
continue
|
||||
}
|
||||
throw classified
|
||||
}
|
||||
}
|
||||
throw lastErr ?? new AgentsApiError('Request failed after retries', 0)
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<AgentTrigger[]> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.get<ListAgentsResponse>(agentsBaseUrl(), {
|
||||
headers,
|
||||
})
|
||||
return response.data.data ?? []
|
||||
})
|
||||
}
|
||||
|
||||
export async function createAgent(
|
||||
cron: string,
|
||||
prompt: string,
|
||||
): Promise<AgentTrigger> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.post<AgentTrigger>(
|
||||
agentsBaseUrl(),
|
||||
{
|
||||
cron_expr: cron,
|
||||
prompt,
|
||||
// Server-side agent execution always runs in UTC; the timezone field
|
||||
// tells the server how to interpret the cron expression. We use the
|
||||
// system timezone so that "9am every Monday" means 9am local time.
|
||||
// Users can override via the --tz flag parsed in parseArgs.ts.
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC',
|
||||
},
|
||||
{ headers },
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteAgent(id: string): Promise<void> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
await axios.delete(`${agentsBaseUrl()}/${id}`, { headers })
|
||||
})
|
||||
}
|
||||
|
||||
export async function runAgent(id: string): Promise<AgentRunResponse> {
|
||||
return withRetry(async () => {
|
||||
const headers = await buildHeaders()
|
||||
const response = await axios.post<AgentRunResponse>(
|
||||
`${agentsBaseUrl()}/${id}/run`,
|
||||
{},
|
||||
{ headers },
|
||||
)
|
||||
return response.data
|
||||
})
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
name: 'agents-platform',
|
||||
type: 'local',
|
||||
isEnabled: () => false,
|
||||
}
|
||||
29
src/commands/agents-platform/index.ts
Normal file
29
src/commands/agents-platform/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getGlobalConfig } from '../../utils/config.js'
|
||||
import type { Command } from '../../types/command.js'
|
||||
|
||||
// Visible when a workspace API key is available from env or saved settings.
|
||||
// Use a getter so getGlobalConfig() is called lazily (after enableConfigs()
|
||||
// has run in the entry path) instead of at module-load time, which races
|
||||
// the config-system bootstrap and throws "Config accessed before allowed".
|
||||
const agentsPlatform: Command = {
|
||||
type: 'local-jsx',
|
||||
name: 'agents-platform',
|
||||
aliases: ['agents', 'schedule-agent'],
|
||||
description: 'Manage scheduled remote agents (cron-style triggers)',
|
||||
// REPL markdown renderer strips `<...>` as HTML tags — use uppercase.
|
||||
argumentHint: 'list | create CRON PROMPT | delete ID | run ID',
|
||||
get isHidden(): boolean {
|
||||
return (
|
||||
!process.env['ANTHROPIC_API_KEY'] && !getGlobalConfig().workspaceApiKey
|
||||
)
|
||||
},
|
||||
isEnabled: () => true,
|
||||
bridgeSafe: false,
|
||||
availability: ['claude-ai'],
|
||||
load: async () => {
|
||||
const m = await import('./launchAgentsPlatform.js')
|
||||
return { call: m.callAgentsPlatform }
|
||||
},
|
||||
}
|
||||
|
||||
export default agentsPlatform
|
||||
132
src/commands/agents-platform/launchAgentsPlatform.tsx
Normal file
132
src/commands/agents-platform/launchAgentsPlatform.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from '../../services/analytics/index.js';
|
||||
import { parseCronExpression } from '../../utils/cron.js';
|
||||
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
|
||||
import { createAgent, deleteAgent, listAgents, runAgent } from './agentsApi.js';
|
||||
import { AgentsPlatformView } from './AgentsPlatformView.js';
|
||||
import { parseAgentsPlatformArgs } from './parseArgs.js';
|
||||
import { launchCommand } from '../_shared/launchCommand.js';
|
||||
|
||||
type AgentsPlatformViewProps = React.ComponentProps<typeof AgentsPlatformView>;
|
||||
|
||||
async function dispatchAgentsPlatform(
|
||||
parsed: ReturnType<typeof parseAgentsPlatformArgs>,
|
||||
onDone: LocalJSXCommandOnDone,
|
||||
): Promise<AgentsPlatformViewProps | null> {
|
||||
if (parsed.action === 'list') {
|
||||
logEvent('tengu_agents_platform_list', {});
|
||||
try {
|
||||
const agents = await listAgents();
|
||||
onDone(agents.length === 0 ? 'No scheduled agents found.' : `${agents.length} scheduled agent(s).`, {
|
||||
display: 'system',
|
||||
});
|
||||
return { mode: 'list', agents };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to list agents: ${msg}`, { display: 'system' });
|
||||
return { mode: 'error', message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.action === 'create') {
|
||||
const { cron, prompt } = parsed;
|
||||
|
||||
// Validate cron expression client-side before hitting the network
|
||||
const cronFields = parseCronExpression(cron);
|
||||
if (!cronFields) {
|
||||
const reason = `Invalid cron expression: "${cron}". Expected 5 fields (minute hour day month weekday).`;
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(reason, { display: 'system' });
|
||||
return null;
|
||||
}
|
||||
|
||||
logEvent('tengu_agents_platform_create', {
|
||||
cron: cron as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const agent = await createAgent(cron, prompt);
|
||||
onDone(`Agent created: ${agent.id}`, { display: 'system' });
|
||||
return { mode: 'created', agent };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to create agent: ${msg}`, { display: 'system' });
|
||||
return { mode: 'error', message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.action === 'delete') {
|
||||
const { id } = parsed;
|
||||
logEvent('tengu_agents_platform_delete', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
await deleteAgent(id);
|
||||
onDone(`Agent ${id} deleted.`, { display: 'system' });
|
||||
return { mode: 'deleted', id };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to delete agent ${id}: ${msg}`, { display: 'system' });
|
||||
return { mode: 'error', message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
// parsed.action === 'run' (all other actions handled above)
|
||||
const runParsed = parsed as { action: 'run'; id: string };
|
||||
const { id } = runParsed;
|
||||
logEvent('tengu_agents_platform_run', {
|
||||
id: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
try {
|
||||
const result = await runAgent(id);
|
||||
onDone(`Agent ${id} triggered. Run ID: ${result.run_id}`, { display: 'system' });
|
||||
return { mode: 'ran', id, runId: result.run_id };
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: msg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
onDone(`Failed to run agent ${id}: ${msg}`, { display: 'system' });
|
||||
return { mode: 'error', message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export const callAgentsPlatform: LocalJSXCommandCall = launchCommand<
|
||||
ReturnType<typeof parseAgentsPlatformArgs>,
|
||||
AgentsPlatformViewProps
|
||||
>({
|
||||
commandName: 'agents-platform',
|
||||
parseArgs: (raw: string) => {
|
||||
logEvent('tengu_agents_platform_started', {
|
||||
args: raw as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
const result = parseAgentsPlatformArgs(raw);
|
||||
if (result.action === 'invalid') {
|
||||
logEvent('tengu_agents_platform_failed', {
|
||||
reason: result.reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
});
|
||||
return {
|
||||
action: 'invalid' as const,
|
||||
reason: `Usage: /agents-platform list | create CRON PROMPT | delete ID | run ID\n${result.reason}`,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
dispatch: dispatchAgentsPlatform,
|
||||
View: AgentsPlatformView,
|
||||
// Invalid args returns null to match original behaviour (error already surfaced via onDone)
|
||||
errorView: (_msg: string) => null,
|
||||
});
|
||||
102
src/commands/agents-platform/parseArgs.ts
Normal file
102
src/commands/agents-platform/parseArgs.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Parse the args string for the /agents-platform command.
|
||||
*
|
||||
* Supported sub-commands:
|
||||
* list → { action: 'list' }
|
||||
* create <cron-expr> <prompt> → { action: 'create', cron, prompt }
|
||||
* delete <id> → { action: 'delete', id }
|
||||
* run <id> → { action: 'run', id }
|
||||
* (empty) → { action: 'list' }
|
||||
* anything else → { action: 'invalid', reason }
|
||||
*/
|
||||
|
||||
export type AgentsPlatformArgs =
|
||||
| { action: 'list' }
|
||||
| { action: 'create'; cron: string; prompt: string }
|
||||
| { action: 'delete'; id: string }
|
||||
| { action: 'run'; id: string }
|
||||
| { action: 'invalid'; reason: string }
|
||||
|
||||
/**
|
||||
* Cron expressions are 5 space-separated fields.
|
||||
* This helper extracts the first 5 whitespace-separated tokens and joins them.
|
||||
* The remainder of the string is the prompt.
|
||||
* Returns null if fewer than 5 tokens are present.
|
||||
*/
|
||||
export function splitCronAndPrompt(
|
||||
rest: string,
|
||||
): { cron: string; prompt: string } | null {
|
||||
const tokens = rest.trim().split(/\s+/)
|
||||
if (tokens.length < 6) return null
|
||||
const cron = tokens.slice(0, 5).join(' ')
|
||||
const prompt = tokens.slice(5).join(' ')
|
||||
return { cron, prompt }
|
||||
}
|
||||
|
||||
export function parseAgentsPlatformArgs(args: string): AgentsPlatformArgs {
|
||||
const trimmed = args.trim()
|
||||
|
||||
if (trimmed === '' || trimmed === 'list') {
|
||||
return { action: 'list' }
|
||||
}
|
||||
|
||||
// Extract first token as sub-command
|
||||
const spaceIdx = trimmed.indexOf(' ')
|
||||
const subCmd = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)
|
||||
const rest = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim()
|
||||
|
||||
if (subCmd === 'create') {
|
||||
if (!rest) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'create requires a cron expression and prompt, e.g. create "0 9 * * 1" Run daily standup',
|
||||
}
|
||||
}
|
||||
const parsed = splitCronAndPrompt(rest)
|
||||
if (!parsed) {
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason:
|
||||
'create requires at least 5 cron fields followed by a prompt, e.g. create "0 9 * * 1" Run daily standup',
|
||||
}
|
||||
}
|
||||
const { cron, prompt } = parsed
|
||||
// splitCronAndPrompt joins slice(5) so prompt is non-empty by construction;
|
||||
// this guard is a defensive fallback against future refactors.
|
||||
/* istanbul ignore next -- prompt is non-empty by construction from splitCronAndPrompt */
|
||||
if (!prompt.trim()) {
|
||||
return { action: 'invalid', reason: 'prompt cannot be empty' }
|
||||
}
|
||||
return { action: 'create', cron, prompt: prompt.trim() }
|
||||
}
|
||||
|
||||
if (subCmd === 'delete') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'delete requires an agent id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next -- rest is non-empty; split(/\s+/) always yields a non-empty first token */
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'delete requires an agent id' }
|
||||
}
|
||||
return { action: 'delete', id }
|
||||
}
|
||||
|
||||
if (subCmd === 'run') {
|
||||
if (!rest) {
|
||||
return { action: 'invalid', reason: 'run requires an agent id' }
|
||||
}
|
||||
const id = rest.split(/\s+/)[0]
|
||||
/* istanbul ignore next -- rest is non-empty; split(/\s+/) always yields a non-empty first token */
|
||||
if (!id) {
|
||||
return { action: 'invalid', reason: 'run requires an agent id' }
|
||||
}
|
||||
return { action: 'run', id }
|
||||
}
|
||||
|
||||
return {
|
||||
action: 'invalid',
|
||||
reason: `Unknown sub-command "${subCmd}". Use: list | create CRON PROMPT | delete ID | run ID`,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user