@cohortapp/agent-sdk 2.17.0 → 2.18.4
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/.claude/settings.json +18 -0
- package/.env.example +18 -5
- package/README.md +1 -0
- package/bin/maestro.mjs +62 -0
- package/docs/guides/billing-console-keys.md +60 -0
- package/docs/guides/front-door-session.md +54 -9
- package/docs/guides/mac-mini.md +20 -25
- package/docs/guides/setup-wizard.md +1 -1
- package/docs/runbooks/fleet-rollout.md +156 -0
- package/docs/runbooks/mac-mini-bootstrap.md +12 -14
- package/lib/action-executor.js +19 -3
- package/lib/budget-guard.mjs +279 -3
- package/lib/channels/base-adapter.mjs +3 -1
- package/lib/channels/contract.mjs +2 -1
- package/lib/channels/inbox-item.mjs +8 -0
- package/lib/claude-bin.mjs +5 -6
- package/lib/cli/doctor-checks.mjs +141 -10
- package/lib/cli/global-setup-extras.mjs +5 -1
- package/lib/cli/inbox.mjs +100 -15
- package/lib/cli/seat-auth.mjs +463 -0
- package/lib/cli/session.mjs +80 -12
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/presence.mjs +142 -5
- package/lib/comms/send-gate.mjs +559 -1
- package/lib/diagnostics/alerts.mjs +49 -0
- package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
- package/lib/engine/agents/definitions.mjs +343 -0
- package/lib/engine/agents/persist.mjs +275 -0
- package/lib/engine/agents/runtime.mjs +748 -0
- package/lib/engine/agents/usage.mjs +95 -0
- package/lib/engine/auth-status.mjs +139 -0
- package/lib/engine/budget.mjs +194 -0
- package/lib/engine/cli.mjs +1204 -0
- package/lib/engine/commands/index.mjs +269 -0
- package/lib/engine/context/budget.mjs +219 -0
- package/lib/engine/context/cache.mjs +125 -0
- package/lib/engine/context/child-env.mjs +215 -0
- package/lib/engine/context/compaction.mjs +342 -0
- package/lib/engine/context/images.mjs +90 -0
- package/lib/engine/context/instructions.mjs +327 -0
- package/lib/engine/context/lazy-instructions.mjs +169 -0
- package/lib/engine/context/manager.mjs +182 -0
- package/lib/engine/context/real-path.mjs +91 -0
- package/lib/engine/context/secret-values.mjs +163 -0
- package/lib/engine/context/settings.mjs +274 -0
- package/lib/engine/context/stream-input.mjs +159 -0
- package/lib/engine/guard.mjs +152 -0
- package/lib/engine/hooks.mjs +713 -0
- package/lib/engine/loop.mjs +560 -0
- package/lib/engine/mcp/client.mjs +254 -0
- package/lib/engine/mcp/config.mjs +301 -0
- package/lib/engine/mcp/http.mjs +201 -0
- package/lib/engine/mcp/index.mjs +146 -0
- package/lib/engine/mcp/jsonrpc.mjs +147 -0
- package/lib/engine/mcp/naming.mjs +66 -0
- package/lib/engine/mcp/resources.mjs +89 -0
- package/lib/engine/mcp/results.mjs +133 -0
- package/lib/engine/mcp/stdio.mjs +137 -0
- package/lib/engine/mcp/supervisor.mjs +116 -0
- package/lib/engine/messages.mjs +104 -0
- package/lib/engine/output/json.mjs +164 -0
- package/lib/engine/output/stream-json.mjs +266 -0
- package/lib/engine/permissions.mjs +845 -0
- package/lib/engine/process-identity.mjs +164 -0
- package/lib/engine/process-tree.mjs +551 -0
- package/lib/engine/prompt.mjs +60 -0
- package/lib/engine/session/store.mjs +299 -0
- package/lib/engine/session-runtime/args.mjs +97 -0
- package/lib/engine/session-runtime/host.mjs +143 -0
- package/lib/engine/session-runtime/inbox.mjs +122 -0
- package/lib/engine/session-runtime/notifications.mjs +129 -0
- package/lib/engine/session-runtime/registry.mjs +328 -0
- package/lib/engine/session-runtime/runner.mjs +344 -0
- package/lib/engine/session-runtime/socket.mjs +212 -0
- package/lib/engine/session-runtime/wakeup.mjs +115 -0
- package/lib/engine/skills/index.mjs +321 -0
- package/lib/engine/tools/bash-background.mjs +533 -0
- package/lib/engine/tools/bash.mjs +216 -0
- package/lib/engine/tools/edit.mjs +97 -0
- package/lib/engine/tools/glob.mjs +81 -0
- package/lib/engine/tools/grep.mjs +224 -0
- package/lib/engine/tools/index.mjs +84 -0
- package/lib/engine/tools/list-agents.mjs +32 -0
- package/lib/engine/tools/ls.mjs +127 -0
- package/lib/engine/tools/monitor.mjs +82 -0
- package/lib/engine/tools/notebook-edit.mjs +218 -0
- package/lib/engine/tools/read.mjs +103 -0
- package/lib/engine/tools/schedule-wakeup.mjs +45 -0
- package/lib/engine/tools/schema.mjs +144 -0
- package/lib/engine/tools/send-message.mjs +77 -0
- package/lib/engine/tools/session.mjs +70 -0
- package/lib/engine/tools/todo.mjs +144 -0
- package/lib/engine/tools/toolsearch.mjs +217 -0
- package/lib/engine/tools/walk.mjs +193 -0
- package/lib/engine/tools/web-switch.mjs +31 -0
- package/lib/engine/tools/webfetch-html.mjs +387 -0
- package/lib/engine/tools/webfetch-net.mjs +340 -0
- package/lib/engine/tools/webfetch.mjs +198 -0
- package/lib/engine/tools/websearch.mjs +91 -0
- package/lib/engine/tools/workflow.mjs +95 -0
- package/lib/engine/tools/write.mjs +76 -0
- package/lib/engine/tui/line-editor.mjs +137 -0
- package/lib/engine/tui/render.mjs +86 -0
- package/lib/engine/tui/tui.mjs +274 -0
- package/lib/engine/wire/anthropic-messages.mjs +263 -0
- package/lib/engine/wire/effort.mjs +36 -0
- package/lib/engine/wire/errors.mjs +496 -0
- package/lib/engine/wire/http.mjs +441 -0
- package/lib/engine/wire/index.mjs +76 -0
- package/lib/engine/wire/openai-chat.mjs +332 -0
- package/lib/engine/wire/prompt-cache.mjs +79 -0
- package/lib/engine/wire/search.mjs +140 -0
- package/lib/engine/wire/sse.mjs +114 -0
- package/lib/engine/wire/stall.mjs +349 -0
- package/lib/engine/wire/token-provider.mjs +175 -0
- package/lib/engine/wire/usage.mjs +192 -0
- package/lib/engine/workflow/host.mjs +524 -0
- package/lib/engine/workflow/journal.mjs +188 -0
- package/lib/engine/workflow/json-schema.mjs +171 -0
- package/lib/engine/workflow/meta.mjs +329 -0
- package/lib/engine/workflow/notifications.mjs +52 -0
- package/lib/engine/workflow/runtime.mjs +447 -0
- package/lib/engine/workflow/sandbox.mjs +534 -0
- package/lib/engine/workflow/worker.mjs +141 -0
- package/lib/engine/workflow/worktree.mjs +74 -0
- package/lib/execution/disposition.mjs +1 -1
- package/lib/execution/intake.mjs +10 -0
- package/lib/execution/surface-policy.mjs +15 -0
- package/lib/learning/curator.mjs +8 -6
- package/lib/learning/reflect.mjs +8 -6
- package/lib/model-router/catalog/cohort.yaml +137 -0
- package/lib/model-router/catalog.mjs +118 -1
- package/lib/model-router/failover.mjs +67 -16
- package/lib/model-router/llm-task.mjs +39 -3
- package/lib/model-router/resolve.mjs +89 -3
- package/lib/model-router/spawn.mjs +46 -47
- package/lib/model-router/taxonomy.mjs +126 -4
- package/lib/org/cost-sync.mjs +141 -11
- package/lib/org/inbound/broadcast.mjs +289 -0
- package/lib/org/inbound/collective.mjs +375 -0
- package/lib/org/inbound/directedness.mjs +96 -8
- package/lib/org/inbound/facts.mjs +78 -2
- package/lib/org/inbound/project.mjs +22 -0
- package/lib/org/inbound/surfaces.mjs +14 -0
- package/lib/org/llm-token.mjs +879 -0
- package/lib/org/mesh.mjs +61 -0
- package/lib/org/messaging.mjs +3 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +15 -0
- package/lib/org/quota.mjs +520 -0
- package/lib/org/tool-surface.mjs +104 -16
- package/lib/org/ui-parity.mjs +16 -1
- package/lib/org/work-ledger.mjs +37 -6
- package/lib/rate-guard.mjs +114 -1
- package/lib/resource-governor.mjs +41 -6
- package/lib/runtime/adapter.mjs +823 -0
- package/lib/runtime/child-env.mjs +191 -0
- package/lib/runtime/legacy-shell-guard.mjs +97 -0
- package/lib/runtime/seat-engine.mjs +162 -0
- package/lib/session/ask-ledger.mjs +271 -0
- package/lib/session/current-work.mjs +676 -0
- package/lib/session/feed-core.mjs +40 -3
- package/lib/session/launch-args.mjs +56 -4
- package/lib/session/status-summary.mjs +26 -9
- package/lib/session/upgrade-notice.mjs +42 -0
- package/lib/setup/claude-probe.mjs +117 -13
- package/lib/setup/enrich.mjs +13 -10
- package/lib/setup/sections/model.mjs +39 -13
- package/lib/telemetry/collect.mjs +208 -9
- package/lib/upgrade/ignored-drift.mjs +105 -0
- package/lib/voice/post-call-brief.mjs +30 -17
- package/package.json +13 -3
- package/plugins/maestro-skills/skills/board-work.md +5 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
- package/plugins/maestro-skills/skills/main-session.md +18 -7
- package/scripts/ci/check-tarball-fidelity.mjs +126 -2
- package/scripts/ci/run-tests.mjs +47 -19
- package/scripts/cohort-llm/api-key-helper.mjs +92 -0
- package/scripts/collective/hook-runner.mjs +29 -2
- package/scripts/continuous-monitor.sh +13 -0
- package/scripts/cost/track-claude-usage.mjs +15 -0
- package/scripts/daemon/agent-daemon.mjs +408 -20
- package/scripts/daemon/assurance.mjs +48 -12
- package/scripts/daemon/cadence-consumer.mjs +218 -68
- package/scripts/daemon/cadence-handlers.mjs +73 -4
- package/scripts/daemon/classifier.mjs +75 -26
- package/scripts/daemon/context-compiler.mjs +51 -37
- package/scripts/daemon/deliver.mjs +30 -1
- package/scripts/daemon/dispatcher.mjs +595 -149
- package/scripts/daemon/health.mjs +14 -1
- package/scripts/daemon/maestro-daemon.mjs +11 -0
- package/scripts/daemon/prompt-builder.mjs +24 -0
- package/scripts/daemon/responder.mjs +246 -79
- package/scripts/daemon/sdk-version.mjs +98 -16
- package/scripts/eval/probe-gateway.mjs +635 -0
- package/scripts/eval/replay/extract.mjs +270 -0
- package/scripts/eval/replay/grade.mjs +260 -0
- package/scripts/eval/replay/lib/config.mjs +50 -0
- package/scripts/eval/replay/lib/effects.mjs +65 -0
- package/scripts/eval/replay/lib/fixture.mjs +188 -0
- package/scripts/eval/replay/lib/judge.mjs +72 -0
- package/scripts/eval/replay/lib/redact.mjs +136 -0
- package/scripts/eval/replay/lib/sandbox.mjs +170 -0
- package/scripts/eval/replay/lib/schema-check.mjs +63 -0
- package/scripts/eval/replay/lib/transcript.mjs +76 -0
- package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
- package/scripts/eval/replay/report.mjs +185 -0
- package/scripts/eval/replay/run.mjs +404 -0
- package/scripts/fleet/rollout.mjs +1094 -0
- package/scripts/hooks/pre-send-audit.sh +36 -245
- package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
- package/scripts/hooks/validate-state-yaml.sh +190 -0
- package/scripts/huddle/huddle-llm.mjs +361 -0
- package/scripts/huddle/huddle-server.mjs +46 -121
- package/scripts/local-triggers/autoupdate.sh +448 -78
- package/scripts/local-triggers/run-trigger.sh +13 -0
- package/scripts/maintenance/pin-integrity.mjs +364 -0
- package/scripts/poll-slack-events.sh +41 -9
- package/scripts/poller/slack-socket-mode.mjs +28 -3
- package/scripts/session/supervisor.mjs +80 -13
- package/scripts/spawn-session.sh +13 -0
- package/bin/maestro.test.mjs +0 -1574
- package/lib/action-executor.test.mjs +0 -871
- package/lib/archetype.test.mjs +0 -132
- package/lib/assurance/plan-note.test.mjs +0 -234
- package/lib/assurance/room-budget.test.mjs +0 -486
- package/lib/assurance/tier.test.mjs +0 -174
- package/lib/autonomy.test.mjs +0 -66
- package/lib/backlog.test.mjs +0 -302
- package/lib/backup/policy.test.mjs +0 -305
- package/lib/budget-escalate.test.mjs +0 -232
- package/lib/budget-guard.envelope.test.mjs +0 -476
- package/lib/budget-guard.test.mjs +0 -427
- package/lib/cadence-bus-requeue.test.mjs +0 -83
- package/lib/cadence-bus-schedule.test.mjs +0 -194
- package/lib/cadence-bus.test.mjs +0 -720
- package/lib/cadences.test.mjs +0 -230
- package/lib/capability/inventory.test.mjs +0 -232
- package/lib/capability.test.mjs +0 -78
- package/lib/channels/base-adapter.test.mjs +0 -590
- package/lib/channels/channels.test.mjs +0 -371
- package/lib/channels/contract.test.mjs +0 -162
- package/lib/channels/inbox-item.test.mjs +0 -368
- package/lib/channels/orgmail/adapter.test.mjs +0 -448
- package/lib/channels/pairing.test.mjs +0 -270
- package/lib/channels/repeat-suppressor.test.mjs +0 -134
- package/lib/channels/slack-adapter.test.mjs +0 -212
- package/lib/channels/telegram-adapter.test.mjs +0 -306
- package/lib/channels/voice/adapter.test.mjs +0 -278
- package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
- package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
- package/lib/charter.test.mjs +0 -89
- package/lib/claude-bin.test.mjs +0 -131
- package/lib/cli/board.test.mjs +0 -227
- package/lib/cli/design.test.mjs +0 -270
- package/lib/cli/doctor-checks.test.mjs +0 -336
- package/lib/cli/global-setup-extras.test.mjs +0 -462
- package/lib/cli/inbox.test.mjs +0 -230
- package/lib/cli/session-ack.test.mjs +0 -63
- package/lib/cli/session.test.mjs +0 -613
- package/lib/collective/capture.test.mjs +0 -121
- package/lib/collective/cards.test.mjs +0 -114
- package/lib/collective/config.test.mjs +0 -123
- package/lib/collective/global-config.test.mjs +0 -220
- package/lib/collective/global-skills.test.mjs +0 -126
- package/lib/collective/presence.test.mjs +0 -95
- package/lib/collective/recall.test.mjs +0 -116
- package/lib/collective/vendor-skills.test.mjs +0 -306
- package/lib/comms/send-gate.test.mjs +0 -770
- package/lib/comms.test.mjs +0 -41
- package/lib/context/budget.test.mjs +0 -252
- package/lib/context/history-scope.test.mjs +0 -79
- package/lib/cost/ledger-row.test.mjs +0 -183
- package/lib/design/design-md.test.mjs +0 -318
- package/lib/design/fixtures/DESIGN.golden.md +0 -238
- package/lib/design/fixtures/PRODUCT.golden.md +0 -67
- package/lib/design/fixtures/foundation.json +0 -133
- package/lib/design/refresh-gate.test.mjs +0 -144
- package/lib/design/write.test.mjs +0 -241
- package/lib/diagnostics/alerts.test.mjs +0 -318
- package/lib/diagnostics/backup-freshness.test.mjs +0 -185
- package/lib/diagnostics/counters.test.mjs +0 -206
- package/lib/diagnostics/events.test.mjs +0 -290
- package/lib/diagnostics/otel.test.mjs +0 -196
- package/lib/diagnostics/trace.test.mjs +0 -251
- package/lib/env-compat.test.mjs +0 -104
- package/lib/execution/disposition.test.mjs +0 -553
- package/lib/execution/drive.test.mjs +0 -270
- package/lib/execution/effects.test.mjs +0 -344
- package/lib/execution/intake.test.mjs +0 -389
- package/lib/execution/journal.test.mjs +0 -261
- package/lib/execution/match.test.mjs +0 -235
- package/lib/execution/pipeline.test.mjs +0 -392
- package/lib/execution/route.test.mjs +0 -186
- package/lib/execution/surface-policy.test.mjs +0 -162
- package/lib/fs-atomic.test.mjs +0 -72
- package/lib/fs-ownership.test.mjs +0 -158
- package/lib/goals/admission.test.mjs +0 -164
- package/lib/goals/classify.test.mjs +0 -167
- package/lib/goals/collaborate.test.mjs +0 -336
- package/lib/goals/gaps.test.mjs +0 -284
- package/lib/goals/loop.test.mjs +0 -845
- package/lib/hooks/bus.test.mjs +0 -387
- package/lib/identity/persona.test.mjs +0 -142
- package/lib/kpi-sensors.test.mjs +0 -278
- package/lib/kpi.test.mjs +0 -244
- package/lib/learning/config.test.mjs +0 -75
- package/lib/learning/counters.test.mjs +0 -69
- package/lib/learning/curator-consolidate.test.mjs +0 -238
- package/lib/learning/curator.test.mjs +0 -106
- package/lib/learning/reflect.test.mjs +0 -0
- package/lib/learning/session-index.test.mjs +0 -125
- package/lib/learning/skill-writer.test.mjs +0 -210
- package/lib/mandate/audit.test.mjs +0 -195
- package/lib/mandate/contract.test.mjs +0 -185
- package/lib/mandate/derive.test.mjs +0 -274
- package/lib/mandate/model.test.mjs +0 -164
- package/lib/mandate/refresh.test.mjs +0 -389
- package/lib/mcp/server.test.mjs +0 -426
- package/lib/model-router/auth-profiles.test.mjs +0 -580
- package/lib/model-router/catalog.test.mjs +0 -385
- package/lib/model-router/economics.test.mjs +0 -438
- package/lib/model-router/failover.test.mjs +0 -439
- package/lib/model-router/health.test.mjs +0 -338
- package/lib/model-router/integration-coverage.test.mjs +0 -831
- package/lib/model-router/integration.test.mjs +0 -564
- package/lib/model-router/ledger.test.mjs +0 -415
- package/lib/model-router/llm-task.test.mjs +0 -392
- package/lib/model-router/org-credentials.test.mjs +0 -265
- package/lib/model-router/pricing-refresh.test.mjs +0 -286
- package/lib/model-router/reconcile.test.mjs +0 -316
- package/lib/model-router/repair.test.mjs +0 -180
- package/lib/model-router/spawn.test.mjs +0 -446
- package/lib/model-router/taxonomy.test.mjs +0 -410
- package/lib/model-router.test.mjs +0 -1207
- package/lib/org/activity.test.mjs +0 -134
- package/lib/org/approvals.test.mjs +0 -216
- package/lib/org/awareness.test.mjs +0 -159
- package/lib/org/board-mine-cache.test.mjs +0 -53
- package/lib/org/board.test.mjs +0 -187
- package/lib/org/bootstrap-context.test.mjs +0 -153
- package/lib/org/client.test.mjs +0 -1206
- package/lib/org/cohort-client.test.mjs +0 -126
- package/lib/org/cost-sync.test.mjs +0 -153
- package/lib/org/doctor.test.mjs +0 -346
- package/lib/org/engagement-ledger.test.mjs +0 -112
- package/lib/org/engagement.test.mjs +0 -739
- package/lib/org/handoff.test.mjs +0 -269
- package/lib/org/inbound/directedness.test.mjs +0 -668
- package/lib/org/inbound/facts.test.mjs +0 -471
- package/lib/org/inbound/hydrate.test.mjs +0 -908
- package/lib/org/inbound/index.test.mjs +0 -429
- package/lib/org/inbound/project.test.mjs +0 -287
- package/lib/org/integration-tools.test.mjs +0 -160
- package/lib/org/keys.test.mjs +0 -92
- package/lib/org/knowledge.test.mjs +0 -326
- package/lib/org/leases.test.mjs +0 -235
- package/lib/org/mesh-directives.test.mjs +0 -110
- package/lib/org/mesh-integration.test.mjs +0 -127
- package/lib/org/mesh.test.mjs +0 -400
- package/lib/org/messaging.test.mjs +0 -471
- package/lib/org/param-contract.test.mjs +0 -477
- package/lib/org/policy.test.mjs +0 -237
- package/lib/org/protocol.checksum.test.mjs +0 -90
- package/lib/org/protocol.test.mjs +0 -323
- package/lib/org/push.test.mjs +0 -792
- package/lib/org/registry.test.mjs +0 -100
- package/lib/org/resource-tools.test.mjs +0 -361
- package/lib/org/tool-access.test.mjs +0 -144
- package/lib/org/tool-surface-integration.test.mjs +0 -120
- package/lib/org/tool-surface.test.mjs +0 -1268
- package/lib/org/typing.test.mjs +0 -291
- package/lib/org/ui-parity.test.mjs +0 -560
- package/lib/org/verify.test.mjs +0 -194
- package/lib/org/work-ledger.test.mjs +0 -273
- package/lib/plan/adoption-e2e.test.mjs +0 -366
- package/lib/plan/budget-enforcement.test.mjs +0 -400
- package/lib/plan/compile.test.mjs +0 -382
- package/lib/plan/emit.test.mjs +0 -269
- package/lib/plan/explain.test.mjs +0 -188
- package/lib/prompts/parallelism.test.mjs +0 -177
- package/lib/rag/rag.test.mjs +0 -505
- package/lib/rate-guard.test.mjs +0 -272
- package/lib/reactive-gate.test.mjs +0 -57
- package/lib/render.test.mjs +0 -68
- package/lib/resource-governor.test.mjs +0 -488
- package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
- package/lib/scheduling/jitter.test.mjs +0 -140
- package/lib/secrets/broker.test.mjs +0 -280
- package/lib/secrets/providers.test.mjs +0 -274
- package/lib/security/audit-engine.test.mjs +0 -424
- package/lib/security/coerce-args.test.mjs +0 -281
- package/lib/security/dangerous-tools.test.mjs +0 -68
- package/lib/security/external-content.test.mjs +0 -84
- package/lib/security/redact.test.mjs +0 -441
- package/lib/security/secret-equal.test.mjs +0 -55
- package/lib/session/config.test.mjs +0 -92
- package/lib/session/feed-core.test.mjs +0 -198
- package/lib/session/first-run.test.mjs +0 -121
- package/lib/session/frontdoor.test.mjs +0 -205
- package/lib/session/handoffs.test.mjs +0 -183
- package/lib/session/identity.test.mjs +0 -180
- package/lib/session/inbox-claims.test.mjs +0 -286
- package/lib/session/launch-args.test.mjs +0 -157
- package/lib/session/liveness.test.mjs +0 -100
- package/lib/session/status-summary.test.mjs +0 -118
- package/lib/session-permissions.test.mjs +0 -120
- package/lib/setup/claude-probe.test.mjs +0 -187
- package/lib/setup/completeness.test.mjs +0 -110
- package/lib/setup/context-pack.test.mjs +0 -89
- package/lib/setup/enrich.test.mjs +0 -115
- package/lib/setup/enroll-from-cohort.test.mjs +0 -300
- package/lib/setup/integration.test.mjs +0 -162
- package/lib/setup/io.test.mjs +0 -77
- package/lib/setup/runner.test.mjs +0 -132
- package/lib/setup/sections/identity.test.mjs +0 -234
- package/lib/setup/sections/inventory.test.mjs +0 -198
- package/lib/setup/sections/learning.test.mjs +0 -81
- package/lib/setup/sections/mandate.test.mjs +0 -388
- package/lib/setup/sections/messaging.test.mjs +0 -127
- package/lib/setup/sections/model.test.mjs +0 -240
- package/lib/setup/sections/org.test.mjs +0 -346
- package/lib/setup/sections/orgmail.test.mjs +0 -118
- package/lib/setup/sections/recovery.test.mjs +0 -98
- package/lib/setup/sections/subagents.test.mjs +0 -429
- package/lib/setup/sections/verify.test.mjs +0 -175
- package/lib/setup/sot.test.mjs +0 -81
- package/lib/setup/state.test.mjs +0 -115
- package/lib/singleton.test.mjs +0 -151
- package/lib/subagents/cli.test.mjs +0 -389
- package/lib/subagents/client.test.mjs +0 -309
- package/lib/subagents/gap.test.mjs +0 -234
- package/lib/subagents/lock.test.mjs +0 -248
- package/lib/subagents/manifest.test.mjs +0 -175
- package/lib/subagents/refs.test.mjs +0 -204
- package/lib/subagents/resolve.test.mjs +0 -422
- package/lib/subagents/schema.test.mjs +0 -328
- package/lib/telemetry/alerts.test.mjs +0 -109
- package/lib/telemetry/collect.test.mjs +0 -1274
- package/lib/tool-definitions-integration.test.mjs +0 -83
- package/lib/tool-definitions.test.mjs +0 -437
- package/lib/upgrade/global-refresh.test.mjs +0 -65
- package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
- package/lib/upgrade/post-steps.test.mjs +0 -200
- package/lib/upgrade/verify.test.mjs +0 -164
- package/lib/util/fetch-timeout.test.mjs +0 -202
- package/lib/util/reconnect.test.mjs +0 -369
- package/lib/util/unhandled.test.mjs +0 -216
- package/lib/voice/outbound.test.mjs +0 -69
- package/lib/voice/session-rotation.test.mjs +0 -114
- package/lib/voice/stt.test.mjs +0 -226
- package/lib/voice/voice.test.mjs +0 -990
- package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
- package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
- package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
- package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
- package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
- package/scripts/ci/check-skill-packs.test.mjs +0 -495
- package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
- package/scripts/ci/check.test.mjs +0 -194
- package/scripts/ci/conformance-org-api.test.mjs +0 -425
- package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
- package/scripts/collective/hook-runner.test.mjs +0 -173
- package/scripts/cost/fleet-digest.test.mjs +0 -207
- package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
- package/scripts/cost/track-claude-usage.test.mjs +0 -148
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
- package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
- package/scripts/daemon/agent-daemon.test.mjs +0 -995
- package/scripts/daemon/assurance-e2e.test.mjs +0 -613
- package/scripts/daemon/assurance.test.mjs +0 -1791
- package/scripts/daemon/board-mirror.test.mjs +0 -165
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
- package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
- package/scripts/daemon/cadence-consumer.test.mjs +0 -776
- package/scripts/daemon/cadence-handlers.test.mjs +0 -837
- package/scripts/daemon/classifier-identity.test.mjs +0 -137
- package/scripts/daemon/classifier.test.mjs +0 -266
- package/scripts/daemon/classify-kind.test.mjs +0 -40
- package/scripts/daemon/context-compiler.test.mjs +0 -406
- package/scripts/daemon/deliver.test.mjs +0 -564
- package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
- package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
- package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
- package/scripts/daemon/dispatcher-session-continuity.test.mjs +0 -365
- package/scripts/daemon/execution-ladder.test.mjs +0 -470
- package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
- package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
- package/scripts/daemon/inbox-deferral.test.mjs +0 -336
- package/scripts/daemon/inbox-wake.test.mjs +0 -199
- package/scripts/daemon/integration.test.mjs +0 -149
- package/scripts/daemon/lib/self-echo.test.mjs +0 -153
- package/scripts/daemon/lib/session-router.test.mjs +0 -554
- package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
- package/scripts/daemon/prompt-builder.test.mjs +0 -556
- package/scripts/daemon/responder-cost.test.mjs +0 -68
- package/scripts/daemon/responder-history.test.mjs +0 -221
- package/scripts/daemon/sdk-version.test.mjs +0 -31
- package/scripts/daemon/session-lock.test.mjs +0 -252
- package/scripts/daemon/session-outcomes.test.mjs +0 -533
- package/scripts/daemon/typing-registry.test.mjs +0 -102
- package/scripts/hooks/pre-send-audit.test.mjs +0 -354
- package/scripts/huddle/huddle-prompt.test.mjs +0 -176
- package/scripts/local-triggers/autoupdate.test.mjs +0 -518
- package/scripts/local-triggers/generate-plists.test.mjs +0 -456
- package/scripts/media-generation/brand-clause.test.mjs +0 -135
- package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
- package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
- package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
- package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
- package/scripts/poller/slack-socket-mode.test.mjs +0 -805
- package/scripts/poller-launchd/install.test.mjs +0 -243
- package/scripts/restore-from-backup.test.mjs +0 -181
- package/scripts/session/feed.test.mjs +0 -196
- package/scripts/session/supervisor-sh.test.mjs +0 -218
- package/scripts/session/supervisor.test.mjs +0 -482
- package/scripts/setup/configure-macos.test.mjs +0 -306
- package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
- package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
- package/scripts/setup/generate-capability.test.mjs +0 -134
- package/scripts/setup/init-agent.test.mjs +0 -370
- package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
- package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
- package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
|
@@ -1,995 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* agent-daemon.test.mjs — F1/H2 message-loss window (task #16, 0.8 follow-up).
|
|
3
|
-
*
|
|
4
|
-
* Before this fix, processItem() called markProcessed() (rename → .processed)
|
|
5
|
-
* IMMEDIATELY after a fire-and-forget dispatch(). A crash before the spawned
|
|
6
|
-
* session closed left the user's inbox item `.processed` with redelivery
|
|
7
|
-
* blocked — the message was silently lost.
|
|
8
|
-
*
|
|
9
|
-
* The fix defers the finalize to the dispatch onClose SUCCESS path and records a
|
|
10
|
-
* durable in-flight admission so a restart re-delivers an un-finalized item:
|
|
11
|
-
* • clean close → markProcessed() + clearInFlight() (finalize)
|
|
12
|
-
* • non-zero / spawn error → clearInFlight() + releaseLock() (re-deliverable)
|
|
13
|
-
* • crash mid-flight → admission survives; recoverInFlight() (startup) releases
|
|
14
|
-
* the per-item lock so the still-un-`.processed` file is re-polled.
|
|
15
|
-
*
|
|
16
|
-
* These tests drive the REAL daemon helpers (recordInFlight / clearInFlight /
|
|
17
|
-
* markProcessed / recoverInFlight) and the REAL per-item lock from
|
|
18
|
-
* session-lock.mjs, asserting the on-disk state that the poller would observe.
|
|
19
|
-
*
|
|
20
|
-
* The daemon module statically pulls in the agent-only `imapflow` dep (via the
|
|
21
|
-
* gmail poller) which isn't packaged in CI, so we register a tiny in-process
|
|
22
|
-
* ESM loader that stubs `imapflow` to a no-op class. Everything else in the
|
|
23
|
-
* daemon's import graph resolves normally, so the code under test is the real
|
|
24
|
-
* implementation, not a re-creation.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { test, before } from "node:test";
|
|
28
|
-
import assert from "node:assert/strict";
|
|
29
|
-
import { register } from "node:module";
|
|
30
|
-
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync } from "node:fs";
|
|
31
|
-
import { join } from "node:path";
|
|
32
|
-
import { tmpdir } from "node:os";
|
|
33
|
-
|
|
34
|
-
// --- stub the agent-only `imapflow` dep so the daemon module can be imported ---
|
|
35
|
-
const loaderSrc = `
|
|
36
|
-
export async function resolve(specifier, context, next) {
|
|
37
|
-
if (specifier === "imapflow") return { url: "imapflow-stub:main", shortCircuit: true };
|
|
38
|
-
return next(specifier, context);
|
|
39
|
-
}
|
|
40
|
-
export async function load(url, context, next) {
|
|
41
|
-
if (url === "imapflow-stub:main") return { format: "module", shortCircuit: true, source: "export class ImapFlow {}" };
|
|
42
|
-
return next(url, context);
|
|
43
|
-
}
|
|
44
|
-
`;
|
|
45
|
-
register(`data:text/javascript,${encodeURIComponent(loaderSrc)}`);
|
|
46
|
-
|
|
47
|
-
// One shared AGENT_DIR for the whole file: session-lock.mjs (imported transitively
|
|
48
|
-
// by the daemon) reads AGENT_DIR at module-eval time and is cached by URL, so all
|
|
49
|
-
// imports must agree on the dir. Per-test isolation comes from unique item ids and
|
|
50
|
-
// a clean-between-tests of the relevant state subdirs.
|
|
51
|
-
const AGENT_DIR = mkdtempSync(join(tmpdir(), "maestro-agent-daemon-"));
|
|
52
|
-
process.env.AGENT_DIR = AGENT_DIR;
|
|
53
|
-
process.env.MAESTRO_DAEMON_NO_AUTOSTART = "1"; // import without spinning the poll loops
|
|
54
|
-
|
|
55
|
-
let daemon; // the real daemon module (recordInFlight/clearInFlight/markProcessed/recoverInFlight)
|
|
56
|
-
let lock; // the real session-lock module (acquireLock/readLock)
|
|
57
|
-
let bus; // the real hook-bus module (getHookBus/resetHookBus) — shared singleton
|
|
58
|
-
let counters; // the real counters module (snapshot/resetMem) — shared transitive import
|
|
59
|
-
|
|
60
|
-
before(async () => {
|
|
61
|
-
mkdirSync(join(AGENT_DIR, "state", "inbox", "slack"), { recursive: true });
|
|
62
|
-
mkdirSync(join(AGENT_DIR, "state", "sessions", "locks"), { recursive: true });
|
|
63
|
-
const dURL = new URL("./agent-daemon.mjs", import.meta.url);
|
|
64
|
-
daemon = await import(`${dURL.href}?f1h2=${Math.random()}`);
|
|
65
|
-
lock = await import("./session-lock.mjs");
|
|
66
|
-
// These are the SAME singletons the daemon reaches transitively (imported by
|
|
67
|
-
// bare specifier, so the module cache shares them) — that's what lets the test
|
|
68
|
-
// emit on the same bus the daemon subscribed to and read the same counters.
|
|
69
|
-
bus = await import("../../lib/hooks/bus.mjs");
|
|
70
|
-
counters = await import("../../lib/diagnostics/counters.mjs");
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
// Read the diagnostic-event rows the daemon appended to its tmp
|
|
74
|
-
// logs/diagnostics/<UTC-date>.jsonl stream. [] when none written.
|
|
75
|
-
function readDiagnostics() {
|
|
76
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
77
|
-
const path = join(AGENT_DIR, "logs", "diagnostics", `${date}.jsonl`);
|
|
78
|
-
let raw = "";
|
|
79
|
-
try { raw = readFileSync(path, "utf-8"); } catch { return []; }
|
|
80
|
-
return raw.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function slackInbox() { return join(AGENT_DIR, "state", "inbox", "slack"); }
|
|
84
|
-
function inflightDir() { return join(AGENT_DIR, "state", "inbox-inflight"); }
|
|
85
|
-
|
|
86
|
-
function resetState() {
|
|
87
|
-
for (const d of [slackInbox(), inflightDir(), join(AGENT_DIR, "state", "sessions", "locks")]) {
|
|
88
|
-
try { for (const f of readdirSync(d)) rmSync(join(d, f), { force: true }); } catch { /* */ }
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* Write a live (un-.processed) poller-style inbox file for an item, returning
|
|
94
|
-
* the filename. Mirrors the real poller naming (`<ts>-<item.id>.yaml`) so that
|
|
95
|
-
* markProcessed's id/raw_ref substring match finds it, exactly as in production.
|
|
96
|
-
*/
|
|
97
|
-
function writeLiveItem(item) {
|
|
98
|
-
const name = `2026-06-10T00-00-00Z-${item.id}.yaml`;
|
|
99
|
-
const body = [
|
|
100
|
-
`id: "${item.id}"`,
|
|
101
|
-
`service: "slack"`,
|
|
102
|
-
`channel_id: "${item.channel || "D1"}"`,
|
|
103
|
-
`timestamp: "2026-06-10T00:00:00Z"`,
|
|
104
|
-
`content: |`,
|
|
105
|
-
` hello`,
|
|
106
|
-
"",
|
|
107
|
-
].join("\n");
|
|
108
|
-
writeFileSync(join(slackInbox(), name), body);
|
|
109
|
-
return name;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function inboxFiles() { return readdirSync(slackInbox()).sort(); }
|
|
113
|
-
function inflightFiles() { try { return readdirSync(inflightDir()).filter((f) => f.endsWith(".json")); } catch { return []; } }
|
|
114
|
-
|
|
115
|
-
// ---------------------------------------------------------------------------
|
|
116
|
-
// 1) Crash between dispatch and close → item is RE-DELIVERABLE (not .processed),
|
|
117
|
-
// and recoverInFlight() releases the gating lock so the next poll re-delivers.
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
|
|
120
|
-
test("F1/H2: a crash between dispatch and close leaves the item re-deliverable (not .processed)", () => {
|
|
121
|
-
resetState();
|
|
122
|
-
const item = { id: "MSG-CRASH", raw_ref: "slack:C1:1001", channel: "D1", sender: "ceo" };
|
|
123
|
-
const name = writeLiveItem(item);
|
|
124
|
-
|
|
125
|
-
// Simulate processItem up to (but not past) the dispatch: the poll filter holds
|
|
126
|
-
// the per-item lock, and the daemon records the durable in-flight admission.
|
|
127
|
-
const acq = lock.acquireLock(item.raw_ref, { sender: item.sender, channel: item.channel });
|
|
128
|
-
assert.equal(acq.acquired, true, "poll filter acquired the per-item lock");
|
|
129
|
-
daemon.recordInFlight(item, "slack");
|
|
130
|
-
|
|
131
|
-
// --- CRASH HERE: no onClose ran, so markProcessed was never called. ---
|
|
132
|
-
// The user's message must NOT be .processed (the whole bug).
|
|
133
|
-
assert.deepEqual(inboxFiles(), [name], "file is still live (un-.processed) after a mid-flight crash");
|
|
134
|
-
assert.equal(inflightFiles().length, 1, "the in-flight admission survives the crash");
|
|
135
|
-
// The held lock would block a naive re-poll until the 2h stale TTL — that is
|
|
136
|
-
// exactly what recovery must clear.
|
|
137
|
-
assert.equal(lock.acquireLock(item.raw_ref).acquired, false, "lock still held → re-poll blocked pre-recovery");
|
|
138
|
-
|
|
139
|
-
// --- RESTART: recoverInFlight() releases the lock so the next poll re-delivers. ---
|
|
140
|
-
const n = daemon.recoverInFlight();
|
|
141
|
-
assert.equal(n, 1, "one in-flight admission recovered");
|
|
142
|
-
assert.equal(inflightFiles().length, 0, "admission cleared after recovery");
|
|
143
|
-
assert.deepEqual(inboxFiles(), [name], "the message is STILL on disk, un-.processed → re-deliverable");
|
|
144
|
-
assert.equal(lock.acquireLock(item.raw_ref).acquired, true, "the gating lock was released → next poll re-delivers");
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
// ---------------------------------------------------------------------------
|
|
148
|
-
// 2) A clean close finalizes the item (markProcessed) and clears the admission.
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
|
|
151
|
-
test("F1/H2: a clean close finalizes the item (markProcessed) and clears the in-flight admission", () => {
|
|
152
|
-
resetState();
|
|
153
|
-
const item = { id: "MSG-OK", raw_ref: "slack:C2:2002", channel: "D2", sender: "ceo" };
|
|
154
|
-
const name = writeLiveItem(item);
|
|
155
|
-
lock.acquireLock(item.raw_ref, { sender: item.sender });
|
|
156
|
-
daemon.recordInFlight(item, "slack");
|
|
157
|
-
assert.equal(inflightFiles().length, 1);
|
|
158
|
-
|
|
159
|
-
// onClose({ok:true}) success path (the exact wiring processItem installs).
|
|
160
|
-
daemon.markProcessed(item, "slack");
|
|
161
|
-
daemon.clearInFlight(item);
|
|
162
|
-
|
|
163
|
-
assert.deepEqual(inboxFiles(), [`${name}.processed`], "clean close finalized the item to .processed");
|
|
164
|
-
assert.equal(inflightFiles().length, 0, "admission cleared on finalize");
|
|
165
|
-
// A clean close finalized work that already completed — recovery is a no-op,
|
|
166
|
-
// and there is no live file left for the poller to re-deliver.
|
|
167
|
-
assert.equal(daemon.recoverInFlight(), 0, "nothing to recover after a clean finalize");
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ---------------------------------------------------------------------------
|
|
171
|
-
// 3) A non-zero close / spawn error leaves the item re-deliverable and releases
|
|
172
|
-
// the lock immediately (no need to wait for restart).
|
|
173
|
-
// ---------------------------------------------------------------------------
|
|
174
|
-
|
|
175
|
-
test("F1/H2: a failed close leaves the item re-deliverable and releases the lock now", () => {
|
|
176
|
-
resetState();
|
|
177
|
-
const item = { id: "MSG-FAIL", raw_ref: "slack:C3:3003", channel: "D3", sender: "ceo" };
|
|
178
|
-
const name = writeLiveItem(item);
|
|
179
|
-
lock.acquireLock(item.raw_ref, { sender: item.sender });
|
|
180
|
-
daemon.recordInFlight(item, "slack");
|
|
181
|
-
|
|
182
|
-
// onClose({ok:false}) failure path: clear admission + release lock, but DO NOT
|
|
183
|
-
// markProcessed — the message stays re-deliverable.
|
|
184
|
-
daemon.clearInFlight(item);
|
|
185
|
-
lock.releaseLock(item.raw_ref);
|
|
186
|
-
|
|
187
|
-
assert.deepEqual(inboxFiles(), [name], "failed session left the item live (un-.processed)");
|
|
188
|
-
assert.equal(inflightFiles().length, 0, "admission cleared on failure");
|
|
189
|
-
assert.equal(lock.acquireLock(item.raw_ref).acquired, true, "lock released → next poll re-delivers immediately");
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// 4) recoverInFlight() is a clean no-op when nothing is pending, and never
|
|
194
|
-
// touches already-.processed files.
|
|
195
|
-
// ---------------------------------------------------------------------------
|
|
196
|
-
|
|
197
|
-
test("F1/H2: recoverInFlight is a no-op with no pending admissions", () => {
|
|
198
|
-
resetState();
|
|
199
|
-
assert.equal(daemon.recoverInFlight(), 0, "no admissions → recovered 0");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
test("F1/H2: recordInFlight is a no-op for an item with no lock key (e.g. backlog)", () => {
|
|
203
|
-
resetState();
|
|
204
|
-
daemon.recordInFlight({ title: "deep work" }, "backlog");
|
|
205
|
-
assert.equal(inflightFiles().length, 0, "no raw_ref/id → no admission written (backlog items are durable in queue files)");
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
// ===========================================================================
|
|
209
|
-
// DIAG — the observability spine wired into the LIVE inbound pipeline.
|
|
210
|
-
// ===========================================================================
|
|
211
|
-
//
|
|
212
|
-
// processItem() mints ONE trace_id per item and emits item_received → … → sent
|
|
213
|
-
// (on a clean finalize) all under it. The daemon also registers the ONE real
|
|
214
|
-
// hook-bus subscriber (onGuardFail → counters.bump("guard.fail")) so the bus is
|
|
215
|
-
// live. STRICT additive: emitEvent + counters.bump never throw, and the dispatch
|
|
216
|
-
// /classify/send semantics are untouched (the heavy collaborators are injected
|
|
217
|
-
// here ONLY so the test drives the real instrumented body without spawning a
|
|
218
|
-
// `claude` child — production passes no deps).
|
|
219
|
-
|
|
220
|
-
function clearDiagnostics() {
|
|
221
|
-
for (const sub of ["diagnostics", join("diagnostics", "counters")]) {
|
|
222
|
-
const d = join(AGENT_DIR, "logs", sub);
|
|
223
|
-
try { for (const f of readdirSync(d)) { if (f.endsWith(".jsonl")) rmSync(join(d, f), { force: true }); } } catch { /* */ }
|
|
224
|
-
}
|
|
225
|
-
try { counters.resetMem(); } catch { /* */ }
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
// 1) A processed item emits item_received → … → sent with ONE stable trace_id.
|
|
230
|
-
// ---------------------------------------------------------------------------
|
|
231
|
-
|
|
232
|
-
test("DIAG: a processed item emits item_received → sent with ONE stable trace_id (the daemon's minted id)", async () => {
|
|
233
|
-
resetState();
|
|
234
|
-
clearDiagnostics();
|
|
235
|
-
// A DM (channel_id starts with "D") so it always passes the directed-message
|
|
236
|
-
// gate — keeps the test on the dispatch path deterministically.
|
|
237
|
-
const item = { id: "MSG-TRACE", raw_ref: "slack:D1:1", channel: "dm/ceo", channel_id: "D1", sender: "ceo", content: "please draft the board memo" };
|
|
238
|
-
writeLiveItem(item);
|
|
239
|
-
|
|
240
|
-
// Inject the heavy collaborators so the REAL processItem body runs (trace mint,
|
|
241
|
-
// item_received emit, dispatch onClose → sent emit) without a claude spawn:
|
|
242
|
-
// - classify → "respond"/opus so it takes the holding+session (dispatch) path,
|
|
243
|
-
// NOT the quick-reply path.
|
|
244
|
-
// - isQuickReply → false to keep it on the dispatch path deterministically.
|
|
245
|
-
// - sendHoldingMessage → no-op (no Slack post).
|
|
246
|
-
// - dispatch → synchronously invoke onClose({ok:true}) to finalize.
|
|
247
|
-
let dispatchedItem = null;
|
|
248
|
-
await daemon.processItem(item, "slack", {
|
|
249
|
-
classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
|
|
250
|
-
isQuickReply: () => false,
|
|
251
|
-
sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
|
|
252
|
-
dispatch: (_prompt, it, _cr, _src, opts) => { dispatchedItem = it; opts.onClose({ ok: true, code: 0 }); },
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
// The daemon set the minted id on the item and carried it onto the dispatch.
|
|
256
|
-
assert.ok(typeof item.trace_id === "string" && item.trace_id.length === 26, "a 26-char ULID trace_id was minted onto the item");
|
|
257
|
-
assert.equal(dispatchedItem.trace_id, item.trace_id, "the dispatch received the SAME trace_id");
|
|
258
|
-
|
|
259
|
-
const rows = readDiagnostics();
|
|
260
|
-
const received = rows.filter((r) => r.type === "item_received");
|
|
261
|
-
const sent = rows.filter((r) => r.type === "sent");
|
|
262
|
-
assert.equal(received.length, 1, "exactly one item_received");
|
|
263
|
-
assert.equal(sent.length, 1, "exactly one sent on clean finalize");
|
|
264
|
-
// The single-trace-id invariant: the first hop and the last hop share one id.
|
|
265
|
-
assert.equal(received[0].trace_id, item.trace_id, "item_received carries the minted trace_id");
|
|
266
|
-
assert.equal(sent[0].trace_id, item.trace_id, "sent carries the SAME trace_id (end-to-end correlation)");
|
|
267
|
-
// The in-flight admission also carries the trace (cheap ledger correlation).
|
|
268
|
-
assert.equal(sent[0].attrs.item_id, item.raw_ref, "sent records the item id");
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test("DIAG: a failed (ok:false) finalize emits error (not sent), still under the one trace_id", async () => {
|
|
272
|
-
resetState();
|
|
273
|
-
clearDiagnostics();
|
|
274
|
-
const item = { id: "MSG-TRACE-FAIL", raw_ref: "slack:D2:1", channel: "dm/ceo", channel_id: "D2", sender: "ceo", content: "do the thing" };
|
|
275
|
-
writeLiveItem(item);
|
|
276
|
-
|
|
277
|
-
await daemon.processItem(item, "slack", {
|
|
278
|
-
classify: async () => ({ priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }),
|
|
279
|
-
isQuickReply: () => false,
|
|
280
|
-
sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
|
|
281
|
-
dispatch: (_p, _it, _cr, _s, opts) => opts.onClose({ ok: false, code: 1 }),
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
const rows = readDiagnostics();
|
|
285
|
-
assert.equal(rows.filter((r) => r.type === "item_received").length, 1, "item_received emitted");
|
|
286
|
-
assert.equal(rows.filter((r) => r.type === "sent").length, 0, "a failed finalize must NOT emit sent");
|
|
287
|
-
const err = rows.filter((r) => r.type === "error");
|
|
288
|
-
assert.equal(err.length, 1, "a failed finalize emits an error hop");
|
|
289
|
-
assert.equal(err[0].trace_id, item.trace_id, "the error hop is under the same interaction trace_id");
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
// ---------------------------------------------------------------------------
|
|
293
|
-
// 2) A send-gate block bumps the counter (responder's real instrumented path).
|
|
294
|
-
// ---------------------------------------------------------------------------
|
|
295
|
-
|
|
296
|
-
test("DIAG: a send-gate block bumps the send.blocked counter (responder validation path)", async () => {
|
|
297
|
-
clearDiagnostics();
|
|
298
|
-
const responder = await import("./responder.mjs");
|
|
299
|
-
// Force the generator to return text the REAL validateQuickReply blocks (AI
|
|
300
|
-
// self-disclosure) — no claude spawn, no Slack post. The block path must bump.
|
|
301
|
-
const restore = responder.setGenerateResponseForTests(async () => "Hi — I am an AI assistant, happy to help.");
|
|
302
|
-
try {
|
|
303
|
-
const before = (counters.snapshot()["send.blocked"]) || 0;
|
|
304
|
-
const res = await responder.sendQuickResponse(
|
|
305
|
-
{ sender: "ceo", service: "slack", channel: "D1", content: "hello" },
|
|
306
|
-
{ priority: "normal", action: "respond", model: "sonnet", summary: "hi" }
|
|
307
|
-
);
|
|
308
|
-
assert.equal(res.sent, false, "the AI-disclosure reply was blocked, not sent");
|
|
309
|
-
assert.equal(res.blocked, true);
|
|
310
|
-
const after = (counters.snapshot()["send.blocked"]) || 0;
|
|
311
|
-
assert.equal(after, before + 1, "the send-gate block bumped send.blocked by exactly one (durable JSONL)");
|
|
312
|
-
} finally { restore(); }
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// ---------------------------------------------------------------------------
|
|
316
|
-
// 3) The daemon's ONE real onGuardFail subscriber fires (the bus is live).
|
|
317
|
-
// ---------------------------------------------------------------------------
|
|
318
|
-
|
|
319
|
-
test("DIAG: registerHookSubscribers wires a real onGuardFail subscriber that bumps guard.fail", async () => {
|
|
320
|
-
clearDiagnostics();
|
|
321
|
-
// The daemon registers exactly one onGuardFail subscriber. It's idempotent, so
|
|
322
|
-
// calling it here (the test driver) is safe even though main() also would.
|
|
323
|
-
assert.equal(daemon.registerHookSubscribers(), true, "subscriber registered");
|
|
324
|
-
const hb = bus.getHookBus();
|
|
325
|
-
assert.ok(hb.count("onGuardFail") >= 1, "the bus has a live onGuardFail subscriber (not dead)");
|
|
326
|
-
|
|
327
|
-
const before = (counters.snapshot()["guard.fail"]) || 0;
|
|
328
|
-
// Emit a guard-fail (as the responder's send-gate fail-open path does). The
|
|
329
|
-
// real subscriber must fire and bump the durable counter.
|
|
330
|
-
const decision = await hb.emit("onGuardFail", { guard: "send_gate", reason: "policy unreadable", failed_open: true });
|
|
331
|
-
assert.equal(decision.cancelled, false, "onGuardFail is observe-only (never cancels)");
|
|
332
|
-
assert.equal(decision.errors, 0, "the subscriber did not throw");
|
|
333
|
-
|
|
334
|
-
const after = (counters.snapshot()["guard.fail"]) || 0;
|
|
335
|
-
assert.equal(after, before + 1, "the onGuardFail subscriber bumped guard.fail by exactly one");
|
|
336
|
-
});
|
|
337
|
-
|
|
338
|
-
test("DIAG: the responder's send-gate fail-open emits onGuardFail onto the live bus → guard.fail bumps", async () => {
|
|
339
|
-
clearDiagnostics();
|
|
340
|
-
daemon.registerHookSubscribers(); // ensure the real subscriber is live
|
|
341
|
-
const responder = await import("./responder.mjs");
|
|
342
|
-
// Generate benign text (passes validation), but force the Slack send-gate
|
|
343
|
-
// screen to throw a NON-block infra error so the responder fails OPEN and
|
|
344
|
-
// emits onGuardFail. We can't reach sendSlackMessage without a token, so we
|
|
345
|
-
// assert the wiring via the bus directly using the same emit the responder
|
|
346
|
-
// makes — the prior test proves the subscriber; here we prove a real producer
|
|
347
|
-
// shape lands on the same singleton bus the daemon subscribed to.
|
|
348
|
-
const hb = bus.getHookBus();
|
|
349
|
-
const before = (counters.snapshot()["guard.fail"]) || 0;
|
|
350
|
-
await hb.emit("onGuardFail", { guard: "send_gate", channel: "slack", recipient: "D1", reason: "gate module unreadable", failed_open: true });
|
|
351
|
-
const after = (counters.snapshot()["guard.fail"]) || 0;
|
|
352
|
-
assert.equal(after, before + 1, "a send-gate fail-open shape bumps guard.fail through the live subscriber");
|
|
353
|
-
// Tidy: drop the test reference so it doesn't leak the import.
|
|
354
|
-
void responder;
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// ---------------------------------------------------------------------------
|
|
358
|
-
// Post-session org-memory write-back (SP4) — after a clean turn, a bounded
|
|
359
|
-
// distillation is authored to the shared store via remember(). Fail-open when
|
|
360
|
-
// the org integration is disabled; never throws.
|
|
361
|
-
// ---------------------------------------------------------------------------
|
|
362
|
-
|
|
363
|
-
const ENABLED_ORG_CFG = { org: { cohort: { enabled: true, base: "https://org.example" } } };
|
|
364
|
-
|
|
365
|
-
test("MEMORY: sessionDistillation produces a bounded one-line record", () => {
|
|
366
|
-
const line = daemon.sessionDistillation(
|
|
367
|
-
{ sender: "Alice Smith", subject: "Q2 renewal" },
|
|
368
|
-
{ action: "respond", summary: "Acme renewal terms and next steps" },
|
|
369
|
-
{ agent: { firstName: "Jordan" } }
|
|
370
|
-
);
|
|
371
|
-
assert.ok(line, "should produce a distillation");
|
|
372
|
-
assert.ok(line.includes("Jordan"), "names the agent");
|
|
373
|
-
assert.ok(line.includes("respond"), "names the action");
|
|
374
|
-
assert.ok(line.includes("Alice Smith"), "names the sender");
|
|
375
|
-
assert.ok(line.includes("Acme renewal"), "carries the topic");
|
|
376
|
-
assert.ok(line.length <= 401, `bounded length, got ${line.length}`);
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
test("MEMORY: sessionDistillation returns null when there is nothing to remember", () => {
|
|
380
|
-
const line = daemon.sessionDistillation({}, {}, { agent: { firstName: "Jordan" } });
|
|
381
|
-
assert.equal(line, null, "no sender + no topic ⇒ null");
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("MEMORY: writeBackSession authors to the store when the org integration is enabled", async () => {
|
|
385
|
-
const calls = [];
|
|
386
|
-
const res = await daemon.writeBackSession(
|
|
387
|
-
{ sender: "Alice Smith", subject: "Q2 renewal" },
|
|
388
|
-
{ action: "respond", summary: "Acme renewal terms" },
|
|
389
|
-
{
|
|
390
|
-
cfg: ENABLED_ORG_CFG,
|
|
391
|
-
agent: { firstName: "Jordan" },
|
|
392
|
-
sessionId: "sess-xyz",
|
|
393
|
-
rememberImpl: async (text, opts) => { calls.push({ text, opts }); return { ok: true, mode: "append", id: "k1" }; },
|
|
394
|
-
}
|
|
395
|
-
);
|
|
396
|
-
assert.equal(res.ok, true, "write reported ok");
|
|
397
|
-
assert.equal(calls.length, 1, "remember() called exactly once");
|
|
398
|
-
assert.ok(calls[0].text.includes("Jordan"), "authored the distillation text");
|
|
399
|
-
assert.equal(calls[0].opts.cfg, ENABLED_ORG_CFG, "passed the org cfg through");
|
|
400
|
-
assert.equal(calls[0].opts.provenance.sessionId, "sess-xyz", "carried session provenance");
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
test("MEMORY: writeBackSession is a fail-open no-op when the org integration is disabled", async () => {
|
|
404
|
-
let called = false;
|
|
405
|
-
const res = await daemon.writeBackSession(
|
|
406
|
-
{ sender: "Alice Smith" },
|
|
407
|
-
{ action: "respond", summary: "x" },
|
|
408
|
-
{ cfg: {}, rememberImpl: async () => { called = true; return { ok: true }; } }
|
|
409
|
-
);
|
|
410
|
-
assert.equal(res.skipped, true, "skipped when disabled");
|
|
411
|
-
assert.equal(called, false, "remember() not called when disabled");
|
|
412
|
-
});
|
|
413
|
-
|
|
414
|
-
test("MEMORY: writeBackSession never throws even if remember() rejects", async () => {
|
|
415
|
-
const res = await daemon.writeBackSession(
|
|
416
|
-
{ sender: "Alice Smith", subject: "s" },
|
|
417
|
-
{ action: "respond", summary: "y" },
|
|
418
|
-
{ cfg: ENABLED_ORG_CFG, rememberImpl: async () => { throw new Error("network down"); } }
|
|
419
|
-
);
|
|
420
|
-
assert.equal(res.ok, false, "fail-open result");
|
|
421
|
-
assert.equal(res.skipped, true, "reported skipped on throw");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// ---------------------------------------------------------------------------
|
|
425
|
-
// Post-session OUTCOME DISCIPLINE — the daemon's parity with hq's meeting chair.
|
|
426
|
-
// A turn that committed to next steps must not leave them in a session log; the
|
|
427
|
-
// close path lands them through the org protocol. OFF unless the flag is set.
|
|
428
|
-
// The parsing/landing itself is covered by session-outcomes.test.mjs — these
|
|
429
|
-
// tests assert the DAEMON's wiring: identity + config injection, and fail-open.
|
|
430
|
-
// ---------------------------------------------------------------------------
|
|
431
|
-
|
|
432
|
-
test("OUTCOMES: the daemon hook lands a turn's commitments through the org protocol", async () => {
|
|
433
|
-
const created = [];
|
|
434
|
-
const remembered = [];
|
|
435
|
-
const res = await daemon.sweepOutcomesForSession({
|
|
436
|
-
sessionId: "s-daemon-1",
|
|
437
|
-
resultText: "Handled it.\n\nOUTCOMES:\n- Send the revised deck to Alice — due: Monday\n",
|
|
438
|
-
enabled: true,
|
|
439
|
-
cfg: ENABLED_ORG_CFG,
|
|
440
|
-
agent: { firstName: "Robin" },
|
|
441
|
-
item: {
|
|
442
|
-
service: "slack",
|
|
443
|
-
channel: "general",
|
|
444
|
-
channel_id: "C0GENERAL",
|
|
445
|
-
// The PUBLIC signal. A `C…` id alone proves nothing — Slack mints them
|
|
446
|
-
// for private channels too — so the gate needs the conversation type.
|
|
447
|
-
channel_type: "channel",
|
|
448
|
-
sender: "Alice Smith",
|
|
449
|
-
subject: "Deck review",
|
|
450
|
-
},
|
|
451
|
-
classResult: { action: "respond", summary: "deck review" },
|
|
452
|
-
createItemImpl: async (item) => { created.push(item); return { ok: true }; },
|
|
453
|
-
rememberImpl: async (text, opts) => { remembered.push({ text, opts }); return { ok: true }; },
|
|
454
|
-
});
|
|
455
|
-
assert.equal(res.created, 1, "the commitment became a board item");
|
|
456
|
-
assert.equal(created[0].title, "Send the revised deck to Alice");
|
|
457
|
-
assert.ok(created[0].detail.includes("Robin"), "the daemon supplied the agent identity");
|
|
458
|
-
assert.equal(remembered.length, 1, "and one knowledge episode records the set");
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
test("OUTCOMES: a private DM's commitments never leave the DM", async () => {
|
|
462
|
-
// CONFIDENTIALITY. A landed outcome is an ORG-VISIBLE artifact: board.create
|
|
463
|
-
// with no workstreamId is channel-less, and the episode's knowledge.append
|
|
464
|
-
// carries no group (so hq resolves it to the `org` group). Landing a DM's
|
|
465
|
-
// commitments would republish a one-to-one conversation — including its topic
|
|
466
|
-
// line — to every member of the org. The gate is fail-closed.
|
|
467
|
-
let touched = false;
|
|
468
|
-
const res = await daemon.sweepOutcomesForSession({
|
|
469
|
-
sessionId: "s-daemon-dm",
|
|
470
|
-
resultText: "OUTCOMES:\n- Send Alice the confidential term sheet — due: Monday\n",
|
|
471
|
-
enabled: true,
|
|
472
|
-
cfg: ENABLED_ORG_CFG,
|
|
473
|
-
agent: { firstName: "Robin" },
|
|
474
|
-
item: {
|
|
475
|
-
service: "slack",
|
|
476
|
-
channel: "dm/alice-smith",
|
|
477
|
-
channel_id: "D01ALICE",
|
|
478
|
-
is_dm: true,
|
|
479
|
-
sender: "Alice Smith",
|
|
480
|
-
subject: "DM from Alice Smith",
|
|
481
|
-
},
|
|
482
|
-
classResult: { action: "respond", summary: "confidential term sheet" },
|
|
483
|
-
createItemImpl: async () => { touched = true; return { ok: true }; },
|
|
484
|
-
rememberImpl: async () => { touched = true; return { ok: true }; },
|
|
485
|
-
});
|
|
486
|
-
assert.equal(res.created, 0);
|
|
487
|
-
assert.equal(res.reason, "source-dm");
|
|
488
|
-
assert.equal(touched, false, "no board row and no episode for a DM turn");
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test("OUTCOMES: the daemon hook is OFF unless DAEMON_SESSION_OUTCOMES=1", async () => {
|
|
492
|
-
let touched = false;
|
|
493
|
-
const prior = process.env.DAEMON_SESSION_OUTCOMES;
|
|
494
|
-
try {
|
|
495
|
-
delete process.env.DAEMON_SESSION_OUTCOMES;
|
|
496
|
-
const res = await daemon.sweepOutcomesForSession({
|
|
497
|
-
sessionId: "s-daemon-2",
|
|
498
|
-
resultText: "OUTCOMES:\n- Do the thing",
|
|
499
|
-
cfg: ENABLED_ORG_CFG,
|
|
500
|
-
agent: { firstName: "Robin" },
|
|
501
|
-
createItemImpl: async () => { touched = true; return { ok: true }; },
|
|
502
|
-
rememberImpl: async () => { touched = true; return { ok: true }; },
|
|
503
|
-
});
|
|
504
|
-
assert.equal(res.skipped, true);
|
|
505
|
-
assert.equal(res.reason, "disabled");
|
|
506
|
-
assert.equal(touched, false, "nothing is written while the flag is off");
|
|
507
|
-
} finally {
|
|
508
|
-
if (prior === undefined) delete process.env.DAEMON_SESSION_OUTCOMES;
|
|
509
|
-
else process.env.DAEMON_SESSION_OUTCOMES = prior;
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
test("OUTCOMES: the daemon hook never throws into the dispatch path", async () => {
|
|
514
|
-
const res = await daemon.sweepOutcomesForSession({
|
|
515
|
-
sessionId: "s-daemon-3",
|
|
516
|
-
stdout: "{{{ not json",
|
|
517
|
-
enabled: true,
|
|
518
|
-
cfg: ENABLED_ORG_CFG,
|
|
519
|
-
agent: { firstName: "Robin" },
|
|
520
|
-
createItemImpl: async () => { throw new Error("network down"); },
|
|
521
|
-
rememberImpl: async () => { throw new Error("network down"); },
|
|
522
|
-
});
|
|
523
|
-
assert.ok(res && typeof res.created === "number", "benign result, no throw");
|
|
524
|
-
assert.equal(res.created, 0);
|
|
525
|
-
});
|
|
526
|
-
|
|
527
|
-
// ===========================================================================
|
|
528
|
-
// ELECTION — the multi-AI storm fix. An UNDIRECTED message in a cohort channel
|
|
529
|
-
// defers the should-I-respond decision to hq's electResponder; this seat
|
|
530
|
-
// answers IFF elected, and a failed/empty election FAILS SAFE (stays silent).
|
|
531
|
-
// A DIRECT address (@mention / DM) skips the election entirely. And a holding
|
|
532
|
-
// ack is never posted when the reply session cannot run (claude unavailable).
|
|
533
|
-
//
|
|
534
|
-
// These drive the REAL answerItem() with the hq election call + the claude
|
|
535
|
-
// liveness probe stubbed via deps — the same injection seam the DIAG tests use.
|
|
536
|
-
// ===========================================================================
|
|
537
|
-
|
|
538
|
-
// An org config that configFromAgent() reads: enabled, with this seat's own
|
|
539
|
-
// member slug so the verdict can tell "elected me" from "elected someone else".
|
|
540
|
-
const ELECTION_ORG_CFG = {
|
|
541
|
-
org: { cohort: { enabled: true, base: "https://hq.example", orgId: "org_x", agentId: "robin" } },
|
|
542
|
-
};
|
|
543
|
-
|
|
544
|
-
// A cohort CHANNEL item (not a DM): service+channel_id give cohortChannelId,
|
|
545
|
-
// raw_ref's third segment gives cohortMessageId. Content is deliberately generic
|
|
546
|
-
// so isDirectedAtAgent() is false and the election path is taken.
|
|
547
|
-
function cohortChannelItem(overrides = {}) {
|
|
548
|
-
return {
|
|
549
|
-
id: overrides.id || "MSG-EL",
|
|
550
|
-
raw_ref: overrides.raw_ref || "cohort:message:msg-el:0",
|
|
551
|
-
service: "cohort",
|
|
552
|
-
channel: "channel/roadmap",
|
|
553
|
-
channel_id: overrides.channel_id || "chan-el",
|
|
554
|
-
is_dm: false,
|
|
555
|
-
sender: "alice",
|
|
556
|
-
content: overrides.content || "what should our Q3 priorities be?",
|
|
557
|
-
};
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
const ELECTED_CLASS = {
|
|
561
|
-
priority: "high", action: "respond", model: "opus",
|
|
562
|
-
summary: "roadmap discussion", category: "action_required", directed_at_agent: false,
|
|
563
|
-
};
|
|
564
|
-
|
|
565
|
-
test("ELECTION: the elected seat responds (dispatches) and the hq call carried {messageId, channelId}", async () => {
|
|
566
|
-
resetState();
|
|
567
|
-
delete process.env.COHORT_AGENT_ID; // let the cfg's agentId be the seat's slug
|
|
568
|
-
const cls = await import("./classifier.mjs");
|
|
569
|
-
cls._resetAgentRegistry();
|
|
570
|
-
|
|
571
|
-
const item = cohortChannelItem({ id: "MSG-EL-YES", raw_ref: "cohort:message:msg-yes:0", channel_id: "chan-yes" });
|
|
572
|
-
const electionCalls = [];
|
|
573
|
-
let dispatched = false;
|
|
574
|
-
|
|
575
|
-
const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-yes", {
|
|
576
|
-
classify: async () => ({ ...ELECTED_CLASS, summary: "elected-yes topic" }),
|
|
577
|
-
isQuickReply: () => false,
|
|
578
|
-
sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
|
|
579
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
580
|
-
claudeAvailable: () => true,
|
|
581
|
-
orgCfg: ELECTION_ORG_CFG,
|
|
582
|
-
electResponder: async (params) => {
|
|
583
|
-
electionCalls.push(params);
|
|
584
|
-
return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "robin", dimension: "mandate" }], deferredToHuman: false } };
|
|
585
|
-
},
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
assert.equal(electionCalls.length, 1, "the election was consulted exactly once");
|
|
589
|
-
assert.deepEqual(electionCalls[0], { messageId: "msg-yes", channelId: "chan-yes" }, "hq got the message + channel identity");
|
|
590
|
-
assert.equal(dispatched, true, "the elected seat proceeds to dispatch a reply session");
|
|
591
|
-
assert.equal(res.path, "session", "answerItem took the session path");
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
test("ELECTION: a NON-elected seat stays silent on the same message (no dispatch)", async () => {
|
|
595
|
-
resetState();
|
|
596
|
-
delete process.env.COHORT_AGENT_ID;
|
|
597
|
-
const cls = await import("./classifier.mjs");
|
|
598
|
-
cls._resetAgentRegistry();
|
|
599
|
-
|
|
600
|
-
const item = cohortChannelItem({ id: "MSG-EL-NO", raw_ref: "cohort:message:msg-no:0", channel_id: "chan-no" });
|
|
601
|
-
const electionCalls = [];
|
|
602
|
-
let dispatched = false;
|
|
603
|
-
let acked = false;
|
|
604
|
-
|
|
605
|
-
const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-no", {
|
|
606
|
-
classify: async () => ({ ...ELECTED_CLASS, summary: "not-elected topic" }),
|
|
607
|
-
isQuickReply: () => false,
|
|
608
|
-
sendHoldingMessage: async () => { acked = true; return { sent: true, holdingText: "on it" }; },
|
|
609
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
610
|
-
claudeAvailable: () => true,
|
|
611
|
-
orgCfg: ELECTION_ORG_CFG,
|
|
612
|
-
// hq elected a DIFFERENT seat — this one is not in the responders list.
|
|
613
|
-
electResponder: async (params) => {
|
|
614
|
-
electionCalls.push(params);
|
|
615
|
-
return { ok: true, result: { election: "group", mode: "single", responders: [{ slug: "kai", dimension: "mandate" }], deferredToHuman: false } };
|
|
616
|
-
},
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
assert.equal(electionCalls.length, 1, "the election was consulted");
|
|
620
|
-
assert.equal(dispatched, false, "a non-elected seat must NOT dispatch");
|
|
621
|
-
assert.equal(acked, false, "a non-elected seat must NOT post a holding ack");
|
|
622
|
-
assert.equal(res.path, "filtered", "answerItem filtered the item");
|
|
623
|
-
assert.equal(res.reason, "not_elected", "…because it was not elected");
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
test("ELECTION: an election ERROR fails safe — the seat stays silent (never respond-to-all)", async () => {
|
|
627
|
-
resetState();
|
|
628
|
-
delete process.env.COHORT_AGENT_ID;
|
|
629
|
-
const cls = await import("./classifier.mjs");
|
|
630
|
-
cls._resetAgentRegistry();
|
|
631
|
-
|
|
632
|
-
const item = cohortChannelItem({ id: "MSG-EL-ERR", raw_ref: "cohort:message:msg-err:0", channel_id: "chan-err" });
|
|
633
|
-
let dispatched = false;
|
|
634
|
-
|
|
635
|
-
const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-err", {
|
|
636
|
-
classify: async () => ({ ...ELECTED_CLASS, summary: "election-error topic" }),
|
|
637
|
-
isQuickReply: () => false,
|
|
638
|
-
sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
|
|
639
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
640
|
-
claudeAvailable: () => true,
|
|
641
|
-
orgCfg: ELECTION_ORG_CFG,
|
|
642
|
-
// hq is unreachable / throwing — the INVERSION: this must resolve to silence.
|
|
643
|
-
electResponder: async () => { throw new Error("hq unreachable"); },
|
|
644
|
-
});
|
|
645
|
-
|
|
646
|
-
assert.equal(dispatched, false, "an unreachable election must NOT fall back to responding");
|
|
647
|
-
assert.equal(res.path, "filtered", "answerItem filtered the item");
|
|
648
|
-
assert.equal(res.reason, "not_elected", "election failure ⇒ treated as not-elected (silent)");
|
|
649
|
-
});
|
|
650
|
-
|
|
651
|
-
test("ELECTION: an @mention/named-address responds WITHOUT calling electResponder (fast path)", async () => {
|
|
652
|
-
resetState();
|
|
653
|
-
delete process.env.COHORT_AGENT_ID;
|
|
654
|
-
// Give this seat a distinctive name and point isDirectedAtAgent at it.
|
|
655
|
-
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
656
|
-
writeFileSync(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({ firstName: "Zephyr", slackMemberId: "" }));
|
|
657
|
-
const cls = await import("./classifier.mjs");
|
|
658
|
-
cls._resetAgentRegistry();
|
|
659
|
-
|
|
660
|
-
const item = cohortChannelItem({
|
|
661
|
-
id: "MSG-EL-MENTION", raw_ref: "cohort:message:msg-mention:0", channel_id: "chan-mention",
|
|
662
|
-
content: "hey zephyr, can you own the Q3 rollout?",
|
|
663
|
-
});
|
|
664
|
-
const electionCalls = [];
|
|
665
|
-
let dispatched = false;
|
|
666
|
-
|
|
667
|
-
const res = await daemon.answerItem(item, "cohort", item.raw_ref, "trace-el-mention", {
|
|
668
|
-
classify: async () => ({ ...ELECTED_CLASS, summary: "mention topic", directed_at_agent: true }),
|
|
669
|
-
isQuickReply: () => false,
|
|
670
|
-
sendHoldingMessage: async () => ({ sent: false, holdingText: null }),
|
|
671
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
672
|
-
claudeAvailable: () => true,
|
|
673
|
-
orgCfg: ELECTION_ORG_CFG,
|
|
674
|
-
electResponder: async (params) => { electionCalls.push(params); return { ok: true, result: { responders: [] } }; },
|
|
675
|
-
});
|
|
676
|
-
|
|
677
|
-
assert.equal(electionCalls.length, 0, "a directly-addressed message does NOT hold an election");
|
|
678
|
-
assert.equal(dispatched, true, "the addressed seat answers on its local fast path");
|
|
679
|
-
assert.equal(res.path, "session", "answerItem took the session path");
|
|
680
|
-
|
|
681
|
-
// Tidy: drop the identity so later tests re-read a nameless seat.
|
|
682
|
-
rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
|
|
683
|
-
cls._resetAgentRegistry();
|
|
684
|
-
});
|
|
685
|
-
|
|
686
|
-
test("FAIL-SAFE: an ask the reply session cannot answer is never promised one (claude unavailable)", async () => {
|
|
687
|
-
resetState();
|
|
688
|
-
// A slack DM — always directed, so this isolates the ack gate from the election.
|
|
689
|
-
// is_dm is normally set by enrichItem; answerItem is called directly here, so
|
|
690
|
-
// set it on the item as the poller/enrichment would.
|
|
691
|
-
// Distinct channel + subject per run so the request-claim from one run does not
|
|
692
|
-
// deny the other (the claim key is recipient + subject + action_type).
|
|
693
|
-
//
|
|
694
|
-
// WHAT THIS PINS SINCE 2026-09-12. Nothing is said at open time on any path
|
|
695
|
-
// any more — `openAndAcknowledge` writes the debt and returns — so the old
|
|
696
|
-
// control ("with claude available a holding ack IS attempted") no longer
|
|
697
|
-
// describes the system. The fail-safe itself is unchanged and is what this
|
|
698
|
-
// asserts: when the CLI that would answer cannot start, the debt is opened
|
|
699
|
-
// with the interim FORBIDDEN on the record, so no sweep in any later tick or
|
|
700
|
-
// process can post a promise the seat cannot keep.
|
|
701
|
-
const assurance = await import("./assurance.mjs");
|
|
702
|
-
const mkItem = (tag) => ({ id: `MSG-ACK-${tag}`, raw_ref: `slack:DACK${tag}:1`, service: "slack", channel: "dm/ceo", channel_id: `DACK${tag}0001`, is_dm: true, sender: "ceo", content: "please draft the memo" });
|
|
703
|
-
const baseDeps = (spy, claudeUp, tag) => ({
|
|
704
|
-
classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: `draft memo ${tag}`, category: "action_required", directed_at_agent: true }),
|
|
705
|
-
isQuickReply: () => false,
|
|
706
|
-
sendHoldingMessage: async () => { spy.calls++; return { sent: true, holdingText: "on it" }; },
|
|
707
|
-
dispatch: (_p, _it, _cr, _s, opts) => { spy.dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
708
|
-
claudeAvailable: () => claudeUp,
|
|
709
|
-
});
|
|
710
|
-
|
|
711
|
-
// CONTROL: claude available ⇒ the debt is opened and an interim is PERMITTED
|
|
712
|
-
// later, by the sweep, after ACK_AFTER_MS and inside the room's budget.
|
|
713
|
-
const up = { calls: 0, dispatched: false };
|
|
714
|
-
await daemon.answerItem(mkItem("UP"), "slack", "slack:DACKUP:1", "trace-ack-up", baseDeps(up, true, "UP"));
|
|
715
|
-
assert.equal(up.calls, 0, "nothing is said at open time on ANY path — the interim belongs to the sweep");
|
|
716
|
-
assert.equal(up.dispatched, true, "control: the session is dispatched");
|
|
717
|
-
const upRec = assurance.readObligation("slack:DACKUP:1");
|
|
718
|
-
assert.ok(upRec, "the debt exists");
|
|
719
|
-
assert.notEqual(upRec.interimForbidden, true, "and an interim is permitted for it");
|
|
720
|
-
|
|
721
|
-
// TREATMENT: claude unavailable ⇒ the interim is forbidden on the record, but
|
|
722
|
-
// the session still runs (the assurance sweep owns the outcome; the human
|
|
723
|
-
// just isn't promised a reply the seat cannot produce).
|
|
724
|
-
const down = { calls: 0, dispatched: false };
|
|
725
|
-
const res = await daemon.answerItem(mkItem("DN"), "slack", "slack:DACKDN:1", "trace-ack-down", baseDeps(down, false, "DN"));
|
|
726
|
-
assert.equal(down.calls, 0, "with claude unavailable, no interim is ever posted for this ask");
|
|
727
|
-
assert.equal(down.dispatched, true, "the session is still dispatched");
|
|
728
|
-
assert.equal(res.path, "session", "answerItem took the session path");
|
|
729
|
-
const downRec = assurance.readObligation("slack:DACKDN:1");
|
|
730
|
-
assert.ok(downRec, "the debt is opened all the same — an ask we cannot promise is exactly the one an operator must see");
|
|
731
|
-
assert.equal(downRec.interimForbidden, true, "durable on the record, because the sweep runs in another tick");
|
|
732
|
-
});
|
|
733
|
-
|
|
734
|
-
// ===========================================================================
|
|
735
|
-
// is_dm END-TO-END — the Cohort channel_kind is the authoritative signal.
|
|
736
|
-
// ===========================================================================
|
|
737
|
-
|
|
738
|
-
test("enrichItem: Cohort channel_kind decides is_dm (DM yes, GROUP_DM no)", () => {
|
|
739
|
-
// A 1:1 DM skips the election gate; a GROUP_DM is multi-participant and must
|
|
740
|
-
// go through it. Cohort ids are cuids, so the kind is the only reliable signal.
|
|
741
|
-
const dm = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "DM" }, "cohort");
|
|
742
|
-
assert.equal(dm.is_dm, true, "channel_kind DM → is_dm true (skips election)");
|
|
743
|
-
const group = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "GROUP_DM" }, "cohort");
|
|
744
|
-
assert.equal(group.is_dm, false, "channel_kind GROUP_DM → is_dm false (goes through election)");
|
|
745
|
-
const pub = daemon.enrichItem({ channel: "channel/x", channel_id: "cuid_abc", channel_kind: "public" }, "cohort");
|
|
746
|
-
assert.equal(pub.is_dm, false, "any other explicit kind → not a 1:1 DM");
|
|
747
|
-
// No kind → fall back to the Slack-style id/label prefix heuristics.
|
|
748
|
-
assert.equal(daemon.enrichItem({ channel: "dm/ceo", channel_id: "D123" }, "slack").is_dm, true);
|
|
749
|
-
assert.equal(daemon.enrichItem({ channel: "channel/roadmap", channel_id: "C123" }, "slack").is_dm, false);
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
test("enrichItem: a WIDE-path Cohort 1:1 DM (is_dm:true, NO channel_kind, cuid id) stays is_dm through the whole chain", async () => {
|
|
753
|
-
// REGRESSION. The DEFAULT inbound reader is lib/org/inbound.pullWideInbound,
|
|
754
|
-
// whose projection (lib/org/inbound/project.mjs toMessageEvent) marks a 1:1 DM
|
|
755
|
-
// with is_dm:true + channel_type:"dm" but does NOT emit channel_kind (only the
|
|
756
|
-
// non-default MESSAGING_INBOUND_WIDE=0 narrow reader sets channel_kind). Cohort
|
|
757
|
-
// channel ids are cuids, so the Slack-style "dm/"/"D…" prefix heuristics never
|
|
758
|
-
// fire on them. Before the fix, enrichItem derived is_dm ONLY from an (absent)
|
|
759
|
-
// channel_kind or those (never-matching) heuristics and CLOBBERED the true
|
|
760
|
-
// is_dm to false — routing a genuine 1:1 DM through the group election gate,
|
|
761
|
-
// which then suppressed it. This drives the real chain end to end:
|
|
762
|
-
// toMessageEvent → eventToInboxItem → writeInboxItem → parseInboxItemYaml → enrichItem
|
|
763
|
-
// and asserts is_dm survives as true.
|
|
764
|
-
const { toMessageEvent } = await import("../../lib/org/inbound/project.mjs");
|
|
765
|
-
const { classifyEvent } = await import("../../lib/org/inbound/directedness.mjs");
|
|
766
|
-
const { eventToInboxItem } = await import("../../lib/channels/inbox-item.mjs");
|
|
767
|
-
const { writeInboxItem } = await import("../poller/utils.mjs");
|
|
768
|
-
const { parseInboxItemYaml } = await import("../poller/inbox-scan-poller.mjs");
|
|
769
|
-
|
|
770
|
-
const CUID = "clw9x7k4a0000abcd1234efgh"; // a real cuid: no "D…"/"dm/" prefix to key off
|
|
771
|
-
const cand = classifyEvent({
|
|
772
|
-
seq: 501, at: "2026-08-25T09:00:00.000Z", actor: "M-them",
|
|
773
|
-
family: "messaging", kind: "send", entity_id: "m501",
|
|
774
|
-
payload: { actor: "M-them", channelId: CUID, channelKind: "DM" },
|
|
775
|
-
});
|
|
776
|
-
const facts = { channels: new Map([[CUID, { id: CUID, kind: "DM", name: "them" }]]) };
|
|
777
|
-
const mev = toMessageEvent({
|
|
778
|
-
candidate: cand,
|
|
779
|
-
verdict: { surface: "dm", reason: "dm", directed: true },
|
|
780
|
-
hydrated: { ok: true, text: "ping", from: { id: "M-them", name: "Casey" }, channelId: CUID, channelLabel: "them" },
|
|
781
|
-
me: "M-me",
|
|
782
|
-
facts,
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
// The premise the regression rests on: the wide projection marks the DM but
|
|
786
|
-
// does NOT emit channel_kind.
|
|
787
|
-
assert.equal(mev.is_dm, true, "wide-path DM projects is_dm:true");
|
|
788
|
-
assert.equal(mev.channel_type, "dm");
|
|
789
|
-
assert.equal("channel_kind" in mev, false, "the DEFAULT wide reader emits NO channel_kind");
|
|
790
|
-
|
|
791
|
-
// eventToInboxItem carries is_dm through but (correctly) attaches no channel_kind.
|
|
792
|
-
const item = eventToInboxItem(mev);
|
|
793
|
-
assert.equal(item.is_dm, true);
|
|
794
|
-
assert.equal("channel_kind" in item, false);
|
|
795
|
-
|
|
796
|
-
// writeInboxItem → parseInboxItemYaml: the on-disk round-trip the poller does.
|
|
797
|
-
writeInboxItem("cohort", item, AGENT_DIR);
|
|
798
|
-
const dir = join(AGENT_DIR, "state", "inbox", "cohort");
|
|
799
|
-
const file = readdirSync(dir).find((f) => f.includes(item.id) && f.endsWith(".yaml"));
|
|
800
|
-
assert.ok(file, "the inbox item was written to disk");
|
|
801
|
-
const parsed = parseInboxItemYaml(readFileSync(join(dir, file), "utf8"));
|
|
802
|
-
assert.equal(parsed.is_dm, true, "is_dm survives the YAML round-trip");
|
|
803
|
-
assert.equal("channel_kind" in parsed, false, "and still no channel_kind on the wide path");
|
|
804
|
-
|
|
805
|
-
// enrichItem is the last hop before the election gate. It MUST honor the
|
|
806
|
-
// pre-existing boolean is_dm when channel_kind is absent, not clobber it.
|
|
807
|
-
const enriched = daemon.enrichItem(parsed, "cohort");
|
|
808
|
-
assert.equal(enriched.is_dm, true, "the wide-path 1:1 DM stays is_dm — it must SKIP the group election gate");
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
// ===========================================================================
|
|
812
|
-
// NO FALLTHROUGH ON PERMANENT FAILURE — a permanent quick-reply send failure
|
|
813
|
-
// (FORBIDDEN_SCOPE) must escalate, not spawn an autonomous session that would
|
|
814
|
-
// post UNRELATED work into a channel the seat may not write to.
|
|
815
|
-
// ===========================================================================
|
|
816
|
-
|
|
817
|
-
test("NO FALLTHROUGH: a PERMANENT quick-reply failure escalates instead of spawning a session", async () => {
|
|
818
|
-
resetState();
|
|
819
|
-
const item = { id: "MSG-PERM", raw_ref: "slack:DPERM:1", service: "slack", channel: "dm/ceo-perm", channel_id: "DPERM0001", is_dm: true, sender: "ceo", subject: "forbidden send", content: "please post this" };
|
|
820
|
-
let dispatched = false;
|
|
821
|
-
const res = await daemon.answerItem(item, "slack", "MSG-PERM", "trace-perm", {
|
|
822
|
-
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
823
|
-
isQuickReply: () => true,
|
|
824
|
-
sendQuickResponse: async () => ({ sent: false, permanent: true, code: "FORBIDDEN_SCOPE", error: "FORBIDDEN_SCOPE: blocked by send-gate" }),
|
|
825
|
-
dispatch: () => { dispatched = true; },
|
|
826
|
-
claudeAvailable: () => true,
|
|
827
|
-
});
|
|
828
|
-
assert.equal(dispatched, false, "a permanent send failure must NOT spawn an autonomous session");
|
|
829
|
-
assert.equal(res.path, "quick_reply_permanent");
|
|
830
|
-
assert.match(res.reason, /FORBIDDEN_SCOPE/);
|
|
831
|
-
const naDir = join(AGENT_DIR, "state", "obligations", "needs-attention");
|
|
832
|
-
assert.ok(existsSync(naDir) && readdirSync(naDir).length > 0, "a durable needs-attention escalation was written for an operator");
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
test("NO FALLTHROUGH: a TRANSIENT quick-reply failure still falls through to a full session", async () => {
|
|
836
|
-
resetState();
|
|
837
|
-
const item = { id: "MSG-TRAN", raw_ref: "slack:DTRAN:1", service: "slack", channel: "dm/ceo-tran", channel_id: "DTRAN0001", is_dm: true, sender: "ceo", subject: "transient send", content: "please post this" };
|
|
838
|
-
let dispatched = false;
|
|
839
|
-
const res = await daemon.answerItem(item, "slack", "MSG-TRAN", "trace-tran", {
|
|
840
|
-
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
841
|
-
isQuickReply: () => true,
|
|
842
|
-
sendQuickResponse: async () => ({ sent: false, error: "hq 503 blip" }), // transient: NO permanent flag
|
|
843
|
-
sendHoldingMessage: async () => ({ sent: true, holdingText: "on it" }),
|
|
844
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
845
|
-
claudeAvailable: () => true,
|
|
846
|
-
});
|
|
847
|
-
assert.equal(dispatched, true, "a transient failure falls through to the full session, as before");
|
|
848
|
-
assert.equal(res.path, "session");
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
test("REACTIVE PRIORITY: a quick reply yields the subscription (evicts backlog) BEFORE it generates", async () => {
|
|
852
|
-
resetState();
|
|
853
|
-
const item = { id: "MSG-YIELD", raw_ref: "slack:DYIELD:1", service: "slack", channel: "dm/ceo", channel_id: "DYIELD001", is_dm: true, sender: "ceo", subject: "quick q", content: "you around?" };
|
|
854
|
-
const order = [];
|
|
855
|
-
const res = await daemon.answerItem(item, "slack", "MSG-YIELD", "trace-yield", {
|
|
856
|
-
classify: async () => ({ priority: "high", action: "respond", model: "haiku", summary: "quick answer", category: "action_required", directed_at_agent: true }),
|
|
857
|
-
isQuickReply: () => true,
|
|
858
|
-
yieldBacklogForReactive: () => { order.push("yield"); return 2; },
|
|
859
|
-
sendQuickResponse: async () => { order.push("send"); return { sent: true }; },
|
|
860
|
-
claudeAvailable: () => true,
|
|
861
|
-
});
|
|
862
|
-
assert.equal(res.path, "quick_reply");
|
|
863
|
-
assert.deepEqual(order, ["yield", "send"], "backlog is yielded the subscription BEFORE the reply's claude call, not after");
|
|
864
|
-
});
|
|
865
|
-
|
|
866
|
-
test("resolveSelfMemberId reads the member cuid from the res frame's `result` (own-echo keystone)", async () => {
|
|
867
|
-
daemon._resetSelfMemberId();
|
|
868
|
-
const pk = process.env.COHORT_API_KEY, po = process.env.COHORT_ORG_ID, pa = process.env.COHORT_AGENT_ID;
|
|
869
|
-
process.env.COHORT_API_KEY = "nlk_test";
|
|
870
|
-
process.env.COHORT_ORG_ID = "org_test";
|
|
871
|
-
process.env.COHORT_AGENT_ID = "A016";
|
|
872
|
-
try {
|
|
873
|
-
let calls = 0;
|
|
874
|
-
// fetchSelfProfile returns the authed call() res frame: the member is under
|
|
875
|
-
// `result`, and the org key is passed as apiKey. (Both were wrong in 2.11.7.)
|
|
876
|
-
const mid = await daemon.resolveSelfMemberId({
|
|
877
|
-
fetchSelfProfileImpl: async (o) => {
|
|
878
|
-
calls++;
|
|
879
|
-
assert.equal(o.agentId, "A016", "must pass the slug so /members/<slug> resolves");
|
|
880
|
-
return { ok: true, result: { memberId: "cmSELFcuid", slug: o.agentId } };
|
|
881
|
-
},
|
|
882
|
-
});
|
|
883
|
-
assert.equal(mid, "cmSELFcuid");
|
|
884
|
-
// cached: a second call must NOT refetch
|
|
885
|
-
const again = await daemon.resolveSelfMemberId({
|
|
886
|
-
fetchSelfProfileImpl: async () => { throw new Error("must not refetch a cached self id"); },
|
|
887
|
-
});
|
|
888
|
-
assert.equal(again, "cmSELFcuid");
|
|
889
|
-
assert.equal(calls, 1);
|
|
890
|
-
} finally {
|
|
891
|
-
daemon._resetSelfMemberId();
|
|
892
|
-
if (pk === undefined) delete process.env.COHORT_API_KEY; else process.env.COHORT_API_KEY = pk;
|
|
893
|
-
if (po === undefined) delete process.env.COHORT_ORG_ID; else process.env.COHORT_ORG_ID = po;
|
|
894
|
-
if (pa === undefined) delete process.env.COHORT_AGENT_ID; else process.env.COHORT_AGENT_ID = pa;
|
|
895
|
-
}
|
|
896
|
-
});
|
|
897
|
-
|
|
898
|
-
test("fastPathClassify: a short directed question skips the classifier; work-requests do not", async () => {
|
|
899
|
-
const fp = (content, isDm = true, extra = {}) => daemon.fastPathClassify({ content, ...extra }, isDm);
|
|
900
|
-
// Plain short questions → fast-path (quick reply, high priority, sonnet).
|
|
901
|
-
const q = fp("what OS is your machine on?");
|
|
902
|
-
assert.equal(q && q.fast_path, true);
|
|
903
|
-
assert.equal(q.answerable, true);
|
|
904
|
-
assert.equal(q.priority, "high");
|
|
905
|
-
assert.equal(q.action, "respond");
|
|
906
|
-
assert.ok(daemon.fastPathClassify({ content: "hey, you around?" }, true));
|
|
907
|
-
assert.ok(daemon.fastPathClassify({ content: "thanks!" }, true));
|
|
908
|
-
// Work-requests → NOT fast-pathed (fall through to full classify → session).
|
|
909
|
-
assert.equal(fp("can you analyze the Q3 report and send me the findings?"), null);
|
|
910
|
-
assert.equal(fp("please review the PR and fix the failing tests"), null);
|
|
911
|
-
assert.equal(fp("build a dashboard for the sales pipeline"), null);
|
|
912
|
-
// Non-questions, long messages, group, and non-DM → NOT fast-pathed.
|
|
913
|
-
assert.equal(fp("here are the notes from the meeting for your records"), null);
|
|
914
|
-
assert.equal(fp("x".repeat(300)), null);
|
|
915
|
-
assert.equal(fp("what OS?", false), null); // not a DM
|
|
916
|
-
assert.equal(fp(""), null);
|
|
917
|
-
// Attachments → full path.
|
|
918
|
-
assert.equal(daemon.fastPathClassify({ content: "what's this?", has_attachments: true }, true), null);
|
|
919
|
-
// Kill switch (deps.disableFastPath) forces the full classify path.
|
|
920
|
-
assert.ok(daemon.fastPathClassify({ content: "what OS?" }, true));
|
|
921
|
-
assert.equal(daemon.fastPathClassify({ content: "what OS?" }, true, { disableFastPath: true }), null);
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
// ===========================================================================
|
|
925
|
-
// SELF-ECHO — the agent's own outbound, re-ingested by a poll lane, must be
|
|
926
|
-
// dropped BEFORE it can be classified, elected on, acked or answered. Recurred
|
|
927
|
-
// live 2026-08-25 / 08-27 / 08-30: the old filter normalised the agent's full
|
|
928
|
-
// name to "isla-roselli" while real events carry "Isla Roselli", so the agent
|
|
929
|
-
// said "Looking now." to itself (and, in shared DMs, to another agent's echo).
|
|
930
|
-
// ===========================================================================
|
|
931
|
-
|
|
932
|
-
test("SELF-ECHO: an echoed own message is dropped at the processItem choke point — file retired, drop audited", async () => {
|
|
933
|
-
resetState();
|
|
934
|
-
const selfEcho = await import("./lib/self-echo.mjs");
|
|
935
|
-
const { writeFileSync: wf } = await import("node:fs");
|
|
936
|
-
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
937
|
-
wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
|
|
938
|
-
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
939
|
-
}));
|
|
940
|
-
selfEcho._resetSelfIdentity();
|
|
941
|
-
try {
|
|
942
|
-
// The REAL echo shape: display name verbatim, spaces and all.
|
|
943
|
-
const item = { id: "MSG-SELF", raw_ref: "slack:DSELF:1", channel: "dm/candace", channel_id: "DSELF0001", sender: "Isla Roselli", content: "Both answers — and the second one settles it." };
|
|
944
|
-
const name = writeLiveItem(item);
|
|
945
|
-
|
|
946
|
-
let pipelineTouched = null;
|
|
947
|
-
const res = await daemon.processItem(item, "slack", {
|
|
948
|
-
classify: async () => { pipelineTouched = "classify"; return { priority: "high", action: "respond", model: "opus", summary: "x", category: "action_required", directed_at_agent: true }; },
|
|
949
|
-
isQuickReply: () => { pipelineTouched = "isQuickReply"; return false; },
|
|
950
|
-
sendQuickResponse: async () => { pipelineTouched = "quick_reply"; return { sent: true }; },
|
|
951
|
-
sendHoldingMessage: async () => { pipelineTouched = "holding_ack"; return { sent: true, holdingText: "x" }; },
|
|
952
|
-
dispatch: () => { pipelineTouched = "dispatch"; },
|
|
953
|
-
claudeAvailable: () => true,
|
|
954
|
-
});
|
|
955
|
-
|
|
956
|
-
assert.equal(res && res.path, "filtered", "the echo is filtered, not answered");
|
|
957
|
-
assert.equal(res && res.reason, "self_echo");
|
|
958
|
-
assert.equal(pipelineTouched, null, "no classify, no ack, no reply, no session — the echo never enters the pipeline");
|
|
959
|
-
assert.deepEqual(inboxFiles(), [`${name}.processed`], "the echo's inbox file is RETIRED — an un-retired echo would be re-scanned and re-dropped every tick forever");
|
|
960
|
-
const auditPath = join(AGENT_DIR, "logs", "daemon", "self-echo-drops.jsonl");
|
|
961
|
-
assert.ok(existsSync(auditPath), "every drop leaves an audit row");
|
|
962
|
-
assert.match(readFileSync(auditPath, "utf-8"), /slack:DSELF:1/, "the audit row names the dropped event");
|
|
963
|
-
} finally {
|
|
964
|
-
rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
|
|
965
|
-
selfEcho._resetSelfIdentity();
|
|
966
|
-
}
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
test("SELF-ECHO: a genuine colleague's message is untouched by the guard", async () => {
|
|
970
|
-
resetState();
|
|
971
|
-
const selfEcho = await import("./lib/self-echo.mjs");
|
|
972
|
-
const { writeFileSync: wf } = await import("node:fs");
|
|
973
|
-
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
974
|
-
wf(join(AGENT_DIR, "config", "agent.json"), JSON.stringify({
|
|
975
|
-
firstName: "Isla", fullName: "Isla Roselli", memberId: "cmqh0tcml004ihhl6zev6ocb5",
|
|
976
|
-
}));
|
|
977
|
-
selfEcho._resetSelfIdentity();
|
|
978
|
-
try {
|
|
979
|
-
const item = { id: "MSG-COLLEAGUE", raw_ref: "slack:DCOLL:1", channel: "dm/jordan", channel_id: "DCOLL0001", is_dm: true, sender: "Jordan Blake", content: "please draft the board memo" };
|
|
980
|
-
writeLiveItem(item);
|
|
981
|
-
let dispatched = false;
|
|
982
|
-
const res = await daemon.answerItem(item, "slack", "slack:DCOLL:1", "trace-coll", {
|
|
983
|
-
classify: async () => ({ priority: "critical", action: "respond", model: "opus", summary: "draft board memo", category: "action_required", directed_at_agent: true }),
|
|
984
|
-
isQuickReply: () => false,
|
|
985
|
-
sendHoldingMessage: async () => ({ sent: true, holdingText: "Starting on the board memo now." }),
|
|
986
|
-
dispatch: (_p, _it, _cr, _s, opts) => { dispatched = true; opts.onClose({ ok: true, code: 0 }); },
|
|
987
|
-
claudeAvailable: () => true,
|
|
988
|
-
});
|
|
989
|
-
assert.equal(dispatched, true, "a real colleague's ask still dispatches");
|
|
990
|
-
assert.equal(res.path, "session");
|
|
991
|
-
} finally {
|
|
992
|
-
rmSync(join(AGENT_DIR, "config", "agent.json"), { force: true });
|
|
993
|
-
selfEcho._resetSelfIdentity();
|
|
994
|
-
}
|
|
995
|
-
});
|