switchroom 0.7.15 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -59
- package/bin/run-hook.sh +27 -11
- package/bin/timezone-hook.sh +9 -7
- package/dist/agent-scheduler/index.js +410 -133
- package/dist/auth-broker/index.js +13932 -0
- package/dist/cli/switchroom.js +26937 -5601
- package/dist/host-control/main.js +12702 -0
- package/dist/vault/approvals/kernel-server.js +467 -184
- package/dist/vault/broker/server.js +1430 -724
- package/examples/minimal.yaml +63 -0
- package/examples/personal-google-workspace-mcp/.env.example +34 -0
- package/examples/personal-google-workspace-mcp/README.md +194 -0
- package/examples/personal-google-workspace-mcp/compose.yaml +66 -0
- package/examples/switchroom.yaml +220 -0
- package/package.json +7 -4
- package/profiles/_base/settings.json.hbs +20 -5
- package/profiles/_base/start.sh.hbs +16 -3
- package/profiles/_shared/agent-self-service.md.hbs +126 -0
- package/profiles/_shared/telegram-style.md.hbs +20 -90
- package/profiles/_shared/vault-protocol.md.hbs +68 -0
- package/profiles/default/CLAUDE.md +50 -96
- package/profiles/default/CLAUDE.md.hbs +36 -6
- package/profiles/default/workspace/SOUL.md.hbs +12 -5
- package/skills/buildkite-agent-infrastructure/SKILL.md +30 -11
- package/skills/buildkite-agent-runtime/SKILL.md +44 -11
- package/skills/buildkite-api/SKILL.md +31 -8
- package/skills/buildkite-cli/SKILL.md +27 -9
- package/skills/buildkite-migration/SKILL.md +22 -9
- package/skills/buildkite-pipelines/SKILL.md +26 -9
- package/skills/buildkite-secure-delivery/SKILL.md +23 -9
- package/skills/buildkite-test-engine/SKILL.md +25 -8
- package/skills/docx/SKILL.md +1 -1
- package/skills/docx/scripts/office/validators/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/docx/scripts/office/validators/__pycache__/base.cpython-313.pyc +0 -0
- package/skills/file-bug/SKILL.md +34 -6
- package/skills/humanizer/SKILL.md +15 -0
- package/skills/humanizer-calibrate/SKILL.md +7 -1
- package/skills/mcp-builder/SKILL.md +1 -1
- package/skills/pdf/SKILL.md +1 -1
- package/skills/pptx/SKILL.md +1 -1
- package/skills/skill-creator/SKILL.md +21 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-313.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-313.pyc +0 -0
- package/skills/switchroom-cli/SKILL.md +63 -64
- package/skills/switchroom-health/SKILL.md +23 -10
- package/skills/switchroom-install/SKILL.md +3 -3
- package/skills/switchroom-manage/SKILL.md +26 -19
- package/skills/switchroom-runtime/SKILL.md +191 -0
- package/skills/switchroom-status/SKILL.md +27 -2
- package/skills/telegram-test-harness/SKILL.md +3 -0
- package/skills/token-helpers/SKILL.md +24 -1
- package/skills/webapp-testing/SKILL.md +31 -1
- package/skills/xlsx/SKILL.md +1 -1
- package/telegram-plugin/admin-commands/index.ts +7 -5
- package/telegram-plugin/analytics-posthog.ts +191 -0
- package/telegram-plugin/bridge/bridge.ts +69 -0
- package/telegram-plugin/bridge/ipc-client.ts +4 -1
- package/telegram-plugin/dist/bridge/bridge.js +194 -119
- package/telegram-plugin/dist/gateway/gateway.js +23611 -19671
- package/telegram-plugin/dist/server.js +245 -189
- package/telegram-plugin/first-paint.ts +3 -24
- package/telegram-plugin/gateway/auth-add-flow.ts +326 -0
- package/telegram-plugin/gateway/auth-broker-client.ts +75 -0
- package/telegram-plugin/gateway/auth-command.ts +794 -0
- package/telegram-plugin/gateway/auth-line.ts +123 -0
- package/telegram-plugin/gateway/boot-card.ts +169 -40
- package/telegram-plugin/gateway/boot-issue-cache.ts +308 -0
- package/telegram-plugin/gateway/boot-probes.ts +166 -123
- package/telegram-plugin/gateway/boot-reason.ts +41 -7
- package/telegram-plugin/gateway/boot-version.ts +66 -0
- package/telegram-plugin/gateway/gateway.ts +3499 -1885
- package/telegram-plugin/gateway/hostd-dispatch.ts +117 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +18 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +106 -0
- package/telegram-plugin/gateway/quarantine.ts +69 -0
- package/telegram-plugin/gateway/quota-cache.ts +9 -4
- package/telegram-plugin/gateway/reaction-trigger.ts +401 -0
- package/telegram-plugin/gateway/recent-denials.test.ts +103 -0
- package/telegram-plugin/gateway/recent-denials.ts +77 -0
- package/telegram-plugin/gateway/startup-network-retry.ts +109 -31
- package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +125 -0
- package/telegram-plugin/history.ts +91 -0
- package/telegram-plugin/hooks/hooks.json +10 -0
- package/telegram-plugin/hooks/sandbox-hint-posttool.mjs +130 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +19 -2
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +22 -2
- package/telegram-plugin/hooks/tool-label-pretool.mjs +11 -0
- package/telegram-plugin/hooks/wedge-detect-posttool.mjs +303 -0
- package/telegram-plugin/inbound-classifier.ts +50 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +136 -0
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/telegram-plugin/package.json +4 -2
- package/telegram-plugin/permission-rule.ts +51 -0
- package/telegram-plugin/permission-title.ts +56 -0
- package/telegram-plugin/quota-check.ts +19 -41
- package/telegram-plugin/registry/reaper.ts +223 -0
- package/telegram-plugin/retry-api-call.ts +80 -0
- package/telegram-plugin/runtime-metrics.ts +177 -0
- package/telegram-plugin/scripts/build.mjs +0 -1
- package/telegram-plugin/secret-detect/index.ts +24 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +64 -12
- package/telegram-plugin/secret-detect/vault-error.ts +78 -11
- package/telegram-plugin/secret-detect/vault-write.ts +14 -2
- package/telegram-plugin/server.js +41795 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/shared/bot-runtime.ts +5 -4
- package/telegram-plugin/silence-poke.ts +420 -0
- package/telegram-plugin/silent-end.ts +174 -0
- package/telegram-plugin/stream-controller.ts +13 -0
- package/telegram-plugin/stream-reply-handler.ts +7 -0
- package/telegram-plugin/subagent-watcher.ts +213 -4
- package/telegram-plugin/tests/auth-add-flow.test.ts +559 -0
- package/telegram-plugin/tests/auth-code-redact.test.ts +8 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +531 -0
- package/telegram-plugin/tests/boot-card-issue-dedup.test.ts +247 -0
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +182 -0
- package/telegram-plugin/tests/boot-card-reason.test.ts +65 -2
- package/telegram-plugin/tests/boot-card-render.test.ts +146 -0
- package/telegram-plugin/tests/boot-card-silent-on-operator.test.ts +103 -0
- package/telegram-plugin/tests/boot-probes.test.ts +216 -10
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/finalize-callback.test.ts +190 -0
- package/telegram-plugin/tests/gateway-message-validator.test.ts +26 -0
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +12 -3
- package/telegram-plugin/tests/gateway-startup-network-retry.test.ts +104 -0
- package/telegram-plugin/tests/history-reaper.test.ts +378 -0
- package/telegram-plugin/tests/hostd-dispatch.test.ts +129 -0
- package/telegram-plugin/tests/inbound-classifier.test.ts +76 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +267 -0
- package/telegram-plugin/tests/issues-card.test.ts +49 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +132 -0
- package/telegram-plugin/tests/permission-rule.test.ts +80 -1
- package/telegram-plugin/tests/permission-title.test.ts +31 -0
- package/telegram-plugin/tests/quota-check.test.ts +5 -35
- package/telegram-plugin/tests/races.test.ts +179 -0
- package/telegram-plugin/tests/reaction-trigger-flow.test.ts +353 -0
- package/telegram-plugin/tests/reaction-trigger.test.ts +397 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +152 -1
- package/telegram-plugin/tests/runtime-metrics.test.ts +145 -0
- package/telegram-plugin/tests/sandbox-hint-posttool.test.ts +155 -0
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +133 -0
- package/telegram-plugin/tests/secret-detect-false-positives.test.ts +137 -0
- package/telegram-plugin/tests/silence-poke.test.ts +493 -0
- package/telegram-plugin/tests/silent-end.test.ts +206 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +107 -0
- package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +224 -0
- package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +263 -0
- package/telegram-plugin/tests/turn-signal-tracker.test.ts +81 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +256 -0
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +73 -0
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +226 -0
- package/telegram-plugin/tests/vault-grant-union.test.ts +130 -0
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +140 -0
- package/telegram-plugin/tests/vault-posture-quarantine.test.ts +104 -0
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +114 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +106 -0
- package/telegram-plugin/turn-signal-tracker.ts +100 -24
- package/telegram-plugin/uat/SETUP.md +210 -35
- package/telegram-plugin/uat/assertions.ts +264 -37
- package/telegram-plugin/uat/driver-info.ts +57 -0
- package/telegram-plugin/uat/driver.ts +590 -51
- package/telegram-plugin/uat/harness.ts +140 -94
- package/telegram-plugin/uat/load-env.test.ts +72 -0
- package/telegram-plugin/uat/load-env.ts +48 -0
- package/telegram-plugin/uat/login.ts +96 -53
- package/telegram-plugin/uat/runners/agent-self-sufficiency.ts +457 -0
- package/telegram-plugin/uat/runners/paraphrases.ts +231 -0
- package/telegram-plugin/uat/runners/report.ts +150 -0
- package/telegram-plugin/uat/runners/run-agent-self-sufficiency.sh +50 -0
- package/telegram-plugin/uat/runners/scorer.test.ts +196 -0
- package/telegram-plugin/uat/runners/scorer.ts +106 -0
- package/telegram-plugin/uat/runners/skill-coverage.test.ts +100 -0
- package/telegram-plugin/uat/runners/skill-coverage.ts +620 -0
- package/telegram-plugin/uat/scenarios/ask-user-button-tap-dm.test.ts +141 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/fuzz-extended-dm.test.ts +255 -0
- package/telegram-plugin/uat/scenarios/fuzz-human-style-dm.test.ts +275 -0
- package/telegram-plugin/uat/scenarios/fuzz-random-prompts-dm.test.ts +146 -0
- package/telegram-plugin/uat/scenarios/fuzz-status-ask-dm.test.ts +486 -0
- package/telegram-plugin/uat/scenarios/jtbd-interrupt-marker-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-rapid-followup-dm.test.ts +100 -0
- package/telegram-plugin/uat/scenarios/jtbd-soft-commit-dm.test.ts +67 -0
- package/telegram-plugin/uat/scenarios/jtbd-status-query-dm.test.ts +49 -0
- package/telegram-plugin/uat/scenarios/location-inbound-dm.test.ts +65 -0
- package/telegram-plugin/uat/scenarios/midturn-silent-dm.test.ts +175 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +142 -0
- package/telegram-plugin/uat/scenarios/reactions-trigger-turn-dm.test.ts +96 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-deletes-original-dm.test.ts +123 -0
- package/telegram-plugin/uat/scenarios/secret-redaction-no-false-positive-dm.test.ts +87 -0
- package/telegram-plugin/uat/scenarios/silence-poke-soft-dm.test.ts +155 -0
- package/telegram-plugin/uat/scenarios/silent-end-recovery-dm.test.ts +95 -0
- package/telegram-plugin/uat/scenarios/smoke-dm-reply.test.ts +57 -0
- package/telegram-plugin/uat/scenarios/subagent-watcher-no-rerun-dm.test.ts +135 -0
- package/telegram-plugin/uat/scenarios/vault-approval-posture-telegram-id-dm.test.ts +191 -0
- package/telegram-plugin/uat/scenarios/vault-audit-allow-dm.test.ts +108 -0
- package/telegram-plugin/uat/scenarios/vault-grant-auto-resume-dm.test.ts +121 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-concurrent-dm.test.ts +161 -0
- package/telegram-plugin/uat/scenarios/vault-request-access-end-to-end-dm.test.ts +158 -0
- package/telegram-plugin/uat/scenarios/voice-inbound-dm.test.ts +65 -0
- package/telegram-plugin/vault-approval-posture.ts +42 -0
- package/telegram-plugin/welcome-text.ts +1 -0
- package/telegram-plugin/active-pins-sweep.ts +0 -204
- package/telegram-plugin/active-pins.ts +0 -146
- package/telegram-plugin/auth-dashboard.ts +0 -1104
- package/telegram-plugin/auth-slot-parser.ts +0 -497
- package/telegram-plugin/card-event-log.ts +0 -138
- package/telegram-plugin/dist/foreman/foreman.js +0 -31106
- package/telegram-plugin/docs/multi-agent-card-design.md +0 -847
- package/telegram-plugin/docs/pinned-progress-card-reliability.md +0 -144
- package/telegram-plugin/foreman/foreman-create-flow.ts +0 -202
- package/telegram-plugin/foreman/foreman-handlers.ts +0 -493
- package/telegram-plugin/foreman/foreman.ts +0 -1165
- package/telegram-plugin/foreman/setup-flow.ts +0 -345
- package/telegram-plugin/foreman/setup-state.ts +0 -239
- package/telegram-plugin/foreman/state.ts +0 -203
- package/telegram-plugin/pin-event-log.ts +0 -76
- package/telegram-plugin/progress-card-driver.ts +0 -2886
- package/telegram-plugin/progress-card-pin-manager.ts +0 -589
- package/telegram-plugin/progress-card-pin-watchdog.ts +0 -98
- package/telegram-plugin/progress-card.ts +0 -1409
- package/telegram-plugin/tests/HARNESS.md +0 -340
- package/telegram-plugin/tests/_progress-card-harness.ts +0 -109
- package/telegram-plugin/tests/active-pins-boot-reaper.test.ts +0 -211
- package/telegram-plugin/tests/active-pins-sweep.test.ts +0 -309
- package/telegram-plugin/tests/active-pins.test.ts +0 -187
- package/telegram-plugin/tests/auth-account-identity-surface.test.ts +0 -118
- package/telegram-plugin/tests/auth-dashboard-edge-cases.test.ts +0 -260
- package/telegram-plugin/tests/auth-dashboard-restart-flow.test.ts +0 -140
- package/telegram-plugin/tests/auth-dashboard-v3b.test.ts +0 -559
- package/telegram-plugin/tests/auth-dashboard.test.ts +0 -1045
- package/telegram-plugin/tests/auth-slot-commands.test.ts +0 -640
- package/telegram-plugin/tests/bg-agent-progress-card-757.test.ts +0 -201
- package/telegram-plugin/tests/boot-card-account-quota.test.ts +0 -137
- package/telegram-plugin/tests/card-event-log.test.ts +0 -145
- package/telegram-plugin/tests/first-paint.test.ts +0 -257
- package/telegram-plugin/tests/foreman-create-flow.test.ts +0 -359
- package/telegram-plugin/tests/foreman-handlers.test.ts +0 -347
- package/telegram-plugin/tests/foreman-state.test.ts +0 -164
- package/telegram-plugin/tests/foreman-write-ops.test.ts +0 -214
- package/telegram-plugin/tests/harness-ordering-invariants.test.ts +0 -243
- package/telegram-plugin/tests/pin-event-log.test.ts +0 -124
- package/telegram-plugin/tests/progress-card-api-failure-during-deferred.test.ts +0 -73
- package/telegram-plugin/tests/progress-card-close-paths-converge.test.ts +0 -272
- package/telegram-plugin/tests/progress-card-cross-turn.test.ts +0 -258
- package/telegram-plugin/tests/progress-card-delay-842.test.ts +0 -160
- package/telegram-plugin/tests/progress-card-dispose-preservepending.test.ts +0 -81
- package/telegram-plugin/tests/progress-card-draft-flag.test.ts +0 -80
- package/telegram-plugin/tests/progress-card-driver-eviction.test.ts +0 -215
- package/telegram-plugin/tests/progress-card-driver-fleet-shadow.test.ts +0 -123
- package/telegram-plugin/tests/progress-card-driver-force-complete-parent-done.test.ts +0 -76
- package/telegram-plugin/tests/progress-card-edit-timestamps-budget.test.ts +0 -62
- package/telegram-plugin/tests/progress-card-memory-bounds.test.ts +0 -84
- package/telegram-plugin/tests/progress-card-pin-failure-paths.test.ts +0 -139
- package/telegram-plugin/tests/progress-card-pin-manager.test.ts +0 -773
- package/telegram-plugin/tests/progress-card-pin-race-fast-turn.test.ts +0 -66
- package/telegram-plugin/tests/progress-card-pin-sidecar-partial-write.test.ts +0 -64
- package/telegram-plugin/tests/progress-card-pin-watchdog.test.ts +0 -190
- package/telegram-plugin/tests/progress-card-sigterm-pin-flush.test.ts +0 -146
- package/telegram-plugin/tests/real-gateway-f1-ladder-integrity.test.ts +0 -123
- package/telegram-plugin/tests/real-gateway-f2-instant-draft.test.ts +0 -82
- package/telegram-plugin/tests/real-gateway-f3-late-card.test.ts +0 -114
- package/telegram-plugin/tests/real-gateway-harness.ts +0 -699
- package/telegram-plugin/tests/real-gateway-i6-turn-flush-replay-dedup.test.ts +0 -313
- package/telegram-plugin/tests/real-gateway-ipc-lifecycle.test.ts +0 -299
- package/telegram-plugin/tests/real-gateway-spec.test.ts +0 -487
- package/telegram-plugin/tests/real-gateway.smoke.test.ts +0 -101
- package/telegram-plugin/tests/setup-flow.test.ts +0 -510
- package/telegram-plugin/tests/setup-state.test.ts +0 -146
- package/telegram-plugin/tests/sync-chat-running-subagents.test.ts +0 -116
- package/telegram-plugin/tests/turn-end-regressions.test.ts +0 -489
- package/telegram-plugin/tests/turn-flush-card-takeover.test.ts +0 -218
- package/telegram-plugin/tests/turn-flush-prose-recovery.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-bg-carry-full-lifecycle.test.ts +0 -131
- package/telegram-plugin/tests/two-zone-bg-detection.test.ts +0 -120
- package/telegram-plugin/tests/two-zone-bg-done-when-all-terminal.test.ts +0 -116
- package/telegram-plugin/tests/two-zone-bg-early-turn-end.test.ts +0 -87
- package/telegram-plugin/tests/two-zone-bg-survives-next-turn.test.ts +0 -211
- package/telegram-plugin/tests/two-zone-card-cap.test.ts +0 -62
- package/telegram-plugin/tests/two-zone-card-fleet-row.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-card-header-phases.test.ts +0 -78
- package/telegram-plugin/tests/two-zone-card-html-balance.test.ts +0 -110
- package/telegram-plugin/tests/two-zone-card-lifecycle.test.ts +0 -128
- package/telegram-plugin/tests/two-zone-card-sanitise.test.ts +0 -58
- package/telegram-plugin/tests/two-zone-card-snapshot.test.ts +0 -133
- package/telegram-plugin/tests/two-zone-concurrent-turns-isolation.test.ts +0 -155
- package/telegram-plugin/tests/two-zone-phasefor-precedence.test.ts +0 -117
- package/telegram-plugin/tests/two-zone-snapshot-extras.test.ts +0 -187
- package/telegram-plugin/tests/two-zone-stuck-edit-throttle.test.ts +0 -149
- package/telegram-plugin/tests/two-zone-stuck-header-escalation.test.ts +0 -101
- package/telegram-plugin/tests/two-zone-stuck-per-member.test.ts +0 -114
- package/telegram-plugin/tests/two-zone-stuck-recovery.test.ts +0 -105
- package/telegram-plugin/tests/waiting-ux-harness.ts +0 -381
- package/telegram-plugin/tests/waiting-ux.e2e.test.ts +0 -233
- package/telegram-plugin/turn-flush-prose-recovery.ts +0 -40
- package/telegram-plugin/two-zone-card.ts +0 -269
- package/telegram-plugin/uat/scenarios/smoke-clerk-reply.test.ts +0 -61
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PR-C2 — `dispose({ preservePending: true })` must NOT remove chats
|
|
3
|
-
* whose `pendingCompletion === true`.
|
|
4
|
-
*
|
|
5
|
-
* Regression: commit 4c0186d introduced a dispose() that wiped all
|
|
6
|
-
* in-flight card state on every bridge disconnect. The selective
|
|
7
|
-
* dispose path was added to keep cards with running background
|
|
8
|
-
* sub-agents alive across the disconnect/reconnect cycle.
|
|
9
|
-
*
|
|
10
|
-
* fails when: dispose's preservePending branch unconditionally clears
|
|
11
|
-
* `chats`, OR forgets to leave the heartbeat running while a pending
|
|
12
|
-
* chat survives.
|
|
13
|
-
*/
|
|
14
|
-
import { describe, it, expect } from 'vitest'
|
|
15
|
-
import { makeHarness, enqueue } from './_progress-card-harness.js'
|
|
16
|
-
|
|
17
|
-
describe('PR-C2: dispose({ preservePending: true })', () => {
|
|
18
|
-
it('chat with pendingCompletion survives dispose; heartbeat-driven completion still fires after a "reconnect"', () => {
|
|
19
|
-
const completions: string[] = []
|
|
20
|
-
const { driver, advance } = makeHarness({
|
|
21
|
-
minIntervalMs: 0,
|
|
22
|
-
coalesceMs: 0,
|
|
23
|
-
heartbeatMs: 1_000,
|
|
24
|
-
maxIdleMs: 999_999,
|
|
25
|
-
deferredCompletionTimeoutMs: 5_000,
|
|
26
|
-
promoteAfterMs: 999_999,
|
|
27
|
-
onTurnComplete: (s) => completions.push(s.turnKey),
|
|
28
|
-
})
|
|
29
|
-
const maps = driver._debugGetMaps!()
|
|
30
|
-
const CHAT = 'cA'
|
|
31
|
-
|
|
32
|
-
// Set up a turn with a background sub-agent so parent turn_end
|
|
33
|
-
// produces pendingCompletion=true.
|
|
34
|
-
driver.ingest(enqueue(CHAT), null)
|
|
35
|
-
driver.ingest(
|
|
36
|
-
{
|
|
37
|
-
kind: 'tool_use',
|
|
38
|
-
toolName: 'Agent',
|
|
39
|
-
toolUseId: 'tu1',
|
|
40
|
-
input: { prompt: 'bg', run_in_background: true },
|
|
41
|
-
},
|
|
42
|
-
CHAT,
|
|
43
|
-
)
|
|
44
|
-
driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg' }, CHAT)
|
|
45
|
-
driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
|
|
46
|
-
driver.recordOutboundDelivered(CHAT)
|
|
47
|
-
driver.ingest({ kind: 'turn_end', durationMs: 100 }, CHAT)
|
|
48
|
-
|
|
49
|
-
// Confirm pendingCompletion shape.
|
|
50
|
-
expect(maps.chats.size).toBe(1)
|
|
51
|
-
const csBefore = [...maps.chats.values()][0] as { pendingCompletion: boolean }
|
|
52
|
-
expect(csBefore.pendingCompletion).toBe(true)
|
|
53
|
-
|
|
54
|
-
// Bridge disconnect: dispose preserving pending.
|
|
55
|
-
driver.dispose!({ preservePending: true })
|
|
56
|
-
|
|
57
|
-
// Chat must survive.
|
|
58
|
-
expect(maps.chats.size).toBe(1)
|
|
59
|
-
const csAfter = [...maps.chats.values()][0] as { pendingCompletion: boolean }
|
|
60
|
-
expect(csAfter.pendingCompletion).toBe(true)
|
|
61
|
-
|
|
62
|
-
// Now simulate "bridge reconnect" — nothing to do at the driver level
|
|
63
|
-
// for that, but the heartbeat must still be wired so the deferred
|
|
64
|
-
// completion timeout eventually fires.
|
|
65
|
-
advance(15_000)
|
|
66
|
-
|
|
67
|
-
// Stalled-cards heartbeat branch should have closed the chat by now.
|
|
68
|
-
expect(maps.chats.size).toBe(0)
|
|
69
|
-
expect(completions.length).toBe(1)
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
it('chats WITHOUT pendingCompletion are dropped by preservePending dispose', () => {
|
|
73
|
-
const { driver } = makeHarness()
|
|
74
|
-
const maps = driver._debugGetMaps!()
|
|
75
|
-
driver.ingest(enqueue('cActive'), null)
|
|
76
|
-
expect(maps.chats.size).toBe(1)
|
|
77
|
-
|
|
78
|
-
driver.dispose!({ preservePending: true })
|
|
79
|
-
expect(maps.chats.size).toBe(0)
|
|
80
|
-
})
|
|
81
|
-
})
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pin the wiring for #354's PROGRESS_CARD_DRAFT_TRANSPORT env flag.
|
|
3
|
-
*
|
|
4
|
-
* Two regressions this guards against:
|
|
5
|
-
*
|
|
6
|
-
* 1. The flag drifting silently — someone refactors the progress-
|
|
7
|
-
* card emit and forgets to thread isPrivateChat / sendMessageDraft
|
|
8
|
-
* when the flag is on. Result: the card stays on the legacy edit
|
|
9
|
-
* path even though the operator opted in.
|
|
10
|
-
*
|
|
11
|
-
* 2. The flag turning on by default before the spike unknowns are
|
|
12
|
-
* validated. Default-OFF is load-bearing: until we know drafts
|
|
13
|
-
* can be pinned and survive bot crashes, the legacy path is the
|
|
14
|
-
* safe one.
|
|
15
|
-
*
|
|
16
|
-
* Source-level pinning rather than behavioural — the gateway emit
|
|
17
|
-
* lives inside the bot startup wiring and is hard to drive in
|
|
18
|
-
* isolation. The contract here is the literal env-var check + the
|
|
19
|
-
* fact that draft deps are conditional on it.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { describe, it, expect } from 'vitest'
|
|
23
|
-
import { readFileSync } from 'node:fs'
|
|
24
|
-
import { resolve } from 'node:path'
|
|
25
|
-
|
|
26
|
-
const gatewaySrc = readFileSync(
|
|
27
|
-
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
28
|
-
'utf-8',
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
describe('progress-card draft transport flag (#354)', () => {
|
|
32
|
-
it('is gated behind PROGRESS_CARD_DRAFT_TRANSPORT=1 (default OFF)', () => {
|
|
33
|
-
// The flag check must be an explicit `=== '1'` not a truthy check
|
|
34
|
-
// — `process.env.X === '1'` is the project convention, and a
|
|
35
|
-
// truthy check would mis-fire on `=0` or `=false`.
|
|
36
|
-
expect(gatewaySrc).toMatch(
|
|
37
|
-
/process\.env\.PROGRESS_CARD_DRAFT_TRANSPORT\s*===\s*['"]1['"]/,
|
|
38
|
-
)
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('only enables draft when the chat is a DM (no threads, isDmChatId)', () => {
|
|
42
|
-
// Drafts don't support forum topics. The flag-check block must
|
|
43
|
-
// gate on isDmChatId(chatId) and threadId == null — otherwise a
|
|
44
|
-
// forum-topic message would be sent via draft and Telegram would
|
|
45
|
-
// reject with DRAFT_CHAT_UNSUPPORTED.
|
|
46
|
-
const block = extractDraftBlock(gatewaySrc)
|
|
47
|
-
expect(block).toMatch(/isDmChatId\(chatId\)/)
|
|
48
|
-
expect(block).toMatch(/threadId\s*==\s*null/)
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('passes both isPrivateChat AND sendMessageDraft when eligible', () => {
|
|
52
|
-
// Without sendMessageDraft, stream-reply-handler resolves transport
|
|
53
|
-
// to 'message' (line 432: `isForumTopic || deps.sendMessageDraft == null`).
|
|
54
|
-
// Both deps must be threaded together for the draft path to fire.
|
|
55
|
-
const block = extractDraftBlock(gatewaySrc)
|
|
56
|
-
expect(block).toMatch(/isPrivateChat:\s*true/)
|
|
57
|
-
expect(block).toMatch(/sendMessageDraft:\s*sendMessageDraftFn/)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('documents the spike unknowns from #354 inline so an operator can validate', () => {
|
|
61
|
-
// The flag exists because pinning + crash behavior are unverified.
|
|
62
|
-
// The block comment must call out both so a future contributor
|
|
63
|
-
// doesn't flip the default to ON without doing the spike first.
|
|
64
|
-
const block = extractDraftBlock(gatewaySrc)
|
|
65
|
-
expect(block.toLowerCase()).toMatch(/pin/)
|
|
66
|
-
expect(block.toLowerCase()).toMatch(/spike|unknown/)
|
|
67
|
-
})
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
/** Pull the #354 spike block out of gateway.ts for source-level assertions. */
|
|
71
|
-
function extractDraftBlock(src: string): string {
|
|
72
|
-
// The block starts at the spike comment marker and ends at the next
|
|
73
|
-
// `handleStreamReply(` invocation. Big enough to span the gate +
|
|
74
|
-
// the conditional draft-deps spread.
|
|
75
|
-
const start = src.indexOf('// #354 spike')
|
|
76
|
-
expect(start, '#354 spike block not found in gateway.ts').toBeGreaterThan(0)
|
|
77
|
-
const end = src.indexOf(').then', start)
|
|
78
|
-
expect(end, '#354 spike block end not found').toBeGreaterThan(start)
|
|
79
|
-
return src.slice(start, end)
|
|
80
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression: TTL eviction of internal dedup maps must NOT depend on the
|
|
3
|
-
* heartbeat tick. The heartbeat stops whenever `chats.size === 0`, so any
|
|
4
|
-
* eviction inside it leaves `seenEnqueueMsgIds` and `pendingSyncEchoes` to
|
|
5
|
-
* grow unbounded across idle periods. Outer-base-key entries
|
|
6
|
-
* (`chatRunningSubagents`, `baseTurnSeqs`) likewise need an explicit
|
|
7
|
-
* cleanup hook on chat-close because nothing else ever drops them.
|
|
8
|
-
*
|
|
9
|
-
* Fix shape: an inline throttled `maybeEvict(now)` runs at the top of
|
|
10
|
-
* every public ingress, and `completeTurnFully` calls
|
|
11
|
-
* `cleanupBaseKeyIfUnused` after `chats.delete`.
|
|
12
|
-
*/
|
|
13
|
-
import { describe, it, expect } from 'vitest'
|
|
14
|
-
import { createProgressDriver } from '../progress-card-driver.js'
|
|
15
|
-
import type { SessionEvent } from '../session-tail.js'
|
|
16
|
-
|
|
17
|
-
let nextMsgId = 9000
|
|
18
|
-
|
|
19
|
-
function harness() {
|
|
20
|
-
let now = 1000
|
|
21
|
-
const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
|
|
22
|
-
let nextRef = 0
|
|
23
|
-
const emits: Array<{ chatId: string; threadId?: string; turnKey: string; html: string; done: boolean }> = []
|
|
24
|
-
|
|
25
|
-
const driver = createProgressDriver({
|
|
26
|
-
emit: (a) => emits.push(a),
|
|
27
|
-
minIntervalMs: 0,
|
|
28
|
-
coalesceMs: 0,
|
|
29
|
-
initialDelayMs: 0,
|
|
30
|
-
heartbeatMs: 5000,
|
|
31
|
-
now: () => now,
|
|
32
|
-
setTimeout: (fn, ms) => {
|
|
33
|
-
const ref = nextRef++
|
|
34
|
-
timers.push({ fireAt: now + ms, fn, ref })
|
|
35
|
-
return { ref }
|
|
36
|
-
},
|
|
37
|
-
clearTimeout: (handle) => {
|
|
38
|
-
const target = (handle as { ref: number }).ref
|
|
39
|
-
const idx = timers.findIndex((t) => t.ref === target)
|
|
40
|
-
if (idx !== -1) timers.splice(idx, 1)
|
|
41
|
-
},
|
|
42
|
-
setInterval: (fn, ms) => {
|
|
43
|
-
const ref = nextRef++
|
|
44
|
-
timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
|
|
45
|
-
return { ref }
|
|
46
|
-
},
|
|
47
|
-
clearInterval: (handle) => {
|
|
48
|
-
const target = (handle as { ref: number }).ref
|
|
49
|
-
const idx = timers.findIndex((t) => t.ref === target)
|
|
50
|
-
if (idx !== -1) timers.splice(idx, 1)
|
|
51
|
-
},
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
const advance = (ms: number): void => {
|
|
55
|
-
now += ms
|
|
56
|
-
for (;;) {
|
|
57
|
-
timers.sort((a, b) => a.fireAt - b.fireAt)
|
|
58
|
-
const next = timers[0]
|
|
59
|
-
if (!next || next.fireAt > now) break
|
|
60
|
-
if (next.repeat != null) {
|
|
61
|
-
next.fireAt += next.repeat
|
|
62
|
-
next.fn()
|
|
63
|
-
} else {
|
|
64
|
-
timers.shift()
|
|
65
|
-
next.fn()
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
return { driver, emits, advance, getNow: () => now }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function enqueue(chatId: string, threadId?: string): SessionEvent {
|
|
74
|
-
return {
|
|
75
|
-
kind: 'enqueue',
|
|
76
|
-
chatId,
|
|
77
|
-
messageId: String(nextMsgId++),
|
|
78
|
-
threadId: threadId ?? null,
|
|
79
|
-
rawContent: `<channel chat_id="${chatId}">hi</channel>`,
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
describe('progress-card-driver: TTL eviction off the heartbeat', () => {
|
|
84
|
-
it('seenEnqueueMsgIds and pendingSyncEchoes stay bounded across idle periods (chats.size==0)', () => {
|
|
85
|
-
const { driver, advance } = harness()
|
|
86
|
-
const maps = driver._debugGetMaps!()
|
|
87
|
-
|
|
88
|
-
// Drive 20 turn enqueue->complete cycles, advancing past the 60s TTL
|
|
89
|
-
// between cycles. Critically, chats.size returns to 0 between cycles,
|
|
90
|
-
// so the heartbeat stops — exposing the leak the fix targets.
|
|
91
|
-
for (let i = 0; i < 20; i++) {
|
|
92
|
-
driver.ingest(enqueue('chatA'), null)
|
|
93
|
-
driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
|
|
94
|
-
// Advance well past both TTLs (60s for messageIds, 30s for echoes)
|
|
95
|
-
// and past the eviction throttle (30s) so the next ingest evicts.
|
|
96
|
-
advance(65_000)
|
|
97
|
-
expect(maps.chats.size).toBe(0)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// After 20 cycles spread across >20 minutes of fake time, both dedup
|
|
101
|
-
// maps must be tiny — they should never accumulate stale entries.
|
|
102
|
-
expect(maps.seenEnqueueMsgIds.size).toBeLessThanOrEqual(1)
|
|
103
|
-
expect(maps.pendingSyncEchoes.size).toBeLessThanOrEqual(1)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
it('chatRunningSubagents and baseTurnSeqs drop their base-key on full chat close', () => {
|
|
107
|
-
const { driver, advance } = harness()
|
|
108
|
-
const maps = driver._debugGetMaps!()
|
|
109
|
-
|
|
110
|
-
for (let i = 0; i < 20; i++) {
|
|
111
|
-
driver.ingest(enqueue('chatA'), null)
|
|
112
|
-
driver.ingest({ kind: 'sub_agent_started', agentId: `agent-${i}`, firstPromptText: 'x' }, 'chatA')
|
|
113
|
-
driver.ingest({ kind: 'sub_agent_turn_end', agentId: `agent-${i}`, durationMs: 50 }, 'chatA')
|
|
114
|
-
driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
|
|
115
|
-
advance(65_000)
|
|
116
|
-
// Between turns, no chat is alive — outer base-key entries must be
|
|
117
|
-
// gone too.
|
|
118
|
-
expect(maps.chats.size).toBe(0)
|
|
119
|
-
expect(maps.chatRunningSubagents.size).toBe(0)
|
|
120
|
-
expect(maps.baseTurnSeqs.size).toBe(0)
|
|
121
|
-
}
|
|
122
|
-
})
|
|
123
|
-
|
|
124
|
-
it('two chats sharing a baseKey: closing one does NOT delete the shared outer key', () => {
|
|
125
|
-
// baseKey collapses (chatId, threadId) pairs with no thread to a single
|
|
126
|
-
// string. Two threads on the same chat share a base only if they have
|
|
127
|
-
// the same threadId; two distinct chatIds always have distinct bases.
|
|
128
|
-
// Within a single chat, multiple concurrent turn-keys share the same
|
|
129
|
-
// base — closing one of them must NOT prematurely drop the outer key
|
|
130
|
-
// while the other turn is still alive.
|
|
131
|
-
const { driver } = harness()
|
|
132
|
-
const maps = driver._debugGetMaps!()
|
|
133
|
-
|
|
134
|
-
// Start turn 1 on chatA — synthesises an enqueue and creates the card.
|
|
135
|
-
driver.startTurn({ chatId: 'chatA', userText: 'first' })
|
|
136
|
-
expect(maps.chats.size).toBe(1)
|
|
137
|
-
expect(maps.baseTurnSeqs.get('chatA')).toBeGreaterThanOrEqual(1)
|
|
138
|
-
|
|
139
|
-
// A second startTurn on the same chat force-closes turn 1 and creates
|
|
140
|
-
// turn 2 — there is exactly one chat live again, but baseTurnSeqs has
|
|
141
|
-
// ticked to 2. That outer entry must remain because turn 2 is alive.
|
|
142
|
-
driver.startTurn({ chatId: 'chatA', userText: 'second' })
|
|
143
|
-
expect(maps.chats.size).toBe(1)
|
|
144
|
-
const seqAfter = maps.baseTurnSeqs.get('chatA')
|
|
145
|
-
expect(seqAfter).toBeGreaterThanOrEqual(2)
|
|
146
|
-
|
|
147
|
-
// End turn 2 → chats empty → base-key cleanup must run.
|
|
148
|
-
driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
|
|
149
|
-
expect(maps.chats.size).toBe(0)
|
|
150
|
-
expect(maps.baseTurnSeqs.has('chatA')).toBe(false)
|
|
151
|
-
expect(maps.chatRunningSubagents.has('chatA')).toBe(false)
|
|
152
|
-
})
|
|
153
|
-
|
|
154
|
-
it('two distinct chats: closing one does NOT touch the other base-key', () => {
|
|
155
|
-
// The driver routes turn_end via `currentTurnKey`, not `chatIdMaybe` —
|
|
156
|
-
// a quirk of the session-tail single-stream design. To close a specific
|
|
157
|
-
// chat from a test we use `forceCompleteTurn`, which is the path the
|
|
158
|
-
// gateway invokes for explicit per-chat fan-out.
|
|
159
|
-
const { driver } = harness()
|
|
160
|
-
const maps = driver._debugGetMaps!()
|
|
161
|
-
|
|
162
|
-
driver.ingest(enqueue('chatA'), null)
|
|
163
|
-
driver.ingest(enqueue('chatB'), null)
|
|
164
|
-
expect(maps.chats.size).toBe(2)
|
|
165
|
-
expect(maps.baseTurnSeqs.has('chatA')).toBe(true)
|
|
166
|
-
expect(maps.baseTurnSeqs.has('chatB')).toBe(true)
|
|
167
|
-
|
|
168
|
-
// Close A only — must not touch chatB's base-key.
|
|
169
|
-
driver.forceCompleteTurn({ chatId: 'chatA' })
|
|
170
|
-
expect(maps.baseTurnSeqs.has('chatA')).toBe(false)
|
|
171
|
-
expect(maps.baseTurnSeqs.has('chatB')).toBe(true)
|
|
172
|
-
|
|
173
|
-
// Now close B.
|
|
174
|
-
driver.forceCompleteTurn({ chatId: 'chatB' })
|
|
175
|
-
expect(maps.baseTurnSeqs.has('chatB')).toBe(false)
|
|
176
|
-
expect(maps.chats.size).toBe(0)
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('PR-C2 follow-up: bg-subagent-carry guard — chatRunningSubagents inner map survives turn_end while a sub-agent is still in flight', () => {
|
|
180
|
-
// When parent turn_end fires but a sub-agent is still running, the
|
|
181
|
-
// chatState enters pendingCompletion and the per-base
|
|
182
|
-
// `chatRunningSubagents` inner map MUST NOT be cleaned up — the
|
|
183
|
-
// next turn's enqueue will clone it back into the new fleet
|
|
184
|
-
// (issue #334 / #64). Cleanup on close is gated on the inner map
|
|
185
|
-
// being empty.
|
|
186
|
-
const { driver } = harness()
|
|
187
|
-
const maps = driver._debugGetMaps!()
|
|
188
|
-
|
|
189
|
-
driver.ingest(enqueue('chatA'), null)
|
|
190
|
-
driver.ingest(
|
|
191
|
-
{
|
|
192
|
-
kind: 'tool_use', toolName: 'Agent', toolUseId: 'tu1',
|
|
193
|
-
input: { prompt: 'bg', run_in_background: true },
|
|
194
|
-
},
|
|
195
|
-
'chatA',
|
|
196
|
-
)
|
|
197
|
-
driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg' }, 'chatA')
|
|
198
|
-
driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, 'chatA')
|
|
199
|
-
driver.recordOutboundDelivered('chatA')
|
|
200
|
-
driver.ingest({ kind: 'turn_end', durationMs: 100 }, 'chatA')
|
|
201
|
-
|
|
202
|
-
// Pending — chats.size==1 (originating bg-pending state survives).
|
|
203
|
-
expect(maps.chats.size).toBe(1)
|
|
204
|
-
// Critical: the running-subagents inner map for 'chatA' must still
|
|
205
|
-
// contain saBG. If cleanupBaseKeyIfUnused regressed and ran here,
|
|
206
|
-
// the next turn would lose the bg carry.
|
|
207
|
-
expect(maps.chatRunningSubagents.get('chatA')?.has('saBG')).toBe(true)
|
|
208
|
-
|
|
209
|
-
// Resolve the bg sub-agent; now full close should also drain the
|
|
210
|
-
// sync registry inner map.
|
|
211
|
-
driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, 'chatA')
|
|
212
|
-
expect(maps.chats.size).toBe(0)
|
|
213
|
-
expect(maps.chatRunningSubagents.has('chatA')).toBe(false)
|
|
214
|
-
})
|
|
215
|
-
})
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* P0 of #662 — invariant test that the driver's `fleet` shadow Map
|
|
3
|
-
* stays in lockstep with the legacy `chatState.subAgents` map across a
|
|
4
|
-
* full sub-agent lifecycle.
|
|
5
|
-
*
|
|
6
|
-
* We drive the real driver via createProgressDriver (no Telegram bot
|
|
7
|
-
* required — the emit callback just records calls) and feed a complete
|
|
8
|
-
* lifecycle: started → 3× tool_use → tool_result(isError=true) →
|
|
9
|
-
* turn_end. After every event we assert:
|
|
10
|
-
* - cardinality matches between fleet and subAgents
|
|
11
|
-
* - on terminal turn_end: status='failed' (errorSeen accumulated)
|
|
12
|
-
* - originatingTurnKey was snapshotted from currentTurnKey
|
|
13
|
-
* - lastTool reflects the most recent tool_use's sanitised arg
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, it, expect } from 'vitest'
|
|
17
|
-
import { createProgressDriver } from '../progress-card-driver.js'
|
|
18
|
-
import type { SessionEvent } from '../session-tail.js'
|
|
19
|
-
|
|
20
|
-
function harness() {
|
|
21
|
-
let now = 1000
|
|
22
|
-
const timers: Array<{ fireAt: number; fn: () => void; ref: number; repeat?: number }> = []
|
|
23
|
-
let nextRef = 0
|
|
24
|
-
const driver = createProgressDriver({
|
|
25
|
-
emit: () => {},
|
|
26
|
-
minIntervalMs: 500,
|
|
27
|
-
coalesceMs: 400,
|
|
28
|
-
initialDelayMs: 0,
|
|
29
|
-
promoteAfterMs: 999_999,
|
|
30
|
-
now: () => now,
|
|
31
|
-
setTimeout: (fn, ms) => {
|
|
32
|
-
const ref = nextRef++
|
|
33
|
-
timers.push({ fireAt: now + ms, fn, ref })
|
|
34
|
-
return { ref }
|
|
35
|
-
},
|
|
36
|
-
clearTimeout: (h) => {
|
|
37
|
-
const ref = (h as { ref: number }).ref
|
|
38
|
-
const idx = timers.findIndex((t) => t.ref === ref)
|
|
39
|
-
if (idx !== -1) timers.splice(idx, 1)
|
|
40
|
-
},
|
|
41
|
-
setInterval: (fn, ms) => {
|
|
42
|
-
const ref = nextRef++
|
|
43
|
-
timers.push({ fireAt: now + ms, fn, ref, repeat: ms })
|
|
44
|
-
return { ref }
|
|
45
|
-
},
|
|
46
|
-
clearInterval: (h) => {
|
|
47
|
-
const ref = (h as { ref: number }).ref
|
|
48
|
-
const idx = timers.findIndex((t) => t.ref === ref)
|
|
49
|
-
if (idx !== -1) timers.splice(idx, 1)
|
|
50
|
-
},
|
|
51
|
-
})
|
|
52
|
-
return { driver, advance: (ms: number) => { now += ms } }
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const enqueue = (chatId: string): SessionEvent => ({
|
|
56
|
-
kind: 'enqueue',
|
|
57
|
-
chatId,
|
|
58
|
-
messageId: '1',
|
|
59
|
-
threadId: null,
|
|
60
|
-
rawContent: `<channel chat_id="${chatId}">go</channel>`,
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
describe('driver fleet-state shadow', () => {
|
|
64
|
-
it('shadow Map stays in lockstep with chatState.subAgents through a failed sub-agent lifecycle', () => {
|
|
65
|
-
const { driver } = harness()
|
|
66
|
-
const CHAT = 'c1'
|
|
67
|
-
driver.ingest(enqueue(CHAT), null)
|
|
68
|
-
|
|
69
|
-
const events: SessionEvent[] = [
|
|
70
|
-
{ kind: 'sub_agent_started', agentId: 'sa1', firstPromptText: 'do work', subagentType: 'worker' },
|
|
71
|
-
{ kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't1', toolName: 'Read', input: { file_path: '/etc/secrets/k.key' } },
|
|
72
|
-
{ kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't2', toolName: 'Bash', input: { command: 'ls' } },
|
|
73
|
-
{ kind: 'sub_agent_tool_use', agentId: 'sa1', toolUseId: 't3', toolName: 'Edit', input: { file_path: '/tmp/x.ts' } },
|
|
74
|
-
{ kind: 'sub_agent_tool_result', agentId: 'sa1', toolUseId: 't3', isError: true, errorText: 'boom' },
|
|
75
|
-
]
|
|
76
|
-
|
|
77
|
-
for (const ev of events) {
|
|
78
|
-
driver.ingest(ev, CHAT)
|
|
79
|
-
const state = driver.peek(CHAT)
|
|
80
|
-
const fleet = driver.peekFleet(CHAT)
|
|
81
|
-
expect(state).toBeDefined()
|
|
82
|
-
expect(fleet).toBeDefined()
|
|
83
|
-
// Cardinality invariant — every sub-agent in the legacy map has a
|
|
84
|
-
// shadow entry, and vice versa.
|
|
85
|
-
expect(fleet!.size).toBe(state!.subAgents.size)
|
|
86
|
-
for (const id of state!.subAgents.keys()) {
|
|
87
|
-
expect(fleet!.has(id)).toBe(true)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// Pre-turn-end: fleet member exists, status still running, errorSeen true.
|
|
92
|
-
const midFleet = driver.peekFleet(CHAT)!
|
|
93
|
-
const midMember = midFleet.get('sa1')!
|
|
94
|
-
expect(midMember.status).toBe('running')
|
|
95
|
-
expect(midMember.errorSeen).toBe(true)
|
|
96
|
-
expect(midMember.toolCount).toBe(3)
|
|
97
|
-
expect(midMember.lastTool).toEqual({ name: 'Edit', sanitisedArg: 'x.ts' })
|
|
98
|
-
expect(midMember.role).toBe('worker') // from subagentType fallback
|
|
99
|
-
// Snapshotted from currentTurnKey at sub_agent_started.
|
|
100
|
-
expect(midMember.originatingTurnKey).toMatch(/^c1:/)
|
|
101
|
-
|
|
102
|
-
// Now end the sub-agent's turn — fleet member should flip to failed.
|
|
103
|
-
driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'sa1' }, CHAT)
|
|
104
|
-
const finalFleet = driver.peekFleet(CHAT)!
|
|
105
|
-
const finalMember = finalFleet.get('sa1')!
|
|
106
|
-
expect(finalMember.status).toBe('failed')
|
|
107
|
-
expect(finalMember.terminalAt).not.toBeNull()
|
|
108
|
-
})
|
|
109
|
-
|
|
110
|
-
it('uses description as role when present', () => {
|
|
111
|
-
const { driver } = harness()
|
|
112
|
-
driver.ingest(enqueue('c2'), null)
|
|
113
|
-
// session-tail's sub_agent_started doesn't carry description directly,
|
|
114
|
-
// but the watcher path supplies subagentType — verify the fallback chain
|
|
115
|
-
// works when neither is set: first 20 chars of firstPromptText.
|
|
116
|
-
driver.ingest(
|
|
117
|
-
{ kind: 'sub_agent_started', agentId: 'sa2', firstPromptText: 'investigate the auth bug end-to-end' },
|
|
118
|
-
'c2',
|
|
119
|
-
)
|
|
120
|
-
const m = driver.peekFleet('c2')!.get('sa2')!
|
|
121
|
-
expect(m.role).toBe('investigate the auth')
|
|
122
|
-
})
|
|
123
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression: forceCompleteTurn must set `parentTurnEndAt` so the heartbeat's
|
|
3
|
-
* `parentDone` branch lights up and the elapsed counter keeps ticking through
|
|
4
|
-
* `subAgentTickIntervalMs` while sub-agents are still running.
|
|
5
|
-
*
|
|
6
|
-
* Bug shape (#686, fixed in #687): forceCompleteTurn reduced `turn_end` but
|
|
7
|
-
* never set `parentTurnEndAt`. The heartbeat's `parentDone` was therefore
|
|
8
|
-
* always false during the deferred-unpin window, the elapsed-ticker bypass
|
|
9
|
-
* never engaged, and the rendered card froze on its last emit until the
|
|
10
|
-
* sub-agents finished.
|
|
11
|
-
*
|
|
12
|
-
* Test shape: spawn a sub-agent, call forceCompleteTurn, then advance fake
|
|
13
|
-
* time across several heartbeat ticks. The "Done" header (parentDone branch
|
|
14
|
-
* of the renderer) MUST appear and elapsed time MUST keep advancing in the
|
|
15
|
-
* emitted HTML.
|
|
16
|
-
*/
|
|
17
|
-
import { describe, it, expect } from 'vitest'
|
|
18
|
-
import { makeHarness, enqueue } from './_progress-card-harness.js'
|
|
19
|
-
|
|
20
|
-
describe('progress-card-driver: forceCompleteTurn unfreezes elapsed-ticker', () => {
|
|
21
|
-
it('parentDone engages and elapsed advances after forceCompleteTurn while a sub-agent is still running', () => {
|
|
22
|
-
const { driver, emits, advance } = makeHarness({
|
|
23
|
-
minIntervalMs: 0,
|
|
24
|
-
coalesceMs: 0,
|
|
25
|
-
heartbeatMs: 1_000,
|
|
26
|
-
})
|
|
27
|
-
const CHAT = 'chatF'
|
|
28
|
-
|
|
29
|
-
driver.ingest(enqueue(CHAT), null)
|
|
30
|
-
driver.ingest(
|
|
31
|
-
{
|
|
32
|
-
kind: 'tool_use',
|
|
33
|
-
toolName: 'Agent',
|
|
34
|
-
toolUseId: 'tu1',
|
|
35
|
-
input: { prompt: 'bg work', run_in_background: true },
|
|
36
|
-
},
|
|
37
|
-
CHAT,
|
|
38
|
-
)
|
|
39
|
-
driver.ingest({ kind: 'sub_agent_started', agentId: 'saBG', firstPromptText: 'bg work' }, CHAT)
|
|
40
|
-
driver.ingest({ kind: 'tool_use', toolName: 'mcp__switchroom-telegram__reply' }, CHAT)
|
|
41
|
-
driver.recordOutboundDelivered(CHAT)
|
|
42
|
-
|
|
43
|
-
// External completion signal (e.g. stream_reply done=true). Sub-agent
|
|
44
|
-
// is still running, so the chatState enters pendingCompletion and the
|
|
45
|
-
// heartbeat keeps the card alive.
|
|
46
|
-
driver.forceCompleteTurn({ chatId: CHAT })
|
|
47
|
-
|
|
48
|
-
// The flush triggered by forceCompleteTurn itself produces an emit
|
|
49
|
-
// with the parentDone-branch ("Done") header.
|
|
50
|
-
const emitsAfterForce = emits.length
|
|
51
|
-
expect(emitsAfterForce).toBeGreaterThan(0)
|
|
52
|
-
const renderedAtForce = emits[emitsAfterForce - 1].html
|
|
53
|
-
// The renderer surfaces "Background" once parentDone=true and a bg
|
|
54
|
-
// sub-agent is still running; if the bug regresses (parentTurnEndAt
|
|
55
|
-
// stays null), parentDone is false and we'd see "Working".
|
|
56
|
-
expect(renderedAtForce).toMatch(/Background/)
|
|
57
|
-
expect(renderedAtForce).not.toMatch(/Working/)
|
|
58
|
-
|
|
59
|
-
// Advance well past the elapsed-ticker interval to prove the
|
|
60
|
-
// heartbeat keeps emitting fresh elapsed values rather than freezing
|
|
61
|
-
// on the last emit. Several ticks should produce at least one extra
|
|
62
|
-
// emit with different rendered HTML.
|
|
63
|
-
advance(15_000)
|
|
64
|
-
const tailEmits = emits.slice(emitsAfterForce)
|
|
65
|
-
expect(tailEmits.length).toBeGreaterThan(0)
|
|
66
|
-
// At least one post-force emit must differ from the force-emit HTML —
|
|
67
|
-
// proving the elapsed counter advanced rather than freezing.
|
|
68
|
-
const advanced = tailEmits.some((e) => e.html !== renderedAtForce)
|
|
69
|
-
expect(advanced).toBe(true)
|
|
70
|
-
|
|
71
|
-
// Resolve the sub-agent and assert the deferred completion fires.
|
|
72
|
-
driver.ingest({ kind: 'sub_agent_turn_end', agentId: 'saBG' }, CHAT)
|
|
73
|
-
const finalEmit = emits[emits.length - 1]
|
|
74
|
-
expect(finalEmit.done).toBe(true)
|
|
75
|
-
})
|
|
76
|
-
})
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* PR-C2 — `editTimestamps` sliding-window cleanup must keep the array
|
|
3
|
-
* bounded under sustained burst.
|
|
4
|
-
*
|
|
5
|
-
* The driver maintains a per-turn array of recent emit timestamps and
|
|
6
|
-
* uses its length within the trailing 60s to flip "hot" mode (longer
|
|
7
|
-
* coalesce window). The cleanup is `while (arr[0] < cutoff) arr.shift()`,
|
|
8
|
-
* which only fires when `recordEdit` or `isBudgetHot` is called. If
|
|
9
|
-
* that cleanup ever regresses, the array would grow unbounded across a
|
|
10
|
-
* long-running turn.
|
|
11
|
-
*
|
|
12
|
-
* fails when: the `arr.shift()` cleanup is removed from `recordEdit` or
|
|
13
|
-
* `isBudgetHot`, OR the cutoff window is widened beyond 60s without a
|
|
14
|
-
* matching upper bound.
|
|
15
|
-
*/
|
|
16
|
-
import { describe, it, expect } from 'vitest'
|
|
17
|
-
import { makeHarness, enqueue } from './_progress-card-harness.js'
|
|
18
|
-
|
|
19
|
-
describe('PR-C2: editTimestamps stays bounded under sustained emit burst', () => {
|
|
20
|
-
it('after 100 emits spread across 5 minutes, the per-turn array holds <= ~window-worth', () => {
|
|
21
|
-
// Use very low coalesce so each ingest can drive an emit. Keep the
|
|
22
|
-
// turn open for the whole burst.
|
|
23
|
-
const { driver, advance } = makeHarness({
|
|
24
|
-
minIntervalMs: 0,
|
|
25
|
-
coalesceMs: 0,
|
|
26
|
-
heartbeatMs: 999_999, // never auto-fire heartbeat
|
|
27
|
-
promoteAfterMs: 999_999,
|
|
28
|
-
})
|
|
29
|
-
const maps = driver._debugGetMaps!()
|
|
30
|
-
|
|
31
|
-
driver.ingest(enqueue('cA'), null)
|
|
32
|
-
|
|
33
|
-
// Drive 100 events spaced 3s apart — so the trailing 60s only ever
|
|
34
|
-
// contains ~21 timestamps. With a working sliding-window cleanup the
|
|
35
|
-
// array should stay bounded near that figure.
|
|
36
|
-
for (let i = 0; i < 100; i++) {
|
|
37
|
-
driver.ingest(
|
|
38
|
-
{
|
|
39
|
-
kind: 'tool_use',
|
|
40
|
-
toolName: 'Read',
|
|
41
|
-
toolUseId: `tu${i}`,
|
|
42
|
-
input: { file_path: `/tmp/${i}.txt` },
|
|
43
|
-
},
|
|
44
|
-
'cA',
|
|
45
|
-
)
|
|
46
|
-
advance(3000)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Find the per-turn timestamp array. The key is the active turnKey;
|
|
50
|
-
// there's only one turn so we can pick it.
|
|
51
|
-
const sizes = [...maps.editTimestamps.values()].map((a) => a.length)
|
|
52
|
-
expect(sizes.length).toBeGreaterThan(0)
|
|
53
|
-
const max = Math.max(...sizes)
|
|
54
|
-
// 60s window / 3s spacing = 20 entries. Allow tight slack (<= 22)
|
|
55
|
-
// for one or two boundary timestamps recorded by the harness's
|
|
56
|
-
// setup (initial enqueue, etc.) — anything looser fails to catch
|
|
57
|
-
// off-by-N regressions in the sliding-window cleanup.
|
|
58
|
-
expect(max).toBeLessThanOrEqual(22)
|
|
59
|
-
// And critically, NOT 100+ — that would mean cleanup never ran.
|
|
60
|
-
expect(max).toBeLessThan(100)
|
|
61
|
-
})
|
|
62
|
-
})
|