switchroom 0.21.6 → 0.21.8

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.
Files changed (43) hide show
  1. package/bin/autoaccept.exp +21 -1
  2. package/bin/tmp-reaper.sh +234 -0
  3. package/dist/agent-scheduler/index.js +1 -1
  4. package/dist/auth-broker/index.js +2 -2
  5. package/dist/cli/autoaccept-poll.js +95 -7
  6. package/dist/cli/notion-write-pretool.mjs +1 -1
  7. package/dist/cli/switchroom.js +3432 -2751
  8. package/dist/host-control/main.js +177 -13
  9. package/dist/vault/approvals/kernel-server.js +2 -2
  10. package/dist/vault/broker/server.js +2 -2
  11. package/package.json +6 -4
  12. package/profiles/_base/start.sh.hbs +115 -0
  13. package/profiles/_shared/local-time.md.hbs +6 -0
  14. package/profiles/default/CLAUDE.md.hbs +0 -12
  15. package/telegram-plugin/bunfig.toml +12 -4
  16. package/telegram-plugin/dist/gateway/gateway.js +1017 -465
  17. package/telegram-plugin/gateway/agent-process-liveness.ts +558 -0
  18. package/telegram-plugin/gateway/approval-hold.ts +32 -1
  19. package/telegram-plugin/gateway/approval-outcome-sources.ts +274 -0
  20. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +21 -9
  21. package/telegram-plugin/gateway/callback-query-handlers.ts +87 -15
  22. package/telegram-plugin/gateway/eval-case-proposal-inbound-builders.ts +197 -0
  23. package/telegram-plugin/gateway/gateway.ts +12 -10
  24. package/telegram-plugin/gateway/pending-inbound-buffer.ts +167 -11
  25. package/telegram-plugin/gateway/self-improve-proposal-wiring.test.ts +333 -0
  26. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +152 -3
  27. package/telegram-plugin/gateway/subagent-handback-marker.ts +19 -0
  28. package/telegram-plugin/tests/agent-process-liveness.test.ts +406 -0
  29. package/telegram-plugin/tests/approval-hold-record.test.ts +21 -8
  30. package/telegram-plugin/tests/boot-resume-gateway-only-respawn.test.ts +752 -0
  31. package/telegram-plugin/tests/boot-resume-guard-wiring.test.ts +203 -0
  32. package/telegram-plugin/tests/callback-query-handlers.test.ts +143 -1
  33. package/telegram-plugin/tests/eval-case-proposal-inbound-builders.test.ts +144 -0
  34. package/telegram-plugin/tests/framework-fallback-drains-parked.test.ts +7 -1
  35. package/telegram-plugin/tests/hermes-messages-paging.test.ts +149 -0
  36. package/telegram-plugin/tests/hermes-session-search.test.ts +146 -0
  37. package/telegram-plugin/tests/parked-turn-start-preload.test.ts +65 -0
  38. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +443 -2
  39. package/telegram-plugin/tests/queued-card-surface.test.ts +9 -1
  40. package/telegram-plugin/tests/stream-render-golden.test.ts +10 -1
  41. package/telegram-plugin/tests/subagent-handback-marker.test.ts +14 -0
  42. package/telegram-plugin/tests/turn-mint-defers-until-dequeue.test.ts +7 -1
  43. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +7 -1
