gentle-pi 3.2.0 → 3.3.0

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 (80) hide show
  1. package/assets/orchestrator-delegation.md +13 -8
  2. package/assets/orchestrator.md +2 -2
  3. package/docs/gentle-shell.md +40 -17
  4. package/docs/readme-reference.md +41 -7
  5. package/docs/review-integration.md +25 -11
  6. package/extensions/gentle-agents.ts +85 -17
  7. package/extensions/gentle-ai.ts +179 -12
  8. package/extensions/gentle-shell.ts +408 -38
  9. package/extensions/gentle-todo.ts +19 -1
  10. package/lib/agents-view.ts +41 -14
  11. package/lib/agents-widget.ts +84 -13
  12. package/lib/command-palette-catalog.ts +1 -0
  13. package/lib/double-esc-cancel-policy.ts +138 -0
  14. package/lib/inprocess-reviewer.ts +260 -0
  15. package/lib/model-routing-authority.ts +1 -1
  16. package/lib/native-review-cli.ts +23 -0
  17. package/lib/odd-runtime-delegation-gate.ts +88 -0
  18. package/lib/review-host-relay.ts +262 -94
  19. package/lib/review-integration-v2.ts +110 -26
  20. package/lib/shell-bar.ts +158 -29
  21. package/lib/shell-card.ts +19 -9
  22. package/lib/shell-changes-view.ts +43 -5
  23. package/lib/shell-changes.ts +92 -5
  24. package/lib/shell-hover.ts +39 -0
  25. package/lib/shell-prompt.ts +10 -1
  26. package/lib/shell-sidebar-layout.ts +111 -15
  27. package/lib/shell-sidebar.ts +16 -0
  28. package/lib/shell-todo.ts +7 -1
  29. package/lib/shell-usage-view.ts +98 -10
  30. package/lib/shell-usage.ts +226 -10
  31. package/package.json +2 -1
  32. package/runtime/native-review-cli.mjs +23 -0
  33. package/runtime/review-integration-v2.mjs +110 -26
  34. package/scripts/gentle-ai-installer.mjs +10 -10
  35. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  36. package/scripts/mirror-odd-routing.mjs +242 -0
  37. package/scripts/verify-package-files.mjs +3 -3
  38. package/tests/agents-grouping.test.ts +75 -18
  39. package/tests/agents-view.test.ts +28 -18
  40. package/tests/agents-widget.test.ts +100 -12
  41. package/tests/command-palette.test.ts +1 -0
  42. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  43. package/tests/double-esc-cancel-policy.test.ts +194 -0
  44. package/tests/gentle-agents.test.ts +528 -5
  45. package/tests/gentle-ai-binary.test.ts +1 -1
  46. package/tests/gentle-ai-installer.test.ts +47 -47
  47. package/tests/gentle-ai.test.ts +69 -5
  48. package/tests/gentle-shell.test.ts +903 -25
  49. package/tests/gentle-todo.test.ts +17 -4
  50. package/tests/inprocess-reviewer.test.ts +368 -0
  51. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  52. package/tests/native-review-capability-contract.test.ts +32 -1
  53. package/tests/odd-routing-canonical-ratchet.test.ts +293 -0
  54. package/tests/odd-routing-contract.test.ts +57 -0
  55. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  56. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  57. package/tests/package-manifest.test.ts +6 -6
  58. package/tests/review-controller-native-routing.test.ts +60 -1
  59. package/tests/review-host-relay-routing.test.ts +77 -0
  60. package/tests/review-host-relay.test.ts +285 -239
  61. package/tests/review-integration-v2-forward.test.ts +61 -0
  62. package/tests/review-integration-v2.test.ts +116 -1
  63. package/tests/review-relay-transport-agent.test.ts +83 -0
  64. package/tests/runtime-harness.mjs +11 -0
  65. package/tests/session-changes-shell.test.ts +27 -0
  66. package/tests/session-worktree-registry.test.ts +41 -0
  67. package/tests/shell-bar.test.ts +224 -6
  68. package/tests/shell-card.test.ts +5 -3
  69. package/tests/shell-changes-view.test.ts +47 -0
  70. package/tests/shell-changes.test.ts +177 -0
  71. package/tests/shell-hover.test.ts +19 -0
  72. package/tests/shell-prompt.test.ts +20 -0
  73. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  74. package/tests/shell-sidebar-layout.test.ts +243 -5
  75. package/tests/shell-sidebar.test.ts +25 -1
  76. package/tests/shell-todo.test.ts +36 -0
  77. package/tests/shell-usage-view.test.ts +123 -3
  78. package/tests/shell-usage.test.ts +254 -6
  79. package/lib/opaque-pi-reviewer-adapter.ts +0 -284
  80. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -266
