switchroom 0.18.3 → 0.18.7
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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1331 -151
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +249 -46
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +17 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +115 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/worker-activity-feed.ts +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -17,6 +17,7 @@ function makeSub(over: Partial<Subagent>): Subagent {
|
|
|
17
17
|
result_summary: null,
|
|
18
18
|
jsonl_agent_id: 'a37ad7639ae61476c',
|
|
19
19
|
parent_agent_id: null,
|
|
20
|
+
model: null,
|
|
20
21
|
...over,
|
|
21
22
|
}
|
|
22
23
|
}
|
|
@@ -61,6 +62,85 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
|
|
|
61
62
|
// flip a foreground turn into a background one.
|
|
62
63
|
expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
|
|
63
64
|
})
|
|
65
|
+
|
|
66
|
+
it('surfaces the persisted registry model as feedModel (first-paint fallback)', () => {
|
|
67
|
+
const sub = makeSub({ background: true, model: 'claude-opus-4-8' })
|
|
68
|
+
expect(resolveWorkerFeedDispatch(sub, 'sub-agent').feedModel).toBe('claude-opus-4-8')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('feedModel is null when the row is missing or carries no model', () => {
|
|
72
|
+
expect(resolveWorkerFeedDispatch(null, 'sub-agent').feedModel).toBeNull()
|
|
73
|
+
expect(resolveWorkerFeedDispatch(makeSub({ model: null }), 'sub-agent').feedModel).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('gateway onFinish — fix #1: resultText-driven handback fallback (model)', () => {
|
|
78
|
+
// Mirrors the exact fallback gateway.ts's onFinish handler applies on top
|
|
79
|
+
// of `resolveWorkerFeedDispatch`'s own result (gateway.ts, onFinish
|
|
80
|
+
// handler, right after computing `dispatch`):
|
|
81
|
+
//
|
|
82
|
+
// let isBackground = dispatch.isBackground
|
|
83
|
+
// if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
|
|
84
|
+
// isBackground = true
|
|
85
|
+
// }
|
|
86
|
+
//
|
|
87
|
+
// This is the worst case of the #1 bug: the DB row was never linked
|
|
88
|
+
// (unlinked `jsonl_agent_id`) AND the watcher's own cached
|
|
89
|
+
// `entry.background` was also never observed (both `resolveWorkerFeedDispatch`
|
|
90
|
+
// fallbacks came up empty). A finished worker with actual narrative
|
|
91
|
+
// result text is far more likely a dropped background handback than a
|
|
92
|
+
// legitimate foreground no-op, so this degrades to background rather
|
|
93
|
+
// than silently discarding the result.
|
|
94
|
+
function applyOnFinishBackgroundFallback(
|
|
95
|
+
dispatch: { isBackground: boolean; hasRow: boolean },
|
|
96
|
+
entryBackground: boolean | undefined,
|
|
97
|
+
resultText: string,
|
|
98
|
+
): boolean {
|
|
99
|
+
let isBackground = dispatch.isBackground
|
|
100
|
+
if (!dispatch.hasRow && entryBackground == null && resultText.trim().length > 0) {
|
|
101
|
+
isBackground = true
|
|
102
|
+
}
|
|
103
|
+
return isBackground
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it('meta-missing path: no row, no entry.background, non-empty resultText → routes a handback', () => {
|
|
107
|
+
const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
|
|
108
|
+
const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Here is what I found...')
|
|
109
|
+
expect(result).toBe(true)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('no-fuzzy-candidate path: no row, no entry.background, non-empty resultText → routes a handback', () => {
|
|
113
|
+
// Same shape as above — the ambiguous-fuzzy-match-refused case (fix #3)
|
|
114
|
+
// also leaves jsonl_agent_id unlinked, landing in the same no-row state.
|
|
115
|
+
const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
|
|
116
|
+
const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Task complete: 3 files changed.')
|
|
117
|
+
expect(result).toBe(true)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('empty resultText with no row/no entry.background does NOT escalate (true foreground no-op preserved)', () => {
|
|
121
|
+
const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
|
|
122
|
+
const result = applyOnFinishBackgroundFallback(dispatch, undefined, ' ')
|
|
123
|
+
expect(result).toBe(false)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('entry.background=false (watcher DID observe it as foreground) is trusted over resultText', () => {
|
|
127
|
+
const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', false)
|
|
128
|
+
const result = applyOnFinishBackgroundFallback(dispatch, false, 'Some prose result anyway.')
|
|
129
|
+
expect(result).toBe(false)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('entry.background=true (watcher DID observe it) is trusted directly, no resultText fallback needed', () => {
|
|
133
|
+
const dispatch = resolveWorkerFeedDispatch(null, 'sub-agent', true)
|
|
134
|
+
const result = applyOnFinishBackgroundFallback(dispatch, true, '')
|
|
135
|
+
expect(result).toBe(true)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('a present DB row is authoritative — the resultText fallback never overrides a real foreground row', () => {
|
|
139
|
+
const sub = makeSub({ background: false })
|
|
140
|
+
const dispatch = resolveWorkerFeedDispatch(sub, 'sub-agent', undefined)
|
|
141
|
+
const result = applyOnFinishBackgroundFallback(dispatch, undefined, 'Long narrative result...')
|
|
142
|
+
expect(result).toBe(false)
|
|
143
|
+
})
|
|
64
144
|
})
|
|
65
145
|
|
|
66
146
|
// Deterministic PRNG (mulberry32) so a failing case is reproducible from the
|
|
@@ -140,6 +220,52 @@ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
|
|
|
140
220
|
})
|
|
141
221
|
})
|
|
142
222
|
|
|
223
|
+
describe('resolveWorkerFeedDispatch — fix #1(+#2): entryBackground fallback', () => {
|
|
224
|
+
it('falls back to entryBackground=true when the registry row is missing (unlinked jsonl_agent_id)', () => {
|
|
225
|
+
// Simulates onFinish firing for a background worker whose DB row was
|
|
226
|
+
// never linked via `jsonl_agent_id` (meta.json missing, or the fuzzy
|
|
227
|
+
// backfill match never fired) — the watcher's own cached
|
|
228
|
+
// `entry.background` (seeded at dispatch time / kept fresh by
|
|
229
|
+
// `readSubTail`'s liveness lookup) is the only signal left.
|
|
230
|
+
const out = resolveWorkerFeedDispatch(null, 'sub-agent', true)
|
|
231
|
+
expect(out.isBackground).toBe(true)
|
|
232
|
+
expect(out.hasRow).toBe(false)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('falls back to entryBackground=false when the row is missing and the entry was foreground', () => {
|
|
236
|
+
const out = resolveWorkerFeedDispatch(null, 'sub-agent', false)
|
|
237
|
+
expect(out.isBackground).toBe(false)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('defaults to false when the row is missing AND entryBackground was never observed (undefined)', () => {
|
|
241
|
+
// Both fallbacks come up empty — worst case, matches pre-fix
|
|
242
|
+
// behaviour (isBackground=false) at the resolver level. The gateway's
|
|
243
|
+
// onFinish handler applies its OWN further fallback on top of this
|
|
244
|
+
// (routing a handback anyway when resultText is non-empty) — that's
|
|
245
|
+
// pinned separately in the gateway-level test, not here.
|
|
246
|
+
const out = resolveWorkerFeedDispatch(null, 'sub-agent', undefined)
|
|
247
|
+
expect(out.isBackground).toBe(false)
|
|
248
|
+
expect(out.hasRow).toBe(false)
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('a present registry row always wins over entryBackground, even when they disagree', () => {
|
|
252
|
+
// The DB row is the authoritative source whenever it's actually
|
|
253
|
+
// linked — entryBackground is a pure fallback for the unlinked case,
|
|
254
|
+
// never a second vote.
|
|
255
|
+
const sub = makeSub({ background: true })
|
|
256
|
+
const out = resolveWorkerFeedDispatch(sub, 'sub-agent', false)
|
|
257
|
+
expect(out.isBackground).toBe(true)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('omitting entryBackground entirely preserves prior 2-arg call-site behaviour', () => {
|
|
261
|
+
// #2002-era callers pass only 2 args — the optional 3rd param must not
|
|
262
|
+
// change their observed result.
|
|
263
|
+
expect(resolveWorkerFeedDispatch(null, 'sub-agent')).toEqual(
|
|
264
|
+
resolveWorkerFeedDispatch(null, 'sub-agent', undefined),
|
|
265
|
+
)
|
|
266
|
+
})
|
|
267
|
+
})
|
|
268
|
+
|
|
143
269
|
describe('resolveWorkerFeedDispatch — nested/row-presence signals (unified progress cards)', () => {
|
|
144
270
|
it('hasRow=false for a missing registry row (never silently foreground-nest it)', () => {
|
|
145
271
|
const out = resolveWorkerFeedDispatch(null, 'sub-agent')
|
|
@@ -91,6 +91,7 @@ import {
|
|
|
91
91
|
} from './status-no-truncate.js'
|
|
92
92
|
import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
|
|
93
93
|
import { isTelegramSurfaceTool } from './tool-names.js'
|
|
94
|
+
import { formatModelLabel } from './model-label.js'
|
|
94
95
|
|
|
95
96
|
/**
|
|
96
97
|
* Optional header for the main-session activity card, matching the worker
|
|
@@ -106,6 +107,14 @@ export interface SessionActivityHeader {
|
|
|
106
107
|
elapsedMs: number
|
|
107
108
|
toolCount: number
|
|
108
109
|
state: 'running' | 'done'
|
|
110
|
+
/**
|
|
111
|
+
* Live model in use, as a raw resolved model id (e.g. `claude-opus-4-8`,
|
|
112
|
+
* `sr-glm-5`) sourced from the transcript. Rendered as a short friendly form
|
|
113
|
+
* appended to the metrics line (`… · opus 4.8`). Omitted when unknown — the
|
|
114
|
+
* card never guesses from config. Formatting + sentinel-filtering live in
|
|
115
|
+
* `formatModelLabel` (model-label.ts).
|
|
116
|
+
*/
|
|
117
|
+
model?: string
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
/**
|
|
@@ -173,14 +182,19 @@ export function renderActivityHeader(
|
|
|
173
182
|
elapsedMs: number,
|
|
174
183
|
toolCount: number,
|
|
175
184
|
state: 'running' | 'done' | 'failed',
|
|
185
|
+
model?: string,
|
|
176
186
|
): [string, string] {
|
|
177
187
|
const toolWord = toolCount === 1 ? 'tool' : 'tools'
|
|
178
188
|
const elapsed = formatFeedElapsed(elapsedMs)
|
|
179
189
|
const descPart = description.length > 0 ? ` · _${escapeMarkdown(description)}_` : ''
|
|
180
190
|
const line1 = `${emoji} **${escapeMarkdown(label)}**${descPart}`
|
|
191
|
+
// Subtle live-model tag: joins the existing dot-separated metrics (never a new
|
|
192
|
+
// line). formatModelLabel returns null for absent/sentinel values → no suffix.
|
|
193
|
+
const modelLabel = formatModelLabel(model)
|
|
194
|
+
const modelPart = modelLabel != null ? ` · ${escapeMarkdown(modelLabel)}` : ''
|
|
181
195
|
const line2 = state === 'running'
|
|
182
|
-
? `_${elapsed} · ${toolCount} ${toolWord}_`
|
|
183
|
-
: `_${state} · ${toolCount} ${toolWord} · ${elapsed}_`
|
|
196
|
+
? `_${elapsed} · ${toolCount} ${toolWord}${modelPart}_`
|
|
197
|
+
: `_${state} · ${toolCount} ${toolWord} · ${elapsed}${modelPart}_`
|
|
184
198
|
return [line1, line2]
|
|
185
199
|
}
|
|
186
200
|
|
|
@@ -270,6 +284,9 @@ export interface StatusCardHeader {
|
|
|
270
284
|
elapsedMs: number
|
|
271
285
|
toolCount: number
|
|
272
286
|
state: 'running' | 'done' | 'failed'
|
|
287
|
+
/** Live model id (raw, e.g. `claude-opus-4-8`) — rendered as a short friendly
|
|
288
|
+
* tag on the metrics line via `formatModelLabel`. Omitted when unknown. */
|
|
289
|
+
model?: string
|
|
273
290
|
}
|
|
274
291
|
|
|
275
292
|
/** Inputs to the unified status-card renderer. */
|
|
@@ -321,6 +338,7 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
321
338
|
// when the result block is empty. The agent surface never passes
|
|
322
339
|
// 'failed', so only the worker card is affected.
|
|
323
340
|
header.state,
|
|
341
|
+
header.model,
|
|
324
342
|
)
|
|
325
343
|
: []
|
|
326
344
|
|
|
@@ -476,6 +494,7 @@ export function renderActivityFeed(
|
|
|
476
494
|
elapsedMs: header.elapsedMs,
|
|
477
495
|
toolCount: header.toolCount,
|
|
478
496
|
state: header.state,
|
|
497
|
+
model: header.model,
|
|
479
498
|
}
|
|
480
499
|
: undefined,
|
|
481
500
|
steps: lines,
|
|
@@ -527,6 +546,7 @@ export function renderActivityFeedWithNested(
|
|
|
527
546
|
elapsedMs: header.elapsedMs,
|
|
528
547
|
toolCount: header.toolCount,
|
|
529
548
|
state: header.state,
|
|
549
|
+
model: header.model,
|
|
530
550
|
}
|
|
531
551
|
: undefined,
|
|
532
552
|
steps: lines,
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
// Auto-wrap tool dispatch with a Telegram typing-indicator loop so the user
|
|
2
|
-
// sees a live "agent is working" signal
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// sees a live "agent is working" signal for the run of a turn. This is now
|
|
3
|
+
// the PRIMARY liveness signal for short tool-using turns, not a bridge to a
|
|
4
|
+
// soon-to-appear pinned progress card: the pinned card is retired
|
|
5
|
+
// (`unpinProgressCardForChat = null`, see
|
|
6
|
+
// `docs/diagrams/deterministic-status-anatomy.spec.md`), and the worker
|
|
7
|
+
// progress card only gates in at `elapsed >= 60s OR sub-agent appeared` — for
|
|
8
|
+
// most short turns it never appears at all. The first tool call on a given
|
|
9
|
+
// (chat, thread) fires the typing loop immediately so there's no silent dead
|
|
10
|
+
// window at turn start. Subsequent calls on the same lane honour the
|
|
11
|
+
// debounce to avoid churn. Surface tools own their own loop — see
|
|
12
|
+
// isSurfaceTool.
|
|
8
13
|
//
|
|
9
14
|
// Keying changed from `chatId` to `(chatId, threadId)` in PR3 of the
|
|
10
15
|
// supergroup-mode rollout. In supergroup mode one agent owns many topics
|
|
@@ -38,17 +43,55 @@ export interface TypingWrapper {
|
|
|
38
43
|
interface Entry {
|
|
39
44
|
chatId: string
|
|
40
45
|
threadId: number | null
|
|
46
|
+
lane: string
|
|
41
47
|
timer: ReturnType<typeof setTimeout>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Per-lane bookkeeping: `count` is the number of outstanding (dispatched but
|
|
52
|
+
* not yet resulted) tool-uses on the lane; `started` is whether the typing
|
|
53
|
+
* loop is currently believed to be running for it. Ref-counted rather than a
|
|
54
|
+
* boolean-per-lane `Set` (pre-fix#7 shape) because two PARALLEL tool_use
|
|
55
|
+
* blocks on one lane must both hold the lane open: with a plain Set,
|
|
56
|
+
* `onToolResult` for the first one deleted the lane and stopped the loop
|
|
57
|
+
* while the second tool was still running, flickering the typing indicator
|
|
58
|
+
* off until the next tool re-fired it. Only the count reaching zero may stop
|
|
59
|
+
* the loop.
|
|
60
|
+
*/
|
|
61
|
+
interface LaneState {
|
|
62
|
+
count: number
|
|
42
63
|
started: boolean
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
46
67
|
const debounceMs = deps.debounceMs ?? 500
|
|
47
68
|
const pending = new Map<string, Entry>()
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
69
|
+
const lanes = new Map<string, LaneState>()
|
|
70
|
+
|
|
71
|
+
function laneFor(lane: string): LaneState {
|
|
72
|
+
let s = lanes.get(lane)
|
|
73
|
+
if (s == null) {
|
|
74
|
+
s = { count: 0, started: false }
|
|
75
|
+
lanes.set(lane, s)
|
|
76
|
+
}
|
|
77
|
+
return s
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Decrement the lane's outstanding count. Returns true iff the count just
|
|
81
|
+
// reached zero AND the loop was running — i.e. iff the CALLER must now
|
|
82
|
+
// call `stopTypingLoop`. This is the ref-count release: the loop only
|
|
83
|
+
// stops once every outstanding tool-use on the lane has resulted.
|
|
84
|
+
function release(lane: string): boolean {
|
|
85
|
+
const s = lanes.get(lane)
|
|
86
|
+
if (s == null) return false
|
|
87
|
+
s.count = Math.max(0, s.count - 1)
|
|
88
|
+
if (s.count === 0) {
|
|
89
|
+
const wasStarted = s.started
|
|
90
|
+
lanes.delete(lane)
|
|
91
|
+
return wasStarted
|
|
92
|
+
}
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
52
95
|
|
|
53
96
|
return {
|
|
54
97
|
onToolUse(toolUseId, chatId, toolName, threadId) {
|
|
@@ -60,19 +103,21 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
60
103
|
const prior = pending.get(toolUseId)
|
|
61
104
|
if (prior) {
|
|
62
105
|
clearTimeout(prior.timer)
|
|
63
|
-
if (prior.
|
|
106
|
+
if (release(prior.lane)) deps.stopTypingLoop(prior.chatId, prior.threadId)
|
|
64
107
|
pending.delete(toolUseId)
|
|
65
108
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
109
|
+
const state = laneFor(lane)
|
|
110
|
+
state.count += 1
|
|
111
|
+
// First outstanding tool-use on this lane: fire immediately rather
|
|
112
|
+
// than waiting for the debounce — this closes the silent dead window
|
|
113
|
+
// at turn start.
|
|
114
|
+
if (state.count === 1) {
|
|
70
115
|
deps.startTypingLoop(chatId, tid)
|
|
71
|
-
|
|
116
|
+
state.started = true
|
|
72
117
|
const entry: Entry = {
|
|
73
118
|
chatId,
|
|
74
119
|
threadId: tid,
|
|
75
|
-
|
|
120
|
+
lane,
|
|
76
121
|
timer: setTimeout(() => {}, 0), // no-op sentinel
|
|
77
122
|
}
|
|
78
123
|
pending.set(toolUseId, entry)
|
|
@@ -81,10 +126,10 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
81
126
|
const entry: Entry = {
|
|
82
127
|
chatId,
|
|
83
128
|
threadId: tid,
|
|
84
|
-
|
|
129
|
+
lane,
|
|
85
130
|
timer: setTimeout(() => {
|
|
86
131
|
deps.startTypingLoop(chatId, tid)
|
|
87
|
-
|
|
132
|
+
state.started = true
|
|
88
133
|
}, debounceMs),
|
|
89
134
|
}
|
|
90
135
|
pending.set(toolUseId, entry)
|
|
@@ -95,20 +140,22 @@ export function createTypingWrapper(deps: TypingWrapperDeps): TypingWrapper {
|
|
|
95
140
|
const entry = pending.get(toolUseId)
|
|
96
141
|
if (!entry) return
|
|
97
142
|
clearTimeout(entry.timer)
|
|
98
|
-
if (entry.
|
|
99
|
-
deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
100
|
-
activeLanes.delete(chatKey(entry.chatId, entry.threadId) as string)
|
|
101
|
-
}
|
|
143
|
+
if (release(entry.lane)) deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
102
144
|
pending.delete(toolUseId)
|
|
103
145
|
},
|
|
104
146
|
|
|
105
147
|
drainAll() {
|
|
148
|
+
const stoppedLanes = new Set<string>()
|
|
106
149
|
for (const entry of pending.values()) {
|
|
107
150
|
clearTimeout(entry.timer)
|
|
108
|
-
|
|
151
|
+
const state = lanes.get(entry.lane)
|
|
152
|
+
if (state?.started && !stoppedLanes.has(entry.lane)) {
|
|
153
|
+
deps.stopTypingLoop(entry.chatId, entry.threadId)
|
|
154
|
+
stoppedLanes.add(entry.lane)
|
|
155
|
+
}
|
|
109
156
|
}
|
|
110
157
|
pending.clear()
|
|
111
|
-
|
|
158
|
+
lanes.clear()
|
|
112
159
|
},
|
|
113
160
|
}
|
|
114
161
|
}
|