switchroom 0.16.47 → 0.17.1

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 (117) hide show
  1. package/dist/agent-scheduler/index.js +3 -1
  2. package/dist/auth-broker/index.js +24 -8
  3. package/dist/cli/drive-write-pretool.mjs +0 -5
  4. package/dist/cli/notion-write-pretool.mjs +3 -1
  5. package/dist/cli/switchroom.js +1359 -1030
  6. package/dist/cli/ui/index.html +84 -12
  7. package/dist/host-control/main.js +53 -17
  8. package/dist/vault/approvals/kernel-server.js +4 -1
  9. package/dist/vault/broker/server.js +201 -56
  10. package/package.json +3 -3
  11. package/profiles/_base/cron-session.sh.hbs +1 -1
  12. package/profiles/_base/start.sh.hbs +54 -3
  13. package/skills/switchroom-architecture/telegram.md +8 -15
  14. package/skills/switchroom-cli/SKILL.md +4 -5
  15. package/skills/telegram-test-harness/SKILL.md +1 -1
  16. package/telegram-plugin/README.md +18 -29
  17. package/telegram-plugin/bridge/bridge.ts +1 -41
  18. package/telegram-plugin/bridge/tool-filter.ts +3 -4
  19. package/telegram-plugin/dist/bridge/bridge.js +8 -43
  20. package/telegram-plugin/dist/gateway/gateway.js +842 -832
  21. package/telegram-plugin/dist/server.js +8 -43
  22. package/telegram-plugin/format.ts +119 -17
  23. package/telegram-plugin/gateway/approvals-commands.ts +6 -2
  24. package/telegram-plugin/gateway/busy-key-reaper.ts +113 -0
  25. package/telegram-plugin/gateway/disconnect-flush.ts +11 -0
  26. package/telegram-plugin/gateway/escalation-bridge-gate.ts +46 -0
  27. package/telegram-plugin/gateway/gate-parity-probe.ts +102 -0
  28. package/telegram-plugin/gateway/gateway.ts +535 -627
  29. package/telegram-plugin/gateway/inbound-delivery-confirm.ts +89 -7
  30. package/telegram-plugin/gateway/inbound-spool.ts +108 -10
  31. package/telegram-plugin/gateway/model-command.ts +51 -3
  32. package/telegram-plugin/gateway/ms365-write-approval.test.ts +13 -0
  33. package/telegram-plugin/gateway/ms365-write-approval.ts +5 -1
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +26 -0
  35. package/telegram-plugin/gateway/represent-guard.ts +28 -11
  36. package/telegram-plugin/gateway/status-pin-store.ts +124 -45
  37. package/telegram-plugin/gateway/vault-request-access-card.ts +5 -1
  38. package/telegram-plugin/gateway/worker-feed-dispatch.ts +19 -0
  39. package/telegram-plugin/history.ts +5 -0
  40. package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +1 -2
  41. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +9 -1
  42. package/telegram-plugin/registry/subagents-schema.ts +126 -1
  43. package/telegram-plugin/registry/turns-schema.ts +65 -1
  44. package/telegram-plugin/session-tail.ts +26 -4
  45. package/telegram-plugin/slot-banner-driver.ts +42 -2
  46. package/telegram-plugin/status-query-telemetry.ts +100 -0
  47. package/telegram-plugin/stream-reply-handler.ts +15 -16
  48. package/telegram-plugin/subagent-watcher.ts +182 -30
  49. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +4 -10
  50. package/telegram-plugin/tests/busy-key-reaper.test.ts +191 -0
  51. package/telegram-plugin/tests/emission-authority-facade.test.ts +11 -17
  52. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +5 -26
  53. package/telegram-plugin/tests/escalation-bridge-gate.test.ts +38 -0
  54. package/telegram-plugin/tests/format-consistency.test.ts +79 -0
  55. package/telegram-plugin/tests/gate-parity-probe.test.ts +171 -0
  56. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +13 -0
  57. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +14 -11
  58. package/telegram-plugin/tests/inbound-delivery-confirm.test.ts +146 -0
  59. package/telegram-plugin/tests/inbound-spool.test.ts +143 -0
  60. package/telegram-plugin/tests/model-command.test.ts +54 -1
  61. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +5 -11
  62. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +329 -0
  63. package/telegram-plugin/tests/pending-inbound-buffer.test.ts +53 -0
  64. package/telegram-plugin/tests/progress-update-redact.test.ts +99 -0
  65. package/telegram-plugin/tests/registry-turns.test.ts +67 -0
  66. package/telegram-plugin/tests/represent-guard.test.ts +42 -6
  67. package/telegram-plugin/tests/resume-inbound-builder.test.ts +1 -0
  68. package/telegram-plugin/tests/session-tail.test.ts +10 -1
  69. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +246 -0
  70. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +0 -14
  71. package/telegram-plugin/tests/status-pin-store.test.ts +220 -5
  72. package/telegram-plugin/tests/status-query-telemetry.test.ts +115 -0
  73. package/telegram-plugin/tests/subagent-nested-dispatch.test.ts +209 -0
  74. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +37 -0
  75. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +167 -0
  76. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +46 -3
  77. package/telegram-plugin/tests/subagent-watcher-stall-notification.test.ts +70 -0
  78. package/telegram-plugin/tests/tool-activity-summary.test.ts +16 -0
  79. package/telegram-plugin/tests/tool-filter.test.ts +1 -3
  80. package/telegram-plugin/tests/tool-label-pretool.test.ts +1 -4
  81. package/telegram-plugin/tests/turn-flush-safety.test.ts +222 -1
  82. package/telegram-plugin/tests/vault-request-access-card.test.ts +17 -0
  83. package/telegram-plugin/tests/welcome-text.test.ts +64 -0
  84. package/telegram-plugin/tests/worker-activity-feed.test.ts +202 -9
  85. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +25 -0
  86. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +295 -0
  87. package/telegram-plugin/tool-activity-summary.ts +19 -0
  88. package/telegram-plugin/turn-flush-safety.ts +16 -1
  89. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +8 -9
  90. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +1 -1
  91. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +1 -1
  92. package/telegram-plugin/welcome-text.ts +13 -9
  93. package/telegram-plugin/worker-activity-feed.ts +75 -15
  94. package/vendor/hindsight-memory/CHANGELOG.md +66 -0
  95. package/vendor/hindsight-memory/README.md +5 -0
  96. package/vendor/hindsight-memory/scripts/lib/client.py +31 -1
  97. package/vendor/hindsight-memory/scripts/lib/config.py +41 -2
  98. package/vendor/hindsight-memory/scripts/lib/content.py +43 -4
  99. package/vendor/hindsight-memory/scripts/lib/daemon.py +11 -2
  100. package/vendor/hindsight-memory/scripts/recall.py +74 -1
  101. package/vendor/hindsight-memory/scripts/retain.py +79 -11
  102. package/vendor/hindsight-memory/scripts/tests/test_config_client_casts.py +111 -0
  103. package/vendor/hindsight-memory/scripts/tests/test_recall_context_slice.py +126 -0
  104. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +85 -1
  105. package/vendor/hindsight-memory/scripts/tests/test_recall_tag_filters.py +107 -0
  106. package/vendor/hindsight-memory/scripts/tests/test_retain_window.py +261 -0
  107. package/vendor/hindsight-memory/settings.json +4 -0
  108. package/vendor/hindsight-memory/tests/test_client.py +130 -0
  109. package/vendor/hindsight-memory/tests/test_config.py +47 -0
  110. package/vendor/hindsight-memory/tests/test_content.py +123 -0
  111. package/vendor/hindsight-memory/tests/test_hooks.py +62 -0
  112. package/telegram-plugin/gateway/error-envelope-card.ts +0 -64
  113. package/telegram-plugin/gateway/resolve-calling-subagent.ts +0 -78
  114. package/telegram-plugin/silent-reply.ts +0 -58
  115. package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +0 -79
  116. package/telegram-plugin/tests/resolve-calling-subagent.test.ts +0 -269
  117. package/telegram-plugin/tests/silent-reply-guard.test.ts +0 -122