@@ -0,0 +1,203 @@
1
+ /**
2
+ * switchroom#4641 — wiring pin for the boot-resume generation guard.
3
+ *
4
+ * `boot-resume-gateway-only-respawn.test.ts` proves the OUTCOME (a stamped
5
+ * generation token yields zero `resume_interrupted` spool entries, an unstamped
6
+ * one still resumes) over the real registry, builders and spool, but it
7
+ * supplies the ORDERING itself: gateway.ts is a 24k-line module whose boot
8
+ * block cannot be imported without booting a gateway. This file closes that gap
9
+ * by pinning gateway.ts's own wiring, so a refactor cannot move the guard below
10
+ * the reaper, drop it, or move the token stamp back inside the block while
11
+ * the outcome suite stays green against its own copy of the sequence.
12
+ *
13
+ * Pinned:
14
+ * 1. the label exists and is on the `isGatewayMain` boot-registry block,
15
+ * 2. the guard runs and `break`s out of that label,
16
+ * 3. it precedes the orphan-turn reaper, the bridge-dead marker consumption,
17
+ * the interrupted-turn finder, the resume builders, the crash-redelivery
18
+ * capture and `writePendingTurnEnv` — i.e. `break` skips ALL of them,
19
+ * 4. the break carries a comment naming those skipped effects (the reviewer
20
+ * MEDIUM: the skip is wider than the resume path and was undocumented),
21
+ * 5. THE STAMP FOLLOWS THE DURABLE PUT. The `bootResumeInit` block only
22
+ * builds `bootResumeInbound` in MEMORY; the resume becomes crash-
23
+ * survivable ~8k lines later at `inboundSpool.put(...)`. Stamping at the
24
+ * tail of the block (the shape this PR's first revision shipped) means a
25
+ * crash in between leaves a token, no `agent-process.json`, and a
26
+ * successor that suppresses a turn already stamped `ended_via='restart'`
27
+ * — silent, permanent work loss. So: the stamp must NOT appear inside the
28
+ * block at all, must come after the put and after `markTurnResumed`, and
29
+ * must be UNCONDITIONAL (top-level, gated only on `isGatewayMain`) so a
30
+ * boot with nothing to resume still writes a token. Same rule
31
+ * `markTurnResumed` already obeys — see turns-schema.ts's "the caller
32
+ * must stamp only AFTER the resume inbound is durably spooled".
33
+ */
34
+
35
+ import { describe, it, expect } from 'vitest'
36
+ import { readFileSync } from 'node:fs'
37
+ import { fileURLToPath } from 'node:url'
38
+ import { dirname, resolve } from 'node:path'
39
+
40
+ const __dirname = dirname(fileURLToPath(import.meta.url))
41
+ const SRC = readFileSync(
42
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
43
+ 'utf8',
44
+ )
45
+
46
+ /** First index of `needle`, asserted present. */
47
+ function idx(needle: string): number {
48
+ const i = SRC.indexOf(needle)
49
+ expect(i, `gateway.ts should contain ${JSON.stringify(needle)}`).toBeGreaterThan(-1)
50
+ return i
51
+ }
52
+
53
+ describe('#4641 boot-resume guard wiring in gateway.ts', () => {
54
+ it('labels the boot-registry block so the guard can exit it', () => {
55
+ expect(SRC).toContain('bootResumeInit: if (isGatewayMain) try {')
56
+ })
57
+
58
+ it('calls the guard and breaks out of the whole block', () => {
59
+ expect(SRC).toMatch(
60
+ /if \(shouldSkipBootResumeForGatewayOnlyRespawn\(STATE_DIR\)\) break bootResumeInit/,
61
+ )
62
+ expect(SRC).toContain(
63
+ "import { shouldSkipBootResumeForGatewayOnlyRespawn, markBootResumeComplete } from './agent-process-liveness.js'",
64
+ )
65
+ })
66
+
67
+ it('runs the guard BEFORE every side effect a gateway-only respawn must skip', () => {
68
+ const guard = idx('shouldSkipBootResumeForGatewayOnlyRespawn(STATE_DIR)')
69
+ // The orphan-turn reaper — what stamped the live turn `ended_via='restart'`.
70
+ expect(guard).toBeLessThan(idx('markOrphanedWithTimeoutClassification(turnsDb'))
71
+ // #3038 bridge-dead escalation marker consumption.
72
+ expect(guard).toBeLessThan(idx('consumeBridgeDeadEscalationMarker('))
73
+ // The interrupted-turn finder + the synthetic that lied to the session.
74
+ expect(guard).toBeLessThan(idx('findLatestTurnIfInterrupted(turnsDb)'))
75
+ expect(guard).toBeLessThan(idx('buildResumeInterruptedInbound({'))
76
+ // The sub-agent "killed by the restart" list.
77
+ expect(guard).toBeLessThan(idx('listNonTerminalSubagentsForTurn(turnsDb'))
78
+ // The crash-redelivery candidate capture.
79
+ expect(guard).toBeLessThan(idx('pendingRedelivery = { turn: pending'))
80
+ // The bridge-dead idle notice.
81
+ expect(guard).toBeLessThan(idx('buildBridgeDeadIdleNoticeInbound({'))
82
+ // The one-shot wake-audit env file.
83
+ expect(guard).toBeLessThan(idx('writePendingTurnEnv(agentDir, pending)'))
84
+ })
85
+
86
+ it('documents at the break every effect it skips beyond the resume path', () => {
87
+ // The reviewer MEDIUM: `break bootResumeInit` silently skipped the
88
+ // bridge-dead marker (whose own comment claims it is ALWAYS cleared), the
89
+ // idle notice and the redelivery capture. The break must name them, so the
90
+ // next reader of gateway.ts:1721 is not misled by that stale invariant.
91
+ const line = SRC.split('\n').find((l) => l.includes('break bootResumeInit'))
92
+ expect(line, 'gateway.ts should contain the guarded break').toBeDefined()
93
+ for (const named of [
94
+ 'reaper',
95
+ 'consumeBridgeDeadEscalationMarker',
96
+ 'idle notice',
97
+ 'pendingRedelivery',
98
+ 'writePendingTurnEnv',
99
+ ]) {
100
+ expect(line!, `the break comment must name ${named}`).toContain(named)
101
+ }
102
+ })
103
+
104
+ it('stamps the generation token AFTER the durable spool put, never inside the block', () => {
105
+ // The invariant this pins, and why it is NOT "the stamp is last in the
106
+ // block": the block builds `bootResumeInbound` in memory only. Stamping at
107
+ // its tail marks the generation done while the resume is still nowhere on
108
+ // disk, so a crash before the put leaves a token + no `agent-process.json`
109
+ // + a turn stamped `ended_via='restart'` and `resumed_at` NULL — and the
110
+ // successor returns `gateway-only-respawn-no-record` and drops it forever.
111
+ const durablePut = idx('inboundSpool.put(bootResumeInbound.agent, bootResumeInbound.msg)')
112
+ const stamps = [...SRC.matchAll(/markBootResumeComplete\(STATE_DIR\)/g)].map((m) => m.index!)
113
+ expect(stamps.length, 'exactly one token stamp site in gateway.ts').toBe(1)
114
+ const stamp = stamps[0]!
115
+
116
+ // 1. It is OUTSIDE the bootResumeInit block: the block's `} catch (err) {`
117
+ // closes long before the stamp.
118
+ const blockStart = idx('bootResumeInit: if (isGatewayMain) try {')
119
+ const blockEnd = SRC.indexOf('} catch (err) {', blockStart)
120
+ expect(blockEnd).toBeGreaterThan(blockStart)
121
+ expect(
122
+ stamp,
123
+ 'the token stamp must NOT live inside the bootResumeInit block — the block only builds the resume in memory',
124
+ ).toBeGreaterThan(blockEnd)
125
+
126
+ // 2. It follows the durable put, and the at-most-once `markTurnResumed`
127
+ // ledger that obeys the identical ordering rule.
128
+ expect(durablePut, 'the durable spool put must precede the token stamp').toBeLessThan(stamp)
129
+ expect(idx('markTurnResumed(turnsDb, resumeTurnKey)')).toBeLessThan(stamp)
130
+
131
+ // 3. It is top-level (zero indentation, so not nested inside
132
+ // `if (isGatewayMain && bootResumeInbound != null)`) and independent of
133
+ // whether there was anything to resume — a boot that found nothing must
134
+ // still stamp, or a later gateway-only respawn reaps a
135
+ // meanwhile-started live turn — but it IS gated on the block not having
136
+ // thrown. gateway.ts's block-level `catch` swallows and lets init
137
+ // continue, so an ungated stamp marks the generation done after the
138
+ // reaper has already durably ended a turn that was never spooled.
139
+ const stampLine = SRC.split('\n').find((l) => l.includes('markBootResumeComplete(STATE_DIR)'))!
140
+ expect(stampLine).toMatch(
141
+ /^if \(isGatewayMain && !bootResumeThrew\) markBootResumeComplete\(STATE_DIR\)/,
142
+ )
143
+
144
+ // 4. Nothing durable-resume-related may sneak between the put and the
145
+ // stamp except the resumed_at ledger — i.e. the stamp closes that
146
+ // sequence rather than floating somewhere later in module init.
147
+ const between = SRC.slice(durablePut + 1, stamp)
148
+ expect(between, 'no second spool put may separate the durable put from the stamp')
149
+ .not.toContain('inboundSpool.put(bootResumeInbound')
150
+ expect(
151
+ between.split('\n').length,
152
+ 'the stamp must sit immediately after the resume-commit block, not drift away from it',
153
+ ).toBeLessThan(40)
154
+ })
155
+
156
+ it('does not re-join the stamp onto writePendingTurnEnv inside the block', () => {
157
+ // The exact regression shape: `writePendingTurnEnv(agentDir, pending); markBootResumeComplete(STATE_DIR)`
158
+ // — joined onto one line to satisfy the gateway line ratchet. The ratchet
159
+ // is not a licence to stamp early.
160
+ const envLine = SRC.split('\n').find((l) => l.includes('writePendingTurnEnv(agentDir, pending)'))!
161
+ expect(
162
+ envLine,
163
+ 'the generation token must not be stamped alongside writePendingTurnEnv (still in-memory-only territory)',
164
+ ).not.toContain('markBootResumeComplete')
165
+ })
166
+
167
+ it("sets bootResumeThrew in the block's own catch, and nowhere else", () => {
168
+ // The catch is a SWALLOW: it logs, nulls turnsDb and lets module init run
169
+ // on to the stamp. By then the reaper may already have durably written
170
+ // `ended_via='restart'` with nothing spooled, so the catch must record the
171
+ // failure and the stamp must honour it. Pinned by source text because the
172
+ // block is module-init code this suite cannot import.
173
+ const blockStart = idx('bootResumeInit: if (isGatewayMain) try {')
174
+ const catchAt = SRC.indexOf('} catch (err) {', blockStart)
175
+ const stamp = idx('markBootResumeComplete(STATE_DIR)')
176
+
177
+ const sets = [...SRC.matchAll(/bootResumeThrew = true/g)].map((m) => m.index!)
178
+ expect(sets.length, 'exactly one assignment site').toBe(1)
179
+ expect(sets[0]!, "the assignment must be in the block's catch").toBeGreaterThan(catchAt)
180
+ expect(sets[0]!, 'and before the stamp reads it').toBeLessThan(stamp)
181
+
182
+ // The catch must not early-return/exit instead: init continues, which is
183
+ // precisely why the flag is needed.
184
+ const catchBody = SRC.slice(catchAt, SRC.indexOf('\n}\n', catchAt) + 2)
185
+ expect(catchBody).toContain('turnsDb = null')
186
+ expect(catchBody).not.toContain('process.exit')
187
+
188
+ const decl = SRC.indexOf('let bootResumeThrew = false')
189
+ expect(decl, 'declared before the block so the stamp can read it').toBeGreaterThan(-1)
190
+ expect(decl).toBeLessThan(blockStart)
191
+ })
192
+
193
+ it('keeps the guard AFTER the registry is opened, so turn tracking still works', () => {
194
+ // A gateway-only respawn still needs a live turnsDb for the rest of the
195
+ // gateway; the guard skips the boot-resume side effects, not the DB.
196
+ expect(idx('turnsDb = openTurnsDb(agentDir)')).toBeLessThan(
197
+ idx('shouldSkipBootResumeForGatewayOnlyRespawn(STATE_DIR)'),
198
+ )
199
+ expect(idx('applySubagentsSchema(turnsDb)')).toBeLessThan(
200
+ idx('shouldSkipBootResumeForGatewayOnlyRespawn(STATE_DIR)'),
201
+ )
202
+ })
203
+ })
@@ -25,7 +25,7 @@
25
25
  * broker call, which is exactly the behavior the extraction must not change.
