@cohortapp/agent-sdk 2.17.0 → 2.18.5
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-slots.mjs +234 -0
- package/lib/collective/capture.mjs +8 -6
- package/lib/collective/config.mjs +2 -0
- package/lib/collective/global-config.mjs +63 -1
- package/lib/collective/loop-guard.mjs +155 -0
- 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 +833 -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 +229 -11
- 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/scaffold/config/collective.yaml +7 -0
- package/scripts/ci/check-durable-write-seam.mjs +3 -1
- 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 +142 -19
- 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 +1151 -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 +465 -81
- 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,908 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* hydrate.test.mjs — per-family body/context hydration.
|
|
3
|
-
*
|
|
4
|
-
* Two things are being asserted throughout:
|
|
5
|
-
* 1. every surface produces a body a classifier can actually act on, and
|
|
6
|
-
* 2. hydration only ever uses the agent's OWN entitled reads — the call log is
|
|
7
|
-
* checked so a hydrator cannot quietly acquire a wider aperture than the
|
|
8
|
-
* directedness probe that let the item through.
|
|
9
|
-
*
|
|
10
|
-
* Run: node --test lib/org/inbound/hydrate.test.mjs
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
"use strict";
|
|
14
|
-
|
|
15
|
-
import { test } from "node:test";
|
|
16
|
-
import assert from "node:assert/strict";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
hydrate,
|
|
20
|
-
clip,
|
|
21
|
-
renderThread,
|
|
22
|
-
priorComments,
|
|
23
|
-
isCommentKind,
|
|
24
|
-
htmlToText,
|
|
25
|
-
clampDocHtml,
|
|
26
|
-
DOC_HTML_MAX_CHARS,
|
|
27
|
-
describeBoardKind,
|
|
28
|
-
describeApprovalKind,
|
|
29
|
-
} from "./hydrate.mjs";
|
|
30
|
-
import { classifyEvent } from "./directedness.mjs";
|
|
31
|
-
|
|
32
|
-
const ME = "M-me";
|
|
33
|
-
const THEM = "M-them";
|
|
34
|
-
const okFrame = (result) => ({ ok: true, result });
|
|
35
|
-
|
|
36
|
-
function fakeIo(routes = {}) {
|
|
37
|
-
const calls = [];
|
|
38
|
-
const logs = [];
|
|
39
|
-
return {
|
|
40
|
-
calls,
|
|
41
|
-
logs,
|
|
42
|
-
log: (level, message, meta) => logs.push({ level, message, meta }),
|
|
43
|
-
async call(method, params) {
|
|
44
|
-
calls.push({ method, params });
|
|
45
|
-
const h = routes[method];
|
|
46
|
-
if (!h) return { ok: false, error: { code: "NOT_FOUND", message: method } };
|
|
47
|
-
return typeof h === "function" ? await h(params) : h;
|
|
48
|
-
},
|
|
49
|
-
async read() {
|
|
50
|
-
return { ok: false, error: { code: "NOT_FOUND", message: "read" }, status: 404 };
|
|
51
|
-
},
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function facts(over = {}) {
|
|
56
|
-
return {
|
|
57
|
-
me: ME,
|
|
58
|
-
myNames: [],
|
|
59
|
-
channels: new Map(),
|
|
60
|
-
visibleChannelIds: null,
|
|
61
|
-
channelPages: new Map(),
|
|
62
|
-
myEvents: null,
|
|
63
|
-
myThreadRootIds: new Set(),
|
|
64
|
-
tasks: null,
|
|
65
|
-
decisions: null,
|
|
66
|
-
escalations: null,
|
|
67
|
-
approvals: null,
|
|
68
|
-
fileAcl: null,
|
|
69
|
-
degraded: [],
|
|
70
|
-
...over,
|
|
71
|
-
};
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function ev(over) {
|
|
75
|
-
return { seq: 7, at: "2026-08-11T00:00:00.000Z", actor: THEM, ...over };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// messaging
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
test("messaging hydrates from the ACL'd history page — no extra RPC", async () => {
|
|
83
|
-
const f = facts();
|
|
84
|
-
f.channels.set("C-pub", { id: "C-pub", kind: "PUBLIC", name: "General", slug: "general" });
|
|
85
|
-
f.channelPages.set("C-pub", {
|
|
86
|
-
byId: new Map([
|
|
87
|
-
["m1", { id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
|
|
88
|
-
]),
|
|
89
|
-
messages: [{ id: "m1", authorId: THEM, authorName: "Casey", body: "@Isla take a look", mentions: [ME] }],
|
|
90
|
-
});
|
|
91
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", mentionCount: 1 } }));
|
|
92
|
-
const io = fakeIo({});
|
|
93
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "mention", reason: "mention" }, facts: f, io });
|
|
94
|
-
|
|
95
|
-
assert.equal(h.ok, true);
|
|
96
|
-
assert.equal(h.text, "@Isla take a look");
|
|
97
|
-
assert.deepEqual(h.from, { id: THEM, name: "Casey" });
|
|
98
|
-
assert.equal(h.channelLabel, "General");
|
|
99
|
-
assert.equal(io.calls.length, 0, "the body was already on the page");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("a message that never hydrated is DROPPED, not delivered blank", async () => {
|
|
103
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m404", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC" } }));
|
|
104
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "mention" }, facts: facts(), io: fakeIo({}) });
|
|
105
|
-
assert.equal(h.ok, false);
|
|
106
|
-
assert.equal(h.reason, "message_not_in_page");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("an empty body is dropped (a blank inbound produces a nonsense reply)", async () => {
|
|
110
|
-
const f = facts();
|
|
111
|
-
f.channelPages.set("C", { byId: new Map([["m1", { id: "m1", authorId: THEM, body: " " }]]), messages: [] });
|
|
112
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C", channelKind: "DM" } }));
|
|
113
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "dm" }, facts: f, io: fakeIo({}) });
|
|
114
|
-
assert.equal(h.ok, false);
|
|
115
|
-
assert.equal(h.reason, "empty_body");
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test("a threaded message carries prior-thread context", async () => {
|
|
119
|
-
const msgs = [
|
|
120
|
-
{ id: "r1", authorId: ME, authorName: "Isla", body: "kick off", threadRootId: null },
|
|
121
|
-
{ id: "m2", authorId: THEM, authorName: "Casey", body: "one more thing", threadRootId: "r1" },
|
|
122
|
-
];
|
|
123
|
-
const f = facts();
|
|
124
|
-
f.channelPages.set("C-pub", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
125
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-pub", channelKind: "PUBLIC", threaded: true } }));
|
|
126
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "thread_reply", reason: "thread" }, facts: f, io: fakeIo({}) });
|
|
127
|
-
|
|
128
|
-
assert.equal(h.isReply, true);
|
|
129
|
-
assert.equal(h.threadId, "r1");
|
|
130
|
-
assert.match(h.threadContext, /Isla: kick off/);
|
|
131
|
-
assert.doesNotMatch(h.threadContext, /one more thing/, "the trigger itself is not its own context");
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// A DM has no thread root — the CHANNEL is the conversation. Before this, the
|
|
135
|
-
// `if (root)` guard meant a top-level message carried no context at all, so the
|
|
136
|
-
// agent answered "fix this" with no idea what "this" referred to. The page is
|
|
137
|
-
// already in hand from the ACL'd `messaging.history` read, so this costs no RPC.
|
|
138
|
-
test("a top-level DM carries the preceding conversation as context", async () => {
|
|
139
|
-
// Newest-first, exactly as hq's messaging.history returns it.
|
|
140
|
-
const msgs = [
|
|
141
|
-
{ id: "m3", authorId: THEM, authorName: "Casey", body: "fix this then", createdAt: "2026-08-11T15:33:54.000Z" },
|
|
142
|
-
{ id: "m2", authorId: ME, authorName: "Alex", body: "16 min pickup on the morning batch", createdAt: "2026-08-11T15:32:00.000Z" },
|
|
143
|
-
{ id: "m1", authorId: THEM, authorName: "Casey", body: "what is the reply latency here", createdAt: "2026-08-11T15:31:00.000Z" },
|
|
144
|
-
];
|
|
145
|
-
const f = facts();
|
|
146
|
-
f.channels.set("C-dm", { id: "C-dm", kind: "DM", name: "dm-h001-a016" });
|
|
147
|
-
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
148
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m3", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
149
|
-
const io = fakeIo({});
|
|
150
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io });
|
|
151
|
-
|
|
152
|
-
assert.equal(h.ok, true);
|
|
153
|
-
assert.equal(io.calls.length, 0, "the page was already in hand — context must cost no extra RPC");
|
|
154
|
-
assert.ok(h.threadContext, "a DM with prior turns must carry them");
|
|
155
|
-
assert.match(h.threadContext, /Casey: what is the reply latency here/);
|
|
156
|
-
assert.match(h.threadContext, /Alex: 16 min pickup/);
|
|
157
|
-
assert.doesNotMatch(h.threadContext, /fix this then/, "the trigger itself is not its own context");
|
|
158
|
-
assert.ok(
|
|
159
|
-
h.threadContext.indexOf("what is the reply latency") < h.threadContext.indexOf("16 min pickup"),
|
|
160
|
-
"oldest first — the conversation must read forwards",
|
|
161
|
-
);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("a first-ever message in a channel has no context, not an empty block", async () => {
|
|
165
|
-
const msgs = [{ id: "m1", authorId: THEM, authorName: "Casey", body: "hello", createdAt: "2026-08-11T15:31:00.000Z" }];
|
|
166
|
-
const f = facts();
|
|
167
|
-
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
168
|
-
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
169
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m1", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
170
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
171
|
-
|
|
172
|
-
assert.equal(h.ok, true);
|
|
173
|
-
assert.equal(h.threadContext, null);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test("messages AFTER the trigger are never passed off as prior context", async () => {
|
|
177
|
-
const msgs = [
|
|
178
|
-
{ id: "m3", authorId: THEM, authorName: "Casey", body: "later message", createdAt: "2026-08-11T16:00:00.000Z" },
|
|
179
|
-
{ id: "m2", authorId: THEM, authorName: "Casey", body: "the trigger", createdAt: "2026-08-11T15:00:00.000Z" },
|
|
180
|
-
{ id: "m1", authorId: ME, authorName: "Alex", body: "earlier message", createdAt: "2026-08-11T14:00:00.000Z" },
|
|
181
|
-
];
|
|
182
|
-
const f = facts();
|
|
183
|
-
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
184
|
-
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
185
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
186
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
187
|
-
|
|
188
|
-
assert.match(h.threadContext, /earlier message/);
|
|
189
|
-
assert.doesNotMatch(h.threadContext, /later message/);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// board
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
|
|
196
|
-
test("board hydrates the task card + its comment thread", async () => {
|
|
197
|
-
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Ship the thing", col: "doing", priority: 1, detail: "make it fast", channelId: "C-eng" }]]) });
|
|
198
|
-
const io = fakeIo({
|
|
199
|
-
"board.taskComments": ({ taskId }) =>
|
|
200
|
-
okFrame({
|
|
201
|
-
taskId,
|
|
202
|
-
comments: [
|
|
203
|
-
{ id: "c1", authorId: ME, authorName: "Isla", body: "on it" },
|
|
204
|
-
{ id: "c2", authorId: THEM, authorName: "Casey", body: "any update?" },
|
|
205
|
-
],
|
|
206
|
-
}),
|
|
207
|
-
});
|
|
208
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM, kind: "human" } }));
|
|
209
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
210
|
-
|
|
211
|
-
assert.equal(h.ok, true);
|
|
212
|
-
assert.match(h.text, /New comment on your board item — "Ship the thing"/);
|
|
213
|
-
assert.match(h.text, /Column: doing/);
|
|
214
|
-
assert.match(h.text, /any update\?/);
|
|
215
|
-
assert.match(h.threadContext, /Isla: on it/);
|
|
216
|
-
assert.equal(h.threadId, "T-1");
|
|
217
|
-
assert.deepEqual(io.calls.map((x) => x.method), ["board.taskComments"]);
|
|
218
|
-
});
|
|
219
|
-
|
|
220
|
-
test("a reviewer is TOLD they are the reviewer", async () => {
|
|
221
|
-
const f = facts({ tasks: new Map([["T-2", { id: "T-2", title: "Review me", col: "review" }]]) });
|
|
222
|
-
const io = fakeIo({ "board.taskComments": okFrame({ comments: [] }) });
|
|
223
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.moved", entity_id: "T-2", payload: { actor: THEM, to: "review" } }));
|
|
224
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "reviewer" }, facts: f, io });
|
|
225
|
-
assert.match(h.text, /You are the REVIEWER/);
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
test("two candidates on one task share a single taskComments read", async () => {
|
|
229
|
-
let n = 0;
|
|
230
|
-
const io = fakeIo({ "board.taskComments": () => { n += 1; return okFrame({ comments: [] }); } });
|
|
231
|
-
const f = facts({ tasks: new Map([["T-3", { id: "T-3", title: "x" }]]) });
|
|
232
|
-
const cache = new Map();
|
|
233
|
-
const mk = (seq, kind) => classifyEvent(ev({ seq, family: "board", kind, entity_id: "T-3", payload: { actor: THEM } }));
|
|
234
|
-
await hydrate({ candidate: mk(1, "item.commented"), verdict: { surface: "task_comment" }, facts: f, io, cache });
|
|
235
|
-
await hydrate({ candidate: mk(2, "item.moved"), verdict: { surface: "task_comment" }, facts: f, io, cache });
|
|
236
|
-
assert.equal(n, 1);
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
// ---------------------------------------------------------------------------
|
|
240
|
-
// file / doc
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
|
|
243
|
-
test("a chat-file comment hydrates through file.listComments, scoped by channel", async () => {
|
|
244
|
-
const f = facts();
|
|
245
|
-
f.channels.set("C-team", { id: "C-team", kind: "PRIVATE", name: "Leads" });
|
|
246
|
-
const io = fakeIo({
|
|
247
|
-
"file.listComments": okFrame({ comments: [{ id: "fc1", authorId: THEM, authorName: "Casey", body: "typo on slide 3" }] }),
|
|
248
|
-
});
|
|
249
|
-
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { fileKey: "message-attachment:abc", channelId: "C-team", authorId: THEM } }));
|
|
250
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "channel" }, facts: f, io });
|
|
251
|
-
|
|
252
|
-
assert.equal(h.ok, true);
|
|
253
|
-
assert.match(h.text, /typo on slide 3/);
|
|
254
|
-
// The channel is forwarded so hq can run its own visibility gate.
|
|
255
|
-
assert.equal(io.calls[0].params.channelId, "C-team");
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
test("an unreadable file thread is dropped rather than delivered empty", async () => {
|
|
259
|
-
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc2", payload: { fileKey: "message-attachment:z", channelId: "C-team" } }));
|
|
260
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment" }, facts: facts(), io: fakeIo({}) });
|
|
261
|
-
assert.equal(h.ok, false);
|
|
262
|
-
assert.equal(h.reason, "file_comment_unreadable");
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
test("a workspace doc comment hydrates through files.comments", async () => {
|
|
266
|
-
const f = facts({ fileAcl: new Map([["F-1", { ownerId: ME, shared: false, name: "Spec" }]]) });
|
|
267
|
-
const io = fakeIo({
|
|
268
|
-
"files.comments": okFrame({ comments: [{ id: "dc1", authorId: THEM, authorName: "Casey", body: "can we cut section 4?" }] }),
|
|
269
|
-
});
|
|
270
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-1", payload: { name: "Spec", commentId: "dc1" } }));
|
|
271
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
272
|
-
|
|
273
|
-
assert.equal(h.ok, true);
|
|
274
|
-
assert.match(h.text, /New comment on "Spec"/);
|
|
275
|
-
assert.match(h.text, /cut section 4/);
|
|
276
|
-
});
|
|
277
|
-
|
|
278
|
-
// ---------------------------------------------------------------------------
|
|
279
|
-
// decision / escalation / approval / handoff
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
|
|
282
|
-
test("a decision carries status, body, rationale and the latest comment", async () => {
|
|
283
|
-
const f = facts({
|
|
284
|
-
decisions: new Map([["D-1", { id: "D-1", title: "Adopt X", status: "proposed", scope: "eng", body: "we adopt X", rationale: "cheaper" }]]),
|
|
285
|
-
});
|
|
286
|
-
const io = fakeIo({
|
|
287
|
-
"decision.listComments": okFrame({ comments: [{ id: "c1", authorId: THEM, authorName: "Casey", body: "what about Y?" }] }),
|
|
288
|
-
});
|
|
289
|
-
const c = classifyEvent(ev({ family: "decision", kind: "decision.adjustment_requested", entity_id: "D-1", payload: { commentId: "c1", authorId: THEM } }));
|
|
290
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "decision", reason: "proposer" }, facts: f, io });
|
|
291
|
-
|
|
292
|
-
assert.match(h.text, /An adjustment was requested on a decision — "Adopt X"/);
|
|
293
|
-
assert.match(h.text, /Status: proposed/);
|
|
294
|
-
assert.match(h.text, /Rationale: cheaper/);
|
|
295
|
-
assert.match(h.text, /what about Y\?/);
|
|
296
|
-
assert.match(h.text, /needs a response/);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
test("an escalation needs no extra read — the facts pass already had it", async () => {
|
|
300
|
-
const f = facts({ escalations: new Map([["E-1", { id: "E-1", title: "prod down", severity: "high", detail: "500s on /v1", waitingOn: ME }]]) });
|
|
301
|
-
const io = fakeIo({});
|
|
302
|
-
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.raised", entity_id: "E-1", payload: { actor: THEM, severity: "high" } }));
|
|
303
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "waiting_on" }, facts: f, io });
|
|
304
|
-
|
|
305
|
-
assert.match(h.text, /Escalation raised — "prod down"/);
|
|
306
|
-
assert.match(h.text, /Severity: high/);
|
|
307
|
-
assert.match(h.text, /Waiting on: M-me/);
|
|
308
|
-
assert.equal(io.calls.length, 0);
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
test("an approval renders the whole decision packet", async () => {
|
|
312
|
-
const f = facts({
|
|
313
|
-
approvals: new Map([["A-1", { id: "A-1", requester: THEM, approver: ME, status: "pending", actionClass: "spend.over_1k", reason: "vendor renewal", itemId: "T-9", expiresAt: "2026-08-12T00:00:00.000Z" }]]),
|
|
314
|
-
});
|
|
315
|
-
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { requester: THEM, actionClass: "spend.over_1k" } }));
|
|
316
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io: fakeIo({}) });
|
|
317
|
-
|
|
318
|
-
assert.match(h.text, /An approval is waiting on you — spend\.over_1k/);
|
|
319
|
-
assert.match(h.text, /Status: pending/);
|
|
320
|
-
assert.match(h.text, /vendor renewal/);
|
|
321
|
-
assert.match(h.text, /Board item: T-9/);
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
test("a handoff renders intent and both ends from the payload alone", async () => {
|
|
325
|
-
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Migrate DB", detail: "zero downtime" }]]) });
|
|
326
|
-
const c = classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { itemId: "T-1", from: THEM, to: ME, intent: "please take this over" } }));
|
|
327
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "handoff", reason: "direct" }, facts: f, io: fakeIo({}) });
|
|
328
|
-
|
|
329
|
-
assert.match(h.text, /A handoff was offered to you — "Migrate DB"/);
|
|
330
|
-
assert.match(h.text, /Intent: please take this over/);
|
|
331
|
-
assert.match(h.text, /From M-them → to M-me/);
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
// ---------------------------------------------------------------------------
|
|
335
|
-
// email
|
|
336
|
-
// ---------------------------------------------------------------------------
|
|
337
|
-
|
|
338
|
-
test("email hydrates through email.thread, which hq scopes to MY mailbox", async () => {
|
|
339
|
-
const io = fakeIo({
|
|
340
|
-
"email.thread": ({ threadId }) =>
|
|
341
|
-
okFrame({
|
|
342
|
-
thread: { id: threadId, subject: "Contract renewal" },
|
|
343
|
-
messages: [
|
|
344
|
-
{ id: "EM-0", subject: "Contract renewal", text: "here is the draft", from: { name: "Dana", address: "dana@acme.com" } },
|
|
345
|
-
{ id: "EM-1", subject: "Re: Contract renewal", text: "any thoughts?", from: { name: "Dana", address: "dana@acme.com" } },
|
|
346
|
-
],
|
|
347
|
-
}),
|
|
348
|
-
});
|
|
349
|
-
const c = classifyEvent(ev({ family: "email", kind: "received", actor: "system", entity_id: "EM-1", payload: { emailMessageId: "EM-1", threadId: "ET-1", mailboxId: "MB-1", memberId: ME } }));
|
|
350
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "email", reason: "direct" }, facts: facts(), io });
|
|
351
|
-
|
|
352
|
-
assert.equal(h.ok, true);
|
|
353
|
-
assert.equal(h.text, "any thoughts?");
|
|
354
|
-
assert.equal(h.subjectDetail, "Re: Contract renewal");
|
|
355
|
-
assert.equal(h.from.name, "Dana");
|
|
356
|
-
assert.match(h.threadContext, /here is the draft/);
|
|
357
|
-
assert.deepEqual(io.calls.map((x) => x.method), ["email.thread"]);
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
test("unreadable mail is dropped (not another seat's mail leaking through)", async () => {
|
|
361
|
-
const c = classifyEvent(ev({ family: "email", kind: "received", entity_id: "EM-9", payload: { emailMessageId: "EM-9", threadId: "ET-9", memberId: ME } }));
|
|
362
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "email" }, facts: facts(), io: fakeIo({}) });
|
|
363
|
-
assert.equal(h.ok, false);
|
|
364
|
-
assert.equal(h.reason, "email_unreadable");
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
// ---------------------------------------------------------------------------
|
|
368
|
-
// robustness
|
|
369
|
-
// ---------------------------------------------------------------------------
|
|
370
|
-
|
|
371
|
-
test("a hydrator that throws fails open, loudly, and never takes the pull down", async () => {
|
|
372
|
-
const io = fakeIo({
|
|
373
|
-
"board.taskComments": () => {
|
|
374
|
-
throw new Error("boom");
|
|
375
|
-
},
|
|
376
|
-
});
|
|
377
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-1", payload: { actor: THEM } }));
|
|
378
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment" }, facts: facts(), io });
|
|
379
|
-
assert.equal(h.ok, false);
|
|
380
|
-
assert.equal(h.reason, "hydrate_threw");
|
|
381
|
-
assert.ok(io.logs.some((l) => l.level === "warn" && /hydrate board threw/.test(l.message)));
|
|
382
|
-
});
|
|
383
|
-
|
|
384
|
-
test("clip and renderThread are bounded", () => {
|
|
385
|
-
assert.equal(clip("abc", 10), "abc");
|
|
386
|
-
assert.ok(clip("x".repeat(100), 10).length <= 11);
|
|
387
|
-
assert.equal(renderThread([]), null);
|
|
388
|
-
assert.equal(renderThread(null), null);
|
|
389
|
-
assert.match(renderThread([{ authorName: "A", body: "hi" }]), /A: hi/);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
// `messaging.history` returns DESCENDING; every comment source returns ASCENDING.
|
|
393
|
-
// A bare slice(-limit) therefore took the OLDEST rows off a message page and
|
|
394
|
-
// printed them backwards — the wrong window, in the wrong order.
|
|
395
|
-
test("renderThread reads forwards and keeps the most RECENT turns", () => {
|
|
396
|
-
const rows = [];
|
|
397
|
-
for (let i = 12; i >= 1; i -= 1) {
|
|
398
|
-
rows.push({ authorName: "A", body: `turn ${i}`, createdAt: `2026-08-11T00:${String(i).padStart(2, "0")}:00.000Z` });
|
|
399
|
-
}
|
|
400
|
-
const out = renderThread(rows); // limit 8
|
|
401
|
-
|
|
402
|
-
assert.match(out, /turn 12/, "the newest turn is the one that matters most");
|
|
403
|
-
assert.doesNotMatch(out, /turn 1\b/, "the oldest turns fall off the window, not the newest");
|
|
404
|
-
assert.ok(out.indexOf("turn 5") < out.indexOf("turn 12"), "oldest first");
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
test("renderThread leaves undated rows in the order they arrived", () => {
|
|
408
|
-
const out = renderThread([
|
|
409
|
-
{ authorName: "A", body: "first" },
|
|
410
|
-
{ authorName: "B", body: "second" },
|
|
411
|
-
]);
|
|
412
|
-
assert.ok(out.indexOf("first") < out.indexOf("second"));
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// The `limit` window and the `max` CHARACTER budget are two different windows,
|
|
416
|
-
// and only the first was covered: twelve short turns never reach 2000 chars, so
|
|
417
|
-
// the clip never fired and the direction it clips in was never asserted. On the
|
|
418
|
-
// real DM it fired immediately — one long reply filled the budget and the four
|
|
419
|
-
// newest turns, including the question being answered, were the ones discarded.
|
|
420
|
-
test("renderThread keeps the NEWEST turns when the CHARACTER budget binds", () => {
|
|
421
|
-
const out = renderThread(
|
|
422
|
-
[
|
|
423
|
-
{ authorName: "A", body: "x".repeat(1500), createdAt: "2026-08-11T00:01:00.000Z" },
|
|
424
|
-
{ authorName: "B", body: "the thing I actually asked", createdAt: "2026-08-11T00:02:00.000Z" },
|
|
425
|
-
],
|
|
426
|
-
{ max: 300 },
|
|
427
|
-
);
|
|
428
|
-
assert.match(out, /the thing I actually asked/, "the latest turn must survive the budget");
|
|
429
|
-
});
|
|
430
|
-
|
|
431
|
-
test("renderThread does not let one huge turn starve the turns around it", () => {
|
|
432
|
-
const out = renderThread(
|
|
433
|
-
[
|
|
434
|
-
{ authorName: "A", body: "y".repeat(5000), createdAt: "2026-08-11T00:01:00.000Z" },
|
|
435
|
-
{ authorName: "B", body: "second", createdAt: "2026-08-11T00:02:00.000Z" },
|
|
436
|
-
{ authorName: "C", body: "third", createdAt: "2026-08-11T00:03:00.000Z" },
|
|
437
|
-
],
|
|
438
|
-
{ max: 2000 },
|
|
439
|
-
);
|
|
440
|
-
assert.match(out, /B: second/);
|
|
441
|
-
assert.match(out, /C: third/);
|
|
442
|
-
assert.ok(out.length <= 2100, `bounded, got ${out.length}`);
|
|
443
|
-
});
|
|
444
|
-
|
|
445
|
-
// Until hq's `authorName` denormalisation is deployed every row carries a bare
|
|
446
|
-
// cuid, and the agent must still be able to see which turns are its own.
|
|
447
|
-
test("a transcript labels my own turns even when the server sends no names", async () => {
|
|
448
|
-
const msgs = [
|
|
449
|
-
{ id: "m1", authorId: ME, body: "already answered that", createdAt: "2026-08-11T14:00:00.000Z" },
|
|
450
|
-
{ id: "m2", authorId: THEM, body: "and the other thing?", createdAt: "2026-08-11T15:00:00.000Z" },
|
|
451
|
-
];
|
|
452
|
-
const f = facts({ myNames: ["Isla"] });
|
|
453
|
-
f.channels.set("C-dm", { id: "C-dm", kind: "DM" });
|
|
454
|
-
f.channelPages.set("C-dm", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
455
|
-
const c = classifyEvent(ev({ family: "messaging", kind: "send", entity_id: "m2", payload: { actor: THEM, channelId: "C-dm", channelKind: "DM" } }));
|
|
456
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "dm", reason: "dm" }, facts: f, io: fakeIo({}) });
|
|
457
|
-
|
|
458
|
-
assert.match(h.threadContext, /Isla: already answered that/, "my own turn is mine, not a cuid");
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
test("event-kind phrasing is stable and total", () => {
|
|
462
|
-
assert.match(describeBoardKind("item.assigned"), /assigned to you/);
|
|
463
|
-
assert.match(describeBoardKind("who.knows"), /Update on your board item/);
|
|
464
|
-
assert.match(describeApprovalKind("approval.requested"), /waiting on you/);
|
|
465
|
-
assert.match(describeApprovalKind("approval.rejected"), /REJECTED/);
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
// ---------------------------------------------------------------------------
|
|
469
|
-
// the slice(0,-1) guard — four surfaces used to lose their newest comment
|
|
470
|
-
//
|
|
471
|
-
// `renderThread(comments.slice(0,-1))` assumed the event was always a comment.
|
|
472
|
-
// It is not: item.assigned, item.moved, file.shared and decision.signed are
|
|
473
|
-
// events ABOUT an entity that HAS a thread, and for those the newest comment is
|
|
474
|
-
// context, not the trigger. On a freshly assigned task with one comment, that
|
|
475
|
-
// comment was the whole of the context and the agent saw none of it.
|
|
476
|
-
// ---------------------------------------------------------------------------
|
|
477
|
-
|
|
478
|
-
test("item.assigned KEEPS its newest comment — it is context, not the trigger", async () => {
|
|
479
|
-
const f = facts({ tasks: new Map([["T-1", { id: "T-1", title: "Ship the thing", col: "todo" }]]) });
|
|
480
|
-
const io = fakeIo({
|
|
481
|
-
"board.taskComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "the client needs this by Friday" }] }),
|
|
482
|
-
});
|
|
483
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.assigned", entity_id: "T-1", payload: { actor: THEM, assignee: ME } }));
|
|
484
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_assigned", reason: "assignee" }, facts: f, io });
|
|
485
|
-
|
|
486
|
-
assert.equal(h.ok, true);
|
|
487
|
-
assert.match(h.threadContext, /Casey: the client needs this by Friday/);
|
|
488
|
-
assert.equal(h.isReply, true);
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
test("item.moved keeps its newest comment", async () => {
|
|
492
|
-
const f = facts({ tasks: new Map([["T-2", { id: "T-2", title: "Rework", col: "blocked" }]]) });
|
|
493
|
-
const io = fakeIo({
|
|
494
|
-
"board.taskComments": () => okFrame({ comments: [{ id: "c9", authorName: "Casey", body: "moving this back, the numbers are wrong" }] }),
|
|
495
|
-
});
|
|
496
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.moved", entity_id: "T-2", payload: { actor: THEM } }));
|
|
497
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
498
|
-
assert.match(h.threadContext, /the numbers are wrong/);
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
test("file.shared keeps its newest comment", async () => {
|
|
502
|
-
const f = facts({ fileAcl: new Map([["F-1", { ownerId: ME, shared: true, name: "Q3 plan", kind: "DOC" }]]) });
|
|
503
|
-
const io = fakeIo({
|
|
504
|
-
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "read section two first" }] }),
|
|
505
|
-
"files.docRead": () => okFrame({ html: "<p>Q3 plan</p>" }),
|
|
506
|
-
});
|
|
507
|
-
const c = classifyEvent(ev({ family: "files", kind: "file.shared", entity_id: "F-1", payload: { actor: THEM, name: "Q3 plan" } }));
|
|
508
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "shared" }, facts: f, io });
|
|
509
|
-
assert.match(h.threadContext, /Casey: read section two first/);
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
test("decision.signed keeps its newest comment", async () => {
|
|
513
|
-
const f = facts({ decisions: new Map([["D-1", { id: "D-1", title: "Pick a vendor", status: "signed" }]]) });
|
|
514
|
-
const io = fakeIo({
|
|
515
|
-
"decision.listComments": () => okFrame({ comments: [{ id: "d1", authorName: "Casey", body: "signing on the understanding we revisit in Q4" }] }),
|
|
516
|
-
});
|
|
517
|
-
const c = classifyEvent(ev({ family: "decision", kind: "decision.signed", entity_id: "D-1", payload: { actor: THEM, signedBy: THEM } }));
|
|
518
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "decision", reason: "voice" }, facts: f, io });
|
|
519
|
-
assert.match(h.threadContext, /revisit in Q4/);
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
test("a comment event still drops its OWN comment from the context", async () => {
|
|
523
|
-
const f = facts({ tasks: new Map([["T-3", { id: "T-3", title: "Ship it" }]]) });
|
|
524
|
-
const io = fakeIo({
|
|
525
|
-
"board.taskComments": () => okFrame({ comments: [
|
|
526
|
-
{ id: "c1", authorName: "Isla", body: "first pass is up" },
|
|
527
|
-
{ id: "c2", authorName: "Casey", body: "any update?" },
|
|
528
|
-
] }),
|
|
529
|
-
});
|
|
530
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-3", payload: { actor: THEM } }));
|
|
531
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
532
|
-
assert.match(h.threadContext, /first pass is up/);
|
|
533
|
-
assert.doesNotMatch(h.threadContext, /any update\?/, "the trigger is not its own context");
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
test("the payload's commentId drops the RIGHT comment when a newer one raced in", async () => {
|
|
537
|
-
const f = facts({ tasks: new Map([["T-4", { id: "T-4", title: "Ship it" }]]) });
|
|
538
|
-
const io = fakeIo({
|
|
539
|
-
"board.taskComments": () => okFrame({ comments: [
|
|
540
|
-
{ id: "c1", authorName: "Isla", body: "first pass is up" },
|
|
541
|
-
{ id: "c2", authorName: "Casey", body: "the trigger comment" },
|
|
542
|
-
{ id: "c3", authorName: "Dana", body: "landed while we were reading" },
|
|
543
|
-
] }),
|
|
544
|
-
});
|
|
545
|
-
const c = classifyEvent(ev({ family: "board", kind: "item.commented", entity_id: "T-4", payload: { actor: THEM, commentId: "c2" } }));
|
|
546
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "task_comment", reason: "assignee" }, facts: f, io });
|
|
547
|
-
assert.match(h.threadContext, /first pass is up/);
|
|
548
|
-
assert.match(h.threadContext, /landed while we were reading/);
|
|
549
|
-
assert.doesNotMatch(h.threadContext, /the trigger comment/);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
// ---------------------------------------------------------------------------
|
|
553
|
-
// pure guards
|
|
554
|
-
// ---------------------------------------------------------------------------
|
|
555
|
-
|
|
556
|
-
test("priorComments never mutates the caller's array", () => {
|
|
557
|
-
const rows = [{ id: "a" }, { id: "b" }];
|
|
558
|
-
const out = priorComments(rows, { kind: "item.commented" });
|
|
559
|
-
assert.equal(rows.length, 2);
|
|
560
|
-
assert.equal(out.length, 1);
|
|
561
|
-
});
|
|
562
|
-
|
|
563
|
-
test("isCommentKind knows the six kinds that ARE comments", () => {
|
|
564
|
-
for (const k of ["item.commented", "board.commented", "file.commented", "doc.comment", "decision.comment", "decision.adjustment_requested"]) {
|
|
565
|
-
assert.equal(isCommentKind(k), true, k);
|
|
566
|
-
}
|
|
567
|
-
for (const k of ["item.assigned", "item.moved", "file.shared", "decision.signed", "", null]) {
|
|
568
|
-
assert.equal(isCommentKind(k), false, String(k));
|
|
569
|
-
}
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
test("htmlToText keeps the prose and drops the markup", () => {
|
|
573
|
-
assert.equal(htmlToText("<h1>Title</h1><p>One & two</p><p>Three</p>"), "Title\nOne & two\nThree");
|
|
574
|
-
assert.equal(htmlToText("<script>bad()</script><p>ok</p>"), "ok");
|
|
575
|
-
assert.equal(htmlToText(null), "");
|
|
576
|
-
});
|
|
577
|
-
|
|
578
|
-
// ---------------------------------------------------------------------------
|
|
579
|
-
// call — participants, the room, and the transcript
|
|
580
|
-
// ---------------------------------------------------------------------------
|
|
581
|
-
|
|
582
|
-
test("a call carries the roster, the room's recent messages and the transcript", async () => {
|
|
583
|
-
const msgs = [
|
|
584
|
-
{ id: "m1", authorId: THEM, authorName: "Casey", body: "can we talk through the pricing", createdAt: "2026-08-11T09:00:00.000Z" },
|
|
585
|
-
{ id: "m2", authorId: ME, authorName: "Isla", body: "yes — starting a huddle", createdAt: "2026-08-11T09:01:00.000Z" },
|
|
586
|
-
];
|
|
587
|
-
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
588
|
-
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
589
|
-
f.channelPages.set("C-eng", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
590
|
-
const io = fakeIo({
|
|
591
|
-
"calling.getDetails": () => okFrame({ callId: "CALL-1", live: true, participantCount: 2, participants: [
|
|
592
|
-
{ kind: "MEMBER", memberId: THEM, inCall: true },
|
|
593
|
-
{ kind: "GUEST", contactId: "contact-9", inCall: true },
|
|
594
|
-
] }),
|
|
595
|
-
"calling.getTranscript": () => okFrame({ lines: [
|
|
596
|
-
{ id: "l1", memberName: "Casey", text: "the list price is wrong on tier two", at: "2026-08-11T09:02:00.000Z" },
|
|
597
|
-
] }),
|
|
598
|
-
});
|
|
599
|
-
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-1", payload: { actor: THEM, channelId: "C-eng", topic: "pricing" } }));
|
|
600
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "member" }, facts: f, io });
|
|
601
|
-
|
|
602
|
-
assert.equal(h.ok, true);
|
|
603
|
-
assert.match(h.text, /Call invite: pricing/);
|
|
604
|
-
assert.match(h.text, new RegExp(`In the call: ${THEM}, contact-9`));
|
|
605
|
-
assert.match(h.threadContext, /Casey: can we talk through the pricing/);
|
|
606
|
-
assert.match(h.threadContext, /Call transcript so far:/);
|
|
607
|
-
assert.match(h.threadContext, /the list price is wrong on tier two/);
|
|
608
|
-
assert.equal(io.calls.filter((k) => k.method === "messaging.history").length, 0, "the page was already in hand");
|
|
609
|
-
});
|
|
610
|
-
|
|
611
|
-
test("a call whose probes all fail still hydrates — fail-open, never blank", async () => {
|
|
612
|
-
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
613
|
-
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
614
|
-
const io = fakeIo({}); // every method NOT_FOUND
|
|
615
|
-
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-2", payload: { actor: THEM, channelId: "C-eng", topic: "standup" } }));
|
|
616
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "member" }, facts: f, io });
|
|
617
|
-
|
|
618
|
-
assert.equal(h.ok, true);
|
|
619
|
-
assert.match(h.text, /Call invite: standup/);
|
|
620
|
-
assert.equal(h.threadContext, null);
|
|
621
|
-
});
|
|
622
|
-
|
|
623
|
-
test("a call in a room outside my roster is never paged for history", async () => {
|
|
624
|
-
const f = facts({ visibleChannelIds: new Set(["C-mine"]) });
|
|
625
|
-
const io = fakeIo({ "calling.getDetails": () => okFrame({ participants: [] }) });
|
|
626
|
-
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-3", payload: { actor: THEM, channelId: "C-not-mine", topic: "x" } }));
|
|
627
|
-
await hydrate({ candidate: c, verdict: { surface: "call", reason: "invited" }, facts: f, io });
|
|
628
|
-
assert.equal(io.calls.some((k) => k.method === "messaging.history"), false, "a room outside the roster must never be touched");
|
|
629
|
-
});
|
|
630
|
-
|
|
631
|
-
test("an UNREADABLE roster is not permission — no room is paged when visibleChannelIds is null", async () => {
|
|
632
|
-
// The guard read `visible instanceof Set && !visible.has(id)`, so the null
|
|
633
|
-
// case — the roster call failed, or has not run — fell straight through into
|
|
634
|
-
// `messaging.history` on a channel id that arrived on the EVENT PAYLOAD.
|
|
635
|
-
// facts.mjs enforces the opposite (`if (!visible) … return`) and this file's
|
|
636
|
-
// own docstring claimed it; now the code does too.
|
|
637
|
-
const f = facts({ visibleChannelIds: null });
|
|
638
|
-
const io = fakeIo({ "calling.getDetails": () => okFrame({ participants: [] }) });
|
|
639
|
-
const c = classifyEvent(ev({ family: "calling", kind: "start", entity_id: "CALL-4", payload: { actor: THEM, channelId: "C-PRIVATE-NOT-MINE", topic: "x" } }));
|
|
640
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "call", reason: "invited" }, facts: f, io });
|
|
641
|
-
assert.equal(h.ok, true, "declining a read is not a failed hydration");
|
|
642
|
-
assert.equal(
|
|
643
|
-
io.calls.some((k) => k.method === "messaging.history"),
|
|
644
|
-
false,
|
|
645
|
-
"an unknown roster must decline, not page an arbitrary payload-supplied channel",
|
|
646
|
-
);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
test("an UNREADABLE roster is not permission — pinned files are not listed either", async () => {
|
|
650
|
-
const f = facts({ visibleChannelIds: null });
|
|
651
|
-
f.channels.set("C-PRIVATE-NOT-MINE", { id: "C-PRIVATE-NOT-MINE", kind: "PRIVATE", name: "board" });
|
|
652
|
-
const io = fakeIo({ "file.listComments": () => okFrame({ comments: [
|
|
653
|
-
{ id: "fc1", authorId: THEM, authorName: "Casey", body: "@Isla thoughts?" },
|
|
654
|
-
] }) });
|
|
655
|
-
const c = classifyEvent(ev({
|
|
656
|
-
family: "file", kind: "comment", entity_id: "FK-1",
|
|
657
|
-
payload: { actor: THEM, channelId: "C-PRIVATE-NOT-MINE", fileKey: "FK-1", body: "@Isla thoughts?", mentions: [ME] },
|
|
658
|
-
}));
|
|
659
|
-
await hydrate({ candidate: c, verdict: { surface: "chatFile", reason: "mention" }, facts: f, io });
|
|
660
|
-
assert.equal(io.calls.some((k) => k.method === "file.listPinned"), false);
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
// ---------------------------------------------------------------------------
|
|
664
|
-
// approval / escalation / handoff / calendar
|
|
665
|
-
// ---------------------------------------------------------------------------
|
|
666
|
-
|
|
667
|
-
test("an approval carries the thread of the item it is an approval OF", async () => {
|
|
668
|
-
const f = facts({
|
|
669
|
-
approvals: new Map([["A-1", { id: "A-1", status: "pending", requester: THEM, actionClass: "financial", itemId: "T-9" }]]),
|
|
670
|
-
tasks: new Map([["T-9", { id: "T-9", title: "Wire the retainer", col: "doing", priority: 1 }]]),
|
|
671
|
-
});
|
|
672
|
-
const io = fakeIo({
|
|
673
|
-
"board.taskComments": () => okFrame({ comments: [
|
|
674
|
-
{ id: "c1", authorName: "Casey", body: "legal cleared this on Tuesday" },
|
|
675
|
-
] }),
|
|
676
|
-
});
|
|
677
|
-
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-1", payload: { actor: THEM, requester: THEM, itemId: "T-9", actionClass: "financial" } }));
|
|
678
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
679
|
-
|
|
680
|
-
assert.equal(h.ok, true);
|
|
681
|
-
assert.match(h.text, /Board item "Wire the retainer" — doing · priority 1/);
|
|
682
|
-
assert.match(h.threadContext, /legal cleared this on Tuesday/);
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
test("an approval on a DECISION falls back to the decision thread, and only then", async () => {
|
|
686
|
-
const f = facts({ approvals: new Map([["A-2", { id: "A-2", status: "pending", requester: THEM, itemId: "D-7" }]]) });
|
|
687
|
-
const io = fakeIo({
|
|
688
|
-
"decision.listComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "we ruled out the cheaper vendor" }] }),
|
|
689
|
-
});
|
|
690
|
-
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-2", payload: { actor: THEM, requester: THEM, itemId: "D-7" } }));
|
|
691
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
692
|
-
|
|
693
|
-
assert.match(h.threadContext, /we ruled out the cheaper vendor/);
|
|
694
|
-
assert.equal(io.calls.filter((k) => k.method === "board.taskComments").length, 1, "board is tried first, once");
|
|
695
|
-
});
|
|
696
|
-
|
|
697
|
-
test("an approval whose board thread is EMPTY does not pay for a second probe", async () => {
|
|
698
|
-
const f = facts({ approvals: new Map([["A-3", { id: "A-3", status: "pending", requester: THEM, itemId: "T-8" }]]) });
|
|
699
|
-
const io = fakeIo({ "board.taskComments": () => okFrame({ comments: [] }) });
|
|
700
|
-
const c = classifyEvent(ev({ family: "approval", kind: "approval.requested", entity_id: "A-3", payload: { actor: THEM, requester: THEM, itemId: "T-8" } }));
|
|
701
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "approval", reason: "approver" }, facts: f, io });
|
|
702
|
-
|
|
703
|
-
assert.equal(h.threadContext, null);
|
|
704
|
-
assert.equal(io.calls.some((k) => k.method === "decision.listComments"), false, "an empty thread is an answer, not a refusal");
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
test("an escalation carries the escalated task's row and its comments", async () => {
|
|
708
|
-
const f = facts({
|
|
709
|
-
escalations: new Map([["E-1", { id: "E-1", title: "Blocked on credentials", severity: "high", taskId: "T-5" }]]),
|
|
710
|
-
tasks: new Map([["T-5", { id: "T-5", title: "Migrate the vault", col: "blocked", priority: 0 }]]),
|
|
711
|
-
});
|
|
712
|
-
const io = fakeIo({
|
|
713
|
-
"board.taskComments": () => okFrame({ comments: [{ id: "c1", authorName: "Casey", body: "the rotation script needs an admin key" }] }),
|
|
714
|
-
});
|
|
715
|
-
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.created", entity_id: "E-1", payload: { actor: THEM, taskId: "T-5", severity: "high" } }));
|
|
716
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "assignee" }, facts: f, io });
|
|
717
|
-
|
|
718
|
-
assert.equal(h.ok, true);
|
|
719
|
-
assert.match(h.text, /Board item "Migrate the vault" — blocked · priority 0/);
|
|
720
|
-
assert.match(h.threadContext, /needs an admin key/);
|
|
721
|
-
});
|
|
722
|
-
|
|
723
|
-
test("an escalation whose task thread is unreadable still hydrates", async () => {
|
|
724
|
-
const f = facts({ escalations: new Map([["E-2", { id: "E-2", title: "Stuck", severity: "low", taskId: "T-6" }]]) });
|
|
725
|
-
const io = fakeIo({});
|
|
726
|
-
const c = classifyEvent(ev({ family: "escalation", kind: "escalation.created", entity_id: "E-2", payload: { actor: THEM, taskId: "T-6" } }));
|
|
727
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "escalation", reason: "assignee" }, facts: f, io });
|
|
728
|
-
assert.equal(h.ok, true);
|
|
729
|
-
assert.equal(h.threadContext, null);
|
|
730
|
-
});
|
|
731
|
-
|
|
732
|
-
test("a handoff carries the originating task's thread", async () => {
|
|
733
|
-
const f = facts({ tasks: new Map([["T-7", { id: "T-7", title: "Draft the board pack", col: "doing", detail: "for Thursday" }]]) });
|
|
734
|
-
const io = fakeIo({
|
|
735
|
-
"board.taskComments": () => okFrame({ comments: [
|
|
736
|
-
{ id: "c1", authorName: "Casey", body: "sections 1-3 are done, 4 needs the latest numbers" },
|
|
737
|
-
] }),
|
|
738
|
-
});
|
|
739
|
-
const c = classifyEvent(ev({ family: "handoff", kind: "handoff.offered", entity_id: "R-1", payload: { actor: THEM, to: ME, from: THEM, itemId: "T-7", intent: "take over section 4" } }));
|
|
740
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "handoff", reason: "named" }, facts: f, io });
|
|
741
|
-
|
|
742
|
-
assert.equal(h.ok, true);
|
|
743
|
-
assert.match(h.text, /Intent: take over section 4/);
|
|
744
|
-
assert.match(h.text, /Board item "Draft the board pack" — doing/);
|
|
745
|
-
assert.match(h.threadContext, /4 needs the latest numbers/);
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
test("a calendar event carries the room it was agreed in and earlier occurrences", async () => {
|
|
749
|
-
const msgs = [{ id: "m1", authorId: THEM, authorName: "Casey", body: "let's move the weekly to Thursdays", createdAt: "2026-08-10T09:00:00.000Z" }];
|
|
750
|
-
const f = facts({
|
|
751
|
-
visibleChannelIds: new Set(["C-ops"]),
|
|
752
|
-
myEvents: new Map([
|
|
753
|
-
["E-now", { id: "E-now", title: "Ops weekly", startsAt: "2026-08-13T09:00:00.000Z", channelId: "C-ops" }],
|
|
754
|
-
["E-old", { id: "E-old", title: "Ops weekly", startsAt: "2026-08-06T09:00:00.000Z", location: "Room 2" }],
|
|
755
|
-
["E-other", { id: "E-other", title: "Board review", startsAt: "2026-08-05T09:00:00.000Z" }],
|
|
756
|
-
]),
|
|
757
|
-
});
|
|
758
|
-
f.channels.set("C-ops", { id: "C-ops", kind: "PUBLIC", name: "ops" });
|
|
759
|
-
f.channelPages.set("C-ops", { byId: new Map(msgs.map((m) => [m.id, m])), messages: msgs });
|
|
760
|
-
const io = fakeIo({});
|
|
761
|
-
const c = classifyEvent(ev({ family: "calendar", kind: "event.rescheduled", entity_id: "E-now", payload: { actor: THEM, channelId: "C-ops", title: "Ops weekly" } }));
|
|
762
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "calendar", reason: "attendee" }, facts: f, io });
|
|
763
|
-
|
|
764
|
-
assert.equal(h.ok, true);
|
|
765
|
-
assert.match(h.threadContext, /move the weekly to Thursdays/);
|
|
766
|
-
assert.match(h.threadContext, /Earlier in this series:/);
|
|
767
|
-
assert.match(h.threadContext, /2026-08-06T09:00:00.000Z · Room 2/);
|
|
768
|
-
assert.doesNotMatch(h.threadContext, /Board review/, "a different meeting is not this series");
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
test("a calendar event with no room and no series carries no invented context", async () => {
|
|
772
|
-
const f = facts({ myEvents: new Map([["E-1", { id: "E-1", title: "1:1", startsAt: "2026-08-13T09:00:00.000Z" }]]) });
|
|
773
|
-
const io = fakeIo({});
|
|
774
|
-
const c = classifyEvent(ev({ family: "calendar", kind: "event.created", entity_id: "E-1", payload: { actor: THEM, calendarMemberId: ME, title: "1:1" } }));
|
|
775
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "calendar", reason: "owner" }, facts: f, io });
|
|
776
|
-
assert.equal(h.ok, true);
|
|
777
|
-
assert.equal(h.threadContext, null);
|
|
778
|
-
assert.equal(io.calls.length, 0);
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
// ---------------------------------------------------------------------------
|
|
782
|
-
// artifact bodies — the agent must have READ the thing it is commenting on
|
|
783
|
-
// ---------------------------------------------------------------------------
|
|
784
|
-
|
|
785
|
-
test("a doc comment carries the document body, bounded", async () => {
|
|
786
|
-
const f = facts({ fileAcl: new Map([["F-2", { ownerId: ME, shared: true, name: "Pricing memo", kind: "DOC" }]]) });
|
|
787
|
-
const io = fakeIo({
|
|
788
|
-
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "is the tier-two number right?" }] }),
|
|
789
|
-
"files.docRead": () => okFrame({ html: `<h1>Pricing memo</h1><p>Tier two is ${"$"}4,800 per seat.</p>` }),
|
|
790
|
-
});
|
|
791
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-2", payload: { actor: THEM, name: "Pricing memo", commentId: "k1" } }));
|
|
792
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
793
|
-
|
|
794
|
-
assert.equal(h.ok, true);
|
|
795
|
-
assert.match(h.text, /Document:/);
|
|
796
|
-
assert.match(h.text, /Tier two is \$4,800 per seat\./);
|
|
797
|
-
assert.match(h.text, /is the tier-two number right\?/);
|
|
798
|
-
assert.doesNotMatch(h.text, /<p>/, "markup must not reach the model");
|
|
799
|
-
});
|
|
800
|
-
|
|
801
|
-
test("a doc body that is too long is clipped, and the comment still survives", async () => {
|
|
802
|
-
const f = facts({ fileAcl: new Map([["F-3", { ownerId: ME, shared: true, name: "Long", kind: "DOC" }]]) });
|
|
803
|
-
const io = fakeIo({
|
|
804
|
-
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "THE QUESTION" }] }),
|
|
805
|
-
"files.docRead": () => okFrame({ html: `<p>${"w".repeat(20000)}</p>` }),
|
|
806
|
-
});
|
|
807
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-3", payload: { actor: THEM, commentId: "k1" } }));
|
|
808
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
809
|
-
|
|
810
|
-
assert.ok(h.text.length < 6000, `doc body was not bounded (${h.text.length} chars)`);
|
|
811
|
-
assert.match(h.text, /THE QUESTION/, "the comment being answered must survive the doc body");
|
|
812
|
-
});
|
|
813
|
-
|
|
814
|
-
test("clampDocHtml bounds what a pull holds and converts", () => {
|
|
815
|
-
// files.docRead has no `limit` on the wire (ui-parity documents `{ fileId }`),
|
|
816
|
-
// so the only place this can be bounded is the instant it lands — before
|
|
817
|
-
// htmlToText walks it and before the per-pull cache keeps it. Measured before
|
|
818
|
-
// the clamp: 8 MB of HTML → 7,435,349 characters converted to retain 4,000.
|
|
819
|
-
assert.equal(clampDocHtml("<p>short</p>"), "<p>short</p>");
|
|
820
|
-
assert.equal(clampDocHtml("x".repeat(DOC_HTML_MAX_CHARS + 10)).length, DOC_HTML_MAX_CHARS);
|
|
821
|
-
assert.equal(clampDocHtml(null), "");
|
|
822
|
-
assert.ok(DOC_HTML_MAX_CHARS > 4000, "the clamp must leave room for a full text budget after markup");
|
|
823
|
-
});
|
|
824
|
-
|
|
825
|
-
test("a HUGE doc is clamped before it is converted, and never exceeds the shared ceiling", async () => {
|
|
826
|
-
// Two separate bounds, and the second one used to be missing entirely.
|
|
827
|
-
// 1. the raw HTML is sliced the instant it lands, so an 8 MB document does
|
|
828
|
-
// not walk 7.4 M characters through htmlToText to retain 4,000;
|
|
829
|
-
// 2. the JOINED text is clipped like every other surface's — hydrateDoc had
|
|
830
|
-
// dropped its outer clip(), making it the one hydrator that could hand
|
|
831
|
-
// `item.content` more than the shared clamp allows.
|
|
832
|
-
// The name is unbounded on the wire (it rides `c.ids.name` / the ACL row), so
|
|
833
|
-
// it is the part that proves the OUTER clip is doing work rather than sitting
|
|
834
|
-
// above a ceiling the parts can never reach.
|
|
835
|
-
const f = facts({ fileAcl: new Map([["F-BIG", { ownerId: ME, shared: true, name: "N".repeat(50_000), kind: "DOC" }]]) });
|
|
836
|
-
const huge = `<p>${"w".repeat(2_000_000)}</p>`;
|
|
837
|
-
const io = fakeIo({
|
|
838
|
-
"files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "z".repeat(9000) }] }),
|
|
839
|
-
"files.docRead": () => okFrame({ html: huge }),
|
|
840
|
-
});
|
|
841
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-BIG", payload: { actor: THEM, commentId: "k1" } }));
|
|
842
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
843
|
-
|
|
844
|
-
assert.equal(h.ok, true);
|
|
845
|
-
assert.ok(h.text.length <= 5601, `hydrateDoc must honour its declared ceiling (${h.text.length} chars)`);
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
test("a SHEET is never asked for a doc body — hq would only refuse", async () => {
|
|
849
|
-
const f = facts({ fileAcl: new Map([["F-4", { ownerId: ME, shared: true, name: "Model", kind: "SHEET" }]]) });
|
|
850
|
-
const io = fakeIo({ "files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "row 12?" }] }) });
|
|
851
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-4", payload: { actor: THEM, commentId: "k1" } }));
|
|
852
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
853
|
-
|
|
854
|
-
assert.equal(h.ok, true);
|
|
855
|
-
assert.equal(io.calls.some((k) => k.method === "files.docRead"), false);
|
|
856
|
-
});
|
|
857
|
-
|
|
858
|
-
test("an unreadable doc body leaves the comment thread intact", async () => {
|
|
859
|
-
const f = facts({ fileAcl: new Map([["F-5", { ownerId: ME, shared: true, name: "Memo", kind: "DOC" }]]) });
|
|
860
|
-
const io = fakeIo({ "files.comments": () => okFrame({ comments: [{ id: "k1", authorName: "Casey", body: "thoughts?" }] }) });
|
|
861
|
-
const c = classifyEvent(ev({ family: "files", kind: "doc.comment", entity_id: "F-5", payload: { actor: THEM, commentId: "k1" } }));
|
|
862
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "doc_comment", reason: "owner" }, facts: f, io });
|
|
863
|
-
|
|
864
|
-
assert.equal(h.ok, true);
|
|
865
|
-
assert.match(h.text, /thoughts\?/);
|
|
866
|
-
assert.doesNotMatch(h.text, /Document:/);
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
test("a file comment names the file — type and size, not just an opaque key", async () => {
|
|
870
|
-
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
871
|
-
f.channels.set("C-eng", { id: "C-eng", kind: "PUBLIC", name: "engineering" });
|
|
872
|
-
const io = fakeIo({
|
|
873
|
-
"file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "page 3 is stale" }] }),
|
|
874
|
-
"file.listPinned": () => okFrame({ files: [
|
|
875
|
-
{ id: "pf1", fileKey: "fk-1", name: "q3-forecast.pdf", mimeType: "application/pdf", sizeBytes: 1258291 },
|
|
876
|
-
] }),
|
|
877
|
-
});
|
|
878
|
-
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { actor: THEM, fileKey: "fk-1", channelId: "C-eng" } }));
|
|
879
|
-
const h = await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io });
|
|
880
|
-
|
|
881
|
-
assert.equal(h.ok, true);
|
|
882
|
-
assert.match(h.text, /Comment on file q3-forecast\.pdf/);
|
|
883
|
-
assert.match(h.text, /File: q3-forecast\.pdf · application\/pdf · 1\.2 MB/);
|
|
884
|
-
assert.match(h.text, /page 3 is stale/);
|
|
885
|
-
assert.equal(h.subjectDetail, "q3-forecast.pdf");
|
|
886
|
-
});
|
|
887
|
-
|
|
888
|
-
test("a file in a room outside my roster is never asked about", async () => {
|
|
889
|
-
const f = facts({ visibleChannelIds: new Set(["C-mine"]) });
|
|
890
|
-
const io = fakeIo({ "file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "x" }] }) });
|
|
891
|
-
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: "fc1", payload: { actor: THEM, fileKey: "fk-2", channelId: "C-not-mine" } }));
|
|
892
|
-
await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io });
|
|
893
|
-
assert.equal(io.calls.some((k) => k.method === "file.listPinned"), false);
|
|
894
|
-
});
|
|
895
|
-
|
|
896
|
-
test("two comments on files in ONE room share a single listPinned read", async () => {
|
|
897
|
-
const f = facts({ visibleChannelIds: new Set(["C-eng"]) });
|
|
898
|
-
const io = fakeIo({
|
|
899
|
-
"file.listComments": () => okFrame({ comments: [{ id: "fc1", authorName: "Casey", body: "x" }] }),
|
|
900
|
-
"file.listPinned": () => okFrame({ files: [] }),
|
|
901
|
-
});
|
|
902
|
-
const cache = new Map();
|
|
903
|
-
for (const key of ["fk-a", "fk-b"]) {
|
|
904
|
-
const c = classifyEvent(ev({ family: "file", kind: "file.commented", entity_id: `c-${key}`, payload: { actor: THEM, fileKey: key, channelId: "C-eng" } }));
|
|
905
|
-
await hydrate({ candidate: c, verdict: { surface: "file_comment", reason: "member" }, facts: f, io, cache });
|
|
906
|
-
}
|
|
907
|
-
assert.equal(io.calls.filter((k) => k.method === "file.listPinned").length, 1);
|
|
908
|
-
});
|