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.
- package/dist/agent-scheduler/index.js +13 -0
- package/dist/auth-broker/index.js +13 -0
- package/dist/cli/notion-write-pretool.mjs +13 -0
- package/dist/cli/switchroom.js +605 -479
- package/dist/host-control/main.js +17 -1
- package/dist/vault/approvals/kernel-server.js +13 -0
- package/dist/vault/broker/server.js +13 -0
- package/package.json +1 -1
- package/telegram-plugin/bridge/bridge.ts +7 -1
- package/telegram-plugin/dist/bridge/bridge.js +26 -1
- package/telegram-plugin/dist/gateway/gateway.js +1544 -619
- package/telegram-plugin/dist/server.js +32 -1
- package/telegram-plugin/fleet-fallback-resume.ts +26 -3
- package/telegram-plugin/format.ts +137 -213
- package/telegram-plugin/gateway/approval-hold.ts +49 -0
- package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
- package/telegram-plugin/gateway/gateway.ts +399 -85
- package/telegram-plugin/gateway/linear-activity.ts +20 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
- package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
- package/telegram-plugin/gateway/session-model-file.ts +103 -0
- package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
- package/telegram-plugin/llm-error-present.ts +474 -0
- package/telegram-plugin/operator-events.ts +7 -1
- package/telegram-plugin/permission-title.ts +172 -10
- package/telegram-plugin/premium-recovery.ts +101 -0
- package/telegram-plugin/raw-error-scrub.ts +73 -0
- package/telegram-plugin/retry-api-call.ts +8 -2
- package/telegram-plugin/send-gate-degraded.test.ts +152 -1
- package/telegram-plugin/send-gate-observability.test.ts +140 -0
- package/telegram-plugin/send-gate-observability.ts +65 -20
- package/telegram-plugin/send-gate.test.ts +143 -1
- package/telegram-plugin/send-gate.ts +212 -19
- package/telegram-plugin/session-tail.ts +16 -0
- package/telegram-plugin/shared/local-time.ts +69 -0
- package/telegram-plugin/stream-reply-handler.ts +5 -14
- package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
- package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
- package/telegram-plugin/tests/format-consistency.test.ts +68 -53
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
- package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
- package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
- package/telegram-plugin/tests/permission-title.test.ts +167 -4
- package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
- package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
- package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
- package/telegram-plugin/tests/telegram-format.test.ts +86 -31
- package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
- package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
- package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
- package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
- package/telegram-plugin/tier-downgrade.ts +198 -0
- package/telegram-plugin/tool-activity-summary.ts +99 -0
- package/telegram-plugin/turn-flush-safety.ts +4 -3
- 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.
|
|
5
|
-
*
|
|
6
|
-
* blockquote/table boundaries), never
|
|
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('
|
|
104
|
-
|
|
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('
|
|
110
|
-
const out =
|
|
111
|
-
expect(out).toBe(
|
|
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('
|
|
115
|
-
|
|
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('
|
|
120
|
-
expect(
|
|
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('
|
|
132
|
-
|
|
133
|
-
expect(
|
|
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('
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
expect(
|
|
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(
|
|
132
|
+
expect(normalizeParagraphBreaks(input)).toBe(input)
|
|
152
133
|
})
|
|
153
134
|
|
|
154
|
-
test('full pipeline: mixed prose+list
|
|
155
|
-
const raw = 'Summary line.\n- item one\n- item two\nClosing prose.'
|
|
156
|
-
const out =
|
|
157
|
-
|
|
158
|
-
expect(out).
|
|
159
|
-
|
|
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
|
|
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${
|
|
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 ->
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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: [
|
|
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: [] } }),
|