@@ -1,64 +0,0 @@
1
- /**
2
- * Render a one-tap unlock card for hostd error_envelopes that carry a
3
- * `flip_yaml_flag` fix (#1758 Phase 1).
4
- *
5
- * CRITICAL safety: the `yaml_path` MUST be on the
6
- * `UNLOCK_CARD_YAML_ALLOWLIST` exported from
7
- * `src/host-control/config-edit-validator.ts`. A malformed or hostile
8
- * envelope from any backend could otherwise nudge the operator into
9
- * one-tap-approving an arbitrary flag flip. Non-allowlisted paths fall
10
- * back to plain-text rendering (the caller surfaces `resp.error` as
11
- * today).
12
- *
13
- * Phase 1 scope: ONLY `flip_yaml_flag`. `request_vault_grant` is
14
- * explicitly deferred to a later phase (still plain-text rendered).
15
- */
16
-
17
- import type { HostdResponse } from "../../src/host-control/protocol.js";
18
- import { isAllowlistedYamlPath } from "../../src/host-control/config-edit-validator.js";
19
- import {
20
- buildApprovalCard,
21
- type BuiltApprovalCard,
22
- } from "./approval-card.js";
23
-
24
- export type UnlockCardOutcome =
25
- | { kind: "card"; card: BuiltApprovalCard; yaml_path: string; to: unknown }
26
- | { kind: "plain-text" };
27
-
28
- /**
29
- * Decide whether to render a one-tap unlock card for the given
30
- * response. Returns `{kind: "plain-text"}` whenever the envelope
31
- * lacks a `flip_yaml_flag` fix OR the path isn't on the allowlist.
32
- *
33
- * `approvalRequestId` is the 32-hex nonce minted by the approval
34
- * kernel; caller is responsible for binding the card to that nonce
35
- * and recording the apply-on-tap intent.
36
- */
37
- export function renderErrorEnvelopeCard(
38
- resp: HostdResponse,
39
- agentName: string,
40
- approvalRequestId: string,
41
- ): UnlockCardOutcome {
42
- const env = resp.error_envelope;
43
- if (!env || !env.fix) return { kind: "plain-text" };
44
- if (env.fix.kind !== "flip_yaml_flag") {
45
- // request_vault_grant is Phase-2 work; everything else has no
46
- // unlock-card UX. Caller falls back to plain-text rendering.
47
- return { kind: "plain-text" };
48
- }
49
- const { yaml_path, to } = env.fix;
50
- if (!isAllowlistedYamlPath(yaml_path)) {
51
- // Defense-in-depth: never render a one-tap card for a path the
52
- // operator hasn't explicitly opted into.
53
- return { kind: "plain-text" };
54
- }
55
- const card = buildApprovalCard({
56
- request_id: approvalRequestId,
57
- agent: agentName,
58
- scope_humanized: `flip ${yaml_path} → ${JSON.stringify(to)}`,
59
- why: env.human + (env.why ? ` — ${env.why}` : ""),
60
- offer_always: false,
61
- offer_ttl: false,
62
- });
63
- return { kind: "card", card, yaml_path, to };
64
- }
@@ -1,78 +0,0 @@
1
- /**
2
- * Issue #305 Option A — resolve which sub-agent (by jsonl_agent_id) is
3
- * calling progress_update.
4
- *
5
- * Three resolution strategies, in priority order:
6
- * 1. agentIdHint — exact match on subagents.jsonl_agent_id
7
- * 2. toolUseIdHint — exact match on subagents.id (parent's Agent tool_use_id)
8
- * 3. Heuristic: most-recently-started running sub-agent in the active turn
9
- * for this chat. Logs a stderr warning when multiple candidates exist.
10
- *
11
- * Returns null if no match (caller falls through to message-send).
12
- * Never throws; SQL errors return null.
13
- *
14
- * Extracted from gateway.ts so the resolver can be unit-tested against an
15
- * in-memory SQLite DB without spinning up the full grammY bot harness.
16
- */
17
-
18
- /**
19
- * Minimal duck-typed interface that matches both bun:sqlite's `Database`
20
- * and the `SqliteDatabase` shape returned by `openTurnsDb`. We accept the
21
- * narrowed shape so the resolver can run against any equivalent handle.
22
- */
23
- export interface ResolverDb {
24
- prepare(sql: string): {
25
- get(...params: unknown[]): unknown
26
- all(...params: unknown[]): unknown[]
27
- }
28
- }
29
-
30
- export interface ResolveCallingSubagentOpts {
31
- db: ResolverDb | null
32
- chatId: string
33
- threadId?: number | string
34
- agentIdHint: string | null
35
- toolUseIdHint: string | null
36
- }
37
-
38
- export type ResolveCallingSubagentResult = { agentId: string } | null
39
-
40
- export function resolveCallingSubagent(
41
- opts: ResolveCallingSubagentOpts,
42
- ): ResolveCallingSubagentResult {
43
- if (opts.db == null) return null
44
- try {
45
- if (opts.agentIdHint != null) {
46
- const row = opts.db.prepare(
47
- "SELECT jsonl_agent_id FROM subagents WHERE jsonl_agent_id = ? AND status = 'running'",
48
- ).get(opts.agentIdHint) as { jsonl_agent_id: string } | undefined
49
- if (row?.jsonl_agent_id) return { agentId: row.jsonl_agent_id }
50
- }
51
- if (opts.toolUseIdHint != null) {
52
- const row = opts.db.prepare(
53
- "SELECT jsonl_agent_id FROM subagents WHERE id = ? AND status = 'running'",
54
- ).get(opts.toolUseIdHint) as { jsonl_agent_id: string | null } | undefined
55
- if (row?.jsonl_agent_id) return { agentId: row.jsonl_agent_id }
56
- }
57
- // Heuristic fallback.
58
- const turnRow = opts.db.prepare(
59
- "SELECT turn_key FROM turns WHERE chat_id = ? AND ended_at IS NULL ORDER BY started_at DESC LIMIT 1",
60
- ).get(opts.chatId) as { turn_key: string } | undefined
61
- if (turnRow?.turn_key == null) return null
62
- const candidates = opts.db.prepare(
63
- "SELECT jsonl_agent_id FROM subagents WHERE parent_turn_key = ? AND status = 'running' AND jsonl_agent_id IS NOT NULL ORDER BY started_at DESC",
64
- ).all(turnRow.turn_key) as Array<{ jsonl_agent_id: string }>
65
- if (candidates.length === 0) return null
66
- if (candidates.length > 1) {
67
- // eslint-disable-next-line no-console
68
- console.warn(
69
- `progress_update: heuristic resolution selected most-recent of ${candidates.length} running sub-agents (chat=${opts.chatId}); pass agent_id explicitly to avoid mis-attribution`,
70
- )
71
- }
72
- return { agentId: candidates[0].jsonl_agent_id }
73
- } catch (err) {
74
- // eslint-disable-next-line no-console
75
- console.warn('progress_update: resolveCallingSubagent SQL error', err)
76
- return null
77
- }
78
- }
@@ -1,58 +0,0 @@
1
- /**
2
- * Silent-reply markers + allowlist guard.
3
- *
4
- * Lives in its own module (separate from server.ts) so that tests and
5
- * other importers can pull these helpers in without booting the
6
- * full MCP server — server.ts has top-level side effects (env load,
7
- * TELEGRAM_BOT_TOKEN check, history.db open, session-tail spawn) that
8
- * are inappropriate for a unit-test import boundary.
9
- *
10
- * Sprint1 review finding #6: an earlier revision of the reply /
11
- * stream_reply tool handlers returned the silent-reply ack BEFORE
12
- * calling `assertAllowedChat`, so unauthorised chats could bypass the
13
- * outbound allowlist by having the agent emit `NO_REPLY`. The ack
14
- * itself is a cross-chat signal (it confirms to the LLM that the chat
15
- * exists and is reachable) even though no Telegram message is sent, so
16
- * we must refuse disallowed chats *before* producing it. The
17
- * guardSilentReply helper locks that ordering in.
18
- */
19
-
20
- const SILENT_REPLY_MARKERS = new Set(['NO_REPLY', 'HEARTBEAT_OK'])
21
-
22
- // Derive the char-length bound from the marker set so adding a new
23
- // marker doesn't silently desync with a hand-tuned constant.
24
- const SILENT_REPLY_MAX_LEN = Math.max(
25
- ...Array.from(SILENT_REPLY_MARKERS, (m) => m.length),
26
- ) + 2 // small buffer for trailing punctuation callers might add accidentally
27
-
28
- export function isSilentReplyMarker(text: string | undefined): boolean {
29
- if (typeof text !== 'string') return false
30
- const trimmed = text.trim()
31
- if (trimmed.length === 0) return false
32
- if (trimmed.length > SILENT_REPLY_MAX_LEN) return false
33
- // Case-insensitive match: models occasionally emit `no_reply` or
34
- // `NoReply`. Require letters/underscores/digits only so legitimate
35
- // prose that happens to contain "NO_REPLY was suggested" still sends.
36
- return SILENT_REPLY_MARKERS.has(trimmed.toUpperCase())
37
- }
38
-
39
- /**
40
- * Decide whether a `reply`/`stream_reply` invocation should be short-
41
- * circuited as a silent-reply ack, enforcing the allowlist FIRST.
42
- *
43
- * `assertAllowed` throws when `chat_id` is not on the allowlist; callers
44
- * let that propagate so the MCP tool call fails loudly.
45
- */
46
- export function guardSilentReply(params: {
47
- chat_id: string
48
- text: string | undefined
49
- hasFiles: boolean
50
- assertAllowed: (chat_id: string) => void
51
- }): { kind: 'silent'; markerText: string } | { kind: 'continue' } {
52
- const { chat_id, text, hasFiles, assertAllowed } = params
53
- if (hasFiles) return { kind: 'continue' }
54
- if (!isSilentReplyMarker(text)) return { kind: 'continue' }
55
- // Allowlist check BEFORE returning the ack — see docblock above.
56
- assertAllowed(chat_id)
57
- return { kind: 'silent', markerText: (text as string).trim() }
58
- }
@@ -1,79 +0,0 @@
1
- /**
2
- * Telegram bridge unlock-card safety (#1758 Phase 1).
3
- *
4
- * The bridge MUST validate `flip_yaml_flag.yaml_path` against the
5
- * config-edit-validator allowlist before rendering a one-tap approval
6
- * card. A malformed or hostile envelope from any backend could
7
- * otherwise nudge the operator into approving an arbitrary flag flip.
8
- */
9
-
10
- import { describe, it, expect } from "vitest";
11
- import { renderErrorEnvelopeCard } from "../gateway/error-envelope-card.js";
12
- import type { HostdResponse } from "../../src/host-control/protocol.js";
13
-
14
- function mkResp(fix: HostdResponse["error_envelope"]["fix"]): HostdResponse {
15
- return {
16
- v: 1,
17
- request_id: "r-1",
18
- result: "error",
19
- exit_code: null,
20
- duration_ms: 0,
21
- error: "E_FOO: foo",
22
- error_envelope: {
23
- v: 1,
24
- code: "E_FOO",
25
- human: "foo",
26
- fix,
27
- request_id: "r-1",
28
- },
29
- } as HostdResponse;
30
- }
31
-
32
- describe("renderErrorEnvelopeCard — allowlist guard", () => {
33
- it("renders an approval card for an allowlisted yaml_path", () => {
34
- const resp = mkResp({
35
- kind: "flip_yaml_flag",
36
- yaml_path: "hostd.config_edit_enabled",
37
- to: true,
38
- });
39
- const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
40
- expect(out.kind).toBe("card");
41
- if (out.kind === "card") {
42
- expect(out.yaml_path).toBe("hostd.config_edit_enabled");
43
- expect(out.to).toBe(true);
44
- expect(out.card.text).toContain("klanker");
45
- }
46
- });
47
-
48
- it("falls back to plain-text for a NON-allowlisted yaml_path", () => {
49
- const resp = mkResp({
50
- kind: "flip_yaml_flag",
51
- yaml_path: "hostd.evil_backdoor_flag",
52
- to: true,
53
- });
54
- const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
55
- expect(out).toEqual({ kind: "plain-text" });
56
- });
57
-
58
- it("falls back to plain-text for request_vault_grant (Phase 2 scope)", () => {
59
- const resp = mkResp({
60
- kind: "request_vault_grant",
61
- vault_key: "openai/api-key",
62
- });
63
- const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
64
- expect(out).toEqual({ kind: "plain-text" });
65
- });
66
-
67
- it("falls back to plain-text when no envelope is present", () => {
68
- const resp: HostdResponse = {
69
- v: 1,
70
- request_id: "r-1",
71
- result: "error",
72
- exit_code: null,
73
- duration_ms: 0,
74
- error: "legacy string",
75
- };
76
- const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
77
- expect(out).toEqual({ kind: "plain-text" });
78
- });
79
- });
@@ -1,269 +0,0 @@
1
- /**
2
- * Unit tests for resolveCallingSubagent (issue #305 Option A).
3
- *
4
- * The resolver picks which sub-agent's row body should host the
5
- * progress_update narrative when called from a sub-agent context.
6
- * Resolution priority:
7
- * 1. agentIdHint → exact match on subagents.jsonl_agent_id
8
- * 2. toolUseIdHint → exact match on subagents.id
9
- * 3. Heuristic → most-recently-started running sub-agent in the active turn
10
- *
11
- * Tests run against an in-memory bun:sqlite DB with the same schema that
12
- * production uses (turns + subagents tables). Run via:
13
- * bun test telegram-plugin/tests/resolve-calling-subagent.test.ts
14
- */
15
-
16
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
17
- import {
18
- openSubagentsDbInMemory,
19
- recordSubagentStart,
20
- } from '../registry/subagents-schema.js'
21
- import { resolveCallingSubagent } from '../gateway/resolve-calling-subagent.js'
22
-
23
- type Db = ReturnType<typeof openSubagentsDbInMemory>
24
-
25
- function insertOpenTurn(db: Db, turnKey: string, chatId: string, startedAt: number): void {
26
- db.prepare(`
27
- INSERT INTO turns
28
- (turn_key, chat_id, thread_id, started_at, last_user_msg_id, created_at, updated_at)
29
- VALUES (?, ?, ?, ?, ?, ?, ?)
30
- `).run(turnKey, chatId, null, startedAt, 'msg-1', startedAt, startedAt)
31
- }
32
-
33
- describe('resolveCallingSubagent', () => {
34
- let db: Db
35
-
36
- beforeEach(() => {
37
- db = openSubagentsDbInMemory()
38
- })
39
-
40
- afterEach(() => {
41
- db.close()
42
- })
43
-
44
- it('returns null when db is null', () => {
45
- const result = resolveCallingSubagent({
46
- db: null,
47
- chatId: 'c1',
48
- agentIdHint: 'jsonl-1',
49
- toolUseIdHint: null,
50
- })
51
- expect(result).toBeNull()
52
- })
53
-
54
- it('matches by agentIdHint (jsonl_agent_id)', () => {
55
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
56
- recordSubagentStart(db, {
57
- id: 'toolu_alpha',
58
- parentTurnKey: 'c1:1',
59
- jsonlAgentId: 'jsonl-alpha',
60
- background: false,
61
- startedAt: 1100,
62
- })
63
- recordSubagentStart(db, {
64
- id: 'toolu_beta',
65
- parentTurnKey: 'c1:1',
66
- jsonlAgentId: 'jsonl-beta',
67
- background: false,
68
- startedAt: 1200,
69
- })
70
-
71
- const result = resolveCallingSubagent({
72
- db,
73
- chatId: 'c1',
74
- agentIdHint: 'jsonl-alpha',
75
- toolUseIdHint: null,
76
- })
77
- expect(result).toEqual({ agentId: 'jsonl-alpha' })
78
- })
79
-
80
- it('agentIdHint miss on completed sub-agent does NOT match (only running rows)', () => {
81
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
82
- recordSubagentStart(db, {
83
- id: 'toolu_done',
84
- parentTurnKey: 'c1:1',
85
- jsonlAgentId: 'jsonl-done',
86
- background: false,
87
- startedAt: 1100,
88
- })
89
- // Mark as completed via direct SQL.
90
- db.prepare("UPDATE subagents SET status = 'completed', ended_at = 2000 WHERE id = ?")
91
- .run('toolu_done')
92
-
93
- const result = resolveCallingSubagent({
94
- db,
95
- chatId: 'c1',
96
- agentIdHint: 'jsonl-done',
97
- toolUseIdHint: null,
98
- })
99
- expect(result).toBeNull()
100
- })
101
-
102
- it('matches by toolUseIdHint (subagents.id) when agentIdHint absent', () => {
103
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
104
- recordSubagentStart(db, {
105
- id: 'toolu_via_id',
106
- parentTurnKey: 'c1:1',
107
- jsonlAgentId: 'jsonl-via-id',
108
- background: false,
109
- startedAt: 1100,
110
- })
111
-
112
- const result = resolveCallingSubagent({
113
- db,
114
- chatId: 'c1',
115
- agentIdHint: null,
116
- toolUseIdHint: 'toolu_via_id',
117
- })
118
- expect(result).toEqual({ agentId: 'jsonl-via-id' })
119
- })
120
-
121
- it('heuristic fallback: returns most-recently-started running sub-agent in active turn', () => {
122
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
123
- recordSubagentStart(db, {
124
- id: 'toolu_old',
125
- parentTurnKey: 'c1:1',
126
- jsonlAgentId: 'jsonl-old',
127
- background: false,
128
- startedAt: 1100,
129
- })
130
- recordSubagentStart(db, {
131
- id: 'toolu_new',
132
- parentTurnKey: 'c1:1',
133
- jsonlAgentId: 'jsonl-new',
134
- background: false,
135
- startedAt: 1500, // strictly newer
136
- })
137
-
138
- const result = resolveCallingSubagent({
139
- db,
140
- chatId: 'c1',
141
- agentIdHint: null,
142
- toolUseIdHint: null,
143
- })
144
- expect(result).toEqual({ agentId: 'jsonl-new' })
145
- })
146
-
147
- it('heuristic skips sub-agents without jsonl_agent_id', () => {
148
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
149
- recordSubagentStart(db, {
150
- id: 'toolu_nojson',
151
- parentTurnKey: 'c1:1',
152
- jsonlAgentId: null,
153
- background: false,
154
- startedAt: 1500,
155
- })
156
- recordSubagentStart(db, {
157
- id: 'toolu_withjson',
158
- parentTurnKey: 'c1:1',
159
- jsonlAgentId: 'jsonl-resolved',
160
- background: false,
161
- startedAt: 1100,
162
- })
163
-
164
- const result = resolveCallingSubagent({
165
- db,
166
- chatId: 'c1',
167
- agentIdHint: null,
168
- toolUseIdHint: null,
169
- })
170
- expect(result).toEqual({ agentId: 'jsonl-resolved' })
171
- })
172
-
173
- it('heuristic returns null when no active turn exists', () => {
174
- // Insert an ENDED turn.
175
- db.prepare(`
176
- INSERT INTO turns
177
- (turn_key, chat_id, thread_id, started_at, ended_at, last_user_msg_id, created_at, updated_at)
178
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
179
- `).run('c1:1', 'c1', null, 1000, 2000, 'msg-1', 1000, 2000)
180
- recordSubagentStart(db, {
181
- id: 'toolu_orphan',
182
- parentTurnKey: 'c1:1',
183
- jsonlAgentId: 'jsonl-orphan',
184
- background: false,
185
- startedAt: 1100,
186
- })
187
-
188
- const result = resolveCallingSubagent({
189
- db,
190
- chatId: 'c1',
191
- agentIdHint: null,
192
- toolUseIdHint: null,
193
- })
194
- expect(result).toBeNull()
195
- })
196
-
197
- it('heuristic returns null when no running sub-agents in active turn', () => {
198
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
199
- // No sub-agents inserted.
200
- const result = resolveCallingSubagent({
201
- db,
202
- chatId: 'c1',
203
- agentIdHint: null,
204
- toolUseIdHint: null,
205
- })
206
- expect(result).toBeNull()
207
- })
208
-
209
- it('heuristic ignores sub-agents from a different chat', () => {
210
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
211
- insertOpenTurn(db, 'c2:1', 'c2', 1000)
212
- recordSubagentStart(db, {
213
- id: 'toolu_other',
214
- parentTurnKey: 'c2:1',
215
- jsonlAgentId: 'jsonl-other-chat',
216
- background: false,
217
- startedAt: 1100,
218
- })
219
-
220
- const result = resolveCallingSubagent({
221
- db,
222
- chatId: 'c1',
223
- agentIdHint: null,
224
- toolUseIdHint: null,
225
- })
226
- expect(result).toBeNull()
227
- })
228
-
229
- it('agentIdHint takes priority over toolUseIdHint and heuristic', () => {
230
- insertOpenTurn(db, 'c1:1', 'c1', 1000)
231
- recordSubagentStart(db, {
232
- id: 'toolu_priority',
233
- parentTurnKey: 'c1:1',
234
- jsonlAgentId: 'jsonl-priority',
235
- background: false,
236
- startedAt: 1100,
237
- })
238
- recordSubagentStart(db, {
239
- id: 'toolu_other',
240
- parentTurnKey: 'c1:1',
241
- jsonlAgentId: 'jsonl-other',
242
- background: false,
243
- startedAt: 1500, // newer; would win heuristic
244
- })
245
-
246
- const result = resolveCallingSubagent({
247
- db,
248
- chatId: 'c1',
249
- agentIdHint: 'jsonl-priority',
250
- toolUseIdHint: 'toolu_other',
251
- })
252
- expect(result).toEqual({ agentId: 'jsonl-priority' })
253
- })
254
-
255
- it('returns null on SQL error (broken db.prepare)', () => {
256
- const brokenDb = {
257
- prepare: (): never => {
258
- throw new Error('boom')
259
- },
260
- }
261
- const result = resolveCallingSubagent({
262
- db: brokenDb as unknown as Parameters<typeof resolveCallingSubagent>[0]['db'],
263
- chatId: 'c1',
264
- agentIdHint: 'whatever',
265
- toolUseIdHint: null,
266
- })
267
- expect(result).toBeNull()
268
- })
269
- })
@@ -1,122 +0,0 @@
1
- import { describe, expect, it, vi } from 'vitest'
2
-
3
- // Import directly from the helper module (not server.js), so this test
4
- // doesn't boot the full MCP server — server.ts has top-level side
5
- // effects (env load, TELEGRAM_BOT_TOKEN check, session-tail spawn) that
6
- // kill the test process when run under bun-test without a real env.
7
- import { guardSilentReply, isSilentReplyMarker } from '../silent-reply.js'
8
-
9
- /**
10
- * Regression coverage for sprint1 review finding #6: the reply /
11
- * stream_reply tool handlers must call `assertAllowedChat(chat_id)`
12
- * BEFORE returning the silent-reply ack, so an unauthorised chat
13
- * cannot bypass the outbound allowlist by having the agent emit
14
- * `NO_REPLY` / `HEARTBEAT_OK`.
15
- *
16
- * We exercise the extracted `guardSilentReply` helper directly rather
17
- * than the full MCP tool handler; the handler scaffolding (IPC + Bot
18
- * harness + history DB) is heavy, and the ordering contract — "assert
19
- * first, then ack" — lives entirely inside the helper.
20
- */
21
- describe('guardSilentReply — allowlist ordering', () => {
22
- it('asserts the allowlist BEFORE returning a silent ack (NO_REPLY)', () => {
23
- const assertAllowed = vi.fn()
24
- const result = guardSilentReply({
25
- chat_id: '123',
26
- text: 'NO_REPLY',
27
- hasFiles: false,
28
- assertAllowed,
29
- })
30
- expect(assertAllowed).toHaveBeenCalledOnce()
31
- expect(assertAllowed).toHaveBeenCalledWith('123')
32
- expect(result).toEqual({ kind: 'silent', markerText: 'NO_REPLY' })
33
- })
34
-
35
- it('recognises HEARTBEAT_OK as a silent marker and still gates on allowlist', () => {
36
- const assertAllowed = vi.fn()
37
- const result = guardSilentReply({
38
- chat_id: '456',
39
- text: ' heartbeat_ok ',
40
- hasFiles: false,
41
- assertAllowed,
42
- })
43
- expect(assertAllowed).toHaveBeenCalledWith('456')
44
- expect(result).toEqual({ kind: 'silent', markerText: 'heartbeat_ok' })
45
- })
46
-
47
- it('throws (does NOT return a silent ack) when the chat is disallowed', () => {
48
- // This is the exact bypass the fix prevents: an unauthorised chat_id
49
- // using NO_REPLY must NOT receive a successful silent-reply ack.
50
- const assertAllowed = vi.fn((chat_id: string) => {
51
- throw new Error(`chat ${chat_id} is not allowlisted`)
52
- })
53
- expect(() =>
54
- guardSilentReply({
55
- chat_id: '999',
56
- text: 'NO_REPLY',
57
- hasFiles: false,
58
- assertAllowed,
59
- }),
60
- ).toThrow(/not allowlisted/)
61
- expect(assertAllowed).toHaveBeenCalledOnce()
62
- })
63
-
64
- it('does not short-circuit (or call assertAllowed) when files are attached', () => {
65
- // Silent-reply semantics are text-only; a NO_REPLY payload with files
66
- // is a real send and must flow through the normal allowlist path
67
- // inside the send codepath, not the silent short-circuit.
68
- const assertAllowed = vi.fn()
69
- const result = guardSilentReply({
70
- chat_id: '123',
71
- text: 'NO_REPLY',
72
- hasFiles: true,
73
- assertAllowed,
74
- })
75
- expect(assertAllowed).not.toHaveBeenCalled()
76
- expect(result).toEqual({ kind: 'continue' })
77
- })
78
-
79
- it('does not short-circuit for normal prose that merely mentions the marker', () => {
80
- const assertAllowed = vi.fn()
81
- const result = guardSilentReply({
82
- chat_id: '123',
83
- text: 'the agent suggested NO_REPLY earlier',
84
- hasFiles: false,
85
- assertAllowed,
86
- })
87
- expect(assertAllowed).not.toHaveBeenCalled()
88
- expect(result).toEqual({ kind: 'continue' })
89
- })
90
-
91
- it('does not short-circuit on undefined/empty text', () => {
92
- const assertAllowed = vi.fn()
93
- expect(
94
- guardSilentReply({
95
- chat_id: '123',
96
- text: undefined,
97
- hasFiles: false,
98
- assertAllowed,
99
- }),
100
- ).toEqual({ kind: 'continue' })
101
- expect(
102
- guardSilentReply({
103
- chat_id: '123',
104
- text: '',
105
- hasFiles: false,
106
- assertAllowed,
107
- }),
108
- ).toEqual({ kind: 'continue' })
109
- expect(assertAllowed).not.toHaveBeenCalled()
110
- })
111
-
112
- // Sanity: the helper the guard delegates to is exported for direct
113
- // marker-recognition testing. Keep a thin smoke assertion so future
114
- // marker-set tweaks break here rather than at the ordering layer.
115
- it('isSilentReplyMarker recognises the documented marker set', () => {
116
- expect(isSilentReplyMarker('NO_REPLY')).toBe(true)
117
- expect(isSilentReplyMarker('HEARTBEAT_OK')).toBe(true)
118
- expect(isSilentReplyMarker('no_reply')).toBe(true)
119
- expect(isSilentReplyMarker('hello')).toBe(false)
120
- expect(isSilentReplyMarker(undefined)).toBe(false)
121
- })
122
- })