@@ -133,7 +133,9 @@ test("the Todo header is a fullscreen left-click control while non-click pointer
133
133
  assert.match(stripAnsi(component.render(70)[0]!), /Todos ▸ Expand/);
134
134
  });
135
135
 
136
- test("the Todo header remains a static visible control without hover handling", async () => {
136
+ // H1 (odd/tasks/usage-click-and-changes-attribution.md): the header control
137
+ // now paints the same shared hover role every other clickable surface uses.
138
+ test("the Todo header paints the shared hover role while hovered, and clears it off the header row or on leave", async () => {
137
139
  const { pi, tools, fire } = fakePi();
138
140
  gentleTodo(pi, {});
139
141
  const { ctx, widgetComponent } = fakeContext();
@@ -141,10 +143,21 @@ test("the Todo header remains a static visible control without hover handling",
141
143
  await tools.get("todo")!.execute("c1", { action: "write", tasks: [{ title: "A" }] }, undefined, undefined, ctx);
142
144
  await fire("tool_execution_end", ctx, { toolName: "todo" });
143
145
  const component = widgetComponent()!;
144
- const move = { type: "move" as const, button: "none" as const, x: 1, y: 0, screenX: 1, screenY: 0, width: 70, height: 5, shift: false, alt: false, ctrl: false };
145
- assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/);
146
- assert.equal(component.handleMouse?.(move), undefined);
146
+ const move = (y: number) => ({ type: "move" as const, button: "none" as const, x: 1, y, screenX: 1, screenY: y, width: 70, height: 5, shift: false, alt: false, ctrl: false });
147
147
  assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/);
148
+
149
+ const entered = component.handleMouse?.(move(0));
150
+ assert.deepEqual(entered, { handled: true, render: true });
151
+ assert.match(stripAnsi(component.render(70)[0]!), /Todos ▾ Collapse/, "the collapse label is unchanged; only its role changes (not observable through plainTheme here)");
152
+
153
+ // Moving to another row of the card (still inside the region, but off the
154
+ // clickable header) clears the hover.
155
+ const movedOff = component.handleMouse?.(move(1));
156
+ assert.deepEqual(movedOff, { handled: true, render: true });
157
+
158
+ // Re-entering, then a second move at the same row is a no-op (already hovered).
159
+ component.handleMouse?.(move(0));
160
+ assert.deepEqual(component.handleMouse?.(move(0)), { handled: true });
148
161
  });
149
162
 
150
163
  test("every turn carries the open tasks in the system prompt and the card goes stale after two silent turns", async () => {
@@ -0,0 +1,368 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai";
4
+ import type { completeSimple } from "@earendil-works/pi-ai/compat";
5
+ import {
6
+ INPROCESS_REVIEWER_FAILURE,
7
+ INPROCESS_REVIEWER_OUTPUT_MAX_BYTES,
8
+ runInProcessReviewer,
9
+ type InProcessReviewerOutcome,
10
+ type InProcessReviewerRegistry,
11
+ type InProcessReviewerRequest,
12
+ } from "../lib/inprocess-reviewer.ts";
13
+
14
+ // The in-process reviewer completion (gentle-ai#4611; gentle-pi#311 P1) runs
15
+ // one reviewer role through pi's live model registry instead of a locked-down
16
+ // `pi --print` child with extension discovery disabled. Every seam here is a
17
+ // fake: no network, no pi process, no process.env reads.
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Fakes — structural subsets of pi's live ModelRegistry and completeSimple.
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function fakeModel(overrides: Partial<Model<Api>> = {}): Model<Api> {
24
+ return {
25
+ id: "gpt-5",
26
+ name: "GPT-5",
27
+ api: "openai-responses",
28
+ provider: "openai",
29
+ baseUrl: "https://api.openai.com",
30
+ reasoning: true,
31
+ input: ["text"],
32
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
33
+ contextWindow: 100_000,
34
+ maxTokens: 8192,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function fakeRegistry(
40
+ models: readonly Model<Api>[],
41
+ auth?: (model: Model<Api>) => ReturnType<InProcessReviewerRegistry["getApiKeyAndHeaders"]>,
42
+ ): InProcessReviewerRegistry {
43
+ return {
44
+ find: (provider, modelId) => models.find((candidate) => candidate.provider === provider && candidate.id === modelId),
45
+ getApiKeyAndHeaders: auth ?? (async () => ({ ok: true, apiKey: "test-key" })),
46
+ };
47
+ }
48
+
49
+ function assistantText(text: string, overrides: Partial<AssistantMessage> = {}): AssistantMessage {
50
+ return {
51
+ role: "assistant",
52
+ content: [{ type: "text", text }],
53
+ api: "openai-responses",
54
+ provider: "openai",
55
+ model: "gpt-5",
56
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
57
+ stopReason: "stop",
58
+ timestamp: 0,
59
+ ...overrides,
60
+ };
61
+ }
62
+
63
+ function baseRequest(overrides: Partial<InProcessReviewerRequest> = {}): InProcessReviewerRequest {
64
+ return {
65
+ selection: "openai/gpt-5",
66
+ prompt: Buffer.from("Review this diff.", "utf8"),
67
+ timeoutMs: 30_000,
68
+ routingKey: "review-risk",
69
+ ...overrides,
70
+ };
71
+ }
72
+
73
+ /** A `complete` fake that records every call for assertion. */
74
+ function capturingComplete(assistant: AssistantMessage) {
75
+ const calls: Array<{ model: Model<Api>; context: Context; options: SimpleStreamOptions | undefined }> = [];
76
+ const complete: typeof completeSimple = (async (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => {
77
+ calls.push({ model, context, options });
78
+ return assistant;
79
+ }) as typeof completeSimple;
80
+ return { complete, calls };
81
+ }
82
+
83
+ /**
84
+ * A `complete` fake that never resolves except when its signal aborts — the
85
+ * timeout and caller-abort paths are exercised without a real network call
86
+ * or a wall-clock wait longer than the request's own timeout.
87
+ */
88
+ function signalAwaitingComplete(): typeof completeSimple {
89
+ return (async (_model: Model<Api>, _context: Context, options?: SimpleStreamOptions) => {
90
+ return await new Promise<AssistantMessage>((_resolve, reject) => {
91
+ const signal = options?.signal;
92
+ if (signal === undefined) return;
93
+ if (signal.aborted) {
94
+ reject(new Error("aborted"));
95
+ return;
96
+ }
97
+ signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
98
+ });
99
+ }) as typeof completeSimple;
100
+ }
101
+
102
+ /**
103
+ * A `complete` fake that follows the pi-ai provider convention on abort:
104
+ * once the signal fires it RESOLVES an AssistantMessage carrying the text
105
+ * streamed so far and `stopReason: "aborted"`, instead of rejecting.
106
+ */
107
+ function signalResolvingAbortedComplete(partialText = "partial revi"): typeof completeSimple {
108
+ return (async (_model, _context, options) => {
109
+ return await new Promise<AssistantMessage>((resolve) => {
110
+ const signal = options?.signal;
111
+ const settle = () => resolve(assistantText(partialText, { stopReason: "aborted" }));
112
+ if (signal === undefined) return;
113
+ if (signal.aborted) {
114
+ settle();
115
+ return;
116
+ }
117
+ signal.addEventListener("abort", settle, { once: true });
118
+ });
119
+ }) as typeof completeSimple;
120
+ }
121
+
122
+ /** A canary `complete` fake for refusals that must never reach the provider. */
123
+ const unreachableComplete: typeof completeSimple = (async () => {
124
+ throw new Error("complete must not be called for this refusal");
125
+ }) as typeof completeSimple;
126
+
127
+ function expectRefused(outcome: InProcessReviewerOutcome): Extract<InProcessReviewerOutcome, { kind: "refused" }> {
128
+ assert.equal(outcome.kind, "refused", outcome.kind === "text" ? `expected a refusal, got text: ${outcome.text}` : undefined);
129
+ return outcome as Extract<InProcessReviewerOutcome, { kind: "refused" }>;
130
+ }
131
+
132
+ function expectText(outcome: InProcessReviewerOutcome): Extract<InProcessReviewerOutcome, { kind: "text" }> {
133
+ assert.equal(outcome.kind, "text", outcome.kind === "refused" ? `expected text, got refusal ${outcome.code}: ${outcome.message}` : undefined);
134
+ return outcome as Extract<InProcessReviewerOutcome, { kind: "text" }>;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Every refusal code
139
+ // ---------------------------------------------------------------------------
140
+
141
+ test("refuses a selection with no provider/id separator", async () => {
142
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "gpt-5" }), {
143
+ registry: fakeRegistry([fakeModel()]),
144
+ complete: unreachableComplete,
145
+ });
146
+ const refused = expectRefused(outcome);
147
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID);
148
+ assert.match(refused.message, /review-risk/);
149
+ });
150
+
151
+ test("refuses when the registry has no matching model", async () => {
152
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "openai/does-not-exist" }), {
153
+ registry: fakeRegistry([fakeModel()]),
154
+ complete: unreachableComplete,
155
+ });
156
+ const refused = expectRefused(outcome);
157
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND);
158
+ assert.match(refused.message, /review-risk/);
159
+ assert.doesNotMatch(refused.message.toLowerCase(), /env var|extension/);
160
+ });
161
+
162
+ test("refuses when the registry cannot resolve auth", async () => {
163
+ const outcome = await runInProcessReviewer(baseRequest(), {
164
+ registry: fakeRegistry([fakeModel()], async () => ({ ok: false, error: "no stored credential" })),
165
+ complete: unreachableComplete,
166
+ });
167
+ const refused = expectRefused(outcome);
168
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE);
169
+ assert.match(refused.message, /openai/);
170
+ assert.match(refused.message, /no stored credential/);
171
+ });
172
+
173
+ test("refuses an unknown thinking label", async () => {
174
+ const outcome = await runInProcessReviewer(baseRequest({ thinking: "bogus" }), {
175
+ registry: fakeRegistry([fakeModel()]),
176
+ complete: unreachableComplete,
177
+ });
178
+ const refused = expectRefused(outcome);
179
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.THINKING_INVALID);
180
+ });
181
+
182
+ test("refuses when the reviewer attempts a tool call", async () => {
183
+ const assistant = assistantText("", {
184
+ content: [{ type: "toolCall", id: "1", name: "bash", arguments: {} }],
185
+ stopReason: "toolUse",
186
+ });
187
+ const outcome = await runInProcessReviewer(baseRequest(), {
188
+ registry: fakeRegistry([fakeModel()]),
189
+ complete: async () => assistant,
190
+ });
191
+ const refused = expectRefused(outcome);
192
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED);
193
+ });
194
+
195
+ test("refuses empty assistant text with stopReason evidence", async () => {
196
+ const assistant = assistantText("", { content: [], stopReason: "stop" });
197
+ const outcome = await runInProcessReviewer(baseRequest(), {
198
+ registry: fakeRegistry([fakeModel()]),
199
+ complete: async () => assistant,
200
+ });
201
+ const refused = expectRefused(outcome);
202
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
203
+ assert.deepEqual(refused.evidence, { stopReason: "stop" });
204
+ });
205
+
206
+ test("refuses with PROVIDER_FAILED when the provider itself reports an aborted completion", async () => {
207
+ const assistant = assistantText("", { content: [], stopReason: "aborted", errorMessage: "provider aborted mid-turn" });
208
+ const outcome = await runInProcessReviewer(baseRequest(), {
209
+ registry: fakeRegistry([fakeModel()]),
210
+ complete: (async () => assistant) as typeof completeSimple,
211
+ });
212
+ const refused = expectRefused(outcome);
213
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
214
+ assert.match(refused.message, /aborted/);
215
+ assert.match(refused.message, /provider aborted mid-turn/);
216
+ });
217
+
218
+ test("refuses output over the byte bound", async () => {
219
+ const oversized = "a".repeat(INPROCESS_REVIEWER_OUTPUT_MAX_BYTES + 16);
220
+ const assistant = assistantText(oversized);
221
+ const outcome = await runInProcessReviewer(baseRequest(), {
222
+ registry: fakeRegistry([fakeModel()]),
223
+ complete: async () => assistant,
224
+ });
225
+ const refused = expectRefused(outcome);
226
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE);
227
+ assert.match(refused.message, new RegExp(String(INPROCESS_REVIEWER_OUTPUT_MAX_BYTES)));
228
+ });
229
+
230
+ test("refuses with PROVIDER_FAILED when stopReason is error", async () => {
231
+ const assistant = assistantText("", { content: [], stopReason: "error", errorMessage: "upstream 500" });
232
+ const outcome = await runInProcessReviewer(baseRequest(), {
233
+ registry: fakeRegistry([fakeModel()]),
234
+ complete: async () => assistant,
235
+ });
236
+ const refused = expectRefused(outcome);
237
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
238
+ assert.match(refused.message, /upstream 500/);
239
+ });
240
+
241
+ test("refuses with a bounded sanitized excerpt when complete throws", async () => {
242
+ const raw = `boom ${"x".repeat(700)}\n\nwith \t whitespace`;
243
+ const outcome = await runInProcessReviewer(baseRequest(), {
244
+ registry: fakeRegistry([fakeModel()]),
245
+ complete: (async () => {
246
+ throw new Error(raw);
247
+ }) as typeof completeSimple,
248
+ });
249
+ const refused = expectRefused(outcome);
250
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
251
+ assert.ok(refused.message.length < raw.length, "the excerpt must be shorter than the raw error");
252
+ assert.match(refused.message, /…/);
253
+ });
254
+
255
+ test("refuses with TIMED_OUT when the completion exceeds its bound", async () => {
256
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 20 }), {
257
+ registry: fakeRegistry([fakeModel()]),
258
+ complete: signalAwaitingComplete(),
259
+ });
260
+ const refused = expectRefused(outcome);
261
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TIMED_OUT);
262
+ assert.match(refused.message, /20ms/);
263
+ });
264
+
265
+ test("refuses with ABORTED when the caller's own signal aborts", async () => {
266
+ const controller = new AbortController();
267
+ controller.abort();
268
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 5_000, signal: controller.signal }), {
269
+ registry: fakeRegistry([fakeModel()]),
270
+ complete: signalAwaitingComplete(),
271
+ });
272
+ const refused = expectRefused(outcome);
273
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.ABORTED);
274
+ });
275
+
276
+ test("refuses with TIMED_OUT when the provider resolves an aborted message after the bound fires", async () => {
277
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 20 }), {
278
+ registry: fakeRegistry([fakeModel()]),
279
+ complete: signalResolvingAbortedComplete(),
280
+ });
281
+ const refused = expectRefused(outcome);
282
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TIMED_OUT);
283
+ assert.match(refused.message, /20ms/);
284
+ });
285
+
286
+ test("refuses with ABORTED when the provider resolves partial text after the caller aborts", async () => {
287
+ const controller = new AbortController();
288
+ controller.abort();
289
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 5_000, signal: controller.signal }), {
290
+ registry: fakeRegistry([fakeModel()]),
291
+ complete: signalResolvingAbortedComplete("truncated findings"),
292
+ });
293
+ const refused = expectRefused(outcome);
294
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.ABORTED);
295
+ });
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // Exact Context passed to complete
299
+ // ---------------------------------------------------------------------------
300
+
301
+ test("passes exactly one user message with the verbatim prompt, no systemPrompt, no tools", async () => {
302
+ const { complete, calls } = capturingComplete(assistantText("looks fine"));
303
+ const prompt = Buffer.from("frozen prompt bytes", "utf8");
304
+ await runInProcessReviewer(baseRequest({ prompt }), {
305
+ registry: fakeRegistry([fakeModel()]),
306
+ complete,
307
+ now: () => 12_345,
308
+ });
309
+ assert.equal(calls.length, 1);
310
+ assert.deepEqual(calls[0]!.context, {
311
+ messages: [{ role: "user", content: [{ type: "text", text: "frozen prompt bytes" }], timestamp: 12_345 }],
312
+ });
313
+ assert.ok(!("systemPrompt" in calls[0]!.context));
314
+ assert.ok(!("tools" in calls[0]!.context));
315
+ });
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Thinking mapping
319
+ // ---------------------------------------------------------------------------
320
+
321
+ test("omits reasoning when thinking is off (or omitted)", async () => {
322
+ const { complete, calls } = capturingComplete(assistantText("ok"));
323
+ await runInProcessReviewer(baseRequest({ thinking: "off" }), { registry: fakeRegistry([fakeModel()]), complete });
324
+ assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
325
+ });
326
+
327
+ test("forwards max verbatim so pi-ai applies the model's own level map", async () => {
328
+ const { complete, calls } = capturingComplete(assistantText("ok"));
329
+ await runInProcessReviewer(baseRequest({ thinking: "max" }), { registry: fakeRegistry([fakeModel()]), complete });
330
+ assert.equal(calls[0]!.options?.reasoning, "max");
331
+ });
332
+
333
+ test("passes a known label through unchanged", async () => {
334
+ const { complete, calls } = capturingComplete(assistantText("ok"));
335
+ await runInProcessReviewer(baseRequest({ thinking: "high" }), { registry: fakeRegistry([fakeModel()]), complete });
336
+ assert.equal(calls[0]!.options?.reasoning, "high");
337
+ });
338
+
339
+ test("omits reasoning for a non-reasoning model even with a valid label", async () => {
340
+ const { complete, calls } = capturingComplete(assistantText("ok"));
341
+ await runInProcessReviewer(baseRequest({ thinking: "high" }), {
342
+ registry: fakeRegistry([fakeModel({ reasoning: false })]),
343
+ complete,
344
+ });
345
+ assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
346
+ });
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // Text concatenation ignoring thinking parts
350
+ // ---------------------------------------------------------------------------
351
+
352
+ test("concatenates only text parts, ignoring thinking parts, in order", async () => {
353
+ const assistant = assistantText("", {
354
+ content: [
355
+ { type: "thinking", thinking: "reasoning about the diff" },
356
+ { type: "text", text: "Part A " },
357
+ { type: "thinking", thinking: "more reasoning" },
358
+ { type: "text", text: "Part B" },
359
+ ],
360
+ });
361
+ const outcome = await runInProcessReviewer(baseRequest(), {
362
+ registry: fakeRegistry([fakeModel()]),
363
+ complete: async () => assistant,
364
+ });
365
+ const text = expectText(outcome);
366
+ assert.equal(text.text, "Part A Part B");
367
+ assert.equal(text.reviewerModel, "openai/gpt-5");
368
+ });