26
26
  */
27
27
 
28
- import { describe, it, expect, vi, beforeEach } from 'vitest'
28
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
29
29
  import type { Context } from 'grammy'
30
30
  import {
31
31
  createCallbackQueryHandlers,
@@ -42,6 +42,10 @@ import { createSweepableCardStore } from '../gateway/approval-card-stores.js'
42
42
  import { createSweepableStore } from '../gateway/pending-state-stores.js'
43
43
  import { StagingMap } from '../secret-detect/staging.js'
44
44
  import { InlineKeyboard } from 'grammy'
45
+ import { mkdtempSync, rmSync } from 'node:fs'
46
+ import { tmpdir } from 'node:os'
47
+ import { join } from 'node:path'
48
+ import { enqueueEvalCaseProposal } from '../../src/self-improve/eval-case-proposals.js'
45
49
 
46
50
  // Mock the auth-broker client so the `auth:use:` swap path is observable
47
51
  // (spy on setActive) without a live UDS broker. Only handleAuthDashboardCallback
@@ -625,6 +629,144 @@ describe('handleSkillProposalCallback', () => {
625
629
  })
626
630
  })
627
631
 
632
+ // ── evcase:* — eval-case proposal ───────────────────────────────────────
633
+ //
634
+ // The defect these pin: this handler was a copy of the skill handler that
635
+ // dropped the `deliverResumeSyntheticOrBuffer` call, so BOTH exits — dismiss
636
+ // and approve — edited the card and returned without telling the proposing
637
+ // agent anything. The agent had been steered to end its turn and wait for a
638
+ // wake-up that no code path sent. On unmodified code every assertion below on
639
+ // `deliverResumeSyntheticOrBuffer` fails with 0 calls.
640
+ //
641
+ // `handleEvalCaseProposalCallback` reads the proposal store off disk (not from
642
+ // an injected store), so these drive a REAL tmp state dir via the real
643
+ // `enqueueEvalCaseProposal`. `TELEGRAM_STATE_DIR` is NOT one of
644
+ // `agent-state-dir-guard`'s GUARDED_STATE_DIR_VARS, so the guard neither
645
+ // redirects nor blocks it — setting it here is the whole isolation.
646
+
647
+ describe('handleEvalCaseProposalCallback', () => {
648
+ let stateDir: string
649
+ let prevStateDir: string | undefined
650
+ let prevAgent: string | undefined
651
+
652
+ beforeEach(() => {
653
+ stateDir = mkdtempSync(join(tmpdir(), 'evcase-cb-'))
654
+ prevStateDir = process.env.TELEGRAM_STATE_DIR
655
+ prevAgent = process.env.SWITCHROOM_AGENT_NAME
656
+ process.env.TELEGRAM_STATE_DIR = stateDir
657
+ process.env.SWITCHROOM_AGENT_NAME = 'test-agent'
658
+ })
659
+
660
+ afterEach(() => {
661
+ if (prevStateDir == null) delete process.env.TELEGRAM_STATE_DIR
662
+ else process.env.TELEGRAM_STATE_DIR = prevStateDir
663
+ if (prevAgent == null) delete process.env.SWITCHROOM_AGENT_NAME
664
+ else process.env.SWITCHROOM_AGENT_NAME = prevAgent
665
+ rmSync(stateDir, { recursive: true, force: true })
666
+ })
667
+
668
+ function seedProposal(over: { heldOut?: boolean } = {}) {
669
+ return enqueueEvalCaseProposal(stateDir, {
670
+ skill_slug: 'deploy-checklist',
671
+ skill_dir: join(stateDir, 'skills', 'deploy-checklist'),
672
+ case: { prompt: 'the correction, as an input' },
673
+ fingerprint: 'fp-1',
674
+ held_out: over.heldOut === true,
675
+ })
676
+ }
677
+
678
+ it('dismiss wakes the agent with an eval_case_rejected inbound', async () => {
679
+ const { deps } = makeDeps()
680
+ const h = createCallbackQueryHandlers(deps)
681
+ const p = seedProposal()
682
+ const { ctx } = makeCtx()
683
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:deny:${p.id}`)
684
+ expect(deps.deliverResumeSyntheticOrBuffer).toHaveBeenCalledWith(
685
+ 'test-agent',
686
+ expect.objectContaining({
687
+ meta: expect.objectContaining({
688
+ source: 'eval_case_rejected',
689
+ proposal_id: p.id,
690
+ skill_slug: 'deploy-checklist',
691
+ }),
692
+ }),
693
+ )
694
+ })
695
+
696
+ it('approve with a successful applier wakes the agent with eval_case_applied', async () => {
697
+ const runEvalCaseApply = vi.fn(() => ({ ok: true, out: 'added 1 case' }))
698
+ const { deps } = makeDeps({ runEvalCaseApply })
699
+ const h = createCallbackQueryHandlers(deps)
700
+ const p = seedProposal()
701
+ const { ctx } = makeCtx()
702
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:approve:${p.id}`)
703
+ expect(runEvalCaseApply).toHaveBeenCalledWith(p.id)
704
+ expect(deps.deliverResumeSyntheticOrBuffer).toHaveBeenCalledWith(
705
+ 'test-agent',
706
+ expect.objectContaining({
707
+ meta: expect.objectContaining({ source: 'eval_case_applied', proposal_id: p.id }),
708
+ }),
709
+ )
710
+ })
711
+
712
+ it('approve with a failing applier wakes the agent with eval_case_apply_failed + the output', async () => {
713
+ const runEvalCaseApply = vi.fn(() => ({ ok: false, out: 'ENOENT: skill dir missing' }))
714
+ const { deps, injected } = makeDeps({ runEvalCaseApply })
715
+ const h = createCallbackQueryHandlers(deps)
716
+ const p = seedProposal()
717
+ const { ctx } = makeCtx()
718
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:approve:${p.id}`)
719
+ expect(deps.deliverResumeSyntheticOrBuffer).toHaveBeenCalledWith(
720
+ 'test-agent',
721
+ expect.objectContaining({
722
+ meta: expect.objectContaining({ source: 'eval_case_apply_failed', proposal_id: p.id }),
723
+ }),
724
+ )
725
+ // The applier's own diagnosis reaches the agent, not just "it failed".
726
+ expect(injected[0]?.text).toContain('ENOENT: skill dir missing')
727
+ })
728
+
729
+ it('carries the card’s forum topic into the outcome inbound', async () => {
730
+ const { deps } = makeDeps()
731
+ const h = createCallbackQueryHandlers(deps)
732
+ const p = seedProposal()
733
+ const { ctx } = makeCtx({ threadId: 7 })
734
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:deny:${p.id}`)
735
+ expect(deps.deliverResumeSyntheticOrBuffer).toHaveBeenCalledWith(
736
+ 'test-agent',
737
+ expect.objectContaining({
738
+ threadId: 7,
739
+ meta: expect.objectContaining({ message_thread_id: '7' }),
740
+ }),
741
+ )
742
+ })
743
+
744
+ it('a second tap injects nothing — the non-pending guard runs before every injection', async () => {
745
+ const runEvalCaseApply = vi.fn(() => ({ ok: true, out: '' }))
746
+ const { deps } = makeDeps({ runEvalCaseApply })
747
+ const h = createCallbackQueryHandlers(deps)
748
+ const p = seedProposal()
749
+ const { ctx } = makeCtx()
750
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:approve:${p.id}`)
751
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:approve:${p.id}`)
752
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:deny:${p.id}`)
753
+ expect(deps.deliverResumeSyntheticOrBuffer).toHaveBeenCalledTimes(1)
754
+ expect(runEvalCaseApply).toHaveBeenCalledTimes(1)
755
+ })
756
+
757
+ it('a non-allowlisted tapper neither applies nor wakes the agent', async () => {
758
+ const runEvalCaseApply = vi.fn(() => ({ ok: true, out: '' }))
759
+ const { deps } = makeDeps({ runEvalCaseApply })
760
+ const h = createCallbackQueryHandlers(deps)
761
+ const p = seedProposal()
762
+ const { ctx, raw } = makeCtx({ senderId: '999' })
763
+ await h.handleEvalCaseProposalCallback(ctx, `evcase:approve:${p.id}`)
764
+ expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
765
+ expect(runEvalCaseApply).not.toHaveBeenCalled()
766
+ expect(deps.deliverResumeSyntheticOrBuffer).not.toHaveBeenCalled()
767
+ })
768
+ })
769
+
628
770
  // ── op:* — operator-event card actions ──────────────────────────────────
629
771
 
630
772
  describe('handleOperatorEventCallback', () => {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Fixture pins for the eval-case outcome inbounds.
3
+ *
4
+ * `meta.source` is what the bridge keys on to render the `<channel source=…>`
5
+ * block that wakes the proposing agent, and the `meta.*` fields are the
6
+ * forensic anchor back to the proposal + the deciding operator. A silent change
7
+ * to either would put the agent back where this fix found it — steered to end
8
+ * its turn and wait for a wake-up nothing recognises. Mirrors
9
+ * `skill-proposal-card.test.ts`'s `buildSkillProposalApplyInbound` block.
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest'
13
+ import {
14
+ buildEvalCaseAppliedInbound,
15
+ buildEvalCaseRejectedInbound,
16
+ buildEvalCaseApplyFailedInbound,
17
+ } from '../gateway/eval-case-proposal-inbound-builders.js'
18
+
19
+ const CTX = { agent: 'klanker', chat_id: '12345' }
20
+
21
+ describe('buildEvalCaseAppliedInbound', () => {
22
+ it('pins source and meta for an applied case', () => {
23
+ const inb = buildEvalCaseAppliedInbound({
24
+ ctx: CTX,
25
+ proposalId: 'p1',
26
+ skillSlug: 'deploy-checklist',
27
+ heldOut: false,
28
+ operatorId: '999',
29
+ nowMs: 1000,
30
+ })
31
+ expect(inb.meta.source).toBe('eval_case_applied')
32
+ expect(inb.meta.agent).toBe('klanker')
33
+ expect(inb.meta.proposal_id).toBe('p1')
34
+ expect(inb.meta.skill_slug).toBe('deploy-checklist')
35
+ expect(inb.meta.held_out).toBe('false')
36
+ expect(inb.meta.operator_id).toBe('999')
37
+ expect(inb.chatId).toBe('12345')
38
+ expect(inb.user).toBe('self-improve')
39
+ expect(inb.messageId).toBe(1000)
40
+ expect(inb.ts).toBe(1000)
41
+ // The agent must NOT re-write a case the gateway already applied.
42
+ expect(inb.text).toContain('do NOT write it yourself')
43
+ expect(inb.text).toContain('evals.json')
44
+ })
45
+
46
+ it('names the held-out sink instead of evals.json when held_out', () => {
47
+ const inb = buildEvalCaseAppliedInbound({
48
+ ctx: CTX,
49
+ proposalId: 'p1',
50
+ skillSlug: 'deploy-checklist',
51
+ heldOut: true,
52
+ operatorId: '999',
53
+ })
54
+ expect(inb.meta.held_out).toBe('true')
55
+ expect(inb.text).toContain('held-out sink')
56
+ expect(inb.text).not.toContain('evals.json')
57
+ })
58
+
59
+ it('carries the forum topic through to threadId and meta', () => {
60
+ const inb = buildEvalCaseAppliedInbound({
61
+ ctx: { ...CTX, threadId: 7 },
62
+ proposalId: 'p1',
63
+ skillSlug: 's',
64
+ heldOut: false,
65
+ operatorId: '999',
66
+ })
67
+ expect(inb.threadId).toBe(7)
68
+ expect(inb.meta.message_thread_id).toBe('7')
69
+ })
70
+
71
+ it('omits threadId/message_thread_id entirely for a DM proposal', () => {
72
+ const inb = buildEvalCaseAppliedInbound({
73
+ ctx: CTX,
74
+ proposalId: 'p1',
75
+ skillSlug: 's',
76
+ heldOut: false,
77
+ operatorId: '999',
78
+ })
79
+ expect('threadId' in inb).toBe(false)
80
+ expect('message_thread_id' in inb.meta).toBe(false)
81
+ })
82
+ })
83
+
84
+ describe('buildEvalCaseRejectedInbound', () => {
85
+ it('pins source and tells the agent nothing was written', () => {
86
+ const inb = buildEvalCaseRejectedInbound({
87
+ ctx: CTX,
88
+ proposalId: 'p2',
89
+ skillSlug: 'deploy-checklist',
90
+ heldOut: false,
91
+ operatorId: '999',
92
+ nowMs: 2000,
93
+ })
94
+ expect(inb.meta.source).toBe('eval_case_rejected')
95
+ expect(inb.meta.proposal_id).toBe('p2')
96
+ expect(inb.user).toBe('self-improve')
97
+ expect(inb.text).toContain('NOTHING was written')
98
+ expect(inb.text).toContain('Do NOT re-propose')
99
+ })
100
+ })
101
+
102
+ describe('buildEvalCaseApplyFailedInbound', () => {
103
+ it('pins source and refuses to let the agent assume the case landed', () => {
104
+ const inb = buildEvalCaseApplyFailedInbound({
105
+ ctx: CTX,
106
+ proposalId: 'p3',
107
+ skillSlug: 'deploy-checklist',
108
+ heldOut: false,
109
+ operatorId: '999',
110
+ applyOut: 'ENOENT: skill dir missing',
111
+ nowMs: 3000,
112
+ })
113
+ expect(inb.meta.source).toBe('eval_case_apply_failed')
114
+ expect(inb.meta.proposal_id).toBe('p3')
115
+ expect(inb.text).toContain('was NOT')
116
+ expect(inb.text).toContain('do NOT assume')
117
+ expect(inb.text).toContain('ENOENT: skill dir missing')
118
+ })
119
+
120
+ it('truncates a runaway applier dump so it cannot dominate the woken turn', () => {
121
+ const inb = buildEvalCaseApplyFailedInbound({
122
+ ctx: CTX,
123
+ proposalId: 'p3',
124
+ skillSlug: 's',
125
+ heldOut: false,
126
+ operatorId: '999',
127
+ applyOut: 'x'.repeat(5000),
128
+ })
129
+ expect(inb.text).toContain('x'.repeat(500))
130
+ expect(inb.text).not.toContain('x'.repeat(501))
131
+ })
132
+
133
+ it('says "(no output)" rather than emitting an empty line', () => {
134
+ const inb = buildEvalCaseApplyFailedInbound({
135
+ ctx: CTX,
136
+ proposalId: 'p3',
137
+ skillSlug: 's',
138
+ heldOut: false,
139
+ operatorId: '999',
140
+ applyOut: ' \n ',
141
+ })
142
+ expect(inb.text).toContain('(no output)')
143
+ })
144
+ })
@@ -23,7 +23,7 @@
23
23
  * parked store is emptied AND the parked message was handed back into turn
24
24
  * processing (its turn begun, its card opened) rather than silently dropped.
25
25
  */
26
- import { describe, it, expect, beforeEach } from 'vitest'
26
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
27
27
  import {
28
28
  handleSessionEvent,
29
29
  drainParkedTurnStartsForChat,
@@ -34,9 +34,15 @@ import { buildSilencePokeOptions } from '../gateway/liveness-wiring.js'
34
34
  import { makeLivenessFixture, makeTurn, statusKeyForTests } from './helpers/liveness-wiring-fixture.js'
35
35
  import { CHAT, enqueue, makeHarness } from './turn-mint-harness.js'
36
36
 
37
+ // Module-scope store + one-process `bun test` sweep: resetting on ENTRY alone
38
+ // leaves a mid-park case's entry behind for every later FILE, which makes the
39
+ // obligation sweep read the session as busy for the rest of the run (#4611).
37
40
  beforeEach(() => {
38
41
  __resetParkedTurnStartsForTest()
39
42
  })
43
+ afterEach(() => {
44
+ __resetParkedTurnStartsForTest()
45
+ })
40
46
 
41
47
  describe('silence-fallback drains parkedTurnStarts so a hung REPL cannot wedge the park gate', () => {
42
48
  it('empties the parked store AND redelivers the parked message into turn processing', async () => {