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
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, readFileSync, existsSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import {
|
|
7
|
+
writeSilentEndState,
|
|
8
|
+
clearSilentEndState,
|
|
9
|
+
readSilentEndState,
|
|
10
|
+
} from '../silent-end.js'
|
|
11
|
+
|
|
12
|
+
let stateDir: string
|
|
13
|
+
const ORIG_ENV = process.env.TELEGRAM_STATE_DIR
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
stateDir = mkdtempSync(join(tmpdir(), 'silent-end-test-'))
|
|
17
|
+
process.env.TELEGRAM_STATE_DIR = stateDir
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(stateDir, { recursive: true, force: true })
|
|
22
|
+
if (ORIG_ENV != null) process.env.TELEGRAM_STATE_DIR = ORIG_ENV
|
|
23
|
+
else delete process.env.TELEGRAM_STATE_DIR
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('silent-end.ts — gateway state writer', () => {
|
|
27
|
+
it('writeSilentEndState creates the file with retryCount=0 on first write', () => {
|
|
28
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
29
|
+
const state = readSilentEndState()
|
|
30
|
+
expect(state).not.toBeNull()
|
|
31
|
+
expect(state!.chatId).toBe('123')
|
|
32
|
+
expect(state!.threadId).toBeNull()
|
|
33
|
+
expect(state!.turnKey).toBe('123:_')
|
|
34
|
+
expect(state!.retryCount).toBe(0)
|
|
35
|
+
expect(typeof state!.timestamp).toBe('number')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('writeSilentEndState inherits retryCount IFF the prior file matches the same turnKey', () => {
|
|
39
|
+
// Prior file at retryCount=1 for the same turn (Stop hook had already
|
|
40
|
+
// blocked once and re-incremented).
|
|
41
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
42
|
+
writeFileSync(path, JSON.stringify({
|
|
43
|
+
chatId: '123', threadId: null, turnKey: '123:_', retryCount: 1, timestamp: 0,
|
|
44
|
+
}))
|
|
45
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
46
|
+
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('writeSilentEndState resets retryCount to 0 when turnKey differs', () => {
|
|
50
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
51
|
+
writeFileSync(path, JSON.stringify({
|
|
52
|
+
chatId: '123', threadId: null, turnKey: '123:_', retryCount: 1, timestamp: 0,
|
|
53
|
+
}))
|
|
54
|
+
// Different turn — new silent-end, fresh counter.
|
|
55
|
+
writeSilentEndState({ chatId: '999', threadId: 42, turnKey: '999:42' })
|
|
56
|
+
const state = readSilentEndState()
|
|
57
|
+
expect(state!.turnKey).toBe('999:42')
|
|
58
|
+
expect(state!.retryCount).toBe(0)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('writeSilentEndState falls back to ~/.claude/channels/telegram when TELEGRAM_STATE_DIR is unset', () => {
|
|
62
|
+
// Updated 2026-05-13 UAT overnight: discovered the writer used to
|
|
63
|
+
// silently no-op when the env var was unset, while the Stop hook
|
|
64
|
+
// (silent-end-interrupt-stop.mjs) and the gateway both fall back
|
|
65
|
+
// to `~/.claude/channels/telegram`. Mismatch meant the hook
|
|
66
|
+
// always read a missing file → silent-end recovery never engaged.
|
|
67
|
+
// The writer now applies the same fallback.
|
|
68
|
+
delete process.env.TELEGRAM_STATE_DIR
|
|
69
|
+
const fakeHome = mkdtempSync(join(tmpdir(), 'silent-end-fallback-home-'))
|
|
70
|
+
const origHome = process.env.HOME
|
|
71
|
+
process.env.HOME = fakeHome
|
|
72
|
+
try {
|
|
73
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
74
|
+
const expected = join(fakeHome, '.claude', 'channels', 'telegram', 'silent-end-pending.json')
|
|
75
|
+
expect(existsSync(expected)).toBe(true)
|
|
76
|
+
} finally {
|
|
77
|
+
if (origHome != null) process.env.HOME = origHome
|
|
78
|
+
else delete process.env.HOME
|
|
79
|
+
rmSync(fakeHome, { recursive: true, force: true })
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('clearSilentEndState removes the file when turnKey matches', () => {
|
|
84
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
85
|
+
expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(true)
|
|
86
|
+
clearSilentEndState('123:_')
|
|
87
|
+
expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('clearSilentEndState leaves the file alone when turnKey does NOT match', () => {
|
|
91
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
92
|
+
clearSilentEndState('different-turn')
|
|
93
|
+
expect(existsSync(join(stateDir, 'silent-end-pending.json'))).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('clearSilentEndState is a no-op when no file exists', () => {
|
|
97
|
+
expect(() => clearSilentEndState('123:_')).not.toThrow()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('clearSilentEndState is a no-op when TELEGRAM_STATE_DIR is unset', () => {
|
|
101
|
+
delete process.env.TELEGRAM_STATE_DIR
|
|
102
|
+
expect(() => clearSilentEndState('123:_')).not.toThrow()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('writeSilentEndState handles corrupt prior file by resetting retryCount', () => {
|
|
106
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
107
|
+
writeFileSync(path, 'not valid json {{{')
|
|
108
|
+
writeSilentEndState({ chatId: '123', threadId: null, turnKey: '123:_' })
|
|
109
|
+
expect(readSilentEndState()!.retryCount).toBe(0)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('round-trip: write → read → clear', () => {
|
|
113
|
+
writeSilentEndState({ chatId: 'c', threadId: 7, turnKey: 'c:7' })
|
|
114
|
+
const state = readSilentEndState()
|
|
115
|
+
expect(state).toMatchObject({ chatId: 'c', threadId: 7, turnKey: 'c:7', retryCount: 0 })
|
|
116
|
+
clearSilentEndState('c:7')
|
|
117
|
+
expect(readSilentEndState()).toBeNull()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('silent-end-interrupt-stop hook — integration', () => {
|
|
122
|
+
const hookPath = join(__dirname, '..', 'hooks', 'silent-end-interrupt-stop.mjs')
|
|
123
|
+
|
|
124
|
+
function runHook(input: object): { exit: number; stdout: string; stderr: string } {
|
|
125
|
+
const { spawnSync } = require('node:child_process') as typeof import('node:child_process')
|
|
126
|
+
const r = spawnSync('node', [hookPath], {
|
|
127
|
+
input: JSON.stringify(input),
|
|
128
|
+
env: { ...process.env, TELEGRAM_STATE_DIR: stateDir },
|
|
129
|
+
encoding: 'utf8',
|
|
130
|
+
timeout: 5_000,
|
|
131
|
+
})
|
|
132
|
+
return { exit: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
it('allows the stop when no state file exists (normal completion)', () => {
|
|
136
|
+
const r = runHook({
|
|
137
|
+
session_id: 's',
|
|
138
|
+
transcript_path: '/tmp/x.jsonl',
|
|
139
|
+
hook_event_name: 'Stop',
|
|
140
|
+
})
|
|
141
|
+
expect(r.exit).toBe(0)
|
|
142
|
+
expect(r.stdout.trim()).toBe('')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('blocks the stop with decision:block when silent-end state exists at retryCount=0', () => {
|
|
146
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
147
|
+
const r = runHook({
|
|
148
|
+
session_id: 's',
|
|
149
|
+
transcript_path: '/tmp/x.jsonl',
|
|
150
|
+
hook_event_name: 'Stop',
|
|
151
|
+
})
|
|
152
|
+
expect(r.exit).toBe(0)
|
|
153
|
+
const out = JSON.parse(r.stdout.trim())
|
|
154
|
+
expect(out.decision).toBe('block')
|
|
155
|
+
expect(out.reason).toContain('reply')
|
|
156
|
+
// retryCount must have been incremented to 1
|
|
157
|
+
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('allows the stop when retryCount >= MAX_RETRIES (1)', () => {
|
|
161
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
162
|
+
writeFileSync(path, JSON.stringify({
|
|
163
|
+
chatId: 'c', threadId: null, turnKey: 'c:_', retryCount: 1, timestamp: 0,
|
|
164
|
+
}))
|
|
165
|
+
const r = runHook({
|
|
166
|
+
session_id: 's',
|
|
167
|
+
transcript_path: '/tmp/x.jsonl',
|
|
168
|
+
hook_event_name: 'Stop',
|
|
169
|
+
})
|
|
170
|
+
expect(r.exit).toBe(0)
|
|
171
|
+
expect(r.stdout.trim()).toBe('')
|
|
172
|
+
expect(r.stderr).toContain('retry exhausted')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('end-to-end: write silent-end → hook blocks → simulate reply → next stop allows', () => {
|
|
176
|
+
// 1. Turn ends silently — gateway writes state
|
|
177
|
+
writeSilentEndState({ chatId: 'c', threadId: null, turnKey: 'c:_' })
|
|
178
|
+
|
|
179
|
+
// 2. Stop hook fires, blocks, increments retryCount
|
|
180
|
+
const r1 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
|
|
181
|
+
expect(JSON.parse(r1.stdout).decision).toBe('block')
|
|
182
|
+
expect(readSilentEndState()!.retryCount).toBe(1)
|
|
183
|
+
|
|
184
|
+
// 3. Re-prompted agent calls reply — gateway clears the file
|
|
185
|
+
clearSilentEndState('c:_')
|
|
186
|
+
expect(readSilentEndState()).toBeNull()
|
|
187
|
+
|
|
188
|
+
// 4. Next Stop allows cleanly (no state file)
|
|
189
|
+
const r2 = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
|
|
190
|
+
expect(r2.stdout.trim()).toBe('')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('fails open on a corrupt state file', () => {
|
|
194
|
+
const path = join(stateDir, 'silent-end-pending.json')
|
|
195
|
+
mkdirSync(stateDir, { recursive: true })
|
|
196
|
+
writeFileSync(path, 'corrupt {{{', 'utf8')
|
|
197
|
+
const r = runHook({ session_id: 's', transcript_path: '/tmp/x.jsonl', hook_event_name: 'Stop' })
|
|
198
|
+
expect(r.exit).toBe(0)
|
|
199
|
+
expect(r.stdout.trim()).toBe('')
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('fails open on empty stdin', () => {
|
|
203
|
+
const r = runHook({}) // serialised as `{}` — but the hook also tolerates empty
|
|
204
|
+
expect(r.exit).toBe(0)
|
|
205
|
+
})
|
|
206
|
+
})
|
|
@@ -211,3 +211,110 @@ describe('subagent-tracker-posttool', () => {
|
|
|
211
211
|
expect(row?.status).toBe('failed')
|
|
212
212
|
})
|
|
213
213
|
})
|
|
214
|
+
|
|
215
|
+
describe('agent-dir resolution (RFC §Bug 2)', () => {
|
|
216
|
+
// The hooks used to look only at SWITCHROOM_AGENT_DIR and then cwd.
|
|
217
|
+
// In production neither matched the path the gateway + watcher used,
|
|
218
|
+
// so rows were written to a registry.db nobody read. The fix adds
|
|
219
|
+
// TELEGRAM_STATE_DIR (the env var start.sh exports for every agent)
|
|
220
|
+
// as a middle lookup. These tests pin the precedence + the legacy
|
|
221
|
+
// fallback so a future refactor can't silently revert.
|
|
222
|
+
|
|
223
|
+
function runWith(scriptPath: string, event: object, env: Record<string, string | undefined>) {
|
|
224
|
+
const finalEnv: Record<string, string> = { ...process.env } as Record<string, string>
|
|
225
|
+
// Clear the inherited overrides; we want a clean baseline.
|
|
226
|
+
delete finalEnv.SWITCHROOM_AGENT_DIR
|
|
227
|
+
delete finalEnv.TELEGRAM_STATE_DIR
|
|
228
|
+
for (const [k, v] of Object.entries(env)) {
|
|
229
|
+
if (v === undefined) delete finalEnv[k]
|
|
230
|
+
else finalEnv[k] = v
|
|
231
|
+
}
|
|
232
|
+
return spawnSync(process.execPath, [scriptPath], {
|
|
233
|
+
input: JSON.stringify(event),
|
|
234
|
+
encoding: 'utf8',
|
|
235
|
+
env: finalEnv,
|
|
236
|
+
timeout: 15_000,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const baseEvent = {
|
|
241
|
+
session_id: 's',
|
|
242
|
+
tool_name: 'Agent',
|
|
243
|
+
tool_use_id: 'toolu_envtest1',
|
|
244
|
+
tool_input: { subagent_type: 'w', description: 'd', run_in_background: true },
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
it('pretool prefers SWITCHROOM_AGENT_DIR over TELEGRAM_STATE_DIR', () => {
|
|
248
|
+
const explicit = mkdtempSync(join(tmpdir(), 'agent-dir-explicit-'))
|
|
249
|
+
const stateDirParent = mkdtempSync(join(tmpdir(), 'state-dir-parent-'))
|
|
250
|
+
mkdirSync(join(explicit, 'telegram'), { recursive: true })
|
|
251
|
+
mkdirSync(join(stateDirParent, 'telegram'), { recursive: true })
|
|
252
|
+
try {
|
|
253
|
+
const result = runWith(PRETOOL_SCRIPT, baseEvent, {
|
|
254
|
+
SWITCHROOM_AGENT_DIR: explicit,
|
|
255
|
+
TELEGRAM_STATE_DIR: join(stateDirParent, 'telegram'),
|
|
256
|
+
})
|
|
257
|
+
expect(result.status).toBe(0)
|
|
258
|
+
// Row landed in the EXPLICIT location, not the state-dir-derived one.
|
|
259
|
+
const explicitDb = join(explicit, 'telegram', 'registry.db')
|
|
260
|
+
const stateDirDb = join(stateDirParent, 'telegram', 'registry.db')
|
|
261
|
+
expect(Bun.file(explicitDb).size).toBeGreaterThan(0)
|
|
262
|
+
expect(Bun.file(stateDirDb).size).toBe(0)
|
|
263
|
+
} finally {
|
|
264
|
+
try { rmSync(explicit, { recursive: true }) } catch { /* */ }
|
|
265
|
+
try { rmSync(stateDirParent, { recursive: true }) } catch { /* */ }
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('pretool derives agentDir from TELEGRAM_STATE_DIR when SWITCHROOM_AGENT_DIR is unset (the production path)', () => {
|
|
270
|
+
const stateDirParent = mkdtempSync(join(tmpdir(), 'state-dir-only-'))
|
|
271
|
+
mkdirSync(join(stateDirParent, 'telegram'), { recursive: true })
|
|
272
|
+
try {
|
|
273
|
+
const result = runWith(PRETOOL_SCRIPT, baseEvent, {
|
|
274
|
+
TELEGRAM_STATE_DIR: join(stateDirParent, 'telegram'),
|
|
275
|
+
})
|
|
276
|
+
expect(result.status).toBe(0)
|
|
277
|
+
const dbPath = join(stateDirParent, 'telegram', 'registry.db')
|
|
278
|
+
expect(Bun.file(dbPath).size).toBeGreaterThan(0)
|
|
279
|
+
// Row landed in the state-dir-derived location.
|
|
280
|
+
const { Database } = require('bun:sqlite') as { Database: new (p: string) => {
|
|
281
|
+
prepare(sql: string): { get(...params: unknown[]): unknown }
|
|
282
|
+
} }
|
|
283
|
+
const db = new Database(dbPath)
|
|
284
|
+
const row = db.prepare('SELECT id FROM subagents WHERE id = ?').get('toolu_envtest1') as
|
|
285
|
+
| { id: string } | undefined
|
|
286
|
+
expect(row?.id).toBe('toolu_envtest1')
|
|
287
|
+
} finally {
|
|
288
|
+
try { rmSync(stateDirParent, { recursive: true }) } catch { /* */ }
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('pretool ignores TELEGRAM_STATE_DIR that does NOT end in /telegram (defensive)', () => {
|
|
293
|
+
// If TELEGRAM_STATE_DIR ever drifts to a non-canonical shape, the
|
|
294
|
+
// hook should NOT silently use it — falling through to cwd is the
|
|
295
|
+
// safer behaviour (you'll notice the wrong location quickly).
|
|
296
|
+
const weirdDir = mkdtempSync(join(tmpdir(), 'state-dir-weird-'))
|
|
297
|
+
const cwdDir = mkdtempSync(join(tmpdir(), 'cwd-dir-'))
|
|
298
|
+
mkdirSync(join(cwdDir, 'telegram'), { recursive: true })
|
|
299
|
+
try {
|
|
300
|
+
const result = spawnSync(process.execPath, [PRETOOL_SCRIPT], {
|
|
301
|
+
input: JSON.stringify(baseEvent),
|
|
302
|
+
encoding: 'utf8',
|
|
303
|
+
cwd: cwdDir,
|
|
304
|
+
env: {
|
|
305
|
+
...process.env,
|
|
306
|
+
SWITCHROOM_AGENT_DIR: undefined as unknown as string,
|
|
307
|
+
TELEGRAM_STATE_DIR: weirdDir, // does NOT end in /telegram
|
|
308
|
+
} as Record<string, string>,
|
|
309
|
+
timeout: 15_000,
|
|
310
|
+
})
|
|
311
|
+
expect(result.status).toBe(0)
|
|
312
|
+
// Fell through to cwd, NOT the weird TELEGRAM_STATE_DIR.
|
|
313
|
+
const cwdDb = join(cwdDir, 'telegram', 'registry.db')
|
|
314
|
+
expect(Bun.file(cwdDb).size).toBeGreaterThan(0)
|
|
315
|
+
} finally {
|
|
316
|
+
try { rmSync(weirdDir, { recursive: true }) } catch { /* */ }
|
|
317
|
+
try { rmSync(cwdDir, { recursive: true }) } catch { /* */ }
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
})
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env-var overrides for the watcher's threshold knobs
|
|
3
|
+
* (`stallThresholdMs`, `silentSynthesisStallThresholdMs`,
|
|
4
|
+
* `silentStallTerminalMs`). Used by the UAT harness to compress the
|
|
5
|
+
* stall+synth window so `bg-sub-agent-dispatch-dm.test.ts` can
|
|
6
|
+
* validate Bug 6's terminal-synthesis path inside its 120s timeout
|
|
7
|
+
* instead of waiting the production-tuned 6min.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order: explicit config arg → env var → compile-time
|
|
10
|
+
* default. Invalid env values (0, negative, NaN, empty string) fall
|
|
11
|
+
* through to the default — we don't want a stray `=0` to silently
|
|
12
|
+
* disable stall detection in production.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
16
|
+
import { startSubagentWatcher } from '../subagent-watcher.js'
|
|
17
|
+
import * as fs from 'fs'
|
|
18
|
+
|
|
19
|
+
function buildJSONL(...lines: object[]): string {
|
|
20
|
+
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
|
|
21
|
+
}
|
|
22
|
+
function subAgentUserMsg(promptText: string) {
|
|
23
|
+
return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function makeHarness(opts: {
|
|
27
|
+
agentId?: string
|
|
28
|
+
configStallThresholdMs?: number
|
|
29
|
+
configSilentStallTerminalMs?: number
|
|
30
|
+
} = {}) {
|
|
31
|
+
const { agentId = 'env-thresh-agent', configStallThresholdMs, configSilentStallTerminalMs } = opts
|
|
32
|
+
|
|
33
|
+
let currentTime = 1000
|
|
34
|
+
const stallCalls: Array<{ idleMs: number }> = []
|
|
35
|
+
const stallTerminalCalls: Array<{ agentId: string }> = []
|
|
36
|
+
const finishCalls: Array<{ outcome: string }> = []
|
|
37
|
+
|
|
38
|
+
const agentDir = '/home/user/.switchroom/agents/myagent'
|
|
39
|
+
const sessionId = 'mock-session'
|
|
40
|
+
const projectsRoot = `${agentDir}/.claude/projects`
|
|
41
|
+
const projectDir = `${projectsRoot}/mock-cwd`
|
|
42
|
+
const sessionDir = `${projectDir}/${sessionId}`
|
|
43
|
+
const subagentsDir = `${sessionDir}/subagents`
|
|
44
|
+
const jsonlPath = `${subagentsDir}/agent-${agentId}.jsonl`
|
|
45
|
+
const fileContents = new Map<string, Buffer>()
|
|
46
|
+
fileContents.set(jsonlPath, Buffer.from(buildJSONL(subAgentUserMsg('bg task')), 'utf-8'))
|
|
47
|
+
|
|
48
|
+
let lastOpenedPath: string | null = null
|
|
49
|
+
const mockFs = {
|
|
50
|
+
existsSync: ((p: fs.PathLike) => {
|
|
51
|
+
const ps = String(p)
|
|
52
|
+
if (ps === projectsRoot || ps === projectDir || ps === sessionDir || ps === subagentsDir) return true
|
|
53
|
+
return fileContents.has(ps)
|
|
54
|
+
}) as typeof fs.existsSync,
|
|
55
|
+
readdirSync: ((p: fs.PathLike) => {
|
|
56
|
+
const ps = String(p)
|
|
57
|
+
if (ps === projectsRoot) return ['mock-cwd']
|
|
58
|
+
if (ps === projectDir) return [sessionId]
|
|
59
|
+
if (ps === sessionDir) return ['subagents']
|
|
60
|
+
if (ps === subagentsDir) return [`agent-${agentId}.jsonl`]
|
|
61
|
+
return []
|
|
62
|
+
}) as unknown as typeof fs.readdirSync,
|
|
63
|
+
statSync: ((p: fs.PathLike) => ({ size: fileContents.get(String(p))?.length ?? 0 }) as fs.Stats) as typeof fs.statSync,
|
|
64
|
+
openSync: ((p: fs.PathLike) => { lastOpenedPath = String(p); return 42 }) as unknown as typeof fs.openSync,
|
|
65
|
+
closeSync: (() => { lastOpenedPath = null }) as typeof fs.closeSync,
|
|
66
|
+
readSync: ((
|
|
67
|
+
_fd: number, buf: NodeJS.ArrayBufferView, offset: number, length: number, position: number | null,
|
|
68
|
+
): number => {
|
|
69
|
+
const content = lastOpenedPath != null ? fileContents.get(lastOpenedPath) : undefined
|
|
70
|
+
if (!content) return 0
|
|
71
|
+
const pos = position ?? 0
|
|
72
|
+
const src = content.slice(pos, pos + length)
|
|
73
|
+
;(src as Buffer).copy(buf as Buffer, offset)
|
|
74
|
+
return src.length
|
|
75
|
+
}) as unknown as typeof fs.readSync,
|
|
76
|
+
watch: (() => ({ close: vi.fn() }) as unknown as fs.FSWatcher) as unknown as typeof fs.watch,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const intervals: Array<{ fn: () => void; ms: number; ref: number; fireAt: number }> = []
|
|
80
|
+
let nextRef = 1
|
|
81
|
+
const watcher = startSubagentWatcher({
|
|
82
|
+
agentDir,
|
|
83
|
+
stallThresholdMs: configStallThresholdMs,
|
|
84
|
+
silentSynthesisStallThresholdMs: configStallThresholdMs,
|
|
85
|
+
silentStallTerminalMs: configSilentStallTerminalMs,
|
|
86
|
+
rescanMs: 500,
|
|
87
|
+
sendNotification: () => {},
|
|
88
|
+
onStall: (_id, idleMs) => stallCalls.push({ idleMs }),
|
|
89
|
+
onStallTerminal: (id) => stallTerminalCalls.push({ agentId: id }),
|
|
90
|
+
onFinish: ({ outcome }) => finishCalls.push({ outcome }),
|
|
91
|
+
now: () => currentTime,
|
|
92
|
+
setInterval: (fn, ms) => {
|
|
93
|
+
const ref = nextRef++
|
|
94
|
+
intervals.push({ fn, ms, ref, fireAt: currentTime + ms })
|
|
95
|
+
return { ref }
|
|
96
|
+
},
|
|
97
|
+
clearInterval: (h) => {
|
|
98
|
+
const { ref } = h as { ref: number }
|
|
99
|
+
const idx = intervals.findIndex((i) => i.ref === ref)
|
|
100
|
+
if (idx !== -1) intervals.splice(idx, 1)
|
|
101
|
+
},
|
|
102
|
+
fs: mockFs,
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const advance = (ms: number): void => {
|
|
106
|
+
currentTime += ms
|
|
107
|
+
for (;;) {
|
|
108
|
+
intervals.sort((a, b) => a.fireAt - b.fireAt)
|
|
109
|
+
const next = intervals[0]
|
|
110
|
+
if (!next || next.fireAt > currentTime) break
|
|
111
|
+
next.fireAt += next.ms
|
|
112
|
+
next.fn()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const unmarkHistorical = (): void => {
|
|
117
|
+
const e = watcher.getRegistry().get(agentId)
|
|
118
|
+
if (e) e.historical = false
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { stallCalls, stallTerminalCalls, finishCalls, advance, unmarkHistorical }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const ENV_KEYS = [
|
|
125
|
+
'SWITCHROOM_SUBAGENT_STALL_MS',
|
|
126
|
+
'SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS',
|
|
127
|
+
'SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS',
|
|
128
|
+
] as const
|
|
129
|
+
|
|
130
|
+
describe('subagent-watcher env-var threshold overrides', () => {
|
|
131
|
+
const saved: Record<string, string | undefined> = {}
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
for (const k of ENV_KEYS) {
|
|
135
|
+
saved[k] = process.env[k]
|
|
136
|
+
delete process.env[k]
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
afterEach(() => {
|
|
141
|
+
for (const k of ENV_KEYS) {
|
|
142
|
+
if (saved[k] === undefined) delete process.env[k]
|
|
143
|
+
else process.env[k] = saved[k]
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('honors SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS for the synth window', () => {
|
|
148
|
+
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '5000'
|
|
149
|
+
const h = makeHarness({
|
|
150
|
+
// Tight stall threshold so the test isn't dominated by the
|
|
151
|
+
// 60s default.
|
|
152
|
+
configStallThresholdMs: 1000,
|
|
153
|
+
})
|
|
154
|
+
h.advance(500) // register
|
|
155
|
+
h.unmarkHistorical()
|
|
156
|
+
h.advance(2_000) // stall fires (idle > 1s)
|
|
157
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
158
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
159
|
+
|
|
160
|
+
// 4s post-stall — still under 5s env override.
|
|
161
|
+
h.advance(4_000)
|
|
162
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
163
|
+
|
|
164
|
+
// Cross 5s — synth fires.
|
|
165
|
+
h.advance(2_000)
|
|
166
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
167
|
+
expect(h.finishCalls).toHaveLength(1)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('explicit config arg overrides env var (config wins)', () => {
|
|
171
|
+
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '5000'
|
|
172
|
+
const h = makeHarness({
|
|
173
|
+
configStallThresholdMs: 1000,
|
|
174
|
+
configSilentStallTerminalMs: 60_000, // overrides env
|
|
175
|
+
})
|
|
176
|
+
h.advance(500)
|
|
177
|
+
h.unmarkHistorical()
|
|
178
|
+
h.advance(2_000) // stall fires
|
|
179
|
+
expect(h.stallCalls).toHaveLength(1)
|
|
180
|
+
|
|
181
|
+
// 10s past stall — env would have synthesised (5s) but config
|
|
182
|
+
// override pins it at 60s.
|
|
183
|
+
h.advance(10_000)
|
|
184
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
185
|
+
|
|
186
|
+
// Cross 60s — synth fires.
|
|
187
|
+
h.advance(55_000)
|
|
188
|
+
expect(h.stallTerminalCalls).toHaveLength(1)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('invalid env value falls through to default (does not disable)', () => {
|
|
192
|
+
// Both negative and NaN should be ignored — not coerced to "disable
|
|
193
|
+
// stall detection" or "fire immediately".
|
|
194
|
+
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '-1'
|
|
195
|
+
const h1 = makeHarness({ configStallThresholdMs: 1000 })
|
|
196
|
+
h1.advance(500)
|
|
197
|
+
h1.unmarkHistorical()
|
|
198
|
+
h1.advance(2_000) // stall fires
|
|
199
|
+
expect(h1.stallCalls).toHaveLength(1)
|
|
200
|
+
// Default is 300_000 — synth must NOT fire after a small advance.
|
|
201
|
+
h1.advance(60_000)
|
|
202
|
+
expect(h1.stallTerminalCalls).toHaveLength(0)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('NaN env value falls through to default', () => {
|
|
206
|
+
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = 'not-a-number'
|
|
207
|
+
const h = makeHarness({ configStallThresholdMs: 1000 })
|
|
208
|
+
h.advance(500)
|
|
209
|
+
h.unmarkHistorical()
|
|
210
|
+
h.advance(2_000)
|
|
211
|
+
h.advance(60_000)
|
|
212
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('zero env value falls through to default (zero is not "fire immediately")', () => {
|
|
216
|
+
process.env.SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS = '0'
|
|
217
|
+
const h = makeHarness({ configStallThresholdMs: 1000 })
|
|
218
|
+
h.advance(500)
|
|
219
|
+
h.unmarkHistorical()
|
|
220
|
+
h.advance(2_000)
|
|
221
|
+
h.advance(60_000)
|
|
222
|
+
expect(h.stallTerminalCalls).toHaveLength(0)
|
|
223
|
+
})
|
|
224
|
+
})
|