switchroom 0.15.10 → 0.15.12
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 +7 -80
- package/dist/auth-broker/index.js +5 -0
- package/dist/cli/notion-write-pretool.mjs +5 -0
- package/dist/cli/switchroom.js +292 -261
- package/dist/host-control/main.js +5 -0
- package/dist/vault/approvals/kernel-server.js +5 -0
- package/dist/vault/broker/server.js +5 -0
- package/package.json +1 -1
- package/profiles/_base/cron-session.sh.hbs +10 -6
- package/telegram-plugin/bridge/bridge.ts +24 -0
- package/telegram-plugin/dist/bridge/bridge.js +23 -0
- package/telegram-plugin/dist/gateway/gateway.js +266 -23
- package/telegram-plugin/dist/server.js +23 -0
- package/telegram-plugin/gateway/gateway.ts +100 -24
- package/telegram-plugin/gateway/linear-activity.ts +160 -0
- package/telegram-plugin/gateway/model-command.ts +13 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +56 -15
- package/telegram-plugin/history.ts +57 -0
- package/telegram-plugin/tests/gateway-request-secret.test.ts +1 -1
- package/telegram-plugin/tests/history.test.ts +83 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +124 -0
- package/telegram-plugin/tests/model-command.test.ts +40 -0
- package/telegram-plugin/tests/obligation-ledger.test.ts +213 -5
- package/telegram-plugin/tests/obligation-store.test.ts +17 -0
|
@@ -546,6 +546,63 @@ export function getRecentOutboundCount(
|
|
|
546
546
|
return row?.cnt ?? 0
|
|
547
547
|
}
|
|
548
548
|
|
|
549
|
+
/**
|
|
550
|
+
* Returns true if at least one SUBSTANTIVE outbound (bot → user, role='assistant')
|
|
551
|
+
* message was delivered to `chatId` (and optionally `threadId`) AFTER `sinceMs`
|
|
552
|
+
* (wall-clock epoch milliseconds). Used by the obligation sweep to suppress a false
|
|
553
|
+
* "I may have missed this" escalation when the agent visibly answered: if a
|
|
554
|
+
* substantive outbound landed since the obligation was opened, the obligation is
|
|
555
|
+
* stale — close it silently rather than alarming the user.
|
|
556
|
+
*
|
|
557
|
+
* SUBSTANTIVE: we never suppress escalation on a bare ack ("on it", "give me a
|
|
558
|
+
* sec") — an agent that acks then ghosts must still escalate. The history schema
|
|
559
|
+
* does not store a done/substantive flag, so we approximate: a row counts only
|
|
560
|
+
* when LENGTH(text) >= 200 (the FINAL_ANSWER_MIN_CHARS constant from
|
|
561
|
+
* final-answer-detect.ts). This is false-negative-safe: a genuine substantive
|
|
562
|
+
* answer that happens to be < 200 chars will still fire an escalation, which is
|
|
563
|
+
* the conservative (safe) outcome. A schema column would be more precise but is
|
|
564
|
+
* disproportionate for this predicate; the reviewer accepted this approach.
|
|
565
|
+
*
|
|
566
|
+
* `threadId` semantics:
|
|
567
|
+
* - undefined → any message in the chat regardless of thread (DMs + supergroups)
|
|
568
|
+
* - explicit number → only that thread (precise for supergroups with topics)
|
|
569
|
+
* - explicit null → only chat-root (non-thread) messages
|
|
570
|
+
*
|
|
571
|
+
* Falls back to false (safe: never suppresses escalation) if history is not yet
|
|
572
|
+
* initialised or the query fails.
|
|
573
|
+
*/
|
|
574
|
+
export function hasOutboundDeliveredSince(
|
|
575
|
+
chatId: string,
|
|
576
|
+
sinceMs: number,
|
|
577
|
+
threadId?: number | null,
|
|
578
|
+
): boolean {
|
|
579
|
+
try {
|
|
580
|
+
const cutoffSec = Math.floor(sinceMs / 1000)
|
|
581
|
+
const params: unknown[] = [chatId, cutoffSec]
|
|
582
|
+
// LENGTH(text) >= 200 scopes to substantive replies only — never suppress
|
|
583
|
+
// escalation on a mere ack. Mirrors FINAL_ANSWER_MIN_CHARS (200) from
|
|
584
|
+
// final-answer-detect.ts; the `done` flag is not stored in the history
|
|
585
|
+
// schema, so length is the closest available proxy.
|
|
586
|
+
let sql =
|
|
587
|
+
"SELECT 1 FROM messages WHERE chat_id = ? AND role = 'assistant' AND ts >= ? AND LENGTH(text) >= 200"
|
|
588
|
+
if (threadId !== undefined) {
|
|
589
|
+
if (threadId === null) {
|
|
590
|
+
sql += ' AND thread_id IS NULL'
|
|
591
|
+
} else {
|
|
592
|
+
sql += ' AND thread_id = ?'
|
|
593
|
+
params.push(threadId)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
sql += ' LIMIT 1'
|
|
597
|
+
const row = requireDb()
|
|
598
|
+
.prepare(sql)
|
|
599
|
+
.get(...(params as [unknown, ...unknown[]])) as Record<string, unknown> | undefined
|
|
600
|
+
return row != null
|
|
601
|
+
} catch {
|
|
602
|
+
return false
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
549
606
|
export function query(opts: QueryOptions): RecordedMessage[] {
|
|
550
607
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
|
|
551
608
|
const params: unknown[] = [opts.chat_id]
|
|
@@ -29,7 +29,7 @@ describe('request_secret — gateway wiring', () => {
|
|
|
29
29
|
})
|
|
30
30
|
|
|
31
31
|
it('is allow-listed and dispatched', () => {
|
|
32
|
-
expect(gw).toMatch(/'request_secret',\n
|
|
32
|
+
expect(gw).toMatch(/'request_secret',\n/)
|
|
33
33
|
expect(gw).toMatch(/case 'request_secret':\s*\n\s*return executeRequestSecret\(args\)/)
|
|
34
34
|
})
|
|
35
35
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
query,
|
|
11
11
|
getRecentOutboundCount,
|
|
12
12
|
getLatestInboundMessageId,
|
|
13
|
+
hasOutboundDeliveredSince,
|
|
13
14
|
_resetForTests,
|
|
14
15
|
} from '../history.js'
|
|
15
16
|
|
|
@@ -363,6 +364,88 @@ describe('getRecentOutboundCount (backstop dedup helper)', () => {
|
|
|
363
364
|
})
|
|
364
365
|
})
|
|
365
366
|
|
|
367
|
+
// A substantive reply: 200+ chars (the FINAL_ANSWER_MIN_CHARS threshold).
|
|
368
|
+
const SUBSTANTIVE = 'A'.repeat(200)
|
|
369
|
+
// A non-substantive ack: short (<200 chars).
|
|
370
|
+
const ACK = 'On it.'
|
|
371
|
+
|
|
372
|
+
describe('hasOutboundDeliveredSince', () => {
|
|
373
|
+
beforeEach(() => initHistory(stateDir, 30))
|
|
374
|
+
|
|
375
|
+
it('returns true when a substantive outbound exists after openedAt', () => {
|
|
376
|
+
const openedAt = 1_000_000 * 1000 // ms
|
|
377
|
+
recordOutbound({
|
|
378
|
+
chat_id: '-100',
|
|
379
|
+
thread_id: null,
|
|
380
|
+
message_ids: [10],
|
|
381
|
+
texts: [SUBSTANTIVE],
|
|
382
|
+
ts: 1_000_001, // sec — 1s after openedAt
|
|
383
|
+
})
|
|
384
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('returns false when the only outbound is BEFORE openedAt', () => {
|
|
388
|
+
const openedAt = 1_000_002 * 1000 // ms — after the message
|
|
389
|
+
recordOutbound({
|
|
390
|
+
chat_id: '-100',
|
|
391
|
+
thread_id: null,
|
|
392
|
+
message_ids: [10],
|
|
393
|
+
texts: [SUBSTANTIVE],
|
|
394
|
+
ts: 1_000_001, // sec — before openedAt
|
|
395
|
+
})
|
|
396
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('returns false for a non-substantive ack after openedAt (blocker regression)', () => {
|
|
400
|
+
// An agent that sends a short ack ("on it") then ghosts must NOT have
|
|
401
|
+
// its escalation suppressed. The predicate must never match a bare ack.
|
|
402
|
+
const openedAt = 1_000_000 * 1000
|
|
403
|
+
recordOutbound({
|
|
404
|
+
chat_id: '-100',
|
|
405
|
+
thread_id: null,
|
|
406
|
+
message_ids: [10],
|
|
407
|
+
texts: [ACK], // < 200 chars — non-substantive
|
|
408
|
+
ts: 1_000_001,
|
|
409
|
+
})
|
|
410
|
+
expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(false)
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
it('thread_id=undefined matches any thread (DM semantics)', () => {
|
|
414
|
+
const openedAt = 1_000_000 * 1000
|
|
415
|
+
recordOutbound({
|
|
416
|
+
chat_id: '-100',
|
|
417
|
+
thread_id: 5,
|
|
418
|
+
message_ids: [10],
|
|
419
|
+
texts: [SUBSTANTIVE],
|
|
420
|
+
ts: 1_000_001,
|
|
421
|
+
})
|
|
422
|
+
// No thread filter → should find it
|
|
423
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, undefined)).toBe(true)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
it('thread_id=number scopes to that thread only', () => {
|
|
427
|
+
const openedAt = 1_000_000 * 1000
|
|
428
|
+
recordOutbound({ chat_id: '-100', thread_id: 5, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
429
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 5)).toBe(true)
|
|
430
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 6)).toBe(false)
|
|
431
|
+
})
|
|
432
|
+
|
|
433
|
+
it('thread_id=null matches only chat-root (non-thread) messages', () => {
|
|
434
|
+
const openedAt = 1_000_000 * 1000
|
|
435
|
+
recordOutbound({ chat_id: '-100', thread_id: null, message_ids: [10], texts: [SUBSTANTIVE], ts: 1_000_001 })
|
|
436
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true)
|
|
437
|
+
// A thread-scoped message should NOT match the root filter
|
|
438
|
+
recordOutbound({ chat_id: '-100', thread_id: 3, message_ids: [11], texts: [SUBSTANTIVE], ts: 1_000_002 })
|
|
439
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, null)).toBe(true) // root still there
|
|
440
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 3)).toBe(true) // thread 3 also there
|
|
441
|
+
expect(hasOutboundDeliveredSince('-100', openedAt, 9)).toBe(false) // thread 9 not there
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
it('returns false when no history is present for the chat', () => {
|
|
445
|
+
expect(hasOutboundDeliveredSince('-999', 0)).toBe(false)
|
|
446
|
+
})
|
|
447
|
+
})
|
|
448
|
+
|
|
366
449
|
describe('secret redaction at persistence (both directions)', () => {
|
|
367
450
|
beforeEach(() => initHistory(stateDir, 30))
|
|
368
451
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import {
|
|
4
|
+
emitLinearAgentActivity,
|
|
5
|
+
type LinearTokenResult,
|
|
6
|
+
} from '../gateway/linear-activity.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tests for the `linear_agent_activity` MCP tool (#2298).
|
|
10
|
+
*
|
|
11
|
+
* Structural part: assert the tool is declared in bridge/bridge.ts and
|
|
12
|
+
* allow-listed + dispatched in gateway/gateway.ts (the gateway IIFE can't be
|
|
13
|
+
* imported in a test, so wiring is verified by reading the source — same
|
|
14
|
+
* constraint as gateway-request-secret.test.ts).
|
|
15
|
+
*
|
|
16
|
+
* Behavioural part: the activity-emit logic lives in gateway/linear-activity.ts
|
|
17
|
+
* with injectable token-resolver + fetch, so the happy path and the
|
|
18
|
+
* vault-denied path are exercised without a broker or the network.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const okToken = (token: string) => async (): Promise<LinearTokenResult> => ({ ok: true, token })
|
|
22
|
+
|
|
23
|
+
function fakeFetch(status: number, jsonBody: unknown): {
|
|
24
|
+
fetchImpl: typeof fetch
|
|
25
|
+
calls: Array<{ url: string; init?: RequestInit }>
|
|
26
|
+
} {
|
|
27
|
+
const calls: Array<{ url: string; init?: RequestInit }> = []
|
|
28
|
+
const fetchImpl = (async (url: string, init?: RequestInit) => {
|
|
29
|
+
calls.push({ url, init })
|
|
30
|
+
return {
|
|
31
|
+
ok: status >= 200 && status < 300,
|
|
32
|
+
status,
|
|
33
|
+
json: async () => jsonBody,
|
|
34
|
+
text: async () => JSON.stringify(jsonBody),
|
|
35
|
+
} as unknown as Response
|
|
36
|
+
}) as unknown as typeof fetch
|
|
37
|
+
return { fetchImpl, calls }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('linear_agent_activity — gateway wiring (#2298)', () => {
|
|
41
|
+
const gw = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
42
|
+
const bridge = readFileSync(new URL('../bridge/bridge.ts', import.meta.url), 'utf8')
|
|
43
|
+
|
|
44
|
+
it('declares the MCP tool with required {agent_session_id,type}', () => {
|
|
45
|
+
const idx = bridge.indexOf(`name: 'linear_agent_activity'`)
|
|
46
|
+
expect(idx).toBeGreaterThan(0)
|
|
47
|
+
const schema = bridge.slice(idx, idx + 2000)
|
|
48
|
+
expect(schema).toMatch(/required: \['agent_session_id', 'type'\]/)
|
|
49
|
+
expect(schema).toMatch(/thought/)
|
|
50
|
+
expect(schema).toMatch(/complete/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is allow-listed and dispatched', () => {
|
|
54
|
+
expect(gw).toMatch(/'linear_agent_activity',\n\]\)/)
|
|
55
|
+
expect(gw).toMatch(/case 'linear_agent_activity':\s*\n\s*return executeLinearAgentActivity\(args\)/)
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('emitLinearAgentActivity — behaviour (#2298)', () => {
|
|
60
|
+
it('POSTs an agentActivityCreate mutation on the happy path', async () => {
|
|
61
|
+
const { fetchImpl, calls } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
|
|
62
|
+
const r = await emitLinearAgentActivity(
|
|
63
|
+
{ agent_session_id: 'sess_1', type: 'thought', body: 'On it.' },
|
|
64
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
65
|
+
)
|
|
66
|
+
expect(r.content[0].text).toMatch(/emitted on session sess_1/)
|
|
67
|
+
expect(calls).toHaveLength(1)
|
|
68
|
+
expect(calls[0].url).toBe('https://api.linear.app/graphql')
|
|
69
|
+
const sent = JSON.parse(calls[0].init!.body as string)
|
|
70
|
+
expect(sent.query).toMatch(/agentActivityCreate/)
|
|
71
|
+
expect(sent.variables.input.agentSessionId).toBe('sess_1')
|
|
72
|
+
expect(sent.variables.input.content).toEqual({ type: 'thought', body: 'On it.' })
|
|
73
|
+
expect((calls[0].init!.headers as Record<string, string>).Authorization).toBe('lin_tok')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('allows complete with no body', async () => {
|
|
77
|
+
const { fetchImpl } = fakeFetch(200, { data: { agentActivityCreate: { success: true } } })
|
|
78
|
+
const r = await emitLinearAgentActivity(
|
|
79
|
+
{ agent_session_id: 'sess_2', type: 'complete' },
|
|
80
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
81
|
+
)
|
|
82
|
+
expect(r.content[0].text).toMatch(/complete emitted/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('requires body for thought/message/error', async () => {
|
|
86
|
+
await expect(
|
|
87
|
+
emitLinearAgentActivity(
|
|
88
|
+
{ agent_session_id: 'sess_3', type: 'message' },
|
|
89
|
+
{ resolveToken: okToken('t'), log: () => {} },
|
|
90
|
+
),
|
|
91
|
+
).rejects.toThrow(/body is required/)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('rejects an unknown type', async () => {
|
|
95
|
+
await expect(
|
|
96
|
+
emitLinearAgentActivity(
|
|
97
|
+
{ agent_session_id: 'sess_4', type: 'banana', body: 'x' },
|
|
98
|
+
{ resolveToken: okToken('t'), log: () => {} },
|
|
99
|
+
),
|
|
100
|
+
).rejects.toThrow(/type must be one of/)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('returns vault_request_access guidance when the token is denied', async () => {
|
|
104
|
+
const r = await emitLinearAgentActivity(
|
|
105
|
+
{ agent_session_id: 'sess_5', type: 'thought', body: 'hi' },
|
|
106
|
+
{
|
|
107
|
+
agent: 'carrie',
|
|
108
|
+
resolveToken: async () => ({ ok: false, reason: 'denied' }),
|
|
109
|
+
log: () => {},
|
|
110
|
+
},
|
|
111
|
+
)
|
|
112
|
+
expect(r.content[0].text).toMatch(/vault_request_access/)
|
|
113
|
+
expect(r.content[0].text).toMatch(/linear\/carrie\/token/)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('surfaces a Linear API error status', async () => {
|
|
117
|
+
const { fetchImpl } = fakeFetch(401, { error: 'bad token' })
|
|
118
|
+
const r = await emitLinearAgentActivity(
|
|
119
|
+
{ agent_session_id: 'sess_6', type: 'thought', body: 'hi' },
|
|
120
|
+
{ agent: 'carrie', resolveToken: okToken('lin_tok'), fetchImpl, log: () => {} },
|
|
121
|
+
)
|
|
122
|
+
expect(r.content[0].text).toMatch(/Linear API 401/)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -113,6 +113,46 @@ describe("isValidModelArg", () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
+
// Regression for the 2026-06-13 fleet outage: defaults.model was pinned to
|
|
117
|
+
// the full codename `claude-fable-5`, which Anthropic retired server-side →
|
|
118
|
+
// every agent 4xx'd. The fix is to select models by ALIAS (durable) instead
|
|
119
|
+
// of pinned ids. This locks in that `fable` (and the other aliases) stay
|
|
120
|
+
// selectable, and documents the alias-vs-codename distinction.
|
|
121
|
+
describe("model selection: aliases stay selectable (incl. fable)", () => {
|
|
122
|
+
it("lists fable as a first-class alias", () => {
|
|
123
|
+
// `fable` is the latest flagship (Fable 5) and must remain pickable.
|
|
124
|
+
expect(MODEL_ALIASES).toContain("fable");
|
|
125
|
+
// The standard set is intact alongside it.
|
|
126
|
+
for (const a of ["opus", "sonnet", "haiku", "default"]) {
|
|
127
|
+
expect(MODEL_ALIASES, a).toContain(a);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("each alias is a valid model arg and parses as a set", () => {
|
|
132
|
+
for (const alias of MODEL_ALIASES) {
|
|
133
|
+
expect(isValidModelArg(alias), alias).toBe(true);
|
|
134
|
+
expect(parseModelCommand(`/model ${alias}`)).toEqual({ kind: "set", model: alias });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("the help text surfaces the fable alias", async () => {
|
|
139
|
+
const reply = await handleModelCommand({ kind: "help" }, makeDeps());
|
|
140
|
+
expect(reply.text).toContain("fable");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("passthrough: a full id (incl. the retired claude-fable-5 codename) is shape-accepted, not allowlisted", () => {
|
|
144
|
+
// switchroom does NOT allowlist models — the SHAPE gate passes any
|
|
145
|
+
// well-formed id through to claude, which is the sole validator. So the
|
|
146
|
+
// retired `claude-fable-5` codename still parses here (it just 4xx's at
|
|
147
|
+
// claude); selection flexibility (any current/future model) is preserved.
|
|
148
|
+
expect(parseModelCommand("/model claude-fable-5")).toEqual({
|
|
149
|
+
kind: "set",
|
|
150
|
+
model: "claude-fable-5",
|
|
151
|
+
});
|
|
152
|
+
expect(isValidModelArg("claude-fable-5")).toBe(true);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
116
156
|
describe("handleModelCommand — show / help never inject (picker-wedge guard)", () => {
|
|
117
157
|
it("show renders configured model + switch options without injecting", async () => {
|
|
118
158
|
const { deps, calls } = makeDeps();
|
|
@@ -105,14 +105,26 @@ describe("ObligationLedger", () => {
|
|
|
105
105
|
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
-
it("no echo + MULTIPLE open → close
|
|
108
|
+
it("no echo + MULTIPLE open + live turn IS open → close the live turn's OWN obligation (Fix 2)", () => {
|
|
109
109
|
const L = new ObligationLedger();
|
|
110
110
|
L.openIfAbsent(input("c:635#713", 1000));
|
|
111
111
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
expect(L.
|
|
112
|
+
// A substantive reply delivered during currentTurn=715 (no model echo, no
|
|
113
|
+
// routed origin) closes 715's own obligation. 713 stays open and is
|
|
114
|
+
// re-presented. The 713/715 invariant holds: this does NOT close 713.
|
|
115
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715")).toBe("c:3#715");
|
|
116
|
+
expect(L.isOpen("c:635#713")).toBe(true); // 713 stays open
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("no echo + MULTIPLE open + live turn NOT one of them → close nothing (713/715 invariant preserved)", () => {
|
|
120
|
+
const L = new ObligationLedger();
|
|
121
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
122
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
123
|
+
// currentTurn=999 is NOT an open obligation. We can't identify the right
|
|
124
|
+
// target → refuse to close anything (a re-present is safer than a wrong close).
|
|
125
|
+
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
126
|
+
expect(L.isOpen("c:635#713")).toBe(true);
|
|
127
|
+
expect(L.isOpen("c:3#715")).toBe(true);
|
|
116
128
|
});
|
|
117
129
|
|
|
118
130
|
it("no echo + live turn not an open obligation → null", () => {
|
|
@@ -120,6 +132,35 @@ describe("ObligationLedger", () => {
|
|
|
120
132
|
L.openIfAbsent(input("c:3#715", 1100));
|
|
121
133
|
expect(L.resolveCloseTarget(undefined, "c:9#999")).toBeNull();
|
|
122
134
|
});
|
|
135
|
+
|
|
136
|
+
it("routedOriginId (Fix 1) closes the routed target even with multiple open + no model echo", () => {
|
|
137
|
+
// Simulate the clerk incident: two obligations open, agent answers #14057
|
|
138
|
+
// via a quoted reply (via=quoted), no model echo. The gateway resolves
|
|
139
|
+
// routedOriginId=#14057 from the quote. That obligation closes; #14059 stays.
|
|
140
|
+
const L = new ObligationLedger();
|
|
141
|
+
L.openIfAbsent(input("c:0#14057", 1000));
|
|
142
|
+
L.openIfAbsent(input("c:0#14059", 1100));
|
|
143
|
+
expect(L.resolveCloseTarget(undefined, "c:0#14059", "c:0#14057")).toBe("c:0#14057");
|
|
144
|
+
// Close it to confirm only #14057 closes
|
|
145
|
+
L.close("c:0#14057");
|
|
146
|
+
expect(L.isOpen("c:0#14057")).toBe(false);
|
|
147
|
+
expect(L.isOpen("c:0#14059")).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("echoedTurnId wins over routedOriginId when both present (echoed is authoritative)", () => {
|
|
151
|
+
const L = new ObligationLedger();
|
|
152
|
+
L.openIfAbsent(input("c:635#713", 1000));
|
|
153
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
154
|
+
// Model explicitly echoed 713 AND router resolved 715 as routed origin.
|
|
155
|
+
// The echo is authoritative — close 713.
|
|
156
|
+
expect(L.resolveCloseTarget("c:635#713", "c:3#715", "c:3#715")).toBe("c:635#713");
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("routedOriginId=null falls through to live-turn fallback", () => {
|
|
160
|
+
const L = new ObligationLedger();
|
|
161
|
+
L.openIfAbsent(input("c:3#715", 1100));
|
|
162
|
+
expect(L.resolveCloseTarget(undefined, "c:3#715", null)).toBe("c:3#715");
|
|
163
|
+
});
|
|
123
164
|
});
|
|
124
165
|
});
|
|
125
166
|
|
|
@@ -411,3 +452,170 @@ describe("ObligationLedger — background-work grace (extended-autonomous fix, g
|
|
|
411
452
|
).toBe("represent");
|
|
412
453
|
});
|
|
413
454
|
});
|
|
455
|
+
|
|
456
|
+
describe("ObligationLedger — per-represent grace (Fix 3: clerk 2026-06-13 incident)", () => {
|
|
457
|
+
function input(id: string, openedAt: number) {
|
|
458
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const REPR_GRACE = 120_000; // 2 min, mirroring the default
|
|
462
|
+
|
|
463
|
+
it("a freshly re-presented obligation is ineligible until representGraceMs elapses", () => {
|
|
464
|
+
const L = new ObligationLedger(2);
|
|
465
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
466
|
+
// Re-present fires at t=5000; markRepresented stamps lastRepresentedAt.
|
|
467
|
+
L.markRepresented("c:3#1", 5000);
|
|
468
|
+
// 10s later — still within 120s represent grace → ineligible → none.
|
|
469
|
+
expect(
|
|
470
|
+
L.decideAtIdle({ now: 15000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
471
|
+
).toBe("none");
|
|
472
|
+
// 120s + 1ms after re-present → grace expired → act.
|
|
473
|
+
expect(
|
|
474
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 1, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
475
|
+
).toBe("represent");
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("markRepresented stamps lastRepresentedAt and persists", () => {
|
|
479
|
+
const snapshots: Obligation[][] = [];
|
|
480
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
481
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
482
|
+
L.markRepresented("c:3#1", 9000);
|
|
483
|
+
const snap = L.list()[0];
|
|
484
|
+
expect(snap.lastRepresentedAt).toBe(9000);
|
|
485
|
+
expect(snap.representCount).toBe(1);
|
|
486
|
+
// Persisted: onChange fired for open + markRepresented = 2 snapshots
|
|
487
|
+
expect(snapshots.length).toBe(2);
|
|
488
|
+
expect(snapshots[1][0].lastRepresentedAt).toBe(9000);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it("representGraceMs=0 (kill switch) → no per-represent grace, acts immediately", () => {
|
|
492
|
+
const L = new ObligationLedger(2);
|
|
493
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
494
|
+
L.markRepresented("c:3#1", 5000);
|
|
495
|
+
// Kill switch: representGraceMs=0 → the freshly-represented obligation is still eligible.
|
|
496
|
+
expect(
|
|
497
|
+
L.decideAtIdle({ now: 5001, graceMs: 45000, representGraceMs: 0 }).action,
|
|
498
|
+
).toBe("represent");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("an obligation that was NEVER re-presented has no per-represent grace (no lastRepresentedAt)", () => {
|
|
502
|
+
const L = new ObligationLedger(2);
|
|
503
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
504
|
+
// No markRepresented call → no lastRepresentedAt → always eligible on this axis.
|
|
505
|
+
expect(
|
|
506
|
+
L.decideAtIdle({ now: 2000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
507
|
+
).toBe("represent");
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it("per-represent grace survives hydration (durable snapshot)", () => {
|
|
511
|
+
const L = new ObligationLedger(2);
|
|
512
|
+
const now = 10_000;
|
|
513
|
+
L.hydrate([
|
|
514
|
+
{
|
|
515
|
+
originTurnId: "c:3#1",
|
|
516
|
+
chatId: "-100123",
|
|
517
|
+
threadId: 3,
|
|
518
|
+
messageId: 1,
|
|
519
|
+
text: "x",
|
|
520
|
+
openedAt: 1000,
|
|
521
|
+
representCount: 1,
|
|
522
|
+
lastRepresentedAt: now - 5000, // represented 5s ago
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
// 5s after re-present, grace=120s → still within grace → none.
|
|
526
|
+
expect(
|
|
527
|
+
L.decideAtIdle({ now, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
528
|
+
).toBe("none");
|
|
529
|
+
// 120s + 1ms after re-present → grace expired → escalate (count=1 < max=2 → represent).
|
|
530
|
+
expect(
|
|
531
|
+
L.decideAtIdle({ now: now - 5000 + REPR_GRACE + 1, graceMs: 0, representGraceMs: REPR_GRACE }).action,
|
|
532
|
+
).toBe("represent");
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it("per-represent grace composes with trailing-answer grace: both must clear", () => {
|
|
536
|
+
const L = new ObligationLedger(2);
|
|
537
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
538
|
+
L.noteTurnEnded("c:3#1", 5000);
|
|
539
|
+
L.markRepresented("c:3#1", 5000);
|
|
540
|
+
// Both graces active; trailing: turn ended 5s ago (grace=45s → in grace);
|
|
541
|
+
// per-represent: re-presented 5s ago (grace=120s → in grace) → none.
|
|
542
|
+
expect(
|
|
543
|
+
L.decideAtIdle({ now: 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
544
|
+
).toBe("none");
|
|
545
|
+
// Trailing grace cleared (50s after turn-end), per-represent NOT yet (only 45s after re-present).
|
|
546
|
+
expect(
|
|
547
|
+
L.decideAtIdle({ now: 5000 + 50000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
548
|
+
).toBe("none");
|
|
549
|
+
// Both cleared (125s after re-present at t=5000 → t=130000).
|
|
550
|
+
expect(
|
|
551
|
+
L.decideAtIdle({ now: 5000 + REPR_GRACE + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
552
|
+
).toBe("represent");
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("the clerk incident: two messages arrive, re-present fires at T; the sweep ticks again at T+5s → grace prevents premature escalation", () => {
|
|
556
|
+
// This is the exact sequence from clerk 2026-06-13: obligation re-presented
|
|
557
|
+
// at T, sweep fires at T+5s before the re-present turn has even landed, would
|
|
558
|
+
// immediately fire AGAIN (burning representCount to 2 → escalate in 5 more
|
|
559
|
+
// seconds). With per-represent grace, the T+5s tick is a no-op.
|
|
560
|
+
const L = new ObligationLedger(2);
|
|
561
|
+
const T = 1_000_000;
|
|
562
|
+
L.openIfAbsent(input("c:0#14057", T));
|
|
563
|
+
// First represent at T
|
|
564
|
+
L.markRepresented("c:0#14057", T);
|
|
565
|
+
expect(L.list()[0].representCount).toBe(1);
|
|
566
|
+
// Sweep at T+5s: MUST be "none" (within per-represent grace)
|
|
567
|
+
expect(
|
|
568
|
+
L.decideAtIdle({ now: T + 5000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
569
|
+
).toBe("none");
|
|
570
|
+
// Sweep at T+10s: still within grace
|
|
571
|
+
expect(
|
|
572
|
+
L.decideAtIdle({ now: T + 10000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
573
|
+
).toBe("none");
|
|
574
|
+
// Sweep after grace: eligible for second represent (not escalate — count=1 < max=2)
|
|
575
|
+
expect(
|
|
576
|
+
L.decideAtIdle({ now: T + REPR_GRACE + 1000, graceMs: 45000, representGraceMs: REPR_GRACE }).action,
|
|
577
|
+
).toBe("represent");
|
|
578
|
+
expect(L.list()[0].representCount).toBe(1); // still 1 — decideAtIdle is pure
|
|
579
|
+
});
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
describe("ObligationLedger — escalation suppression predicate (Fix 4)", () => {
|
|
583
|
+
// Fix 4 is implemented in the gateway (obligationSweep checks
|
|
584
|
+
// hasOutboundDeliveredSince before calling driveEscalation). We test the
|
|
585
|
+
// predicate seam via the pattern the sweep uses: if an outbound was delivered
|
|
586
|
+
// since openedAt, the obligation should be closed silently, not escalated.
|
|
587
|
+
// This suite tests the ledger behaviour that enables that path: after a
|
|
588
|
+
// silent close the ledger is empty and the next sweep sees 'none'.
|
|
589
|
+
function input(id: string, openedAt: number) {
|
|
590
|
+
return { originTurnId: id, chatId: "-100123", threadId: 3, messageId: Number(id.split("#").pop() ?? 0), text: "x", openedAt };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
it("a silently-closed obligation (gateway closed on outbound-delivered check) leaves ledger empty", () => {
|
|
594
|
+
const L = new ObligationLedger(2);
|
|
595
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
596
|
+
// Gateway's Fix 4 path: outbound delivered since openedAt → close silently
|
|
597
|
+
expect(L.close("c:3#1")).toBe(true);
|
|
598
|
+
expect(L.hasOpen()).toBe(false);
|
|
599
|
+
expect(L.decideAtIdle().action).toBe("none");
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("an escalation that reaches maxRepresents WITHOUT any known outbound still proceeds (escalate action)", () => {
|
|
603
|
+
const L = new ObligationLedger(2);
|
|
604
|
+
L.openIfAbsent(input("c:3#1", 1000));
|
|
605
|
+
L.markRepresented("c:3#1");
|
|
606
|
+
L.markRepresented("c:3#1");
|
|
607
|
+
// hasOutboundDeliveredSince returns false (no outbound recorded) → escalate
|
|
608
|
+
const d = L.decideAtIdle({ now: 9_999_999, graceMs: 0, representGraceMs: 0 });
|
|
609
|
+
expect(d.action).toBe("escalate");
|
|
610
|
+
expect(d.obligation?.originTurnId).toBe("c:3#1");
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it("silent close fires onChange (persists the empty set)", () => {
|
|
614
|
+
const snapshots: Obligation[][] = [];
|
|
615
|
+
const L = new ObligationLedger(2, { onChange: (s) => snapshots.push(s) });
|
|
616
|
+
L.openIfAbsent(input("c:3#1", 1000)); // snapshot[0]
|
|
617
|
+
L.close("c:3#1"); // snapshot[1] = []
|
|
618
|
+
expect(snapshots.length).toBe(2);
|
|
619
|
+
expect(snapshots[1]).toEqual([]);
|
|
620
|
+
});
|
|
621
|
+
});
|
|
@@ -101,6 +101,23 @@ describe("obligation-store", () => {
|
|
|
101
101
|
expect(loaded.map((o) => o.originTurnId)).toEqual(["c:3#715", "c:5#900"]);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
it("round-trips lastRepresentedAt through parse → isObligationRow filter → hydrate", () => {
|
|
105
|
+
// Regression: isObligationRow only checks required fields; optional fields
|
|
106
|
+
// (lastRepresentedAt, lastTurnEndedAt, escalateAttempts) must survive the
|
|
107
|
+
// filter without being stripped. A missing check would silently drop the field
|
|
108
|
+
// and break the per-represent grace window across restarts.
|
|
109
|
+
const { fs } = memFs();
|
|
110
|
+
const snap: Obligation[] = [
|
|
111
|
+
ob("c:3#715", { representCount: 1, lastRepresentedAt: 1_700_000_000_000, lastTurnEndedAt: 1_700_000_001_000 }),
|
|
112
|
+
];
|
|
113
|
+
persistObligations(PATH, fs, snap);
|
|
114
|
+
const loaded = loadObligations(PATH, fs);
|
|
115
|
+
expect(loaded).toHaveLength(1);
|
|
116
|
+
expect(loaded[0]!.lastRepresentedAt).toBe(1_700_000_000_000);
|
|
117
|
+
expect(loaded[0]!.lastTurnEndedAt).toBe(1_700_000_001_000);
|
|
118
|
+
expect(loaded[0]!.representCount).toBe(1);
|
|
119
|
+
});
|
|
120
|
+
|
|
104
121
|
it("never throws on a write failure — degrades to in-memory (logs)", () => {
|
|
105
122
|
const logs: string[] = [];
|
|
106
123
|
const fs: ObligationStoreFsSeam = {
|