@meetquinn/animactl 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -6
- package/dist/server/{runtime/activity-payload.d.ts → activities/format.d.ts} +4 -1
- package/dist/server/activities/format.d.ts.map +1 -0
- package/dist/server/{runtime/activity-payload.js → activities/format.js} +16 -1
- package/dist/server/agents/agent-skills.d.ts +7 -3
- package/dist/server/agents/agent-skills.d.ts.map +1 -1
- package/dist/server/agents/agent-skills.js +243 -38
- package/dist/server/agents/agent-slack.service.d.ts +15 -0
- package/dist/server/agents/agent-slack.service.d.ts.map +1 -1
- package/dist/server/agents/agent-slack.service.js +43 -6
- package/dist/server/agents/default-skills.d.ts +9 -0
- package/dist/server/agents/default-skills.d.ts.map +1 -0
- package/dist/server/agents/default-skills.js +52 -0
- package/dist/server/cli/runtime-cli.d.ts +7 -1
- package/dist/server/cli/runtime-cli.d.ts.map +1 -1
- package/dist/server/cli/runtime-cli.js +107 -3
- package/dist/server/cli/services-cli.d.ts.map +1 -1
- package/dist/server/cli/services-cli.js +39 -0
- package/dist/server/inbox/slack-subscriber.d.ts +2 -0
- package/dist/server/inbox/slack-subscriber.d.ts.map +1 -1
- package/dist/server/inbox/slack-subscriber.js +28 -2
- package/dist/server/inbox/slack-subscription.service.d.ts.map +1 -1
- package/dist/server/inbox/slack-subscription.service.js +13 -1
- package/dist/server/inbox/wake-queue.service.d.ts +3 -1
- package/dist/server/inbox/wake-queue.service.d.ts.map +1 -1
- package/dist/server/inbox/wake-queue.service.js +5 -3
- package/dist/server/provider-usage/http.d.ts +1 -0
- package/dist/server/provider-usage/http.d.ts.map +1 -1
- package/dist/server/provider-usage/http.js +23 -1
- package/dist/server/provider-usage/provider-usage.service.d.ts +1 -4
- package/dist/server/provider-usage/provider-usage.service.d.ts.map +1 -1
- package/dist/server/provider-usage/provider-usage.service.js +1 -18
- package/dist/server/provider-usage/providers/claude.d.ts +1 -1
- package/dist/server/provider-usage/providers/claude.d.ts.map +1 -1
- package/dist/server/provider-usage/providers/claude.js +1 -1
- package/dist/server/provider-usage/providers/codex.d.ts +1 -1
- package/dist/server/provider-usage/providers/codex.d.ts.map +1 -1
- package/dist/server/provider-usage/providers/codex.js +1 -1
- package/dist/server/provider-usage/providers/kimi.d.ts +1 -1
- package/dist/server/provider-usage/providers/kimi.d.ts.map +1 -1
- package/dist/server/provider-usage/providers/kimi.js +4 -4
- package/dist/server/provider-usage/result.d.ts +5 -0
- package/dist/server/provider-usage/result.d.ts.map +1 -0
- package/dist/server/provider-usage/result.js +18 -0
- package/dist/server/providers/active-runtime.d.ts +1 -1
- package/dist/server/providers/active-runtime.d.ts.map +1 -1
- package/dist/server/providers/claude-events.d.ts +1 -1
- package/dist/server/providers/claude-events.d.ts.map +1 -1
- package/dist/server/providers/claude-events.js +2 -2
- package/dist/server/providers/claude.d.ts +1 -1
- package/dist/server/providers/claude.d.ts.map +1 -1
- package/dist/server/providers/claude.js +2 -2
- package/dist/server/providers/codex-app-server.d.ts +1 -1
- package/dist/server/providers/codex-app-server.d.ts.map +1 -1
- package/dist/server/providers/codex-app-server.js +1 -1
- package/dist/server/providers/codex-events.js +1 -1
- package/dist/server/providers/codex.d.ts +1 -1
- package/dist/server/providers/codex.d.ts.map +1 -1
- package/dist/server/providers/codex.js +2 -2
- package/dist/server/{runtime/provider-contract.d.ts → providers/contract.d.ts} +1 -1
- package/dist/server/providers/contract.d.ts.map +1 -0
- package/dist/server/providers/factory.d.ts +1 -1
- package/dist/server/providers/factory.d.ts.map +1 -1
- package/dist/server/providers/kimi-events.d.ts +1 -1
- package/dist/server/providers/kimi-events.d.ts.map +1 -1
- package/dist/server/providers/kimi-events.js +1 -1
- package/dist/server/providers/kimi.d.ts +1 -1
- package/dist/server/providers/kimi.d.ts.map +1 -1
- package/dist/server/providers/kimi.js +3 -2
- package/dist/server/providers/reasoning-events.js +1 -1
- package/dist/server/runtime/active-run-control.d.ts +19 -0
- package/dist/server/runtime/active-run-control.d.ts.map +1 -0
- package/dist/server/runtime/active-run-control.js +89 -0
- package/dist/server/runtime/activity.js +1 -1
- package/dist/server/runtime/agent-runner.d.ts +1 -1
- package/dist/server/runtime/agent-runner.d.ts.map +1 -1
- package/dist/server/runtime/delivery-prompt.d.ts +6 -0
- package/dist/server/runtime/delivery-prompt.d.ts.map +1 -1
- package/dist/server/runtime/delivery-prompt.js +53 -34
- package/dist/server/runtime/followup-appender.d.ts +20 -0
- package/dist/server/runtime/followup-appender.d.ts.map +1 -0
- package/dist/server/runtime/followup-appender.js +128 -0
- package/dist/server/runtime/host.d.ts +2 -0
- package/dist/server/runtime/host.d.ts.map +1 -1
- package/dist/server/runtime/host.js +8 -0
- package/dist/server/runtime/provider-runner.d.ts +16 -0
- package/dist/server/runtime/provider-runner.d.ts.map +1 -0
- package/dist/server/runtime/provider-runner.js +86 -0
- package/dist/server/runtime/runtime-bridge.d.ts +1 -1
- package/dist/server/runtime/runtime-bridge.d.ts.map +1 -1
- package/dist/server/runtime/runtime-session.service.d.ts +1 -1
- package/dist/server/runtime/runtime-session.service.d.ts.map +1 -1
- package/dist/server/runtime/runtime-session.service.js +1 -1
- package/dist/server/runtime/runtime-worker.d.ts +2 -10
- package/dist/server/runtime/runtime-worker.d.ts.map +1 -1
- package/dist/server/runtime/runtime-worker.js +60 -286
- package/dist/server/runtime-management/managed-runtime.d.ts.map +1 -0
- package/dist/server/runtime-management/runtime-release.d.ts.map +1 -0
- package/dist/server/{runtime → runtime-management}/runtime-upgrade.d.ts +1 -0
- package/dist/server/runtime-management/runtime-upgrade.d.ts.map +1 -0
- package/dist/server/{runtime → runtime-management}/runtime-upgrade.js +14 -0
- package/dist/server/services/system.service.d.ts +41 -0
- package/dist/server/services/system.service.d.ts.map +1 -0
- package/dist/server/services/system.service.js +154 -0
- package/dist/server/settings/settings.service.d.ts +2 -1
- package/dist/server/settings/settings.service.d.ts.map +1 -1
- package/dist/server/settings/settings.service.js +9 -1
- package/dist/server/slack/shortcuts.d.ts +0 -3
- package/dist/server/slack/shortcuts.d.ts.map +1 -1
- package/dist/server/slack/shortcuts.js +0 -3
- package/dist/server/slack-interactions/shortcut-ids.d.ts +4 -0
- package/dist/server/slack-interactions/shortcut-ids.d.ts.map +1 -0
- package/dist/server/slack-interactions/shortcut-ids.js +3 -0
- package/dist/server/slack-interactions/shortcut-views.d.ts +65 -0
- package/dist/server/slack-interactions/shortcut-views.d.ts.map +1 -0
- package/dist/server/slack-interactions/shortcut-views.js +211 -0
- package/dist/server/{slack → slack-interactions}/shortcut.service.d.ts +2 -47
- package/dist/server/slack-interactions/shortcut.service.d.ts.map +1 -0
- package/dist/server/slack-interactions/shortcut.service.js +227 -0
- package/dist/server/storage/jsonl-log.d.ts +9 -0
- package/dist/server/storage/jsonl-log.d.ts.map +1 -1
- package/dist/server/storage/jsonl-log.js +36 -1
- package/dist/server/storage/schema/server.store.d.ts +5 -0
- package/dist/server/storage/schema/server.store.d.ts.map +1 -1
- package/dist/server/storage/schema/server.store.js +2 -1
- package/dist/server/storage/schema/wake-queue.store.d.ts +15 -1
- package/dist/server/storage/schema/wake-queue.store.d.ts.map +1 -1
- package/dist/server/storage/schema/wake-queue.store.js +5 -3
- package/dist/server/tests/agent-config.test.d.ts +2 -0
- package/dist/server/tests/agent-config.test.d.ts.map +1 -0
- package/dist/server/tests/agent-config.test.js +229 -0
- package/dist/server/tests/agent-runtime.test.d.ts +2 -0
- package/dist/server/tests/agent-runtime.test.d.ts.map +1 -0
- package/dist/server/tests/agent-runtime.test.js +1110 -0
- package/dist/server/tests/agent-seed-memory.test.d.ts +2 -0
- package/dist/server/tests/agent-seed-memory.test.d.ts.map +1 -0
- package/dist/server/tests/agent-seed-memory.test.js +16 -0
- package/dist/server/tests/agent-skills.test.d.ts +2 -0
- package/dist/server/tests/agent-skills.test.d.ts.map +1 -0
- package/dist/server/tests/agent-skills.test.js +140 -0
- package/dist/server/tests/anima-home.d.ts +2 -0
- package/dist/server/tests/anima-home.d.ts.map +1 -0
- package/dist/server/tests/anima-home.js +4 -0
- package/dist/server/tests/cli-file.test.d.ts +2 -0
- package/dist/server/tests/cli-file.test.d.ts.map +1 -0
- package/dist/server/tests/cli-file.test.js +596 -0
- package/dist/server/tests/cli-message.test.d.ts +2 -0
- package/dist/server/tests/cli-message.test.d.ts.map +1 -0
- package/dist/server/tests/cli-message.test.js +1196 -0
- package/dist/server/tests/client-error-routes.test.d.ts +2 -0
- package/dist/server/tests/client-error-routes.test.d.ts.map +1 -0
- package/dist/server/tests/client-error-routes.test.js +163 -0
- package/dist/server/tests/config.test.d.ts +2 -0
- package/dist/server/tests/config.test.d.ts.map +1 -0
- package/dist/server/tests/config.test.js +497 -0
- package/dist/server/tests/default-skills.test.d.ts +2 -0
- package/dist/server/tests/default-skills.test.d.ts.map +1 -0
- package/dist/server/tests/default-skills.test.js +57 -0
- package/dist/server/tests/helpers/inbox.d.ts +10 -0
- package/dist/server/tests/helpers/inbox.d.ts.map +1 -0
- package/dist/server/tests/helpers/inbox.js +21 -0
- package/dist/server/tests/helpers/slack.d.ts +11 -0
- package/dist/server/tests/helpers/slack.d.ts.map +1 -0
- package/dist/server/tests/helpers/slack.js +17 -0
- package/dist/server/tests/helpers/state.d.ts +19 -0
- package/dist/server/tests/helpers/state.d.ts.map +1 -0
- package/dist/server/tests/helpers/state.js +69 -0
- package/dist/server/tests/inbox-slack-events.test.d.ts +2 -0
- package/dist/server/tests/inbox-slack-events.test.d.ts.map +1 -0
- package/dist/server/tests/inbox-slack-events.test.js +271 -0
- package/dist/server/tests/inbox.test.d.ts +2 -0
- package/dist/server/tests/inbox.test.d.ts.map +1 -0
- package/dist/server/tests/inbox.test.js +144 -0
- package/dist/server/tests/interactive-ask.test.d.ts +2 -0
- package/dist/server/tests/interactive-ask.test.d.ts.map +1 -0
- package/dist/server/tests/interactive-ask.test.js +387 -0
- package/dist/server/tests/kb.test.d.ts +2 -0
- package/dist/server/tests/kb.test.d.ts.map +1 -0
- package/dist/server/tests/kb.test.js +371 -0
- package/dist/server/tests/message.service.test.d.ts +2 -0
- package/dist/server/tests/message.service.test.d.ts.map +1 -0
- package/dist/server/tests/message.service.test.js +151 -0
- package/dist/server/tests/messages.test.d.ts +2 -0
- package/dist/server/tests/messages.test.d.ts.map +1 -0
- package/dist/server/tests/messages.test.js +12 -0
- package/dist/server/tests/prompt-attachments.test.d.ts +2 -0
- package/dist/server/tests/prompt-attachments.test.d.ts.map +1 -0
- package/dist/server/tests/prompt-attachments.test.js +274 -0
- package/dist/server/tests/provider-usage.test.d.ts +2 -0
- package/dist/server/tests/provider-usage.test.d.ts.map +1 -0
- package/dist/server/tests/provider-usage.test.js +97 -0
- package/dist/server/tests/reminders.test.d.ts +2 -0
- package/dist/server/tests/reminders.test.d.ts.map +1 -0
- package/dist/server/tests/reminders.test.js +383 -0
- package/dist/server/tests/runtime-cli.test.d.ts +2 -0
- package/dist/server/tests/runtime-cli.test.d.ts.map +1 -0
- package/dist/server/tests/runtime-cli.test.js +21 -0
- package/dist/server/tests/runtime-upgrade.test.d.ts +2 -0
- package/dist/server/tests/runtime-upgrade.test.d.ts.map +1 -0
- package/dist/server/tests/runtime-upgrade.test.js +307 -0
- package/dist/server/tests/runtime-worker.test.d.ts +2 -0
- package/dist/server/tests/runtime-worker.test.d.ts.map +1 -0
- package/dist/server/tests/runtime-worker.test.js +1021 -0
- package/dist/server/tests/runtime.test.d.ts +2 -0
- package/dist/server/tests/runtime.test.d.ts.map +1 -0
- package/dist/server/tests/runtime.test.js +354 -0
- package/dist/server/tests/services.test.d.ts +2 -0
- package/dist/server/tests/services.test.d.ts.map +1 -0
- package/dist/server/tests/services.test.js +636 -0
- package/dist/server/tests/slack-files.test.d.ts +2 -0
- package/dist/server/tests/slack-files.test.d.ts.map +1 -0
- package/dist/server/tests/slack-files.test.js +118 -0
- package/dist/server/tests/slack-shortcuts.test.d.ts +2 -0
- package/dist/server/tests/slack-shortcuts.test.d.ts.map +1 -0
- package/dist/server/tests/slack-shortcuts.test.js +310 -0
- package/dist/server/tests/slack.test.d.ts +2 -0
- package/dist/server/tests/slack.test.d.ts.map +1 -0
- package/dist/server/tests/slack.test.js +213 -0
- package/dist/server/tests/state-cache.test.d.ts +2 -0
- package/dist/server/tests/state-cache.test.d.ts.map +1 -0
- package/dist/server/tests/state-cache.test.js +184 -0
- package/dist/server/tests/subscriptions.test.d.ts +2 -0
- package/dist/server/tests/subscriptions.test.d.ts.map +1 -0
- package/dist/server/tests/subscriptions.test.js +292 -0
- package/dist/server/tests/system-service.test.d.ts +2 -0
- package/dist/server/tests/system-service.test.d.ts.map +1 -0
- package/dist/server/tests/system-service.test.js +20 -0
- package/dist/server/tests/url-routes.test.d.ts +2 -0
- package/dist/server/tests/url-routes.test.d.ts.map +1 -0
- package/dist/server/tests/url-routes.test.js +217 -0
- package/dist/server/tests/web-api.test.d.ts +2 -0
- package/dist/server/tests/web-api.test.d.ts.map +1 -0
- package/dist/server/tests/web-api.test.js +1759 -0
- package/dist/server/web/app.d.ts.map +1 -1
- package/dist/server/web/app.js +2 -0
- package/dist/server/web/client-error-routes.d.ts +3 -0
- package/dist/server/web/client-error-routes.d.ts.map +1 -0
- package/dist/server/web/client-error-routes.js +101 -0
- package/dist/server/web/system-routes.d.ts.map +1 -1
- package/dist/server/web/system-routes.js +21 -131
- package/dist/shared/agent-config.d.ts +8 -18
- package/dist/shared/agent-config.d.ts.map +1 -1
- package/dist/shared/agent-config.js +3 -4
- package/dist/shared/inbox.d.ts +27 -0
- package/dist/shared/inbox.d.ts.map +1 -1
- package/dist/shared/inbox.js +1 -0
- package/dist/shared/server-info.d.ts +6 -5
- package/dist/shared/server-info.d.ts.map +1 -1
- package/dist/shared/server-info.js +3 -1
- package/dist/shared/server-settings.d.ts +6 -0
- package/dist/shared/server-settings.d.ts.map +1 -1
- package/dist/shared/server-settings.js +1 -0
- package/dist/shared/skills.d.ts +24 -3
- package/dist/shared/skills.d.ts.map +1 -1
- package/dist/web/assets/activity-DHjip8tY.js +7 -0
- package/dist/web/assets/clipboard-D2NSC52v.js +1 -0
- package/dist/web/assets/{external-link-Dt0qTpmV.js → external-link-Bhr1WdRS.js} +1 -1
- package/dist/web/assets/{file-text-Dd9ys6py.js → file-text-CYMA_SMA.js} +1 -1
- package/dist/web/assets/{index-CzAQnbuO.js → index-CVpew409.js} +5 -5
- package/dist/web/assets/index-mh0uJTkY.css +2 -0
- package/dist/web/assets/kb-DDVpYkT9.js +35 -0
- package/dist/web/assets/profile-CBy6GNzp.js +1 -0
- package/dist/web/assets/reminders-Bbwb9QNT.js +1 -0
- package/dist/web/favicon.svg +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/templates/runtime-standing-prompt.md +16 -2
- package/templates/skills/find-skills/SKILL.md +133 -0
- package/dist/server/runtime/activity-payload.d.ts.map +0 -1
- package/dist/server/runtime/activity-text.d.ts +0 -4
- package/dist/server/runtime/activity-text.d.ts.map +0 -1
- package/dist/server/runtime/activity-text.js +0 -16
- package/dist/server/runtime/managed-runtime.d.ts.map +0 -1
- package/dist/server/runtime/provider-contract.d.ts.map +0 -1
- package/dist/server/runtime/runtime-release.d.ts.map +0 -1
- package/dist/server/runtime/runtime-upgrade.d.ts.map +0 -1
- package/dist/server/slack/shortcut.service.d.ts.map +0 -1
- package/dist/server/slack/shortcut.service.js +0 -493
- package/dist/web/assets/activity-BP0C81IV.js +0 -7
- package/dist/web/assets/globe-DmDZSgDP.js +0 -1
- package/dist/web/assets/index-DKecrvN0.css +0 -2
- package/dist/web/assets/kb-3wFeP2Jt.js +0 -32
- package/dist/web/assets/profile-D7wPZXW_.js +0 -1
- package/dist/web/assets/reminders-DOhroVwo.js +0 -1
- /package/dist/server/{runtime/provider-contract.js → providers/contract.js} +0 -0
- /package/dist/server/{runtime → runtime-management}/managed-runtime.d.ts +0 -0
- /package/dist/server/{runtime → runtime-management}/managed-runtime.js +0 -0
- /package/dist/server/{runtime → runtime-management}/runtime-release.d.ts +0 -0
- /package/dist/server/{runtime → runtime-management}/runtime-release.js +0 -0
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
import { once } from 'node:events';
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import test from 'node:test';
|
|
7
|
+
import assert from 'node:assert/strict';
|
|
8
|
+
import { redactAgentConfig } from '../agents/agent-config-ops.js';
|
|
9
|
+
import { agentSlackServiceForAgent } from '../agents/agent-slack.service.js';
|
|
10
|
+
import { activityServiceForAgent } from '../activities/activity.service.js';
|
|
11
|
+
import { createWebServer } from '../web/app.js';
|
|
12
|
+
import { defaultAgentRegistryService } from '../agents/agent.service.js';
|
|
13
|
+
import { makeSlackEvent } from './helpers/slack.js';
|
|
14
|
+
import { ingestEvent } from './helpers/inbox.js';
|
|
15
|
+
import { WakeQueueService } from '../inbox/wake-queue.service.js';
|
|
16
|
+
import { recordRuntimeEvent } from '../runtime/activity.js';
|
|
17
|
+
import { persistProviderSession } from '../runtime/runtime-bridge.js';
|
|
18
|
+
import { setActiveRuntimeItem } from '../runtime/active-item.js';
|
|
19
|
+
import { recordLifetimeTokenUsageForItem, tokenDeltaForActivities } from '../runtime/usage.js';
|
|
20
|
+
import { CURRENT_SLACK_MANIFEST_VERSION } from '../../shared/slack-manifest.js';
|
|
21
|
+
import { withAnimaHome } from './anima-home.js';
|
|
22
|
+
const agentService = (agentId) => defaultAgentRegistryService.serviceFor(agentId);
|
|
23
|
+
test('web snapshot summarizes state without exposing secrets', async () => {
|
|
24
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-test-'));
|
|
25
|
+
try {
|
|
26
|
+
await writeConfig(stateDir);
|
|
27
|
+
await withAnimaHome(stateDir, async () => {
|
|
28
|
+
const ctx = await ingestEvent(makeSlackEvent({
|
|
29
|
+
channelId: 'D-demo',
|
|
30
|
+
teamId: 'T-demo',
|
|
31
|
+
text: 'Show this in the web app.',
|
|
32
|
+
ts: '1770000000.000001',
|
|
33
|
+
userId: 'U1',
|
|
34
|
+
}), { agentId: 'anima', stateDir });
|
|
35
|
+
await activityServiceForAgent('anima').record({
|
|
36
|
+
payload: {
|
|
37
|
+
channel: 'D-demo',
|
|
38
|
+
payload: {
|
|
39
|
+
channel: 'D-demo',
|
|
40
|
+
text: 'Visible in output.',
|
|
41
|
+
},
|
|
42
|
+
status: 'dry-run',
|
|
43
|
+
text: 'Visible in output.',
|
|
44
|
+
tool: 'anima.message.send',
|
|
45
|
+
},
|
|
46
|
+
type: 'tool.call.completed',
|
|
47
|
+
});
|
|
48
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
49
|
+
contextWindow: 200000,
|
|
50
|
+
currentContextTokens: 1300,
|
|
51
|
+
eventType: 'codex.context.stats',
|
|
52
|
+
runtimeKind: 'codex-cli',
|
|
53
|
+
});
|
|
54
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
55
|
+
contextWindow: 200000,
|
|
56
|
+
eventType: 'codex.session.stats',
|
|
57
|
+
inputTokens: 1200,
|
|
58
|
+
outputTokens: 80,
|
|
59
|
+
runtimeKind: 'codex-cli',
|
|
60
|
+
});
|
|
61
|
+
const agentConfig = redactAgentConfig(await agentService('anima').getConfig());
|
|
62
|
+
const animaSession = await agentService('anima').getSession();
|
|
63
|
+
const sessionRecord = JSON.parse(await readFile(join(stateDir, 'agents/anima/sessions.json'), 'utf8'));
|
|
64
|
+
assert.equal(animaSession?.currentStartedAt, ctx.session.createdAt);
|
|
65
|
+
assert.equal(sessionRecord.currentStartedAt, ctx.session.createdAt);
|
|
66
|
+
assert.deepEqual(sessionRecord.latestProviderStats, animaSession?.latestProviderStats);
|
|
67
|
+
assert.deepEqual(Object.keys(agentConfig.provider?.env ?? {}), ['CODEX_SECRET']);
|
|
68
|
+
assert.equal(agentConfig.provider?.env?.['CODEX_SECRET'], '');
|
|
69
|
+
assert.equal(agentConfig.provider?.model, 'gpt-5.2-codex');
|
|
70
|
+
assert.ok(agentConfig.provider && 'reasoningEffort' in agentConfig.provider);
|
|
71
|
+
assert.equal(agentConfig.provider.reasoningEffort, 'high');
|
|
72
|
+
assert.equal(agentConfig.slack?.appToken, '');
|
|
73
|
+
assert.equal(agentConfig.slack?.botToken, '');
|
|
74
|
+
assert.equal(agentConfig.slack?.connected, true);
|
|
75
|
+
assert.deepEqual(animaSession?.latestProviderStats, {
|
|
76
|
+
activityId: animaSession?.latestProviderStats?.activityId,
|
|
77
|
+
contextWindow: 200000,
|
|
78
|
+
createdAt: animaSession?.latestProviderStats?.createdAt,
|
|
79
|
+
currentContextTokens: 1300,
|
|
80
|
+
inputTokens: 1200,
|
|
81
|
+
outputTokens: 80,
|
|
82
|
+
runtimeKind: 'codex-cli',
|
|
83
|
+
sessionTokenUsage: 1280,
|
|
84
|
+
usedTokens: 1280,
|
|
85
|
+
});
|
|
86
|
+
const serialized = JSON.stringify(agentConfig);
|
|
87
|
+
assert.match(serialized, /CODEX_SECRET/);
|
|
88
|
+
assert.doesNotMatch(serialized, /runtime-secret-value/);
|
|
89
|
+
assert.doesNotMatch(serialized, /xapp-secret-value/);
|
|
90
|
+
assert.doesNotMatch(serialized, /xoxb-secret-value/);
|
|
91
|
+
// Activities are now a separate call
|
|
92
|
+
const activityFeed = await activityServiceForAgent('anima').listActivityFeed();
|
|
93
|
+
const inboxEvent = activityFeed.events.find((event) => event.kind === 'inbox');
|
|
94
|
+
assert.equal(inboxEvent?.kind === 'inbox' ? inboxEvent.item.id : undefined, ctx.item.id);
|
|
95
|
+
assert.deepEqual(Object.keys(activityFeed).sort(), ['events', 'nextCursor']);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
test('activity feed pages combined feed events without returning all inbox items', async () => {
|
|
103
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-activity-feed-page-test-'));
|
|
104
|
+
try {
|
|
105
|
+
await writeConfig(stateDir);
|
|
106
|
+
await withAnimaHome(stateDir, async () => {
|
|
107
|
+
const first = await ingestEvent(makeSlackEvent({
|
|
108
|
+
channelId: 'D-demo',
|
|
109
|
+
teamId: 'T-demo',
|
|
110
|
+
text: 'First',
|
|
111
|
+
ts: '1770000000.000001',
|
|
112
|
+
userId: 'U1',
|
|
113
|
+
}), { agentId: 'anima', stateDir });
|
|
114
|
+
const second = await ingestEvent(makeSlackEvent({
|
|
115
|
+
channelId: 'D-demo',
|
|
116
|
+
teamId: 'T-demo',
|
|
117
|
+
text: 'Second',
|
|
118
|
+
ts: '1770000001.000001',
|
|
119
|
+
userId: 'U1',
|
|
120
|
+
}), { agentId: 'anima', stateDir });
|
|
121
|
+
const third = await ingestEvent(makeSlackEvent({
|
|
122
|
+
channelId: 'D-demo',
|
|
123
|
+
teamId: 'T-demo',
|
|
124
|
+
text: 'Third',
|
|
125
|
+
ts: '1770000002.000001',
|
|
126
|
+
userId: 'U1',
|
|
127
|
+
}), { agentId: 'anima', stateDir });
|
|
128
|
+
const page = await activityServiceForAgent('anima').listActivityFeed({ limit: 2 });
|
|
129
|
+
assert.equal(page.events.length, 2);
|
|
130
|
+
assert.deepEqual(page.events.map((event) => event.kind === 'inbox' ? event.item.id : event.activity.activityId), [second.item.id, third.item.id]);
|
|
131
|
+
assert.ok(page.nextCursor);
|
|
132
|
+
assert.equal(page.events.some((event) => event.kind === 'inbox' && event.item.id === first.item.id), false);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
test('web snapshot includes Claude auto-compact threshold with provider stats', async () => {
|
|
140
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-claude-stats-test-'));
|
|
141
|
+
try {
|
|
142
|
+
await writeConfig(stateDir, [
|
|
143
|
+
{
|
|
144
|
+
...defaultAgentConfig('iris'),
|
|
145
|
+
provider: {
|
|
146
|
+
env: {
|
|
147
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456',
|
|
148
|
+
CODEX_SECRET: 'runtime-secret-value',
|
|
149
|
+
},
|
|
150
|
+
kind: 'claude-code',
|
|
151
|
+
model: 'opus',
|
|
152
|
+
reasoningEffort: 'xhigh',
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
]);
|
|
156
|
+
await withAnimaHome(stateDir, async () => {
|
|
157
|
+
await ingestEvent(makeSlackEvent({
|
|
158
|
+
channelId: 'D-iris',
|
|
159
|
+
teamId: 'T-demo',
|
|
160
|
+
text: 'Record Claude stats.',
|
|
161
|
+
ts: '1770000000.000002',
|
|
162
|
+
userId: 'U1',
|
|
163
|
+
}), { agentId: 'iris', stateDir });
|
|
164
|
+
await recordRuntimeEvent({ agentId: 'iris' }, 'claude-code', {
|
|
165
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456',
|
|
166
|
+
}, {
|
|
167
|
+
currentContextTokens: 120000,
|
|
168
|
+
eventType: 'claude.context.stats',
|
|
169
|
+
runtimeKind: 'claude-code',
|
|
170
|
+
});
|
|
171
|
+
await recordRuntimeEvent({ agentId: 'iris' }, 'claude-code', {
|
|
172
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456',
|
|
173
|
+
}, {
|
|
174
|
+
cacheReadInputTokens: 210000,
|
|
175
|
+
contextWindow: 1000000,
|
|
176
|
+
eventType: 'claude.session.stats',
|
|
177
|
+
outputTokens: 1000,
|
|
178
|
+
runtimeKind: 'claude-code',
|
|
179
|
+
});
|
|
180
|
+
await recordRuntimeEvent({ agentId: 'iris' }, 'claude-code', {
|
|
181
|
+
CLAUDE_CODE_AUTO_COMPACT_WINDOW: '123456',
|
|
182
|
+
}, {
|
|
183
|
+
eventType: 'claude.compact.completed',
|
|
184
|
+
runtimeKind: 'claude-code',
|
|
185
|
+
});
|
|
186
|
+
const irisSession = await agentService('iris').getSession();
|
|
187
|
+
assert.deepEqual(irisSession?.latestProviderStats, {
|
|
188
|
+
activityId: irisSession?.latestProviderStats?.activityId,
|
|
189
|
+
autoCompactWindow: 123456,
|
|
190
|
+
cacheReadInputTokens: 210000,
|
|
191
|
+
contextWindow: 1000000,
|
|
192
|
+
createdAt: irisSession?.latestProviderStats?.createdAt,
|
|
193
|
+
currentContextTokens: 120000,
|
|
194
|
+
outputTokens: 1000,
|
|
195
|
+
runtimeKind: 'claude-code',
|
|
196
|
+
sessionCompactionCount: 1,
|
|
197
|
+
sessionTokenUsage: 211000,
|
|
198
|
+
usedTokens: 211000,
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
test('web snapshot includes Kimi context-window occupancy', async () => {
|
|
207
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-kimi-stats-test-'));
|
|
208
|
+
try {
|
|
209
|
+
await writeConfig(stateDir, [
|
|
210
|
+
{
|
|
211
|
+
...defaultAgentConfig('kimi'),
|
|
212
|
+
provider: {
|
|
213
|
+
kind: 'kimi-cli',
|
|
214
|
+
model: 'kimi-code/kimi-for-coding',
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
]);
|
|
218
|
+
await withAnimaHome(stateDir, async () => {
|
|
219
|
+
await ingestEvent(makeSlackEvent({
|
|
220
|
+
channelId: 'D-kimi',
|
|
221
|
+
teamId: 'T-demo',
|
|
222
|
+
text: 'Record Kimi stats.',
|
|
223
|
+
ts: '1770000000.000003',
|
|
224
|
+
userId: 'U1',
|
|
225
|
+
}), { agentId: 'kimi', stateDir });
|
|
226
|
+
await recordRuntimeEvent({ agentId: 'kimi' }, 'kimi-cli', undefined, {
|
|
227
|
+
cacheReadInputTokens: 1024,
|
|
228
|
+
contextWindow: 262144,
|
|
229
|
+
currentContextTokens: 13131,
|
|
230
|
+
eventType: 'kimi.context.stats',
|
|
231
|
+
inputTokens: 12107,
|
|
232
|
+
outputTokens: 24,
|
|
233
|
+
runtimeKind: 'kimi-cli',
|
|
234
|
+
});
|
|
235
|
+
const kimiSession = await agentService('kimi').getSession();
|
|
236
|
+
assert.deepEqual(kimiSession?.latestProviderStats, {
|
|
237
|
+
activityId: kimiSession?.latestProviderStats?.activityId,
|
|
238
|
+
cacheReadInputTokens: 1024,
|
|
239
|
+
contextWindow: 262144,
|
|
240
|
+
createdAt: kimiSession?.latestProviderStats?.createdAt,
|
|
241
|
+
currentContextTokens: 13131,
|
|
242
|
+
inputTokens: 12107,
|
|
243
|
+
outputTokens: 24,
|
|
244
|
+
runtimeKind: 'kimi-cli',
|
|
245
|
+
usedTokens: 13155,
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
finally {
|
|
250
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
test('web snapshot exposes persisted lifetime token usage', async () => {
|
|
254
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-lifetime-usage-test-'));
|
|
255
|
+
try {
|
|
256
|
+
await writeConfig(stateDir);
|
|
257
|
+
await withAnimaHome(stateDir, async () => {
|
|
258
|
+
const ctx = await ingestEvent(makeSlackEvent({
|
|
259
|
+
channelId: 'D-demo',
|
|
260
|
+
teamId: 'T-demo',
|
|
261
|
+
text: 'Record lifetime usage.',
|
|
262
|
+
ts: '1770000000.000004',
|
|
263
|
+
userId: 'U1',
|
|
264
|
+
}), { agentId: 'anima', stateDir });
|
|
265
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
266
|
+
cacheReadInputTokens: 20,
|
|
267
|
+
eventType: 'codex.session.stats',
|
|
268
|
+
inputTokens: 100,
|
|
269
|
+
outputTokens: 5,
|
|
270
|
+
runtimeKind: 'codex-cli',
|
|
271
|
+
});
|
|
272
|
+
await new WakeQueueService('anima').complete(ctx.item.id);
|
|
273
|
+
await recordLifetimeTokenUsageForItem('anima', ctx.item.id);
|
|
274
|
+
await recordLifetimeTokenUsageForItem('anima', ctx.item.id);
|
|
275
|
+
const animaSessionTokens = await agentService('anima').getSession();
|
|
276
|
+
assert.equal(animaSessionTokens?.lifetimeTokens, 250);
|
|
277
|
+
const usage = JSON.parse(await readFile(join(stateDir, 'agents', 'anima', 'usage.json'), 'utf8'));
|
|
278
|
+
assert.equal(usage.totalTokens, 250);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
test('web snapshot scopes current-session metrics to the latest rotation boundary', async () => {
|
|
286
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-current-session-test-'));
|
|
287
|
+
try {
|
|
288
|
+
await writeConfig(stateDir);
|
|
289
|
+
await withAnimaHome(stateDir, async () => {
|
|
290
|
+
const before = await ingestEvent(makeSlackEvent({
|
|
291
|
+
channelId: 'D-demo',
|
|
292
|
+
teamId: 'T-demo',
|
|
293
|
+
text: 'Before rotation.',
|
|
294
|
+
ts: '1770000000.000020',
|
|
295
|
+
userId: 'U1',
|
|
296
|
+
}), { agentId: 'anima', stateDir });
|
|
297
|
+
await new WakeQueueService('anima').replaceItem({
|
|
298
|
+
...before.item,
|
|
299
|
+
handling: {
|
|
300
|
+
...before.item.handling,
|
|
301
|
+
createdAt: '2026-05-22T04:28:00.000Z',
|
|
302
|
+
updatedAt: '2026-05-22T04:28:00.000Z',
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
306
|
+
cacheReadInputTokens: 900,
|
|
307
|
+
eventType: 'codex.session.stats',
|
|
308
|
+
inputTokens: 100,
|
|
309
|
+
outputTokens: 1,
|
|
310
|
+
runtimeKind: 'codex-cli',
|
|
311
|
+
}, '2026-05-22T04:28:30.000Z');
|
|
312
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
313
|
+
eventType: 'codex.compact.completed',
|
|
314
|
+
runtimeKind: 'codex-cli',
|
|
315
|
+
}, '2026-05-22T04:28:31.000Z');
|
|
316
|
+
const rotatedAt = '2026-05-22T04:29:56.481Z';
|
|
317
|
+
const sessionPath = join(stateDir, 'agents/anima/sessions.json');
|
|
318
|
+
const session = JSON.parse(await readFile(sessionPath, 'utf8'));
|
|
319
|
+
await writeFile(sessionPath, `${JSON.stringify({
|
|
320
|
+
...session,
|
|
321
|
+
archived: [
|
|
322
|
+
{
|
|
323
|
+
archivedAt: rotatedAt,
|
|
324
|
+
archivedBy: 'operator',
|
|
325
|
+
id: 'old-provider-session',
|
|
326
|
+
kind: 'codex-cli',
|
|
327
|
+
updatedAt: '2026-05-22T04:29:00.000Z',
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
current: {
|
|
331
|
+
id: 'new-provider-session',
|
|
332
|
+
kind: 'codex-cli',
|
|
333
|
+
updatedAt: '2026-05-22T04:31:00.000Z',
|
|
334
|
+
},
|
|
335
|
+
currentStartedAt: rotatedAt,
|
|
336
|
+
latestProviderStats: undefined,
|
|
337
|
+
}, null, 2)}\n`, 'utf8');
|
|
338
|
+
await ingestEvent(makeSlackEvent({
|
|
339
|
+
channelId: 'D-demo',
|
|
340
|
+
teamId: 'T-demo',
|
|
341
|
+
text: 'After rotation.',
|
|
342
|
+
ts: '1770000001.000020',
|
|
343
|
+
userId: 'U1',
|
|
344
|
+
}), { agentId: 'anima', stateDir });
|
|
345
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
346
|
+
contextWindow: 200000,
|
|
347
|
+
currentContextTokens: 2000,
|
|
348
|
+
eventType: 'codex.context.stats',
|
|
349
|
+
runtimeKind: 'codex-cli',
|
|
350
|
+
}, '2026-05-22T04:31:00.000Z');
|
|
351
|
+
await recordRuntimeEvent({ agentId: 'anima' }, 'codex-cli', undefined, {
|
|
352
|
+
eventType: 'codex.session.stats',
|
|
353
|
+
inputTokens: 20,
|
|
354
|
+
outputTokens: 3,
|
|
355
|
+
runtimeKind: 'codex-cli',
|
|
356
|
+
}, '2026-05-22T04:31:01.000Z');
|
|
357
|
+
const animaSessionRotated = await agentService('anima').getSession();
|
|
358
|
+
assert.equal(animaSessionRotated?.currentStartedAt, rotatedAt);
|
|
359
|
+
assert.deepEqual(animaSessionRotated?.latestProviderStats, {
|
|
360
|
+
activityId: animaSessionRotated?.latestProviderStats?.activityId,
|
|
361
|
+
contextWindow: 200000,
|
|
362
|
+
createdAt: animaSessionRotated?.latestProviderStats?.createdAt,
|
|
363
|
+
currentContextTokens: 2000,
|
|
364
|
+
inputTokens: 20,
|
|
365
|
+
outputTokens: 3,
|
|
366
|
+
runtimeKind: 'codex-cli',
|
|
367
|
+
sessionTokenUsage: 23,
|
|
368
|
+
usedTokens: 23,
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
finally {
|
|
373
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
test('lifetime token delta uses one terminal stats activity per item', () => {
|
|
377
|
+
assert.equal(tokenDeltaForActivities([
|
|
378
|
+
{
|
|
379
|
+
activityId: 'actv_1',
|
|
380
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
381
|
+
payload: {
|
|
382
|
+
cacheReadInputTokens: 10,
|
|
383
|
+
eventType: 'kimi.context.stats',
|
|
384
|
+
inputTokens: 20,
|
|
385
|
+
outputTokens: 5,
|
|
386
|
+
},
|
|
387
|
+
type: 'runtime.event',
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
activityId: 'actv_2',
|
|
391
|
+
createdAt: '2026-01-01T00:00:01.000Z',
|
|
392
|
+
payload: {
|
|
393
|
+
cacheCreationInputTokens: 2,
|
|
394
|
+
cacheReadInputTokens: 30,
|
|
395
|
+
eventType: 'kimi.context.stats',
|
|
396
|
+
inputTokens: 40,
|
|
397
|
+
outputTokens: 6,
|
|
398
|
+
},
|
|
399
|
+
type: 'runtime.event',
|
|
400
|
+
},
|
|
401
|
+
]), 78);
|
|
402
|
+
assert.equal(tokenDeltaForActivities([
|
|
403
|
+
{
|
|
404
|
+
activityId: 'actv_3',
|
|
405
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
406
|
+
payload: {
|
|
407
|
+
eventType: 'claude.session.stats',
|
|
408
|
+
inputTokens: 10,
|
|
409
|
+
outputTokens: 5,
|
|
410
|
+
totalTokens: 99,
|
|
411
|
+
},
|
|
412
|
+
type: 'runtime.event',
|
|
413
|
+
},
|
|
414
|
+
]), 99);
|
|
415
|
+
});
|
|
416
|
+
test('web snapshot includes unfiltered agent queue statuses', async () => {
|
|
417
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-status-test-'));
|
|
418
|
+
try {
|
|
419
|
+
await writeConfig(stateDir, [
|
|
420
|
+
defaultAgentConfig('anima'),
|
|
421
|
+
defaultAgentConfig('milo'),
|
|
422
|
+
]);
|
|
423
|
+
await withAnimaHome(stateDir, async () => {
|
|
424
|
+
const running = await ingestEvent(makeSlackEvent({
|
|
425
|
+
channelId: 'C-demo',
|
|
426
|
+
teamId: 'T-demo',
|
|
427
|
+
text: 'Run for Milo.',
|
|
428
|
+
ts: '1770000000.000010',
|
|
429
|
+
userId: 'U1',
|
|
430
|
+
}), { agentId: 'milo', stateDir });
|
|
431
|
+
await new WakeQueueService('milo').claimNext('worker-1');
|
|
432
|
+
await setActiveRuntimeItem({
|
|
433
|
+
agentId: 'milo',
|
|
434
|
+
startedAt: '2026-05-20T08:00:00.000Z',
|
|
435
|
+
itemId: running.item.id,
|
|
436
|
+
workerId: 'worker-1',
|
|
437
|
+
});
|
|
438
|
+
const queued = await ingestEvent(makeSlackEvent({
|
|
439
|
+
channelId: 'C-demo',
|
|
440
|
+
teamId: 'T-demo',
|
|
441
|
+
text: 'Queued for Milo.',
|
|
442
|
+
ts: '1770000000.000011',
|
|
443
|
+
userId: 'U1',
|
|
444
|
+
}), { agentId: 'milo', stateDir });
|
|
445
|
+
// Activities are agent-scoped — anima activities contain no milo items
|
|
446
|
+
const animaActivityFeed = await activityServiceForAgent('anima').listActivityFeed();
|
|
447
|
+
assert.equal(animaActivityFeed.events.some((event) => event.kind === 'inbox' && event.item.id === queued.item.id), false);
|
|
448
|
+
// Agent statuses cover all agents regardless of filter
|
|
449
|
+
const server = await createWebServer();
|
|
450
|
+
try {
|
|
451
|
+
server.listen(0, '127.0.0.1');
|
|
452
|
+
await once(server, 'listening');
|
|
453
|
+
const address = server.address();
|
|
454
|
+
if (!address || typeof address === 'string') {
|
|
455
|
+
throw new Error('Expected API server to listen on a TCP address.');
|
|
456
|
+
}
|
|
457
|
+
const statusesRes = await fetch(`http://127.0.0.1:${address.port}/api/agent-statuses`);
|
|
458
|
+
assert.equal(statusesRes.status, 200);
|
|
459
|
+
const statuses = (await statusesRes.json());
|
|
460
|
+
assert.deepEqual(statuses.find((s) => s.agentId === 'milo'), {
|
|
461
|
+
agentId: 'milo',
|
|
462
|
+
currentItemStartedAt: '2026-05-20T08:00:00.000Z',
|
|
463
|
+
currentItemId: running.item.id,
|
|
464
|
+
queueDepth: 1,
|
|
465
|
+
itemCount: 2,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
finally {
|
|
469
|
+
server.close();
|
|
470
|
+
}
|
|
471
|
+
const miloSession = await agentService('milo').getSession();
|
|
472
|
+
assert.equal(miloSession?.createdAt, running.session.createdAt);
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
finally {
|
|
476
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
test('web API stop endpoint writes stopRequestedAt onto the item record', async () => {
|
|
480
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-stop-test-'));
|
|
481
|
+
await writeConfig(stateDir);
|
|
482
|
+
await withAnimaHome(stateDir, async () => {
|
|
483
|
+
const server = await createWebServer();
|
|
484
|
+
try {
|
|
485
|
+
server.listen(0, '127.0.0.1');
|
|
486
|
+
await once(server, 'listening');
|
|
487
|
+
const address = server.address();
|
|
488
|
+
if (!address || typeof address === 'string')
|
|
489
|
+
throw new Error('Expected TCP address');
|
|
490
|
+
const ctx = await ingestEvent(makeSlackEvent({
|
|
491
|
+
channelId: 'D-stop',
|
|
492
|
+
teamId: 'T-demo',
|
|
493
|
+
text: 'stop me via HTTP',
|
|
494
|
+
ts: '1770000020.000001',
|
|
495
|
+
userId: 'U1',
|
|
496
|
+
}), { agentId: 'anima', stateDir });
|
|
497
|
+
const stopUrl = `http://127.0.0.1:${address.port}/api/agents/anima/stop`;
|
|
498
|
+
// Nothing running yet → 409.
|
|
499
|
+
const noRunning = await fetch(stopUrl, { method: 'POST' });
|
|
500
|
+
assert.equal(noRunning.status, 409);
|
|
501
|
+
// Advance the item to 'running' to simulate an active worker.
|
|
502
|
+
await new WakeQueueService('anima').markRunning({
|
|
503
|
+
itemId: ctx.item.id,
|
|
504
|
+
startedAt: '2026-05-20T10:00:00.000Z',
|
|
505
|
+
workerId: 'test-worker',
|
|
506
|
+
});
|
|
507
|
+
const response = await fetch(stopUrl, { method: 'POST' });
|
|
508
|
+
assert.equal(response.status, 202);
|
|
509
|
+
assert.deepEqual(await response.json(), { ok: true });
|
|
510
|
+
const item = await new WakeQueueService('anima').find(ctx.item.id);
|
|
511
|
+
assert.ok(item?.handling.stopRequestedAt, 'expected stopRequestedAt to be set on the item record');
|
|
512
|
+
}
|
|
513
|
+
finally {
|
|
514
|
+
server.close();
|
|
515
|
+
}
|
|
516
|
+
});
|
|
517
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
518
|
+
});
|
|
519
|
+
test('web API rotates the current provider session and records activity', async () => {
|
|
520
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-rotate-test-'));
|
|
521
|
+
await writeConfig(stateDir);
|
|
522
|
+
await withAnimaHome(stateDir, async () => {
|
|
523
|
+
const ctx = await ingestEvent(makeSlackEvent({
|
|
524
|
+
channelId: 'D-rotate',
|
|
525
|
+
teamId: 'T-demo',
|
|
526
|
+
text: 'create session',
|
|
527
|
+
ts: '1770000030.000001',
|
|
528
|
+
userId: 'U1',
|
|
529
|
+
}), { agentId: 'anima', stateDir });
|
|
530
|
+
const sessionPath = join(stateDir, 'agents/anima/sessions.json');
|
|
531
|
+
const session = JSON.parse(await readFile(sessionPath, 'utf8'));
|
|
532
|
+
await writeFile(sessionPath, `${JSON.stringify({
|
|
533
|
+
...session,
|
|
534
|
+
current: {
|
|
535
|
+
id: 'provider-session-1',
|
|
536
|
+
kind: 'codex-cli',
|
|
537
|
+
updatedAt: '2026-05-19T12:00:00.000Z',
|
|
538
|
+
},
|
|
539
|
+
}, null, 2)}\n`, 'utf8');
|
|
540
|
+
const server = await createWebServer();
|
|
541
|
+
try {
|
|
542
|
+
server.listen(0, '127.0.0.1');
|
|
543
|
+
await once(server, 'listening');
|
|
544
|
+
const address = server.address();
|
|
545
|
+
if (!address || typeof address === 'string')
|
|
546
|
+
throw new Error('Expected TCP address');
|
|
547
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/agents/anima/session/rotate`, { method: 'POST' });
|
|
548
|
+
assert.equal(response.status, 200);
|
|
549
|
+
const body = (await response.json());
|
|
550
|
+
assert.deepEqual(body.archivedProviderSessions.map((item) => `${item.kind}:${item.id}`), ['codex-cli:provider-session-1']);
|
|
551
|
+
const rotatedSession = JSON.parse(await readFile(sessionPath, 'utf8'));
|
|
552
|
+
assert.equal(rotatedSession.current, undefined);
|
|
553
|
+
assert.equal(rotatedSession.archived?.[0]?.id, 'provider-session-1');
|
|
554
|
+
ctx.session.current = {
|
|
555
|
+
id: 'provider-session-1',
|
|
556
|
+
kind: 'codex-cli',
|
|
557
|
+
updatedAt: '2026-05-19T12:00:00.000Z',
|
|
558
|
+
};
|
|
559
|
+
await persistProviderSession(ctx, 'codex-cli', { id: 'provider-session-1', updatedAt: '2026-05-19T12:01:00.000Z' });
|
|
560
|
+
const afterInFlightPersist = JSON.parse(await readFile(sessionPath, 'utf8'));
|
|
561
|
+
assert.equal(afterInFlightPersist.current, undefined);
|
|
562
|
+
await persistProviderSession(ctx, 'codex-cli', { id: 'provider-session-2', updatedAt: '2026-05-19T12:02:00.000Z' });
|
|
563
|
+
const afterFreshPersist = JSON.parse(await readFile(sessionPath, 'utf8'));
|
|
564
|
+
assert.deepEqual(afterFreshPersist.current, {
|
|
565
|
+
id: 'provider-session-2',
|
|
566
|
+
kind: 'codex-cli',
|
|
567
|
+
updatedAt: afterFreshPersist.current?.updatedAt,
|
|
568
|
+
});
|
|
569
|
+
const animaSessionArchive = await agentService('anima').getSession();
|
|
570
|
+
assert.equal(animaSessionArchive?.archived?.[0]?.id, 'provider-session-1');
|
|
571
|
+
const activityFeed = await activityServiceForAgent('anima').listActivityFeed();
|
|
572
|
+
const rotateActivity = activityFeed.events
|
|
573
|
+
.flatMap((event) => event.kind === 'activity' ? [event.activity] : [])
|
|
574
|
+
.find((activity) => activity.type === 'anima.session.rotate');
|
|
575
|
+
assert.equal(Object.hasOwn(rotateActivity ?? {}, 'itemId'), false);
|
|
576
|
+
assert.equal(rotateActivity?.payload?.['archivedCount'], 1);
|
|
577
|
+
}
|
|
578
|
+
finally {
|
|
579
|
+
server.close();
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
583
|
+
});
|
|
584
|
+
test('web API rotate fails closed when no provider session exists', async () => {
|
|
585
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-rotate-empty-test-'));
|
|
586
|
+
await writeConfig(stateDir);
|
|
587
|
+
await withAnimaHome(stateDir, async () => {
|
|
588
|
+
await ingestEvent(makeSlackEvent({
|
|
589
|
+
channelId: 'D-rotate',
|
|
590
|
+
teamId: 'T-demo',
|
|
591
|
+
text: 'create empty session',
|
|
592
|
+
ts: '1770000031.000001',
|
|
593
|
+
userId: 'U1',
|
|
594
|
+
}), { agentId: 'anima', stateDir });
|
|
595
|
+
const sessionPath = join(stateDir, 'agents/anima/sessions.json');
|
|
596
|
+
const before = await readFile(sessionPath, 'utf8');
|
|
597
|
+
const server = await createWebServer();
|
|
598
|
+
try {
|
|
599
|
+
server.listen(0, '127.0.0.1');
|
|
600
|
+
await once(server, 'listening');
|
|
601
|
+
const address = server.address();
|
|
602
|
+
if (!address || typeof address === 'string')
|
|
603
|
+
throw new Error('Expected TCP address');
|
|
604
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/agents/anima/session/rotate`, { method: 'POST' });
|
|
605
|
+
assert.equal(response.status, 409);
|
|
606
|
+
assert.equal(await readFile(sessionPath, 'utf8'), before);
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
server.close();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
613
|
+
});
|
|
614
|
+
test('web API serves the web app and agents API', async () => {
|
|
615
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-server-test-'));
|
|
616
|
+
await writeConfig(stateDir);
|
|
617
|
+
await withAnimaHome(stateDir, async () => {
|
|
618
|
+
const server = await createWebServer();
|
|
619
|
+
try {
|
|
620
|
+
server.listen(0, '127.0.0.1');
|
|
621
|
+
await once(server, 'listening');
|
|
622
|
+
const address = server.address();
|
|
623
|
+
if (!address || typeof address === 'string') {
|
|
624
|
+
throw new Error('Expected web API to listen on a TCP address.');
|
|
625
|
+
}
|
|
626
|
+
const html = await fetch(`http://127.0.0.1:${address.port}/`);
|
|
627
|
+
assert.equal(html.status, 200);
|
|
628
|
+
assert.match(await html.text(), /Anima/);
|
|
629
|
+
const agentsRes = await fetch(`http://127.0.0.1:${address.port}/api/agents`);
|
|
630
|
+
assert.equal(agentsRes.status, 200);
|
|
631
|
+
const agentsBody = (await agentsRes.json());
|
|
632
|
+
assert.ok(Array.isArray(agentsBody));
|
|
633
|
+
assert.ok(agentsBody.some((a) => a.id === 'anima'));
|
|
634
|
+
const statusesRes = await fetch(`http://127.0.0.1:${address.port}/api/agent-statuses`);
|
|
635
|
+
assert.equal(statusesRes.status, 200);
|
|
636
|
+
const statusesBody = (await statusesRes.json());
|
|
637
|
+
assert.ok(Array.isArray(statusesBody));
|
|
638
|
+
assert.ok(statusesBody.some((s) => s.agentId === 'anima'));
|
|
639
|
+
const orderWrite = await fetch(`http://127.0.0.1:${address.port}/api/sidebar-order`, {
|
|
640
|
+
body: JSON.stringify({ agents: ['anima'], kbs: ['team'] }),
|
|
641
|
+
headers: { 'content-type': 'application/json' },
|
|
642
|
+
method: 'PUT',
|
|
643
|
+
});
|
|
644
|
+
assert.equal(orderWrite.status, 200);
|
|
645
|
+
assert.deepEqual(await orderWrite.json(), { sidebarOrder: { agents: ['anima'], kbs: ['team'] } });
|
|
646
|
+
const orderRead = await fetch(`http://127.0.0.1:${address.port}/api/sidebar-order`);
|
|
647
|
+
assert.equal(orderRead.status, 200);
|
|
648
|
+
assert.deepEqual(await orderRead.json(), { sidebarOrder: { agents: ['anima'], kbs: ['team'] } });
|
|
649
|
+
}
|
|
650
|
+
finally {
|
|
651
|
+
server.close();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
655
|
+
});
|
|
656
|
+
test('web API mutates agent configs with redacted responses', async () => {
|
|
657
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-agent-crud-test-'));
|
|
658
|
+
const homeDir = await mkdtemp(join(tmpdir(), 'anima-web-api-agent-home-'));
|
|
659
|
+
const slackApi = await startSlackApiMock((method) => {
|
|
660
|
+
if (method === 'apps.connections.open')
|
|
661
|
+
return { ok: true, url: 'wss://socket.example.test/' };
|
|
662
|
+
if (method === 'auth.test')
|
|
663
|
+
return { app_id: 'ADEMO123', ok: true, team: 'Anima', team_id: 'T-demo', user: 'local-agent', user_id: 'U-bot' };
|
|
664
|
+
if (method === 'users.info')
|
|
665
|
+
return { ok: true, user: { id: 'U-bot', name: 'local-agent', profile: { display_name: 'Local Agent Bot' } } };
|
|
666
|
+
if (method === 'team.info')
|
|
667
|
+
return { ok: true, team: { id: 'T-demo', icon: { image_132: 'https://example.test/workspace.png' }, name: 'Anima' } };
|
|
668
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
669
|
+
});
|
|
670
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
671
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
672
|
+
try {
|
|
673
|
+
await writeConfig(stateDir);
|
|
674
|
+
await withAnimaHome(stateDir, async () => {
|
|
675
|
+
const server = await createWebServer();
|
|
676
|
+
try {
|
|
677
|
+
server.listen(0, '127.0.0.1');
|
|
678
|
+
await once(server, 'listening');
|
|
679
|
+
const address = server.address();
|
|
680
|
+
if (!address || typeof address === 'string')
|
|
681
|
+
throw new Error('Expected TCP address');
|
|
682
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
683
|
+
const rename = await fetch(`${base}/api/agents/anima/profile`, {
|
|
684
|
+
body: JSON.stringify({ displayName: 'Anima Prime' }),
|
|
685
|
+
headers: { 'content-type': 'application/json' },
|
|
686
|
+
method: 'POST',
|
|
687
|
+
});
|
|
688
|
+
await assertStatus(rename, 200, 'rename profile');
|
|
689
|
+
assert.equal('impact' in (await rename.json()), false);
|
|
690
|
+
assert.equal((await agentService('anima').getConfig()).profile?.displayName, 'Anima Prime');
|
|
691
|
+
const invalid = await fetch(`${base}/api/agents/anima/home`, {
|
|
692
|
+
body: JSON.stringify({ homePath: join(homeDir, 'missing') }),
|
|
693
|
+
headers: { 'content-type': 'application/json' },
|
|
694
|
+
method: 'POST',
|
|
695
|
+
});
|
|
696
|
+
assert.equal(invalid.status, 400);
|
|
697
|
+
assert.equal((await agentService('anima').getConfig()).homePath, join(stateDir, 'agent-homes', 'anima'), 'invalid home update leaves last-good config');
|
|
698
|
+
const localOnly = await fetch(`${base}/api/agents`, {
|
|
699
|
+
body: JSON.stringify({
|
|
700
|
+
name: 'Local Agent',
|
|
701
|
+
homePath: homeDir,
|
|
702
|
+
role: 'Local-only test agent.',
|
|
703
|
+
provider: testRuntime(),
|
|
704
|
+
}),
|
|
705
|
+
headers: { 'content-type': 'application/json' },
|
|
706
|
+
method: 'POST',
|
|
707
|
+
});
|
|
708
|
+
assert.equal(localOnly.status, 200);
|
|
709
|
+
const localOnlyBody = (await localOnly.json());
|
|
710
|
+
assert.deepEqual(localOnlyBody.slack, {
|
|
711
|
+
appToken: '',
|
|
712
|
+
botToken: '',
|
|
713
|
+
connected: false,
|
|
714
|
+
manifestVersion: 0,
|
|
715
|
+
teamId: '',
|
|
716
|
+
workspaceIconUrl: '',
|
|
717
|
+
workspaceName: '',
|
|
718
|
+
});
|
|
719
|
+
const seedMemory = await readFile(join(homeDir, 'MEMORY.md'), 'utf8');
|
|
720
|
+
assert.doesNotMatch(seedMemory, /Seed MEMORY scaffold/);
|
|
721
|
+
assert.match(seedMemory, /# Local Agent/);
|
|
722
|
+
assert.doesNotMatch(seedMemory, /local-agent/);
|
|
723
|
+
assert.match(seedMemory, /parent and ancestor directories/);
|
|
724
|
+
const defaultHomeCreate = await fetch(`${base}/api/agents`, {
|
|
725
|
+
body: JSON.stringify({
|
|
726
|
+
name: 'Default Home Agent',
|
|
727
|
+
homePath: join(homeDir, 'default-home-agent'),
|
|
728
|
+
role: 'Default home test agent.',
|
|
729
|
+
provider: testRuntime(),
|
|
730
|
+
}),
|
|
731
|
+
headers: { 'content-type': 'application/json' },
|
|
732
|
+
method: 'POST',
|
|
733
|
+
});
|
|
734
|
+
assert.equal(defaultHomeCreate.status, 200);
|
|
735
|
+
const defaultHomeBody = (await defaultHomeCreate.json());
|
|
736
|
+
const defaultHomePath = join(homeDir, 'default-home-agent');
|
|
737
|
+
assert.equal(defaultHomeBody.homePath, defaultHomePath);
|
|
738
|
+
assert.equal((await stat(defaultHomePath)).isDirectory(), true);
|
|
739
|
+
assert.match(await readFile(join(defaultHomePath, 'MEMORY.md'), 'utf8'), /# Default Home Agent/);
|
|
740
|
+
const parentHomeCreate = await fetch(`${base}/api/agents`, {
|
|
741
|
+
body: JSON.stringify({
|
|
742
|
+
name: 'Parent Home Agent',
|
|
743
|
+
homePath: join(homeDir, 'parent-home-agent'),
|
|
744
|
+
role: 'Parent home test agent.',
|
|
745
|
+
provider: testRuntime(),
|
|
746
|
+
}),
|
|
747
|
+
headers: { 'content-type': 'application/json' },
|
|
748
|
+
method: 'POST',
|
|
749
|
+
});
|
|
750
|
+
assert.equal(parentHomeCreate.status, 200);
|
|
751
|
+
const parentHomeBody = (await parentHomeCreate.json());
|
|
752
|
+
const parentHomePath = join(homeDir, 'parent-home-agent');
|
|
753
|
+
assert.equal(parentHomeBody.homePath, parentHomePath);
|
|
754
|
+
assert.equal((await stat(parentHomePath)).isDirectory(), true);
|
|
755
|
+
const nestedHomeCreate = await fetch(`${base}/api/agents`, {
|
|
756
|
+
body: JSON.stringify({
|
|
757
|
+
name: 'Nested Home Agent',
|
|
758
|
+
homePath: join(homeDir, 'missing-parent', 'nested-home-agent'),
|
|
759
|
+
role: 'Nested home test agent.',
|
|
760
|
+
provider: testRuntime(),
|
|
761
|
+
}),
|
|
762
|
+
headers: { 'content-type': 'application/json' },
|
|
763
|
+
method: 'POST',
|
|
764
|
+
});
|
|
765
|
+
assert.equal(nestedHomeCreate.status, 200);
|
|
766
|
+
const nestedHomeBody = (await nestedHomeCreate.json());
|
|
767
|
+
const nestedHomePath = join(homeDir, 'missing-parent', 'nested-home-agent');
|
|
768
|
+
assert.equal(nestedHomeBody.homePath, nestedHomePath);
|
|
769
|
+
assert.equal((await stat(nestedHomePath)).isDirectory(), true);
|
|
770
|
+
const badConnect = await fetch(`${base}/api/agents/local-agent/slack/connect`, {
|
|
771
|
+
body: JSON.stringify({ appToken: 'bad-xapp', botToken: 'xoxb-local-agent' }),
|
|
772
|
+
headers: { 'content-type': 'application/json' },
|
|
773
|
+
method: 'POST',
|
|
774
|
+
});
|
|
775
|
+
assert.equal(badConnect.status, 400);
|
|
776
|
+
const connectLocal = await fetch(`${base}/api/agents/local-agent/slack/connect`, {
|
|
777
|
+
body: JSON.stringify({ appToken: 'xapp-local-agent', botToken: 'xoxb-local-agent' }),
|
|
778
|
+
headers: { 'content-type': 'application/json' },
|
|
779
|
+
method: 'POST',
|
|
780
|
+
});
|
|
781
|
+
assert.equal(connectLocal.status, 200);
|
|
782
|
+
const connectLocalBody = (await connectLocal.json());
|
|
783
|
+
assert.equal(connectLocalBody.slack?.connected, true);
|
|
784
|
+
const createWithSlack = await fetch(`${base}/api/agents`, {
|
|
785
|
+
body: JSON.stringify({
|
|
786
|
+
name: 'Create With Slack',
|
|
787
|
+
slack: { appToken: 'xapp-new-agent', botToken: 'xoxb-new-agent' },
|
|
788
|
+
homePath: homeDir,
|
|
789
|
+
role: 'Invalid mixed create/connect body.',
|
|
790
|
+
provider: testRuntime(),
|
|
791
|
+
}),
|
|
792
|
+
headers: { 'content-type': 'application/json' },
|
|
793
|
+
method: 'POST',
|
|
794
|
+
});
|
|
795
|
+
assert.equal(createWithSlack.status, 400, 'create rejects Slack fields; slack/connect owns tokens');
|
|
796
|
+
const create = await fetch(`${base}/api/agents`, {
|
|
797
|
+
body: JSON.stringify({
|
|
798
|
+
name: 'New Agent',
|
|
799
|
+
homePath: homeDir,
|
|
800
|
+
role: 'New local agent.',
|
|
801
|
+
provider: testRuntime(),
|
|
802
|
+
}),
|
|
803
|
+
headers: { 'content-type': 'application/json' },
|
|
804
|
+
method: 'POST',
|
|
805
|
+
});
|
|
806
|
+
assert.equal(create.status, 200);
|
|
807
|
+
const createBody = (await create.json());
|
|
808
|
+
assert.equal(createBody.slack?.appToken, '');
|
|
809
|
+
assert.equal(createBody.slack?.botToken, '');
|
|
810
|
+
assert.equal(createBody.slack?.connected, false);
|
|
811
|
+
assert.equal(createBody.homePath, homeDir);
|
|
812
|
+
const remove = await fetch(`${base}/api/agents/new-agent`, { method: 'DELETE' });
|
|
813
|
+
assert.equal(remove.status, 200);
|
|
814
|
+
const removeBody = (await remove.json());
|
|
815
|
+
assert.equal(removeBody.id, 'new-agent');
|
|
816
|
+
await assert.rejects(agentService('new-agent').getConfig(), /Agent not found in config: new-agent/);
|
|
817
|
+
const duplicateRemoved = await fetch(`${base}/api/agents`, {
|
|
818
|
+
body: JSON.stringify({
|
|
819
|
+
name: 'New Agent',
|
|
820
|
+
homePath: homeDir,
|
|
821
|
+
role: 'New agent can be recreated.',
|
|
822
|
+
provider: testRuntime(),
|
|
823
|
+
}),
|
|
824
|
+
headers: { 'content-type': 'application/json' },
|
|
825
|
+
method: 'POST',
|
|
826
|
+
});
|
|
827
|
+
assert.equal(duplicateRemoved.status, 200, 'deleted ids can be recreated');
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
server.close();
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
}
|
|
834
|
+
finally {
|
|
835
|
+
if (previousSlackApiUrl === undefined) {
|
|
836
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
840
|
+
}
|
|
841
|
+
await slackApi.close();
|
|
842
|
+
await rm(homeDir, { force: true, recursive: true });
|
|
843
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
844
|
+
}
|
|
845
|
+
});
|
|
846
|
+
test('web API validates Slack tokens with structured reasons before persisting', async () => {
|
|
847
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-slack-validate-test-'));
|
|
848
|
+
const slackApi = await startSlackApiMock((method, body, request) => {
|
|
849
|
+
const token = bearerToken(request) || slackRequestBody(body)['token'] || '';
|
|
850
|
+
if (method === 'apps.connections.open') {
|
|
851
|
+
if (token.includes('missing-scope'))
|
|
852
|
+
return { error: 'missing_scope', ok: false };
|
|
853
|
+
return { ok: true, url: 'wss://socket.example.test/' };
|
|
854
|
+
}
|
|
855
|
+
if (method === 'auth.test') {
|
|
856
|
+
if (token.includes('other-app')) {
|
|
857
|
+
return { app_id: 'AOTHER999', ok: true, team: 'Acme', team_id: 'T-acme', user: 'other-bot', user_id: 'U-other-bot' };
|
|
858
|
+
}
|
|
859
|
+
return { app_id: 'ADEMO123', ok: true, team: 'Acme', team_id: 'T-acme', user: 'anima-bot', user_id: 'U-bot' };
|
|
860
|
+
}
|
|
861
|
+
if (method === 'users.info') {
|
|
862
|
+
return { ok: true, user: { id: 'U-bot', name: 'anima-bot', profile: { display_name: 'Anima Bot', image_72: 'https://example.test/bot.png' } } };
|
|
863
|
+
}
|
|
864
|
+
if (method === 'team.info') {
|
|
865
|
+
return { ok: true, team: { id: 'T-acme', icon: { image_132: 'https://example.test/acme.png' }, name: 'Acme' } };
|
|
866
|
+
}
|
|
867
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
868
|
+
});
|
|
869
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
870
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
871
|
+
try {
|
|
872
|
+
await writeConfig(stateDir, [
|
|
873
|
+
{
|
|
874
|
+
...defaultAgentConfig('anima'),
|
|
875
|
+
slack: { appToken: '', botToken: '' },
|
|
876
|
+
},
|
|
877
|
+
]);
|
|
878
|
+
await withAnimaHome(stateDir, async () => {
|
|
879
|
+
const server = await createWebServer();
|
|
880
|
+
try {
|
|
881
|
+
server.listen(0, '127.0.0.1');
|
|
882
|
+
await once(server, 'listening');
|
|
883
|
+
const address = server.address();
|
|
884
|
+
if (!address || typeof address === 'string')
|
|
885
|
+
throw new Error('Expected TCP address');
|
|
886
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
887
|
+
const wrongType = await postJson(`${base}/api/agents/anima/slack/tokens/validate`, { appToken: 'xoxb-valid-bot' });
|
|
888
|
+
assert.equal(wrongType.status, 200);
|
|
889
|
+
const wrongTypeBody = await wrongType.json();
|
|
890
|
+
assert.equal(wrongTypeBody.app?.valid, false);
|
|
891
|
+
assert.equal(wrongTypeBody.app?.detected, 'bot');
|
|
892
|
+
assert.equal(wrongTypeBody.app?.reason, 'wrong_token_type');
|
|
893
|
+
assert.equal(wrongTypeBody.app?.message, undefined);
|
|
894
|
+
const missingScope = await postJson(`${base}/api/agents/anima/slack/tokens/validate`, { appToken: 'xapp-1-ADEMO123-missing-scope' });
|
|
895
|
+
assert.equal(missingScope.status, 200);
|
|
896
|
+
const missingScopeBody = await missingScope.json();
|
|
897
|
+
assert.equal(missingScopeBody.app?.valid, false);
|
|
898
|
+
assert.equal(missingScopeBody.app?.reason, 'missing_connections_write');
|
|
899
|
+
const botOnly = await postJson(`${base}/api/agents/anima/slack/tokens/validate`, { botToken: 'xoxb-valid-bot' });
|
|
900
|
+
assert.equal(botOnly.status, 200);
|
|
901
|
+
const botOnlyBody = await botOnly.json();
|
|
902
|
+
assert.equal(botOnlyBody.bot?.valid, true);
|
|
903
|
+
assert.equal(botOnlyBody.bot?.appId, 'ADEMO123');
|
|
904
|
+
assert.equal(botOnlyBody.bot?.botName, 'Anima Bot');
|
|
905
|
+
assert.equal(botOnlyBody.bot?.botAvatarUrl, 'https://example.test/bot.png');
|
|
906
|
+
assert.equal(botOnlyBody.bot?.teamId, 'T-acme');
|
|
907
|
+
assert.equal(botOnlyBody.bot?.workspaceIconUrl, 'https://example.test/acme.png');
|
|
908
|
+
assert.equal(botOnlyBody.bot?.workspaceName, 'Acme');
|
|
909
|
+
assert.equal(botOnlyBody.connection?.valid, false);
|
|
910
|
+
assert.equal(botOnlyBody.connection?.reason, 'incomplete');
|
|
911
|
+
const mismatch = await postJson(`${base}/api/agents/anima/slack/tokens/validate`, {
|
|
912
|
+
appToken: 'xapp-1-ADEMO123-valid',
|
|
913
|
+
botToken: 'xoxb-other-app',
|
|
914
|
+
});
|
|
915
|
+
assert.equal(mismatch.status, 200);
|
|
916
|
+
const mismatchBody = await mismatch.json();
|
|
917
|
+
assert.equal(mismatchBody.connection?.valid, false);
|
|
918
|
+
assert.equal(mismatchBody.connection?.reason, 'app_mismatch');
|
|
919
|
+
assert.equal(mismatchBody.connection?.message, undefined);
|
|
920
|
+
const badConnect = await postJson(`${base}/api/agents/anima/slack/connect`, {
|
|
921
|
+
appToken: 'xapp-1-ADEMO123-valid',
|
|
922
|
+
botToken: 'xoxb-other-app',
|
|
923
|
+
});
|
|
924
|
+
assert.equal(badConnect.status, 400);
|
|
925
|
+
assert.equal((await agentService('anima').getConfig()).slack.connected, false);
|
|
926
|
+
const goodConnect = await postJson(`${base}/api/agents/anima/slack/connect`, {
|
|
927
|
+
appToken: 'xapp-1-ADEMO123-valid',
|
|
928
|
+
botToken: 'xoxb-valid-bot',
|
|
929
|
+
});
|
|
930
|
+
await assertStatus(goodConnect, 200, 'connect Slack tokens');
|
|
931
|
+
const goodConnectBody = await goodConnect.json();
|
|
932
|
+
assert.equal(goodConnectBody.slack?.connected, true);
|
|
933
|
+
assert.equal(goodConnectBody.slack?.appId, 'ADEMO123');
|
|
934
|
+
assert.equal(goodConnectBody.slack?.avatarUrl, 'https://example.test/bot.png');
|
|
935
|
+
assert.equal(goodConnectBody.slack?.teamId, 'T-acme');
|
|
936
|
+
assert.equal(goodConnectBody.slack?.workspaceName, 'Acme');
|
|
937
|
+
assert.equal(goodConnectBody.slack?.appToken, '');
|
|
938
|
+
assert.equal(goodConnectBody.slack?.botToken, '');
|
|
939
|
+
}
|
|
940
|
+
finally {
|
|
941
|
+
server.close();
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
finally {
|
|
946
|
+
if (previousSlackApiUrl === undefined) {
|
|
947
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
951
|
+
}
|
|
952
|
+
await slackApi.close();
|
|
953
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
954
|
+
}
|
|
955
|
+
});
|
|
956
|
+
test('web API exposes Slack manifest update flow and bumps version after scoped bot token save', async () => {
|
|
957
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-slack-manifest-test-'));
|
|
958
|
+
const slackApi = await startSlackApiMock((method, body, request) => {
|
|
959
|
+
const token = bearerToken(request) || slackRequestBody(body)['token'] || '';
|
|
960
|
+
if (method === 'apps.connections.open') {
|
|
961
|
+
return { ok: true, url: 'wss://socket.example.test/' };
|
|
962
|
+
}
|
|
963
|
+
if (method === 'auth.test') {
|
|
964
|
+
const scopes = token.includes('with-commands') ? 'chat:write,commands,users:read' : 'chat:write,users:read';
|
|
965
|
+
return {
|
|
966
|
+
body: { app_id: 'ADEMO123', ok: true, team: 'Acme', team_id: 'T-acme', user: 'anima-bot', user_id: 'U-bot' },
|
|
967
|
+
headers: { 'x-oauth-scopes': scopes },
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
if (method === 'users.info') {
|
|
971
|
+
return { ok: true, user: { id: 'U-bot', name: 'anima-bot', profile: { display_name: 'Anima Bot', image_72: 'https://example.test/bot.png' } } };
|
|
972
|
+
}
|
|
973
|
+
if (method === 'team.info') {
|
|
974
|
+
return { ok: true, team: { id: 'T-acme', icon: { image_132: 'https://example.test/acme.png' }, name: 'Acme' } };
|
|
975
|
+
}
|
|
976
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
977
|
+
});
|
|
978
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
979
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
980
|
+
try {
|
|
981
|
+
await writeConfig(stateDir, [
|
|
982
|
+
{
|
|
983
|
+
...defaultAgentConfig('anima'),
|
|
984
|
+
slack: { appId: 'ADEMO123', appToken: 'xapp-1-ADEMO123-valid', botToken: 'xoxb-old', teamId: 'TDEMO123' },
|
|
985
|
+
},
|
|
986
|
+
]);
|
|
987
|
+
await withAnimaHome(stateDir, async () => {
|
|
988
|
+
const server = await createWebServer();
|
|
989
|
+
try {
|
|
990
|
+
server.listen(0, '127.0.0.1');
|
|
991
|
+
await once(server, 'listening');
|
|
992
|
+
const address = server.address();
|
|
993
|
+
if (!address || typeof address === 'string')
|
|
994
|
+
throw new Error('Expected TCP address');
|
|
995
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
996
|
+
const info = await fetch(`${base}/api/agents/anima/slack/manifest-update`);
|
|
997
|
+
assert.equal(info.status, 200);
|
|
998
|
+
const infoBody = await info.json();
|
|
999
|
+
assert.equal(infoBody.agentVersion, 0);
|
|
1000
|
+
assert.equal(infoBody.currentVersion, CURRENT_SLACK_MANIFEST_VERSION);
|
|
1001
|
+
assert.equal(infoBody.needsUpdate, true);
|
|
1002
|
+
assert.equal(infoBody.appManifestUrl, 'https://app.slack.com/app-settings/TDEMO123/ADEMO123/app-manifest');
|
|
1003
|
+
assert.equal(infoBody.reinstallUrl, 'https://api.slack.com/apps/ADEMO123/install-on-team');
|
|
1004
|
+
assert.match(infoBody.manifestUpdateYaml, /display_information:\n name: Anima/);
|
|
1005
|
+
assert.match(infoBody.manifestUpdateYaml, /- commands/);
|
|
1006
|
+
assert.match(infoBody.manifestUpdateYaml, /callback_id: anima.hand_to_agent/);
|
|
1007
|
+
const missingScope = await postJson(`${base}/api/agents/anima/slack/manifest-upgrade`, {
|
|
1008
|
+
botToken: 'xoxb-without-shortcuts',
|
|
1009
|
+
});
|
|
1010
|
+
assert.equal(missingScope.status, 400);
|
|
1011
|
+
assert.equal((await agentService('anima').getConfig()).slack.manifestVersion, 0);
|
|
1012
|
+
const upgrade = await postJson(`${base}/api/agents/anima/slack/manifest-upgrade`, {
|
|
1013
|
+
botToken: 'xoxb-with-commands',
|
|
1014
|
+
});
|
|
1015
|
+
await assertStatus(upgrade, 200, 'upgrade Slack manifest');
|
|
1016
|
+
const upgradeBody = await upgrade.json();
|
|
1017
|
+
assert.equal(upgradeBody.slack?.botToken, '');
|
|
1018
|
+
assert.equal(upgradeBody.slack?.manifestVersion, CURRENT_SLACK_MANIFEST_VERSION);
|
|
1019
|
+
const updated = await agentService('anima').getConfig();
|
|
1020
|
+
assert.equal(updated.slack.botToken, 'xoxb-with-commands');
|
|
1021
|
+
assert.equal(updated.slack.manifestVersion, CURRENT_SLACK_MANIFEST_VERSION);
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
server.close();
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
finally {
|
|
1029
|
+
if (previousSlackApiUrl === undefined) {
|
|
1030
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1034
|
+
}
|
|
1035
|
+
await slackApi.close();
|
|
1036
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
1039
|
+
test('web API sets Slack owner and queues onboarding wake-up', async () => {
|
|
1040
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-agent-owner-test-'));
|
|
1041
|
+
const homeDir = await mkdtemp(join(tmpdir(), 'anima-web-api-agent-owner-home-'));
|
|
1042
|
+
const slackCalls = [];
|
|
1043
|
+
const slackApi = await startSlackApiMock((method, body) => {
|
|
1044
|
+
slackCalls.push({ method, body: slackRequestBody(body) });
|
|
1045
|
+
if (method === 'users.list') {
|
|
1046
|
+
return {
|
|
1047
|
+
ok: true,
|
|
1048
|
+
members: [
|
|
1049
|
+
{
|
|
1050
|
+
id: 'U-owner',
|
|
1051
|
+
name: 'iris',
|
|
1052
|
+
real_name: 'Iris Lead',
|
|
1053
|
+
profile: { display_name: 'Iris', image_72: 'https://example.test/iris.png' },
|
|
1054
|
+
},
|
|
1055
|
+
{ id: 'U-bot', is_bot: true, name: 'helper-bot' },
|
|
1056
|
+
{ id: 'U-deleted', deleted: true, name: 'deleted' },
|
|
1057
|
+
],
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
if (method === 'auth.test')
|
|
1061
|
+
return { ok: true, team_id: 'T-demo' };
|
|
1062
|
+
if (method === 'conversations.open')
|
|
1063
|
+
return { ok: true, channel: { id: 'D-owner' } };
|
|
1064
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
1065
|
+
});
|
|
1066
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1067
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1068
|
+
try {
|
|
1069
|
+
await writeConfig(stateDir, [{ ...defaultAgentConfig('anima'), homePath: homeDir }]);
|
|
1070
|
+
await withAnimaHome(stateDir, async () => {
|
|
1071
|
+
const server = await createWebServer();
|
|
1072
|
+
try {
|
|
1073
|
+
server.listen(0, '127.0.0.1');
|
|
1074
|
+
await once(server, 'listening');
|
|
1075
|
+
const address = server.address();
|
|
1076
|
+
if (!address || typeof address === 'string') {
|
|
1077
|
+
throw new Error('Expected web API to listen on a TCP address.');
|
|
1078
|
+
}
|
|
1079
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
1080
|
+
const usersRes = await fetch(`${base}/api/agents/anima/slack/users`);
|
|
1081
|
+
assert.equal(usersRes.status, 200);
|
|
1082
|
+
const usersBody = (await usersRes.json());
|
|
1083
|
+
assert.deepEqual(usersBody.users.map((user) => user.slackUserId), ['U-owner']);
|
|
1084
|
+
const setOwner = await fetch(`${base}/api/agents/anima/slack/owner`, {
|
|
1085
|
+
body: JSON.stringify({ slackUserId: 'U-owner' }),
|
|
1086
|
+
headers: { 'content-type': 'application/json' },
|
|
1087
|
+
method: 'POST',
|
|
1088
|
+
});
|
|
1089
|
+
assert.equal(setOwner.status, 200);
|
|
1090
|
+
const setOwnerBody = (await setOwner.json());
|
|
1091
|
+
assert.equal(setOwnerBody.owner?.slackUserId, 'U-owner');
|
|
1092
|
+
assert.equal(setOwnerBody.owner?.displayName, 'Iris');
|
|
1093
|
+
assert.equal(setOwnerBody.owner?.handle, 'iris');
|
|
1094
|
+
assert.match(setOwnerBody.owner?.onboardingPromptedAt ?? '', /^\d{4}-/);
|
|
1095
|
+
const items = await new WakeQueueService('anima').list();
|
|
1096
|
+
const onboarding = items.find((item) => item.id === 'agent-onboarding:anima:U-owner');
|
|
1097
|
+
assert.equal(onboarding?.kind, 'onboarding');
|
|
1098
|
+
assert.equal(onboarding?.kind === 'onboarding' ? onboarding.channelId : undefined, 'D-owner');
|
|
1099
|
+
assert.equal(onboarding?.kind === 'onboarding' ? onboarding.teamId : undefined, 'T-demo');
|
|
1100
|
+
assert.equal(onboarding?.kind === 'onboarding' ? onboarding.operator.slackUserId : undefined, 'U-owner');
|
|
1101
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /<@U-owner>/);
|
|
1102
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /You've been set up here/);
|
|
1103
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /Your owner is Iris/);
|
|
1104
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /MEMORY\.md/);
|
|
1105
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /introduce yourself to Iris/);
|
|
1106
|
+
const openCalls = slackCalls.filter((call) => call.method === 'conversations.open');
|
|
1107
|
+
assert.deepEqual(openCalls.map((call) => call.body['users']), ['U-owner']);
|
|
1108
|
+
}
|
|
1109
|
+
finally {
|
|
1110
|
+
server.close();
|
|
1111
|
+
}
|
|
1112
|
+
});
|
|
1113
|
+
}
|
|
1114
|
+
finally {
|
|
1115
|
+
if (previousSlackApiUrl === undefined) {
|
|
1116
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1117
|
+
}
|
|
1118
|
+
else {
|
|
1119
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1120
|
+
}
|
|
1121
|
+
await slackApi.close();
|
|
1122
|
+
await rm(homeDir, { force: true, recursive: true });
|
|
1123
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1124
|
+
}
|
|
1125
|
+
});
|
|
1126
|
+
test('web API setOwner with openerNote threads it into kickoff text', async () => {
|
|
1127
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-opener-test-'));
|
|
1128
|
+
const homeDir = await mkdtemp(join(tmpdir(), 'anima-web-api-opener-home-'));
|
|
1129
|
+
const slackApi = await startSlackApiMock((method) => {
|
|
1130
|
+
if (method === 'users.list') {
|
|
1131
|
+
return {
|
|
1132
|
+
ok: true,
|
|
1133
|
+
members: [
|
|
1134
|
+
{
|
|
1135
|
+
id: 'U-opener-user',
|
|
1136
|
+
name: 'alice',
|
|
1137
|
+
real_name: 'Alice',
|
|
1138
|
+
profile: { display_name: 'Alice', image_72: 'https://example.test/a.png' },
|
|
1139
|
+
},
|
|
1140
|
+
],
|
|
1141
|
+
};
|
|
1142
|
+
}
|
|
1143
|
+
if (method === 'auth.test')
|
|
1144
|
+
return { ok: true, team_id: 'T-opener' };
|
|
1145
|
+
if (method === 'conversations.open')
|
|
1146
|
+
return { ok: true, channel: { id: 'D-opener' } };
|
|
1147
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
1148
|
+
});
|
|
1149
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1150
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1151
|
+
try {
|
|
1152
|
+
await writeConfig(stateDir, [{ ...defaultAgentConfig('anima'), homePath: homeDir }]);
|
|
1153
|
+
await withAnimaHome(stateDir, async () => {
|
|
1154
|
+
const server = await createWebServer();
|
|
1155
|
+
try {
|
|
1156
|
+
server.listen(0, '127.0.0.1');
|
|
1157
|
+
await once(server, 'listening');
|
|
1158
|
+
const address = server.address();
|
|
1159
|
+
if (!address || typeof address === 'string') {
|
|
1160
|
+
throw new Error('Expected web API to listen on a TCP address.');
|
|
1161
|
+
}
|
|
1162
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
1163
|
+
const res = await fetch(`${base}/api/agents/anima/slack/owner`, {
|
|
1164
|
+
body: JSON.stringify({
|
|
1165
|
+
slackUserId: 'U-opener-user',
|
|
1166
|
+
openerNote: 'Set you up to help with deployment pipelines.',
|
|
1167
|
+
}),
|
|
1168
|
+
headers: { 'content-type': 'application/json' },
|
|
1169
|
+
method: 'POST',
|
|
1170
|
+
});
|
|
1171
|
+
assert.equal(res.status, 200);
|
|
1172
|
+
const items = await new WakeQueueService('anima').list();
|
|
1173
|
+
const onboarding = items.find((item) => item.id === 'agent-onboarding:anima:U-opener-user');
|
|
1174
|
+
assert.equal(onboarding?.kind, 'onboarding');
|
|
1175
|
+
// opener note must appear in kickoff text, hedged and anonymous
|
|
1176
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /Set you up to help with deployment pipelines/);
|
|
1177
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /Context from whoever set you up/);
|
|
1178
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /Treat this as their intent, not fact/);
|
|
1179
|
+
// standard onboarding lines still present
|
|
1180
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /You've been set up here/);
|
|
1181
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /MEMORY\.md/);
|
|
1182
|
+
assert.match(onboarding?.kind === 'onboarding' ? onboarding.text : '', /<@U-opener-user>/);
|
|
1183
|
+
// openerNote must NOT appear on disk config (transient only)
|
|
1184
|
+
const raw = JSON.parse(await readFile(join(stateDir, 'agents', 'anima', 'config.json'), 'utf8'));
|
|
1185
|
+
assert.equal(JSON.stringify(raw).includes('deployment pipelines'), false);
|
|
1186
|
+
}
|
|
1187
|
+
finally {
|
|
1188
|
+
server.close();
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
finally {
|
|
1193
|
+
if (previousSlackApiUrl === undefined) {
|
|
1194
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1195
|
+
}
|
|
1196
|
+
else {
|
|
1197
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1198
|
+
}
|
|
1199
|
+
await slackApi.close();
|
|
1200
|
+
await rm(homeDir, { force: true, recursive: true });
|
|
1201
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1202
|
+
}
|
|
1203
|
+
});
|
|
1204
|
+
test('web API setOwner with introduce:false persists owner without enqueueing onboarding DM', async () => {
|
|
1205
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-no-intro-test-'));
|
|
1206
|
+
const homeDir = await mkdtemp(join(tmpdir(), 'anima-web-api-no-intro-home-'));
|
|
1207
|
+
const slackApi = await startSlackApiMock((method) => {
|
|
1208
|
+
if (method === 'users.list') {
|
|
1209
|
+
return {
|
|
1210
|
+
ok: true,
|
|
1211
|
+
members: [
|
|
1212
|
+
{
|
|
1213
|
+
id: 'U-no-intro',
|
|
1214
|
+
name: 'vera',
|
|
1215
|
+
real_name: 'Vera',
|
|
1216
|
+
profile: { display_name: 'Vera', image_72: 'https://example.test/vera.png' },
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
throw new Error(`unexpected Slack API method in no-intro test: ${method}`);
|
|
1222
|
+
});
|
|
1223
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1224
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1225
|
+
try {
|
|
1226
|
+
await writeConfig(stateDir, [{ ...defaultAgentConfig('anima'), homePath: homeDir }]);
|
|
1227
|
+
await withAnimaHome(stateDir, async () => {
|
|
1228
|
+
const server = await createWebServer();
|
|
1229
|
+
try {
|
|
1230
|
+
server.listen(0, '127.0.0.1');
|
|
1231
|
+
await once(server, 'listening');
|
|
1232
|
+
const address = server.address();
|
|
1233
|
+
if (!address || typeof address === 'string') {
|
|
1234
|
+
throw new Error('Expected web API to listen on a TCP address.');
|
|
1235
|
+
}
|
|
1236
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
1237
|
+
const res = await fetch(`${base}/api/agents/anima/slack/owner`, {
|
|
1238
|
+
body: JSON.stringify({ slackUserId: 'U-no-intro', introduce: false }),
|
|
1239
|
+
headers: { 'content-type': 'application/json' },
|
|
1240
|
+
method: 'POST',
|
|
1241
|
+
});
|
|
1242
|
+
assert.equal(res.status, 200);
|
|
1243
|
+
const body = (await res.json());
|
|
1244
|
+
// Owner is persisted
|
|
1245
|
+
assert.equal(body.owner?.slackUserId, 'U-no-intro');
|
|
1246
|
+
// No onboarding inbox item enqueued
|
|
1247
|
+
const items = await new WakeQueueService('anima').list();
|
|
1248
|
+
const onboarding = items.find((item) => item.id?.startsWith('agent-onboarding:anima:'));
|
|
1249
|
+
assert.equal(onboarding, undefined, 'introduce:false must not enqueue an onboarding inbox item');
|
|
1250
|
+
}
|
|
1251
|
+
finally {
|
|
1252
|
+
server.close();
|
|
1253
|
+
}
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
finally {
|
|
1257
|
+
if (previousSlackApiUrl === undefined) {
|
|
1258
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1259
|
+
}
|
|
1260
|
+
else {
|
|
1261
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1262
|
+
}
|
|
1263
|
+
await slackApi.close();
|
|
1264
|
+
await rm(homeDir, { force: true, recursive: true });
|
|
1265
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1268
|
+
test('web API syncs Slack avatar metadata and exposes app id without secrets', async () => {
|
|
1269
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-sync-avatar-test-'));
|
|
1270
|
+
const slackCalls = [];
|
|
1271
|
+
const slackApi = await startSlackApiMock((method, body) => {
|
|
1272
|
+
slackCalls.push({ method, body: slackRequestBody(body) });
|
|
1273
|
+
if (method === 'auth.test') {
|
|
1274
|
+
return { ok: true, team: 'Anima', team_id: 'T-demo', user_id: 'U-bot' };
|
|
1275
|
+
}
|
|
1276
|
+
if (method === 'users.info') {
|
|
1277
|
+
return { ok: true, user: { id: 'U-bot', profile: { image_72: 'https://example.test/bot.png' } } };
|
|
1278
|
+
}
|
|
1279
|
+
if (method === 'team.info') {
|
|
1280
|
+
return { ok: true, team: { id: 'T-demo', icon: { image_132: 'https://example.test/workspace.png' }, name: 'Anima' } };
|
|
1281
|
+
}
|
|
1282
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
1283
|
+
});
|
|
1284
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1285
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1286
|
+
try {
|
|
1287
|
+
await writeConfig(stateDir, [
|
|
1288
|
+
{
|
|
1289
|
+
...defaultAgentConfig('anima'),
|
|
1290
|
+
slack: { appToken: 'xapp-1-ADEMO123-secret', botToken: 'xoxb-secret-value' },
|
|
1291
|
+
},
|
|
1292
|
+
]);
|
|
1293
|
+
await withAnimaHome(stateDir, async () => {
|
|
1294
|
+
const server = await createWebServer();
|
|
1295
|
+
try {
|
|
1296
|
+
server.listen(0, '127.0.0.1');
|
|
1297
|
+
await once(server, 'listening');
|
|
1298
|
+
const address = server.address();
|
|
1299
|
+
if (!address || typeof address === 'string') {
|
|
1300
|
+
throw new Error('Expected web API to listen on a TCP address.');
|
|
1301
|
+
}
|
|
1302
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
1303
|
+
const sync = await fetch(`${base}/api/agents/anima/slack/sync-avatar`, { method: 'POST' });
|
|
1304
|
+
await assertStatus(sync, 200, 'sync Slack avatar');
|
|
1305
|
+
const syncBody = (await sync.json());
|
|
1306
|
+
assert.equal(syncBody.slack?.appId, 'ADEMO123');
|
|
1307
|
+
assert.equal(syncBody.slack?.avatarUrl, 'https://example.test/bot.png');
|
|
1308
|
+
assert.equal(syncBody.slack?.teamId, 'T-demo');
|
|
1309
|
+
assert.equal(syncBody.slack?.workspaceIconUrl, 'https://example.test/workspace.png');
|
|
1310
|
+
assert.equal(syncBody.slack?.workspaceName, 'Anima');
|
|
1311
|
+
assert.equal(syncBody.slack?.appToken, '');
|
|
1312
|
+
assert.equal(syncBody.slack?.botToken, '');
|
|
1313
|
+
const agents = await fetch(`${base}/api/agents`);
|
|
1314
|
+
assert.equal(agents.status, 200);
|
|
1315
|
+
const agentsBody = (await agents.json());
|
|
1316
|
+
const anima = agentsBody.find((agent) => agent.id === 'anima');
|
|
1317
|
+
assert.equal(anima?.slack?.appId, 'ADEMO123');
|
|
1318
|
+
assert.equal(anima?.slack?.avatarUrl, 'https://example.test/bot.png');
|
|
1319
|
+
assert.equal(anima?.slack?.teamId, 'T-demo');
|
|
1320
|
+
assert.equal(anima?.slack?.workspaceIconUrl, 'https://example.test/workspace.png');
|
|
1321
|
+
assert.equal(anima?.slack?.workspaceName, 'Anima');
|
|
1322
|
+
assert.equal(anima?.slack?.appToken, '');
|
|
1323
|
+
assert.equal(anima?.slack?.botToken, '');
|
|
1324
|
+
const stored = await agentService('anima').getConfig();
|
|
1325
|
+
assert.equal(stored.slack.appId, 'ADEMO123');
|
|
1326
|
+
assert.equal(stored.slack.avatarUrl, 'https://example.test/bot.png');
|
|
1327
|
+
assert.equal(stored.slack.teamId, 'T-demo');
|
|
1328
|
+
assert.equal(stored.slack.workspaceIconUrl, 'https://example.test/workspace.png');
|
|
1329
|
+
assert.equal(stored.slack.workspaceName, 'Anima');
|
|
1330
|
+
assert.equal(stored.slack.appToken, 'xapp-1-ADEMO123-secret');
|
|
1331
|
+
assert.equal(stored.slack.botToken, 'xoxb-secret-value');
|
|
1332
|
+
assert.deepEqual(slackCalls.map((call) => call.method), ['auth.test', 'users.info', 'team.info']);
|
|
1333
|
+
assert.equal(slackCalls.find((call) => call.method === 'users.info')?.body['user'], 'U-bot');
|
|
1334
|
+
assert.equal(slackCalls.find((call) => call.method === 'team.info')?.body['team'], 'T-demo');
|
|
1335
|
+
}
|
|
1336
|
+
finally {
|
|
1337
|
+
server.close();
|
|
1338
|
+
}
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
finally {
|
|
1342
|
+
if (previousSlackApiUrl === undefined) {
|
|
1343
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1347
|
+
}
|
|
1348
|
+
await slackApi.close();
|
|
1349
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
test('syncDisplayInfoIfStale refreshes once then throttles within the TTL', async () => {
|
|
1353
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-sync-throttle-test-'));
|
|
1354
|
+
const slackCalls = [];
|
|
1355
|
+
const slackApi = await startSlackApiMock((method, body) => {
|
|
1356
|
+
slackCalls.push({ method, body: slackRequestBody(body) });
|
|
1357
|
+
if (method === 'auth.test') {
|
|
1358
|
+
return { ok: true, team: 'Anima', team_id: 'T-demo', user_id: 'U-bot' };
|
|
1359
|
+
}
|
|
1360
|
+
if (method === 'users.info') {
|
|
1361
|
+
return { ok: true, user: { id: 'U-bot', profile: { image_72: 'https://example.test/bot.png' } } };
|
|
1362
|
+
}
|
|
1363
|
+
if (method === 'team.info') {
|
|
1364
|
+
return { ok: true, team: { id: 'T-demo', icon: { image_132: 'https://example.test/workspace.png' }, name: 'Anima' } };
|
|
1365
|
+
}
|
|
1366
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
1367
|
+
});
|
|
1368
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1369
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1370
|
+
try {
|
|
1371
|
+
await writeConfig(stateDir, [
|
|
1372
|
+
{
|
|
1373
|
+
...defaultAgentConfig('anima'),
|
|
1374
|
+
slack: { appToken: 'xapp-1-ADEMO123-secret', botToken: 'xoxb-secret-value' },
|
|
1375
|
+
},
|
|
1376
|
+
]);
|
|
1377
|
+
await withAnimaHome(stateDir, async () => {
|
|
1378
|
+
const service = agentSlackServiceForAgent('anima');
|
|
1379
|
+
const sixHoursMs = 6 * 60 * 60 * 1000;
|
|
1380
|
+
// First call: stale (never synced) → hits Slack and stamps the config.
|
|
1381
|
+
const first = await service.syncDisplayInfoIfStale({ ttlMs: sixHoursMs });
|
|
1382
|
+
assert.equal(first.synced, true);
|
|
1383
|
+
assert.deepEqual(slackCalls.map((call) => call.method), ['auth.test', 'users.info', 'team.info']);
|
|
1384
|
+
const stamped = await agentService('anima').getConfig();
|
|
1385
|
+
assert.equal(stamped.slack.avatarUrl, 'https://example.test/bot.png');
|
|
1386
|
+
assert.ok(stamped.slack.botProfileSyncedAt, 'botProfileSyncedAt should be set after a sync');
|
|
1387
|
+
// Second call within the TTL: throttled → no further Slack calls.
|
|
1388
|
+
const second = await service.syncDisplayInfoIfStale({ ttlMs: sixHoursMs });
|
|
1389
|
+
assert.equal(second.synced, false);
|
|
1390
|
+
assert.equal(slackCalls.length, 3, 'throttled call must not hit Slack again');
|
|
1391
|
+
// ttlMs:0 forces a re-sync regardless of the recent stamp. (team.info is
|
|
1392
|
+
// process-cached per workspace, so it may not be re-issued — the avatar
|
|
1393
|
+
// path auth.test + users.info is what proves the re-sync ran.)
|
|
1394
|
+
const forced = await service.syncDisplayInfoIfStale({ ttlMs: 0 });
|
|
1395
|
+
assert.equal(forced.synced, true);
|
|
1396
|
+
assert.equal(slackCalls.filter((call) => call.method === 'auth.test').length, 2);
|
|
1397
|
+
assert.equal(slackCalls.filter((call) => call.method === 'users.info').length, 2);
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
finally {
|
|
1401
|
+
if (previousSlackApiUrl === undefined) {
|
|
1402
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1406
|
+
}
|
|
1407
|
+
await slackApi.close();
|
|
1408
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
test('syncDisplayInfoIfStale does not clobber a concurrent config edit made mid-sync', async () => {
|
|
1412
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-sync-race-test-'));
|
|
1413
|
+
// Simulate an operator editing config (here: display name) WHILE the
|
|
1414
|
+
// background sync is in flight — kicked off during the first Slack call so it
|
|
1415
|
+
// commits before the post-call save. A pre-call snapshot save would revert it.
|
|
1416
|
+
let operatorEdit;
|
|
1417
|
+
const slackApi = await startSlackApiMock((method) => {
|
|
1418
|
+
if (method === 'auth.test') {
|
|
1419
|
+
if (!operatorEdit) {
|
|
1420
|
+
// The mock callback runs outside the test's withAnimaHome async scope,
|
|
1421
|
+
// so re-establish the home before touching agent config.
|
|
1422
|
+
operatorEdit = withAnimaHome(stateDir, () => agentService('anima').updateProfile({ displayName: 'Edited During Sync' }));
|
|
1423
|
+
}
|
|
1424
|
+
return { ok: true, team: 'Race', team_id: 'T-race', user_id: 'U-bot' };
|
|
1425
|
+
}
|
|
1426
|
+
if (method === 'users.info') {
|
|
1427
|
+
return { ok: true, user: { id: 'U-bot', profile: { image_72: 'https://example.test/raced.png' } } };
|
|
1428
|
+
}
|
|
1429
|
+
if (method === 'team.info') {
|
|
1430
|
+
return { ok: true, team: { id: 'T-race', icon: { image_132: 'https://example.test/raced-ws.png' }, name: 'Race' } };
|
|
1431
|
+
}
|
|
1432
|
+
throw new Error(`unexpected Slack API method ${method}`);
|
|
1433
|
+
});
|
|
1434
|
+
const previousSlackApiUrl = process.env.ANIMA_SLACK_API_URL;
|
|
1435
|
+
process.env.ANIMA_SLACK_API_URL = slackApi.url;
|
|
1436
|
+
try {
|
|
1437
|
+
await writeConfig(stateDir, [
|
|
1438
|
+
{
|
|
1439
|
+
...defaultAgentConfig('anima'),
|
|
1440
|
+
profile: { displayName: 'Original Name', role: 'tester' },
|
|
1441
|
+
slack: { appToken: 'xapp-1-ADEMO123-secret', botToken: 'xoxb-secret-value' },
|
|
1442
|
+
},
|
|
1443
|
+
]);
|
|
1444
|
+
await withAnimaHome(stateDir, async () => {
|
|
1445
|
+
const synced = await agentSlackServiceForAgent('anima')
|
|
1446
|
+
.syncDisplayInfoIfStale({ ttlMs: 6 * 60 * 60 * 1000 });
|
|
1447
|
+
assert.equal(synced.synced, true);
|
|
1448
|
+
await operatorEdit; // ensure the concurrent edit settled
|
|
1449
|
+
const stored = await agentService('anima').getConfig();
|
|
1450
|
+
// The mid-sync operator edit survives...
|
|
1451
|
+
assert.equal(stored.profile.displayName, 'Edited During Sync');
|
|
1452
|
+
// ...and the sync's new avatar was still applied on top of the latest config.
|
|
1453
|
+
assert.equal(stored.slack.avatarUrl, 'https://example.test/raced.png');
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
finally {
|
|
1457
|
+
if (previousSlackApiUrl === undefined) {
|
|
1458
|
+
delete process.env.ANIMA_SLACK_API_URL;
|
|
1459
|
+
}
|
|
1460
|
+
else {
|
|
1461
|
+
process.env.ANIMA_SLACK_API_URL = previousSlackApiUrl;
|
|
1462
|
+
}
|
|
1463
|
+
await slackApi.close();
|
|
1464
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1465
|
+
}
|
|
1466
|
+
});
|
|
1467
|
+
test('web API reports provider availability', async () => {
|
|
1468
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-provider-availability-test-'));
|
|
1469
|
+
await writeConfig(stateDir);
|
|
1470
|
+
await withAnimaHome(stateDir, async () => {
|
|
1471
|
+
const server = await createWebServer();
|
|
1472
|
+
try {
|
|
1473
|
+
server.listen(0, '127.0.0.1');
|
|
1474
|
+
await once(server, 'listening');
|
|
1475
|
+
const address = server.address();
|
|
1476
|
+
if (!address || typeof address === 'string')
|
|
1477
|
+
throw new Error('Expected TCP address');
|
|
1478
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/provider-availability`);
|
|
1479
|
+
assert.equal(response.status, 200);
|
|
1480
|
+
const body = (await response.json());
|
|
1481
|
+
assert.deepEqual(body.providers.map((provider) => provider.kind).sort(), ['claude-code', 'codex-cli', 'kimi-cli']);
|
|
1482
|
+
for (const provider of body.providers) {
|
|
1483
|
+
assert.equal(typeof provider.present, 'boolean');
|
|
1484
|
+
assert.equal('authed' in provider, false);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
finally {
|
|
1488
|
+
server.close();
|
|
1489
|
+
}
|
|
1490
|
+
});
|
|
1491
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1492
|
+
});
|
|
1493
|
+
test('server-info exposes the last standalone restart result for UI echoes', async () => {
|
|
1494
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-last-restart-test-'));
|
|
1495
|
+
await writeConfig(stateDir);
|
|
1496
|
+
await mkdir(join(stateDir, 'run'), { recursive: true });
|
|
1497
|
+
await writeFile(join(stateDir, 'run', 'services-restart-result.json'), `${JSON.stringify({
|
|
1498
|
+
completedAt: '2026-05-29T10:55:00.000Z',
|
|
1499
|
+
fallbackToIdle: false,
|
|
1500
|
+
mode: 'drain-active',
|
|
1501
|
+
requestedCount: 2,
|
|
1502
|
+
resumedCount: 2,
|
|
1503
|
+
})}\n`, 'utf8');
|
|
1504
|
+
await withAnimaHome(stateDir, async () => {
|
|
1505
|
+
const server = await createWebServer();
|
|
1506
|
+
try {
|
|
1507
|
+
server.listen(0, '127.0.0.1');
|
|
1508
|
+
await once(server, 'listening');
|
|
1509
|
+
const address = server.address();
|
|
1510
|
+
if (!address || typeof address === 'string')
|
|
1511
|
+
throw new Error('Expected TCP address');
|
|
1512
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/server-info`);
|
|
1513
|
+
assert.equal(response.status, 200);
|
|
1514
|
+
const body = (await response.json());
|
|
1515
|
+
assert.deepEqual(body.lastRestart, {
|
|
1516
|
+
completedAt: '2026-05-29T10:55:00.000Z',
|
|
1517
|
+
fallbackToIdle: false,
|
|
1518
|
+
logPath: join(stateDir, 'logs', 'services-restart.log'),
|
|
1519
|
+
mode: 'drain-active',
|
|
1520
|
+
requestedCount: 2,
|
|
1521
|
+
resumedCount: 2,
|
|
1522
|
+
status: 'succeeded',
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
finally {
|
|
1526
|
+
server.close();
|
|
1527
|
+
}
|
|
1528
|
+
});
|
|
1529
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1530
|
+
});
|
|
1531
|
+
test('server-info exposes blocked standalone restart results for honest UI errors', async () => {
|
|
1532
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-blocked-restart-test-'));
|
|
1533
|
+
await writeConfig(stateDir);
|
|
1534
|
+
await mkdir(join(stateDir, 'run'), { recursive: true });
|
|
1535
|
+
await writeFile(join(stateDir, 'run', 'services-restart-result.json'), `${JSON.stringify({
|
|
1536
|
+
blockers: [
|
|
1537
|
+
{
|
|
1538
|
+
agentId: 'runner',
|
|
1539
|
+
itemId: 'item_running',
|
|
1540
|
+
since: '2026-05-29T12:38:45.950Z',
|
|
1541
|
+
status: 'running',
|
|
1542
|
+
summary: 'Long-running task',
|
|
1543
|
+
workerId: 'runner:123',
|
|
1544
|
+
},
|
|
1545
|
+
],
|
|
1546
|
+
completedAt: '2026-05-29T12:46:22.000Z',
|
|
1547
|
+
message: 'Agents still working — restart did not run. Try again once they reach a safe point.',
|
|
1548
|
+
reason: 'drain_timeout',
|
|
1549
|
+
status: 'blocked',
|
|
1550
|
+
})}\n`, 'utf8');
|
|
1551
|
+
await withAnimaHome(stateDir, async () => {
|
|
1552
|
+
const server = await createWebServer();
|
|
1553
|
+
try {
|
|
1554
|
+
server.listen(0, '127.0.0.1');
|
|
1555
|
+
await once(server, 'listening');
|
|
1556
|
+
const address = server.address();
|
|
1557
|
+
if (!address || typeof address === 'string')
|
|
1558
|
+
throw new Error('Expected TCP address');
|
|
1559
|
+
const response = await fetch(`http://127.0.0.1:${address.port}/api/server-info`);
|
|
1560
|
+
assert.equal(response.status, 200);
|
|
1561
|
+
const body = (await response.json());
|
|
1562
|
+
assert.equal(body.lastRestart?.status, 'blocked');
|
|
1563
|
+
assert.equal(body.lastRestart?.reason, 'drain_timeout');
|
|
1564
|
+
assert.deepEqual(body.lastRestart?.blockers?.map((blocker) => ({
|
|
1565
|
+
agentId: blocker.agentId,
|
|
1566
|
+
itemId: blocker.itemId,
|
|
1567
|
+
status: blocker.status,
|
|
1568
|
+
})), [
|
|
1569
|
+
{ agentId: 'runner', itemId: 'item_running', status: 'running' },
|
|
1570
|
+
]);
|
|
1571
|
+
assert.equal(body.lastRestart?.logPath, join(stateDir, 'logs', 'services-restart.log'));
|
|
1572
|
+
}
|
|
1573
|
+
finally {
|
|
1574
|
+
server.close();
|
|
1575
|
+
}
|
|
1576
|
+
});
|
|
1577
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1578
|
+
});
|
|
1579
|
+
test('web API exposes Slack manifest install links without secrets', async () => {
|
|
1580
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-slack-install-test-'));
|
|
1581
|
+
await writeConfig(stateDir, [
|
|
1582
|
+
{
|
|
1583
|
+
...defaultAgentConfig('scout'),
|
|
1584
|
+
profile: {
|
|
1585
|
+
displayName: 'Lens',
|
|
1586
|
+
role: 'Usage reporting agent.',
|
|
1587
|
+
},
|
|
1588
|
+
slack: undefined,
|
|
1589
|
+
},
|
|
1590
|
+
]);
|
|
1591
|
+
await withAnimaHome(stateDir, async () => {
|
|
1592
|
+
const server = await createWebServer();
|
|
1593
|
+
try {
|
|
1594
|
+
server.listen(0, '127.0.0.1');
|
|
1595
|
+
await once(server, 'listening');
|
|
1596
|
+
const address = server.address();
|
|
1597
|
+
if (!address || typeof address === 'string')
|
|
1598
|
+
throw new Error('Expected TCP address');
|
|
1599
|
+
const base = `http://127.0.0.1:${address.port}/api/agents/scout/slack`;
|
|
1600
|
+
const redirect = await fetch(`${base}/install`, { redirect: 'manual' });
|
|
1601
|
+
assert.equal(redirect.status, 302);
|
|
1602
|
+
const location = redirect.headers.get('location') ?? '';
|
|
1603
|
+
const url = new URL(location);
|
|
1604
|
+
assert.equal(`${url.origin}${url.pathname}`, 'https://api.slack.com/apps');
|
|
1605
|
+
assert.equal(url.searchParams.get('new_app'), '1');
|
|
1606
|
+
assert.match(url.searchParams.get('manifest_yaml') ?? '', /display_name: Lens/);
|
|
1607
|
+
}
|
|
1608
|
+
finally {
|
|
1609
|
+
server.close();
|
|
1610
|
+
}
|
|
1611
|
+
});
|
|
1612
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1613
|
+
});
|
|
1614
|
+
test('web API stamps and exposes createdAt through create response and snapshot', async () => {
|
|
1615
|
+
const stateDir = await mkdtemp(join(tmpdir(), 'anima-web-api-created-at-test-'));
|
|
1616
|
+
const homeDir = await mkdtemp(join(tmpdir(), 'anima-web-api-created-at-home-'));
|
|
1617
|
+
await writeConfig(stateDir);
|
|
1618
|
+
await withAnimaHome(stateDir, async () => {
|
|
1619
|
+
const server = await createWebServer();
|
|
1620
|
+
try {
|
|
1621
|
+
server.listen(0, '127.0.0.1');
|
|
1622
|
+
await once(server, 'listening');
|
|
1623
|
+
const address = server.address();
|
|
1624
|
+
if (!address || typeof address === 'string')
|
|
1625
|
+
throw new Error('Expected TCP address');
|
|
1626
|
+
const base = `http://127.0.0.1:${address.port}`;
|
|
1627
|
+
const before = Date.now();
|
|
1628
|
+
const create = await fetch(`${base}/api/agents`, {
|
|
1629
|
+
body: JSON.stringify({
|
|
1630
|
+
name: 'Timestamped Agent',
|
|
1631
|
+
homePath: homeDir,
|
|
1632
|
+
role: 'Timestamped agent.',
|
|
1633
|
+
provider: testRuntime(),
|
|
1634
|
+
}),
|
|
1635
|
+
headers: { 'content-type': 'application/json' },
|
|
1636
|
+
method: 'POST',
|
|
1637
|
+
});
|
|
1638
|
+
const after = Date.now();
|
|
1639
|
+
assert.equal(create.status, 200);
|
|
1640
|
+
const createBody = (await create.json());
|
|
1641
|
+
// The redacted create response must include a valid ISO createdAt.
|
|
1642
|
+
assert.ok(createBody.createdAt, 'create response should include createdAt');
|
|
1643
|
+
const stamped = new Date(createBody.createdAt).getTime();
|
|
1644
|
+
assert.ok(stamped >= before && stamped <= after, 'createdAt should be within the test window');
|
|
1645
|
+
// The agents API must expose the same createdAt on the agent.
|
|
1646
|
+
const agentsRes = await fetch(`${base}/api/agents`);
|
|
1647
|
+
assert.equal(agentsRes.status, 200);
|
|
1648
|
+
const agentsBody = (await agentsRes.json());
|
|
1649
|
+
const snapshotAgent = agentsBody.find((a) => a.id === 'timestamped-agent');
|
|
1650
|
+
assert.ok(snapshotAgent, 'agent should appear in snapshot');
|
|
1651
|
+
assert.equal(snapshotAgent?.createdAt, createBody.createdAt, 'snapshot createdAt should match create response');
|
|
1652
|
+
}
|
|
1653
|
+
finally {
|
|
1654
|
+
server.close();
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
await rm(homeDir, { force: true, recursive: true });
|
|
1658
|
+
await rm(stateDir, { force: true, recursive: true });
|
|
1659
|
+
});
|
|
1660
|
+
function defaultAgentConfig(id) {
|
|
1661
|
+
return {
|
|
1662
|
+
id,
|
|
1663
|
+
provider: {
|
|
1664
|
+
env: {
|
|
1665
|
+
CODEX_SECRET: 'runtime-secret-value',
|
|
1666
|
+
},
|
|
1667
|
+
kind: 'codex-cli',
|
|
1668
|
+
model: 'gpt-5.2-codex',
|
|
1669
|
+
reasoningEffort: 'high',
|
|
1670
|
+
},
|
|
1671
|
+
slack: {
|
|
1672
|
+
appToken: 'xapp-secret-value',
|
|
1673
|
+
botToken: 'xoxb-secret-value',
|
|
1674
|
+
},
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function testRuntime() {
|
|
1678
|
+
return { kind: 'codex-cli', model: 'gpt-5.5', reasoningEffort: 'medium' };
|
|
1679
|
+
}
|
|
1680
|
+
async function writeConfig(configDir, agents = [defaultAgentConfig('anima')]) {
|
|
1681
|
+
await mkdir(configDir, { recursive: true });
|
|
1682
|
+
await writeFile(join(configDir, 'config.json'), `${JSON.stringify({}, null, 2)}\n`, 'utf8');
|
|
1683
|
+
for (const agent of agents) {
|
|
1684
|
+
const agentDir = join(configDir, 'agents', agent.id);
|
|
1685
|
+
const homePath = agent.homePath ?? join(configDir, 'agent-homes', agent.id);
|
|
1686
|
+
await mkdir(homePath, { recursive: true });
|
|
1687
|
+
await mkdir(agentDir, { recursive: true });
|
|
1688
|
+
await writeFile(join(agentDir, 'config.json'), `${JSON.stringify({ ...agent, homePath }, null, 2)}\n`, 'utf8');
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
async function startSlackApiMock(handler) {
|
|
1692
|
+
const server = createServer(async (request, response) => {
|
|
1693
|
+
const body = await readBody(request);
|
|
1694
|
+
try {
|
|
1695
|
+
const pathname = new URL(request.url ?? '/', 'http://127.0.0.1').pathname;
|
|
1696
|
+
const method = pathname.replace(/^\/api\//, '');
|
|
1697
|
+
const result = handler(method, body, request);
|
|
1698
|
+
const payload = isMockResponseWithHeaders(result) ? result.body : result;
|
|
1699
|
+
response.writeHead(200, {
|
|
1700
|
+
'content-type': 'application/json',
|
|
1701
|
+
...(isMockResponseWithHeaders(result) ? result.headers : {}),
|
|
1702
|
+
});
|
|
1703
|
+
response.end(JSON.stringify(payload));
|
|
1704
|
+
}
|
|
1705
|
+
catch (error) {
|
|
1706
|
+
response.writeHead(500, { 'content-type': 'application/json' });
|
|
1707
|
+
response.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error), ok: false }));
|
|
1708
|
+
}
|
|
1709
|
+
});
|
|
1710
|
+
server.listen(0, '127.0.0.1');
|
|
1711
|
+
await once(server, 'listening');
|
|
1712
|
+
const address = server.address();
|
|
1713
|
+
if (!address || typeof address === 'string') {
|
|
1714
|
+
throw new Error('Expected Slack API mock to listen on a TCP address.');
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
close: async () => {
|
|
1718
|
+
server.close();
|
|
1719
|
+
await once(server, 'close');
|
|
1720
|
+
},
|
|
1721
|
+
url: `http://127.0.0.1:${address.port}/api`,
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
function isMockResponseWithHeaders(value) {
|
|
1725
|
+
return 'body' in value && typeof value.body === 'object';
|
|
1726
|
+
}
|
|
1727
|
+
function postJson(url, body) {
|
|
1728
|
+
return fetch(url, {
|
|
1729
|
+
body: JSON.stringify(body),
|
|
1730
|
+
headers: { 'content-type': 'application/json' },
|
|
1731
|
+
method: 'POST',
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
async function assertStatus(response, expected, label) {
|
|
1735
|
+
if (response.status === expected)
|
|
1736
|
+
return;
|
|
1737
|
+
const body = await response.clone().text().catch((error) => `failed to read body: ${String(error)}`);
|
|
1738
|
+
assert.equal(response.status, expected, `${label} returned ${response.status}: ${body}`);
|
|
1739
|
+
}
|
|
1740
|
+
function bearerToken(request) {
|
|
1741
|
+
const authorization = request.headers.authorization ?? '';
|
|
1742
|
+
return authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : '';
|
|
1743
|
+
}
|
|
1744
|
+
function slackRequestBody(body) {
|
|
1745
|
+
try {
|
|
1746
|
+
return JSON.parse(body);
|
|
1747
|
+
}
|
|
1748
|
+
catch {
|
|
1749
|
+
return Object.fromEntries(new URLSearchParams(body));
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
async function readBody(request) {
|
|
1753
|
+
let body = '';
|
|
1754
|
+
request.setEncoding('utf8');
|
|
1755
|
+
for await (const chunk of request) {
|
|
1756
|
+
body += chunk;
|
|
1757
|
+
}
|
|
1758
|
+
return body;
|
|
1759
|
+
}
|