switchroom 0.19.22 → 0.19.24
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/dist/agent-scheduler/index.js +5 -2
- package/dist/auth-broker/index.js +95 -2
- package/dist/cli/notion-write-pretool.mjs +5 -2
- package/dist/cli/switchroom.js +749 -357
- package/dist/host-control/main.js +96 -3
- package/dist/vault/approvals/kernel-server.js +98 -5
- package/dist/vault/broker/server.js +98 -5
- package/package.json +5 -4
- package/profiles/_base/start.sh.hbs +101 -0
- package/profiles/_shared/agent-self-service.md.hbs +64 -109
- package/profiles/_shared/delegation-golden-rule.md.hbs +5 -5
- package/profiles/_shared/dev-protocol.md.hbs +12 -42
- package/profiles/_shared/execution-discipline.md.hbs +7 -14
- package/profiles/coding/CLAUDE.md.hbs +0 -6
- package/profiles/default/CLAUDE.md.hbs +21 -50
- package/skills/dev-protocol/SKILL.md +97 -107
- package/skills/switchroom-release/SKILL.md +2 -1
- package/telegram-plugin/bunfig.toml +10 -0
- package/telegram-plugin/dist/gateway/gateway.js +267 -52
- package/telegram-plugin/gateway/backstop-delivery.ts +97 -16
- package/telegram-plugin/gateway/captured-answer-resume.ts +46 -17
- package/telegram-plugin/gateway/gateway.ts +43 -42
- package/telegram-plugin/gateway/latest-turn-lookup.ts +60 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +61 -22
- package/telegram-plugin/gateway/stream-render.ts +6 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +1 -1
- package/telegram-plugin/gateway/turn-end.ts +1 -1
- package/telegram-plugin/gateway/turn-record-status.ts +19 -0
- package/telegram-plugin/gateway/turns-jsonl-rotate.ts +65 -0
- package/telegram-plugin/reply-owner-resolve.ts +110 -9
- package/telegram-plugin/send-gate-degraded.test.ts +45 -16
- package/telegram-plugin/send-gate.ts +185 -24
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +9 -9
- package/telegram-plugin/tests/agent-state-dir-preload.test.ts +33 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +204 -7
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +12 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +104 -0
- package/telegram-plugin/tests/latest-turn-lookup.test.ts +77 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +23 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +531 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +296 -28
- package/telegram-plugin/tests/stream-controller-send-gate.test.ts +134 -28
- package/telegram-plugin/tests/stream-render-golden.test.ts +25 -3
- package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +92 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +113 -11
- package/vendor/hindsight-memory/scripts/lib/pending.py +802 -65
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +54 -7
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +1445 -11
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +78 -6
- package/vendor/hindsight-memory/tests/test_drain_pending.py +17 -2
- package/vendor/hindsight-memory/tests/test_pending.py +12 -4
|
@@ -271,6 +271,110 @@ describe('#3282 createCapturedResumeDispatcher — the represent RESUME outcome'
|
|
|
271
271
|
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
272
272
|
})
|
|
273
273
|
|
|
274
|
+
// #3702 L3 — the close condition is LANDED, not confirmed. A snapshot whose
|
|
275
|
+
// chunks all landed but were NEVER corroborated (the read-back is 100% shed in
|
|
276
|
+
// production, #3703) is resolved on HYDRATION alone: the resume re-probes,
|
|
277
|
+
// resolves nothing again, sends zero messages, and closes.
|
|
278
|
+
//
|
|
279
|
+
// This is the case the durable outbound-text oracle CANNOT reach — history
|
|
280
|
+
// disabled here, so `hasOutboundWithText` never fires and the old
|
|
281
|
+
// confirmation-gated verdict would have kept the obligation OPEN, re-running
|
|
282
|
+
// the same shed probe on every sweep with no send available to it, until the
|
|
283
|
+
// represent cap escalated a false "never answered you" to the operator.
|
|
284
|
+
it('fully-landed-but-UNCONFIRMED snapshot, no oracle ⇒ ZERO sends and the obligation CLOSES', async () => {
|
|
285
|
+
const chunks = ['c0', 'c1']
|
|
286
|
+
const snapshot = {
|
|
287
|
+
chunks,
|
|
288
|
+
chunkStates: [
|
|
289
|
+
{ index: 0, messageIds: [5000], confirmed: false },
|
|
290
|
+
{ index: 1, messageIds: [5001], confirmed: false },
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
294
|
+
const sentIdx: number[] = []
|
|
295
|
+
const oblLedger = new ObligationLedger(2)
|
|
296
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
297
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
298
|
+
|
|
299
|
+
// No durable corroboration is available from EITHER source: the snapshot
|
|
300
|
+
// flags are false, history is off, and the re-probe sheds (ambiguous).
|
|
301
|
+
mockOracle = () => false
|
|
302
|
+
|
|
303
|
+
const lines: string[] = []
|
|
304
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
305
|
+
deliverAnswer: async (a) => {
|
|
306
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
307
|
+
const res = await runBackstopDelivery(
|
|
308
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
309
|
+
{
|
|
310
|
+
sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
|
|
311
|
+
readBack: async (): Promise<ReadBackResult> => 'ambiguous',
|
|
312
|
+
},
|
|
313
|
+
3,
|
|
314
|
+
)
|
|
315
|
+
expect(res.confirmed).toBe(false) // nothing corroborated it, at any point
|
|
316
|
+
expect(res.landedUnconfirmedIds).toEqual([5000, 5001]) // ...and it is counted
|
|
317
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
318
|
+
},
|
|
319
|
+
obligationLedger: oblLedger,
|
|
320
|
+
backstopDeliveryLedger: resumeLedger,
|
|
321
|
+
flushedTurnSupersede: { record: () => {} },
|
|
322
|
+
historyEnabled: false,
|
|
323
|
+
stderr: (s) => lines.push(s),
|
|
324
|
+
})
|
|
325
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
326
|
+
await flush(dispatcher)
|
|
327
|
+
|
|
328
|
+
expect(sentIdx).toEqual([]) // nothing re-posted — the user never sees a duplicate
|
|
329
|
+
expect(oblLedger.isOpen('#t1')).toBe(false) // closed on the landed-id evidence
|
|
330
|
+
// ...and the log must say what actually happened. These ids are LANDED, not
|
|
331
|
+
// confirmed: calling them "confirmed" is the overstatement #3702 removed from
|
|
332
|
+
// the verdict, and a log that reintroduces it re-lies to the next debugger.
|
|
333
|
+
const closed = lines.find((l) => l.includes('resume delivered'))
|
|
334
|
+
expect(closed).toBeDefined()
|
|
335
|
+
expect(closed).toContain('2 message id(s) landed')
|
|
336
|
+
expect(closed).not.toContain('confirmed')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// The other half of that decision: a positive ABSENCE still re-opens the send
|
|
340
|
+
// path, so "landed" is not a rubber stamp. (Inert in production until #3703
|
|
341
|
+
// wakes the probe, but the mechanism must remain correct.)
|
|
342
|
+
it('a landed chunk the re-probe finds ABSENT is demoted and RE-SENT, not closed on the stale id', async () => {
|
|
343
|
+
const chunks = ['c0']
|
|
344
|
+
const snapshot = { chunks, chunkStates: [{ index: 0, messageIds: [5000], confirmed: false }] }
|
|
345
|
+
const resumeLedger = new BackstopDeliveryLedger()
|
|
346
|
+
const sentIdx: number[] = []
|
|
347
|
+
const oblLedger = new ObligationLedger(2)
|
|
348
|
+
oblLedger.openIfAbsent(obligation({ capturedDelivery: snapshot }))
|
|
349
|
+
oblLedger.noteCapturedDelivery('#t1', snapshot)
|
|
350
|
+
mockOracle = () => false
|
|
351
|
+
|
|
352
|
+
let probes = 0
|
|
353
|
+
const dispatcher = createCapturedResumeDispatcher({
|
|
354
|
+
deliverAnswer: async (a) => {
|
|
355
|
+
a.resume.hydrate(resumeLedger, a.turnId)
|
|
356
|
+
const res = await runBackstopDelivery(
|
|
357
|
+
resumeLedger, a.turnId, a.resume.snapshot.chunks, a.cardMessageId,
|
|
358
|
+
{
|
|
359
|
+
sendChunk: async (i: number) => { sentIdx.push(i); return [7000 + i] },
|
|
360
|
+
readBack: async (): Promise<ReadBackResult> => (++probes === 1 ? 'absent' : 'exists'),
|
|
361
|
+
},
|
|
362
|
+
3,
|
|
363
|
+
)
|
|
364
|
+
return { delivered: res.delivered, sentIds: res.sentIds }
|
|
365
|
+
},
|
|
366
|
+
obligationLedger: oblLedger,
|
|
367
|
+
backstopDeliveryLedger: resumeLedger,
|
|
368
|
+
flushedTurnSupersede: { record: () => {} },
|
|
369
|
+
historyEnabled: false,
|
|
370
|
+
})
|
|
371
|
+
dispatcher.dispatch(oblLedger.list()[0])
|
|
372
|
+
await flush(dispatcher)
|
|
373
|
+
|
|
374
|
+
expect(sentIdx).toEqual([0]) // the silently-dropped chunk IS re-sent
|
|
375
|
+
expect(oblLedger.isOpen('#t1')).toBe(false)
|
|
376
|
+
})
|
|
377
|
+
|
|
274
378
|
it('a partial RESUME (tail still fails) leaves the obligation OPEN + consumes represent budget', async () => {
|
|
275
379
|
const chunks = ['c0', 'c1']
|
|
276
380
|
const snapshot = {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3725 — the "latest-ended" supersede anchor was the most-recently-STARTED turn.
|
|
3
|
+
*
|
|
4
|
+
* `recentTurnsById` is populated at turn START (`rememberRecentTurn` fires from
|
|
5
|
+
* the `enqueue` lifecycle event in `stream-render.ts`, and the atom is built with
|
|
6
|
+
* `endedAt: null`; `endedAt` is stamped later in `turn-end.ts`). The gateway's
|
|
7
|
+
* lookup had NO `endedAt` filter, so `findLatestEndedTurnForChat` returned the
|
|
8
|
+
* chat's tail entry even when that turn was still RUNNING — its own docstring
|
|
9
|
+
* admitted it ("the most-recently-STARTED turn") while the name and every caller
|
|
10
|
+
* said otherwise. Because the registry is chat-wide and thread-agnostic, a turn
|
|
11
|
+
* running in ANOTHER topic of the same chat was a live route into that state.
|
|
12
|
+
*
|
|
13
|
+
* These tests pin the OUTCOME of the scan (which turn comes back), not the
|
|
14
|
+
* shape of the code.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest'
|
|
18
|
+
import { latestTurnForChat, type LatestTurnLookupAtom } from '../gateway/latest-turn-lookup.js'
|
|
19
|
+
|
|
20
|
+
interface Turn extends LatestTurnLookupAtom {
|
|
21
|
+
turnId: string
|
|
22
|
+
sessionThreadId?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const CHAT = '12345'
|
|
26
|
+
const OTHER_CHAT = '99999'
|
|
27
|
+
|
|
28
|
+
/** Registry insertion order = turn-START order (what `rememberRecentTurn` does). */
|
|
29
|
+
const registry = (...turns: Turn[]): Map<string, Turn> =>
|
|
30
|
+
new Map(turns.map((t) => [t.turnId, t]))
|
|
31
|
+
|
|
32
|
+
describe('latestTurnForChat — endedOnly (#3725)', () => {
|
|
33
|
+
it('does NOT return a still-running turn as the latest ENDED turn — it falls ' +
|
|
34
|
+
'back to the most recent turn that actually ended', () => {
|
|
35
|
+
// Topic A ended at t=1000; topic B started after and is STILL RUNNING, so it
|
|
36
|
+
// sits at the registry tail. This is the exact incident shape.
|
|
37
|
+
const ended = { turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000, sessionThreadId: 11 }
|
|
38
|
+
const running = { turnId: 'B#2', sessionChatId: CHAT, endedAt: null, sessionThreadId: 22 }
|
|
39
|
+
const turns = registry(ended, running)
|
|
40
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBe(ended)
|
|
41
|
+
// The routing consumer still wants the tail entry (running or not) — it only
|
|
42
|
+
// picks a topic to deliver into and deletes nothing.
|
|
43
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).toBe(running)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns NULL when the chat has only a running turn — the destructive tier ' +
|
|
47
|
+
'gets no anchor at all rather than an unbounded one (fail closed)', () => {
|
|
48
|
+
const turns = registry({ turnId: 'B#2', sessionChatId: CHAT, endedAt: null })
|
|
49
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })).toBeNull()
|
|
50
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })).not.toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('picks the LAST ended turn in insertion order when several have ended', () => {
|
|
54
|
+
const turns = registry(
|
|
55
|
+
{ turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
|
|
56
|
+
{ turnId: 'A#2', sessionChatId: CHAT, endedAt: 2_000 },
|
|
57
|
+
{ turnId: 'B#3', sessionChatId: CHAT, endedAt: null },
|
|
58
|
+
)
|
|
59
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#2')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('never crosses chats (the scan is chat-scoped in BOTH modes)', () => {
|
|
63
|
+
const turns = registry(
|
|
64
|
+
{ turnId: 'A#1', sessionChatId: CHAT, endedAt: 1_000 },
|
|
65
|
+
{ turnId: 'X#1', sessionChatId: OTHER_CHAT, endedAt: 2_000 },
|
|
66
|
+
{ turnId: 'X#2', sessionChatId: OTHER_CHAT, endedAt: null },
|
|
67
|
+
)
|
|
68
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
|
|
69
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: false })?.turnId).toBe('A#1')
|
|
70
|
+
expect(latestTurnForChat(turns.values(), 'nobody', { endedOnly: false })).toBeNull()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('an endedAt of 0 counts as ENDED (null is the only "still running" marker)', () => {
|
|
74
|
+
const turns = registry({ turnId: 'A#1', sessionChatId: CHAT, endedAt: 0 })
|
|
75
|
+
expect(latestTurnForChat(turns.values(), CHAT, { endedOnly: true })?.turnId).toBe('A#1')
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -35,6 +35,28 @@ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
|
|
|
35
35
|
import { BackstopDeliveryLedger } from '../gateway/backstop-delivery.js'
|
|
36
36
|
import { redact } from '../secret-detect/redact.js'
|
|
37
37
|
import type { CurrentTurn, NarrativeLaneDeps } from '../gateway/gateway.js'
|
|
38
|
+
import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
|
|
39
|
+
|
|
40
|
+
/** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
|
|
41
|
+
* candidate set the content-gate bypass corroborates against. These fixtures
|
|
42
|
+
* never exercise the supersede path, so the candidates mirror the resolved turn
|
|
43
|
+
* (the corroborated shape) with no override needed. */
|
|
44
|
+
function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
|
|
45
|
+
const id = turn?.turnId ?? null
|
|
46
|
+
return {
|
|
47
|
+
turn,
|
|
48
|
+
tier,
|
|
49
|
+
candidates: {
|
|
50
|
+
liveTurnId: tier === 'live' ? id : null,
|
|
51
|
+
originTurnId: null,
|
|
52
|
+
quotedTurnId: null,
|
|
53
|
+
latestEndedTurnId: id,
|
|
54
|
+
latestEndedAgeMs: 1_000,
|
|
55
|
+
latestEndedTtlMs: 60_000,
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
38
60
|
|
|
39
61
|
const CHAT = '1001'
|
|
40
62
|
|
|
@@ -371,7 +393,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
371
393
|
assertSendable: () => {},
|
|
372
394
|
statusKey: key,
|
|
373
395
|
streamKey: key,
|
|
374
|
-
resolveReplyOwnerTurn: () => (
|
|
396
|
+
resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
|
|
375
397
|
getLastSubagentHandbackAt: () => null,
|
|
376
398
|
findTurnByOriginId: () => null,
|
|
377
399
|
findTurnByQuotedMessageId: () => null,
|