switchroom 0.18.17 → 0.18.19

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1544 -619
  12. package/telegram-plugin/dist/server.js +32 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/format.ts +137 -213
  15. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  16. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  17. package/telegram-plugin/gateway/gateway.ts +399 -85
  18. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
  20. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  21. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  22. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  24. package/telegram-plugin/llm-error-present.ts +474 -0
  25. package/telegram-plugin/operator-events.ts +7 -1
  26. package/telegram-plugin/permission-title.ts +172 -10
  27. package/telegram-plugin/premium-recovery.ts +101 -0
  28. package/telegram-plugin/raw-error-scrub.ts +73 -0
  29. package/telegram-plugin/retry-api-call.ts +8 -2
  30. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  31. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  32. package/telegram-plugin/send-gate-observability.ts +65 -20
  33. package/telegram-plugin/send-gate.test.ts +143 -1
  34. package/telegram-plugin/send-gate.ts +212 -19
  35. package/telegram-plugin/session-tail.ts +16 -0
  36. package/telegram-plugin/shared/local-time.ts +69 -0
  37. package/telegram-plugin/stream-reply-handler.ts +5 -14
  38. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  39. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  41. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  42. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  43. package/telegram-plugin/tests/format-consistency.test.ts +68 -53
  44. package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
  45. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  46. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  47. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  48. package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
  49. package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
  50. package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
  51. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  52. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  53. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  54. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  55. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  56. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
  57. package/telegram-plugin/tests/telegram-format.test.ts +86 -31
  58. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  59. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  60. package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
  61. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  63. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  64. package/telegram-plugin/tier-downgrade.ts +198 -0
  65. package/telegram-plugin/tool-activity-summary.ts +99 -0
  66. package/telegram-plugin/turn-flush-safety.ts +4 -3
  67. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -1,27 +1,25 @@
1
1
  /**
2
2
  * Tests for the fleet-wide consistent-formatting bundle:
3
3
  *
4
- * 1. addParagraphSpacers extension — a visible U+00A0 spacer line at EVERY
5
- * block transition (paragraph→list, list→paragraph, heading→anything,
6
- * blockquote/table boundaries), never inside a list/table interior.
4
+ * 1. paragraph gap spacing — a plain `\n\n` (one blank line) at EVERY block
5
+ * transition (paragraph→list, list→paragraph, heading→anything,
6
+ * blockquote/table boundaries), never a double blank line and never an
7
+ * NBSP spacer (the old spacer pass was removed in the #2669 follow-up).
7
8
  * 2. normalizePunctuation — em/en dashes → comma/hyphen, leading `•`/`·`
8
- * list markers → `- `, on code-masked text, idempotent.
9
+ * list markers → `- `, on code-masked text, idempotent; link hrefs are
10
+ * protected from dash rewriting.
9
11
  * 3. stripExcessBold — over-bold tripwire: >30% bold or fully-bolded
10
12
  * paragraphs/lists lose their bold markers; short messages exempt.
11
13
  */
12
14
  import { describe, test, expect } from 'vitest'
13
15
  import {
14
- addParagraphSpacers,
15
16
  normalizeParagraphBreaks,
16
17
  normalizePunctuation,
17
18
  stripExcessBold,
18
19
  splitMarkdownChunks,
19
20
  hardenCardBreaks,
20
- PARAGRAPH_SPACER,
21
21
  } from '../format.js'
22
22
 
23
- const SP = PARAGRAPH_SPACER // U+00A0
24
-
25
23
  describe('hardenCardBreaks — deterministic card line-break hardener', () => {
26
24
  test('promotes lone field breaks to GFM hard breaks (the blob fix)', () => {
27
25
  const out = hardenCardBreaks('Agent: assistant\nAuth: Max\nStatus: running')
@@ -100,74 +98,60 @@ describe('hardenCardBreaks — deterministic card line-break hardener', () => {
100
98
  })
101
99
  })
102
100
 
103
- describe('addParagraphSpacers — uniform block spacing', () => {
104
- test('still spaces prose→prose (existing behaviour)', () => {
105
- const out = addParagraphSpacers('Alpha.\n\nBravo.')
106
- expect(out).toBe(`Alpha.\n\n${SP}\n\nBravo.`)
107
- })
101
+ describe('paragraph gap spacing — plain single blank line, no NBSP', () => {
102
+ const NBSP = String.fromCharCode(0xa0)
108
103
 
109
- test('spaces paragraph→list transition', () => {
110
- const out = addParagraphSpacers('Intro.\n\n- one\n- two')
111
- expect(out).toBe(`Intro.\n\n${SP}\n\n- one\n- two`)
104
+ test('prose→prose gap is one blank line, no NBSP', () => {
105
+ const out = normalizeParagraphBreaks('Alpha.\n\nBravo.')
106
+ expect(out).toBe('Alpha.\n\nBravo.')
107
+ expect(out).not.toContain(NBSP)
112
108
  })
113
109
 
114
- test('spaces list→paragraph transition', () => {
115
- const out = addParagraphSpacers('- one\n- two\n\nOutro.')
116
- expect(out).toBe(`- one\n- two\n\n${SP}\n\nOutro.`)
110
+ test('paragraph→list transition is a single `\\n\\n` boundary', () => {
111
+ expect(normalizeParagraphBreaks('Intro.\n\n- one\n- two')).toBe('Intro.\n\n- one\n- two')
117
112
  })
118
113
 
119
- test('spaces heading→anything', () => {
120
- expect(addParagraphSpacers('# Title\n\nBody.')).toBe(`# Title\n\n${SP}\n\nBody.`)
121
- expect(addParagraphSpacers('# Title\n\n- a\n- b')).toBe(`# Title\n\n${SP}\n\n- a\n- b`)
122
- })
123
-
124
- test('spaces blockquote and table boundaries', () => {
125
- expect(addParagraphSpacers('> quoted\n\nProse.')).toBe(`> quoted\n\n${SP}\n\nProse.`)
126
- const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
127
- expect(addParagraphSpacers(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${SP}\n\n${table}`)
128
- expect(addParagraphSpacers(`${table}\n\nProse.`)).toBe(`${table}\n\n${SP}\n\nProse.`)
114
+ test('list→paragraph transition is a single `\\n\\n` boundary', () => {
115
+ expect(normalizeParagraphBreaks('- one\n- two\n\nOutro.')).toBe('- one\n- two\n\nOutro.')
129
116
  })
130
117
 
131
- test('does NOT space between items of the same loose list', () => {
132
- const input = '- one\n\n- two\n\n- three'
133
- expect(addParagraphSpacers(input)).toBe(input)
118
+ test('heading→anything is a single `\\n\\n` boundary', () => {
119
+ expect(normalizeParagraphBreaks('# Title\n\nBody.')).toBe('# Title\n\nBody.')
120
+ expect(normalizeParagraphBreaks('# Title\n\n- a\n- b')).toBe('# Title\n\n- a\n- b')
134
121
  })
135
122
 
136
- test('does NOT space inside a tight list or table interior (single \\n)', () => {
137
- const list = 'Intro.\n\n- a\n- b\n- c'
138
- expect(addParagraphSpacers(list)).toBe(`Intro.\n\n${SP}\n\n- a\n- b\n- c`)
139
- const table = '| a |\n| --- |\n| 1 |\n| 2 |'
140
- expect(addParagraphSpacers(table)).toBe(table)
141
- })
142
-
143
- test('idempotent across every transition kind', () => {
144
- const input = '# H\n\nProse one.\n\n- a\n- b\n\nProse two.\n\n> quote'
145
- const once = addParagraphSpacers(input)
146
- expect(addParagraphSpacers(once)).toBe(once)
123
+ test('blockquote and table boundaries are single `\\n\\n`', () => {
124
+ expect(normalizeParagraphBreaks('> quoted\n\nProse.')).toBe('> quoted\n\nProse.')
125
+ const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
126
+ expect(normalizeParagraphBreaks(`Prose.\n\n${table}`)).toBe(`Prose.\n\n${table}`)
127
+ expect(normalizeParagraphBreaks(`${table}\n\nProse.`)).toBe(`${table}\n\nProse.`)
147
128
  })
148
129
 
149
130
  test('never touches code fences', () => {
150
131
  const input = '```\nA\n\nB\n```\n\n```\nC\n```'
151
- expect(addParagraphSpacers(input)).toBe(input)
132
+ expect(normalizeParagraphBreaks(input)).toBe(input)
152
133
  })
153
134
 
154
- test('full pipeline: mixed prose+list+heading message gets uniform gaps', () => {
155
- const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
156
- const out = addParagraphSpacers(normalizeParagraphBreaks(raw))
157
- // Every block transition carries exactly one visible spacer line.
158
- expect(out).toBe(
159
- `Summary line.\n\n${SP}\n\n- item one\n- item two\n\n${SP}\n\nClosing prose.`,
160
- )
135
+ test('full pipeline: mixed prose+list message gets single-blank-line gaps, no NBSP, no double gap', () => {
136
+ const raw = 'Summary line.\n\n- item one\n- item two\n\nClosing prose.'
137
+ const out = normalizeParagraphBreaks(raw)
138
+ expect(out).toBe('Summary line.\n\n- item one\n- item two\n\nClosing prose.')
139
+ expect(out).not.toContain(NBSP)
140
+ expect(out).not.toMatch(/\n\n\n/)
161
141
  })
162
142
 
163
- test('chunk-boundary interaction: a cut in a spacer gap strips the spacer', () => {
143
+ test('chunk-boundary interaction: a cut in a `\\n\\n` gap leaves clean chunks (no stray blank lines)', () => {
164
144
  const a = 'A'.repeat(60)
165
145
  const b = 'B'.repeat(60)
166
- const text = `${a}\n\n${SP}\n\n${b}`
146
+ const text = `${a}\n\n${b}`
167
147
  const chunks = splitMarkdownChunks(text, 80)
168
148
  expect(chunks.length).toBe(2)
169
149
  expect(chunks[0]).toBe(a)
170
150
  expect(chunks[1]).toBe(b)
151
+ // Neither chunk opens or ends with a stray blank line.
152
+ for (const c of chunks) {
153
+ expect(c).toBe(c.replace(/^\n+|\n+$/g, ''))
154
+ }
171
155
  })
172
156
  })
173
157
 
@@ -210,6 +194,37 @@ describe('normalizePunctuation', () => {
210
194
  expect(normalizePunctuation(input)).toBe(input)
211
195
  })
212
196
 
197
+ test('does NOT rewrite a dash inside a markdown link href (#finding-2)', () => {
198
+ // An en-dash in a URL path must survive verbatim — rewriting it to `-`
199
+ // silently points at a different URL.
200
+ expect(normalizePunctuation('[a](https://x.com/foo–bar)')).toBe(
201
+ '[a](https://x.com/foo–bar)',
202
+ )
203
+ // An em-dash in a URL must NOT become `, ` — the injected space would
204
+ // TERMINATE the markdown link and leak the trailing text as prose.
205
+ expect(normalizePunctuation('[a](https://x.com/foo—bar)')).toBe(
206
+ '[a](https://x.com/foo—bar)',
207
+ )
208
+ // The visible LABEL still normalizes (dashes in label text are prose).
209
+ expect(normalizePunctuation('[foo—bar](https://x.com/path)')).toBe(
210
+ '[foo, bar](https://x.com/path)',
211
+ )
212
+ })
213
+
214
+ test('does NOT rewrite a dash inside a `<scheme:…>` autolink (#finding-2 sibling)', () => {
215
+ // An en-dash in an angle-bracket autolink URL must survive verbatim.
216
+ expect(normalizePunctuation('<https://x.com/foo–bar>')).toBe('<https://x.com/foo–bar>')
217
+ // An em-dash in an autolink must NOT become `, `.
218
+ expect(normalizePunctuation('<https://x.com/foo—bar>')).toBe('<https://x.com/foo—bar>')
219
+ // Autolink mid-prose: the surrounding prose still normalizes, the URL does not.
220
+ expect(normalizePunctuation('see <https://x.com/a–b> now — go')).toBe(
221
+ 'see <https://x.com/a–b> now, go',
222
+ )
223
+ // Arbitrary `<…>` prose (no scheme) is NOT treated as an autolink: a dash
224
+ // inside it still normalizes like ordinary text.
225
+ expect(normalizePunctuation('<not—a—url>')).toBe('<not, a, url>')
226
+ })
227
+
213
228
  test('idempotent', () => {
214
229
  const once = normalizePunctuation('a — b\n• c\nd—e\n1–2')
215
230
  expect(normalizePunctuation(once)).toBe(once)
@@ -17,9 +17,10 @@
17
17
  * fixture intended (bold/italic/code/pre/link at the right inner text).
18
18
  *
19
19
  * The pipeline mirrors `telegram-plugin/gateway/gateway.ts`:
20
- * repairEscapedWhitespace -> normalizeParagraphBreaks -> addParagraphSpacers
21
- * -> splitMarkdownChunks. (Card-surface fixtures also apply `normalizeDashes`
22
- * first — that is where F1's voice scrub lives.)
20
+ * repairEscapedWhitespace -> normalizeParagraphBreaks -> splitMarkdownChunks.
21
+ * (No paragraph-spacer pass — the NBSP spacer was removed in the #2669
22
+ * follow-up; gaps are plain `\n\n`. Card-surface fixtures also apply
23
+ * `normalizeDashes` first — that is where F1's voice scrub lives.)
23
24
  *
24
25
  * This suite is TEST-ONLY. It changes no production formatting behaviour. If a
25
26
  * fixture ever fails signal (a), it means the formatter emits markdown Telegram
@@ -30,7 +31,6 @@ import { describe, test, expect } from 'vitest'
30
31
  import {
31
32
  repairEscapedWhitespace,
32
33
  normalizeParagraphBreaks,
33
- addParagraphSpacers,
34
34
  splitMarkdownChunks,
35
35
  RICH_MESSAGE_MAX_CHARS,
36
36
  } from '../format.js'
@@ -57,8 +57,7 @@ function transform(input: string, opts: { cardSurfaceScrub?: boolean; cap?: numb
57
57
  } {
58
58
  let text = input
59
59
  if (opts.cardSurfaceScrub) text = normalizeDashes(text)
60
- text = normalizeParagraphBreaks(repairEscapedWhitespace(text))
61
- const body = addParagraphSpacers(text)
60
+ const body = normalizeParagraphBreaks(repairEscapedWhitespace(text))
62
61
  const chunks = splitMarkdownChunks(body, opts.cap ?? RICH_MESSAGE_MAX_CHARS)
63
62
  return { chunks, body }
64
63
  }
@@ -7,7 +7,7 @@
7
7
  * The `input` is the raw text as a model/card surface would author it — the
8
8
  * regression test runs it through the SAME outbound transform pipeline the
9
9
  * gateway uses (repairEscapedWhitespace -> normalizeParagraphBreaks ->
10
- * addParagraphSpacers -> splitMarkdownChunks) and then asserts:
10
+ * splitMarkdownChunks) and then asserts:
11
11
  *
12
12
  * (a) every emitted chunk is parse-accept valid (no rich-path 400), and
13
13
  * (b) the concatenated output parses into `expect` (entity structure).
@@ -134,6 +134,32 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
134
134
  expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
135
135
  })
136
136
 
137
+ it('operator_event: scrubs event.detail at the top of emitGatewayOperatorEvent, BEFORE render/send', () => {
138
+ // #llm-error-surfacing FIX 2 — operator-event cards are sent via a raw
139
+ // bot.api.sendRichMessage that BYPASSES the normal outbound chokepoint, so a
140
+ // secret in an error `detail` (credentials-expired / credit-exhausted /
141
+ // unknown-4xx) could reach the card verbatim. The detail is redacted ONCE at
142
+ // the top of the function, through the shared redactOutboundText, and MUST
143
+ // run before the renderers escapeMarkdown it (redacting the already-escaped
144
+ // text lets url-query-param secrets slip past url-redact) and before the
145
+ // send. This is the sole regression guard for that wiring line — deleting it
146
+ // must turn this red.
147
+ const start = src.indexOf('function emitGatewayOperatorEvent(')
148
+ const redactIdx = src.indexOf(
149
+ `event = { ...event, detail: redactOutboundText(event.detail, 'operator_event') }`,
150
+ start,
151
+ )
152
+ // The two render surfaces + the wire send this must precede.
153
+ const renderOpIdx = src.indexOf('renderOperatorEvent(event)', start)
154
+ const renderLlmIdx = src.indexOf('renderLlmErrorSafe(parsed', start)
155
+ const sendIdx = src.indexOf('bot.api.sendRichMessage(chat_id, richMessage(renderedText)', start)
156
+ expect(start).toBeGreaterThan(0)
157
+ expect(redactIdx).toBeGreaterThan(start)
158
+ expect(renderOpIdx).toBeGreaterThan(redactIdx) // mask BEFORE the escapeMarkdown render
159
+ expect(renderLlmIdx).toBeGreaterThan(redactIdx) // mask BEFORE the humanized render
160
+ expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the card hits the wire
161
+ })
162
+
137
163
  it('does not log the secret value when a mask fires', () => {
138
164
  const idx = src.indexOf('function redactOutboundText(')
139
165
  const body = src.slice(idx, idx + 400)
@@ -155,9 +155,14 @@ describe('createLinearIssue — behaviour (#2312)', () => {
155
155
  expect(r.content[0].text).toMatch(/default_team_id/)
156
156
  })
157
157
 
158
- it('short-circuits to "Already filed" on a dedup hit', async () => {
158
+ it('short-circuits to "Already filed" only on an EXACT marker match, skipping unrelated top hits', async () => {
159
159
  const { fetchImpl, calls } = routingFetch({
160
- searchIssues: () => ({ searchIssues: { nodes: [{ id: 'old', url: 'https://linear.app/acme/issue/ENG-7', title: 'prior' }] } }),
160
+ searchIssues: () => ({ searchIssues: { nodes: [
161
+ // relevance-ranked top hit that merely shares tokens — must be ignored.
162
+ { id: 'noise', url: 'https://linear.app/acme/issue/ENG-99', title: 'unrelated', description: 'no capture marker here' },
163
+ // the real prior capture, carrying the exact marker for this key.
164
+ { id: 'old', url: 'https://linear.app/acme/issue/ENG-7', title: 'prior', description: `Y${captureDedupMarker('chat:99')}` },
165
+ ] } }),
161
166
  teams: oneTeam,
162
167
  issueCreate: issueOk,
163
168
  })
@@ -165,12 +170,35 @@ describe('createLinearIssue — behaviour (#2312)', () => {
165
170
  { title: 'X', body: 'Y', dedup_key: 'chat:99' },
166
171
  { resolveToken: okToken('t'), fetchImpl, log: () => {} },
167
172
  )
173
+ // matched the marker-bearing node, not the higher-ranked noise node.
168
174
  expect(r.content[0].text).toBe('Already filed: https://linear.app/acme/issue/ENG-7')
169
175
  // only the search ran — no team resolve, no create.
170
176
  expect(calls).toHaveLength(1)
171
177
  expect(calls[0].query).toMatch(/searchIssues/)
172
178
  })
173
179
 
180
+ it('does NOT dedup when the search returns only unrelated hits (fuzzy false-positive regression)', async () => {
181
+ // Regression for the searchIssues fuzzy-match bug: a novel dedup_key
182
+ // returned an unrelated relevance-ranked hit and wrongly suppressed
183
+ // creation. No node carries this key's marker, so the tool must create.
184
+ const { fetchImpl, calls } = routingFetch({
185
+ searchIssues: () => ({ searchIssues: { nodes: [
186
+ { id: 'x', url: 'https://linear.app/acme/issue/ENG-56', title: 'ProductOS Job Spec', description: 'shares tokens, but no capture marker' },
187
+ ] } }),
188
+ teams: oneTeam,
189
+ issueCreate: issueOk,
190
+ })
191
+ const r = await createLinearIssue(
192
+ { title: 'Series QA tracker', body: 'Y', dedup_key: 'productos-qa-2026-07-13' },
193
+ { resolveToken: okToken('t'), fetchImpl, log: () => {} },
194
+ )
195
+ expect(r.content[0].text).toMatch(/Filed:/)
196
+ // fell through past the search to team resolve + create, embedding the marker.
197
+ const create = calls.find((c) => c.query.includes('issueCreate'))!
198
+ expect(create).toBeTruthy()
199
+ expect((create.variables.input as Record<string, unknown>).description).toContain(captureDedupMarker('productos-qa-2026-07-13'))
200
+ })
201
+
174
202
  it('falls through to create when the dedup search misses, and embeds the marker', async () => {
175
203
  const { fetchImpl, calls } = routingFetch({
176
204
  searchIssues: () => ({ searchIssues: { nodes: [] } }),