gentle-pi 3.2.1 → 3.4.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 (108) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +52 -15
  10. package/docs/readme-reference.md +67 -7
  11. package/docs/review-integration.md +22 -17
  12. package/extensions/ask-user-question.ts +210 -0
  13. package/extensions/gentle-agents.ts +93 -18
  14. package/extensions/gentle-ai.ts +180 -37
  15. package/extensions/gentle-shell.ts +476 -39
  16. package/extensions/gentle-todo.ts +19 -1
  17. package/extensions/quiet-tools.ts +28 -5
  18. package/extensions/startup-banner.ts +25 -10
  19. package/lib/agents-view.ts +41 -14
  20. package/lib/agents-widget.ts +84 -13
  21. package/lib/animation-policy.ts +52 -0
  22. package/lib/background-cache-warming.ts +38 -0
  23. package/lib/command-palette-catalog.ts +2 -0
  24. package/lib/double-esc-cancel-policy.ts +138 -0
  25. package/lib/inprocess-reviewer.ts +297 -0
  26. package/lib/native-review-cli.ts +50 -10
  27. package/lib/odd-runtime-delegation-gate.ts +88 -0
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +256 -171
  34. package/lib/review-integration-v2.ts +114 -27
  35. package/lib/shell-bar.ts +163 -75
  36. package/lib/shell-card.ts +19 -9
  37. package/lib/shell-changes-view.ts +43 -5
  38. package/lib/shell-changes.ts +92 -5
  39. package/lib/shell-hover.ts +39 -0
  40. package/lib/shell-prompt.ts +10 -1
  41. package/lib/shell-sidebar-layout.ts +118 -16
  42. package/lib/shell-sidebar.ts +16 -0
  43. package/lib/shell-todo.ts +7 -1
  44. package/lib/shell-usage-view.ts +103 -12
  45. package/lib/shell-usage.ts +120 -6
  46. package/package.json +1 -1
  47. package/runtime/native-review-cli.mjs +49 -9
  48. package/runtime/review-integration-v2.mjs +114 -27
  49. package/scripts/gentle-ai-installer.mjs +10 -10
  50. package/scripts/maintainer/provider-relay-matrix.mjs +118 -47
  51. package/scripts/verify-package-files.mjs +3 -4
  52. package/tests/agents-grouping.test.ts +75 -18
  53. package/tests/agents-view.test.ts +28 -18
  54. package/tests/agents-widget.test.ts +100 -12
  55. package/tests/animation-policy.test.ts +42 -0
  56. package/tests/ask-user-question.test.ts +435 -0
  57. package/tests/background-cache-warming.test.ts +60 -0
  58. package/tests/background-subagents.test.ts +68 -0
  59. package/tests/command-palette.test.ts +10 -0
  60. package/tests/devbinary/pi-host-relay.devtest.ts +176 -138
  61. package/tests/double-esc-cancel-policy.test.ts +194 -0
  62. package/tests/gentle-agents.test.ts +599 -7
  63. package/tests/gentle-ai-binary.test.ts +1 -1
  64. package/tests/gentle-ai-installer.test.ts +47 -47
  65. package/tests/gentle-ai.test.ts +125 -9
  66. package/tests/gentle-shell.test.ts +1149 -24
  67. package/tests/gentle-todo.test.ts +17 -4
  68. package/tests/inprocess-reviewer.test.ts +460 -0
  69. package/tests/maintainer/provider-relay.maintest.ts +101 -143
  70. package/tests/native-review-capability-contract.test.ts +34 -1
  71. package/tests/native-review-parity.test.ts +19 -0
  72. package/tests/odd-runtime-delegation-gate.test.ts +212 -0
  73. package/tests/orchestrator-rdd-ownership.test.ts +3 -3
  74. package/tests/package-manifest.test.ts +6 -17
  75. package/tests/questionnaire-schema.test.ts +274 -0
  76. package/tests/questionnaire-view.test.ts +446 -0
  77. package/tests/rdd-status-line.test.ts +21 -4
  78. package/tests/review-candidate-owner-retry.test.ts +63 -0
  79. package/tests/review-candidate-view.test.ts +15 -0
  80. package/tests/review-controller-native-routing.test.ts +86 -0
  81. package/tests/review-host-relay-routing.test.ts +77 -0
  82. package/tests/review-host-relay.test.ts +297 -299
  83. package/tests/review-integration-v2-forward.test.ts +61 -0
  84. package/tests/review-integration-v2.test.ts +146 -1
  85. package/tests/review-ledger-contract.test.ts +1 -2
  86. package/tests/review-relay-transport-agent.test.ts +129 -26
  87. package/tests/review-risk-assessment.test.ts +104 -0
  88. package/tests/runtime-harness.mjs +11 -0
  89. package/tests/session-changes-shell.test.ts +27 -0
  90. package/tests/session-worktree-registry.test.ts +41 -0
  91. package/tests/shell-bar.test.ts +200 -124
  92. package/tests/shell-card.test.ts +5 -3
  93. package/tests/shell-changes-view.test.ts +47 -0
  94. package/tests/shell-changes.test.ts +177 -0
  95. package/tests/shell-hover.test.ts +19 -0
  96. package/tests/shell-prompt.test.ts +20 -0
  97. package/tests/shell-sidebar-fullscreen.test.ts +59 -0
  98. package/tests/shell-sidebar-layout.test.ts +301 -8
  99. package/tests/shell-sidebar.test.ts +25 -1
  100. package/tests/shell-todo.test.ts +36 -0
  101. package/tests/shell-usage-view.test.ts +120 -1
  102. package/tests/shell-usage.test.ts +129 -0
  103. package/tests/skill-collision-prefixes.test.ts +1 -1
  104. package/tests/startup-banner.test.ts +93 -2
  105. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  106. package/lib/opaque-pi-reviewer-adapter.ts +0 -404
  107. package/skills/release/SKILL.md +0 -137
  108. package/tests/opaque-pi-reviewer-adapter.test.ts +0 -410
@@ -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,460 @@
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
+ openCodeSessionAttributionHeaders,
10
+ type InProcessReviewerOutcome,
11
+ type InProcessReviewerRegistry,
12
+ type InProcessReviewerRequest,
13
+ } from "../lib/inprocess-reviewer.ts";
14
+
15
+ // The in-process reviewer completion (gentle-ai#4611; gentle-pi#311 P1) runs
16
+ // one reviewer role through pi's live model registry instead of a locked-down
17
+ // `pi --print` child with extension discovery disabled. Every seam here is a
18
+ // fake: no network, no pi process, no process.env reads.
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Fakes — structural subsets of pi's live ModelRegistry and completeSimple.
22
+ // ---------------------------------------------------------------------------
23
+
24
+ function fakeModel(overrides: Partial<Model<Api>> = {}): Model<Api> {
25
+ return {
26
+ id: "gpt-5",
27
+ name: "GPT-5",
28
+ api: "openai-responses",
29
+ provider: "openai",
30
+ baseUrl: "https://api.openai.com",
31
+ reasoning: true,
32
+ input: ["text"],
33
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
34
+ contextWindow: 100_000,
35
+ maxTokens: 8192,
36
+ ...overrides,
37
+ };
38
+ }
39
+
40
+ function fakeRegistry(
41
+ models: readonly Model<Api>[],
42
+ auth?: (model: Model<Api>) => ReturnType<InProcessReviewerRegistry["getApiKeyAndHeaders"]>,
43
+ ): InProcessReviewerRegistry {
44
+ return {
45
+ find: (provider, modelId) => models.find((candidate) => candidate.provider === provider && candidate.id === modelId),
46
+ getApiKeyAndHeaders: auth ?? (async () => ({ ok: true, apiKey: "test-key" })),
47
+ };
48
+ }
49
+
50
+ function assistantText(text: string, overrides: Partial<AssistantMessage> = {}): AssistantMessage {
51
+ return {
52
+ role: "assistant",
53
+ content: [{ type: "text", text }],
54
+ api: "openai-responses",
55
+ provider: "openai",
56
+ model: "gpt-5",
57
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
58
+ stopReason: "stop",
59
+ timestamp: 0,
60
+ ...overrides,
61
+ };
62
+ }
63
+
64
+ function baseRequest(overrides: Partial<InProcessReviewerRequest> = {}): InProcessReviewerRequest {
65
+ return {
66
+ selection: "openai/gpt-5",
67
+ prompt: Buffer.from("Review this diff.", "utf8"),
68
+ timeoutMs: 30_000,
69
+ routingKey: "review-risk",
70
+ ...overrides,
71
+ };
72
+ }
73
+
74
+ /** A `complete` fake that records every call for assertion. */
75
+ function capturingComplete(assistant: AssistantMessage) {
76
+ const calls: Array<{ model: Model<Api>; context: Context; options: SimpleStreamOptions | undefined }> = [];
77
+ const complete: typeof completeSimple = (async (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => {
78
+ calls.push({ model, context, options });
79
+ return assistant;
80
+ }) as typeof completeSimple;
81
+ return { complete, calls };
82
+ }
83
+
84
+ /**
85
+ * A `complete` fake that never resolves except when its signal aborts — the
86
+ * timeout and caller-abort paths are exercised without a real network call
87
+ * or a wall-clock wait longer than the request's own timeout.
88
+ */
89
+ function signalAwaitingComplete(): typeof completeSimple {
90
+ return (async (_model: Model<Api>, _context: Context, options?: SimpleStreamOptions) => {
91
+ return await new Promise<AssistantMessage>((_resolve, reject) => {
92
+ const signal = options?.signal;
93
+ if (signal === undefined) return;
94
+ if (signal.aborted) {
95
+ reject(new Error("aborted"));
96
+ return;
97
+ }
98
+ signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
99
+ });
100
+ }) as typeof completeSimple;
101
+ }
102
+
103
+ /**
104
+ * A `complete` fake that follows the pi-ai provider convention on abort:
105
+ * once the signal fires it RESOLVES an AssistantMessage carrying the text
106
+ * streamed so far and `stopReason: "aborted"`, instead of rejecting.
107
+ */
108
+ function signalResolvingAbortedComplete(partialText = "partial revi"): typeof completeSimple {
109
+ return (async (_model, _context, options) => {
110
+ return await new Promise<AssistantMessage>((resolve) => {
111
+ const signal = options?.signal;
112
+ const settle = () => resolve(assistantText(partialText, { stopReason: "aborted" }));
113
+ if (signal === undefined) return;
114
+ if (signal.aborted) {
115
+ settle();
116
+ return;
117
+ }
118
+ signal.addEventListener("abort", settle, { once: true });
119
+ });
120
+ }) as typeof completeSimple;
121
+ }
122
+
123
+ /** A canary `complete` fake for refusals that must never reach the provider. */
124
+ const unreachableComplete: typeof completeSimple = (async () => {
125
+ throw new Error("complete must not be called for this refusal");
126
+ }) as typeof completeSimple;
127
+
128
+ function expectRefused(outcome: InProcessReviewerOutcome): Extract<InProcessReviewerOutcome, { kind: "refused" }> {
129
+ assert.equal(outcome.kind, "refused", outcome.kind === "text" ? `expected a refusal, got text: ${outcome.text}` : undefined);
130
+ return outcome as Extract<InProcessReviewerOutcome, { kind: "refused" }>;
131
+ }
132
+
133
+ function expectText(outcome: InProcessReviewerOutcome): Extract<InProcessReviewerOutcome, { kind: "text" }> {
134
+ assert.equal(outcome.kind, "text", outcome.kind === "refused" ? `expected text, got refusal ${outcome.code}: ${outcome.message}` : undefined);
135
+ return outcome as Extract<InProcessReviewerOutcome, { kind: "text" }>;
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Every refusal code
140
+ // ---------------------------------------------------------------------------
141
+
142
+ test("refuses a selection with no provider/id separator", async () => {
143
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "gpt-5" }), {
144
+ registry: fakeRegistry([fakeModel()]),
145
+ complete: unreachableComplete,
146
+ });
147
+ const refused = expectRefused(outcome);
148
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.SELECTION_INVALID);
149
+ assert.match(refused.message, /review-risk/);
150
+ });
151
+
152
+ test("refuses when the registry has no matching model", async () => {
153
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "openai/does-not-exist" }), {
154
+ registry: fakeRegistry([fakeModel()]),
155
+ complete: unreachableComplete,
156
+ });
157
+ const refused = expectRefused(outcome);
158
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.MODEL_NOT_FOUND);
159
+ assert.match(refused.message, /review-risk/);
160
+ assert.doesNotMatch(refused.message.toLowerCase(), /env var|extension/);
161
+ });
162
+
163
+ test("refuses when the registry cannot resolve auth", async () => {
164
+ const outcome = await runInProcessReviewer(baseRequest(), {
165
+ registry: fakeRegistry([fakeModel()], async () => ({ ok: false, error: "no stored credential" })),
166
+ complete: unreachableComplete,
167
+ });
168
+ const refused = expectRefused(outcome);
169
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.AUTH_UNAVAILABLE);
170
+ assert.match(refused.message, /openai/);
171
+ assert.match(refused.message, /no stored credential/);
172
+ });
173
+
174
+ test("refuses an unknown thinking label", async () => {
175
+ const outcome = await runInProcessReviewer(baseRequest({ thinking: "bogus" }), {
176
+ registry: fakeRegistry([fakeModel()]),
177
+ complete: unreachableComplete,
178
+ });
179
+ const refused = expectRefused(outcome);
180
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.THINKING_INVALID);
181
+ });
182
+
183
+ test("refuses when the reviewer attempts a tool call", async () => {
184
+ const assistant = assistantText("", {
185
+ content: [{ type: "toolCall", id: "1", name: "bash", arguments: {} }],
186
+ stopReason: "toolUse",
187
+ });
188
+ const outcome = await runInProcessReviewer(baseRequest(), {
189
+ registry: fakeRegistry([fakeModel()]),
190
+ complete: async () => assistant,
191
+ });
192
+ const refused = expectRefused(outcome);
193
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TOOL_CALL_ATTEMPTED);
194
+ });
195
+
196
+ test("refuses empty assistant text with stopReason evidence", async () => {
197
+ const assistant = assistantText("", { content: [], stopReason: "stop" });
198
+ const outcome = await runInProcessReviewer(baseRequest(), {
199
+ registry: fakeRegistry([fakeModel()]),
200
+ complete: async () => assistant,
201
+ });
202
+ const refused = expectRefused(outcome);
203
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.EMPTY_OUTPUT);
204
+ assert.deepEqual(refused.evidence, { stopReason: "stop" });
205
+ });
206
+
207
+ test("refuses with PROVIDER_FAILED when the provider itself reports an aborted completion", async () => {
208
+ const assistant = assistantText("", { content: [], stopReason: "aborted", errorMessage: "provider aborted mid-turn" });
209
+ const outcome = await runInProcessReviewer(baseRequest(), {
210
+ registry: fakeRegistry([fakeModel()]),
211
+ complete: (async () => assistant) as typeof completeSimple,
212
+ });
213
+ const refused = expectRefused(outcome);
214
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
215
+ assert.match(refused.message, /aborted/);
216
+ assert.match(refused.message, /provider aborted mid-turn/);
217
+ });
218
+
219
+ test("refuses output over the byte bound", async () => {
220
+ const oversized = "a".repeat(INPROCESS_REVIEWER_OUTPUT_MAX_BYTES + 16);
221
+ const assistant = assistantText(oversized);
222
+ const outcome = await runInProcessReviewer(baseRequest(), {
223
+ registry: fakeRegistry([fakeModel()]),
224
+ complete: async () => assistant,
225
+ });
226
+ const refused = expectRefused(outcome);
227
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.OUTPUT_TOO_LARGE);
228
+ assert.match(refused.message, new RegExp(String(INPROCESS_REVIEWER_OUTPUT_MAX_BYTES)));
229
+ });
230
+
231
+ test("refuses with PROVIDER_FAILED when stopReason is error", async () => {
232
+ const assistant = assistantText("", { content: [], stopReason: "error", errorMessage: "upstream 500" });
233
+ const outcome = await runInProcessReviewer(baseRequest(), {
234
+ registry: fakeRegistry([fakeModel()]),
235
+ complete: async () => assistant,
236
+ });
237
+ const refused = expectRefused(outcome);
238
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
239
+ assert.match(refused.message, /upstream 500/);
240
+ });
241
+
242
+ test("refuses with a bounded sanitized excerpt when complete throws", async () => {
243
+ const raw = `boom ${"x".repeat(700)}\n\nwith \t whitespace`;
244
+ const outcome = await runInProcessReviewer(baseRequest(), {
245
+ registry: fakeRegistry([fakeModel()]),
246
+ complete: (async () => {
247
+ throw new Error(raw);
248
+ }) as typeof completeSimple,
249
+ });
250
+ const refused = expectRefused(outcome);
251
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.PROVIDER_FAILED);
252
+ assert.ok(refused.message.length < raw.length, "the excerpt must be shorter than the raw error");
253
+ assert.match(refused.message, /…/);
254
+ });
255
+
256
+ test("refuses with TIMED_OUT when the completion exceeds its bound", async () => {
257
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 20 }), {
258
+ registry: fakeRegistry([fakeModel()]),
259
+ complete: signalAwaitingComplete(),
260
+ });
261
+ const refused = expectRefused(outcome);
262
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TIMED_OUT);
263
+ assert.match(refused.message, /20ms/);
264
+ });
265
+
266
+ test("refuses with ABORTED when the caller's own signal aborts", async () => {
267
+ const controller = new AbortController();
268
+ controller.abort();
269
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 5_000, signal: controller.signal }), {
270
+ registry: fakeRegistry([fakeModel()]),
271
+ complete: signalAwaitingComplete(),
272
+ });
273
+ const refused = expectRefused(outcome);
274
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.ABORTED);
275
+ });
276
+
277
+ test("refuses with TIMED_OUT when the provider resolves an aborted message after the bound fires", async () => {
278
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 20 }), {
279
+ registry: fakeRegistry([fakeModel()]),
280
+ complete: signalResolvingAbortedComplete(),
281
+ });
282
+ const refused = expectRefused(outcome);
283
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.TIMED_OUT);
284
+ assert.match(refused.message, /20ms/);
285
+ });
286
+
287
+ test("refuses with ABORTED when the provider resolves partial text after the caller aborts", async () => {
288
+ const controller = new AbortController();
289
+ controller.abort();
290
+ const outcome = await runInProcessReviewer(baseRequest({ timeoutMs: 5_000, signal: controller.signal }), {
291
+ registry: fakeRegistry([fakeModel()]),
292
+ complete: signalResolvingAbortedComplete("truncated findings"),
293
+ });
294
+ const refused = expectRefused(outcome);
295
+ assert.equal(refused.code, INPROCESS_REVIEWER_FAILURE.ABORTED);
296
+ });
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Exact Context passed to complete
300
+ // ---------------------------------------------------------------------------
301
+
302
+ test("passes exactly one user message with the verbatim prompt, no systemPrompt, no tools", async () => {
303
+ const { complete, calls } = capturingComplete(assistantText("looks fine"));
304
+ const prompt = Buffer.from("frozen prompt bytes", "utf8");
305
+ await runInProcessReviewer(baseRequest({ prompt }), {
306
+ registry: fakeRegistry([fakeModel()]),
307
+ complete,
308
+ now: () => 12_345,
309
+ });
310
+ assert.equal(calls.length, 1);
311
+ assert.deepEqual(calls[0]!.context, {
312
+ messages: [{ role: "user", content: [{ type: "text", text: "frozen prompt bytes" }], timestamp: 12_345 }],
313
+ });
314
+ assert.ok(!("systemPrompt" in calls[0]!.context));
315
+ assert.ok(!("tools" in calls[0]!.context));
316
+ });
317
+
318
+ // ---------------------------------------------------------------------------
319
+ // Thinking mapping
320
+ // ---------------------------------------------------------------------------
321
+
322
+ test("omits reasoning when thinking is off (or omitted)", async () => {
323
+ const { complete, calls } = capturingComplete(assistantText("ok"));
324
+ await runInProcessReviewer(baseRequest({ thinking: "off" }), { registry: fakeRegistry([fakeModel()]), complete });
325
+ assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
326
+ });
327
+
328
+ test("forwards max verbatim so pi-ai applies the model's own level map", async () => {
329
+ const { complete, calls } = capturingComplete(assistantText("ok"));
330
+ await runInProcessReviewer(baseRequest({ thinking: "max" }), { registry: fakeRegistry([fakeModel()]), complete });
331
+ assert.equal(calls[0]!.options?.reasoning, "max");
332
+ });
333
+
334
+ test("passes a known label through unchanged", async () => {
335
+ const { complete, calls } = capturingComplete(assistantText("ok"));
336
+ await runInProcessReviewer(baseRequest({ thinking: "high" }), { registry: fakeRegistry([fakeModel()]), complete });
337
+ assert.equal(calls[0]!.options?.reasoning, "high");
338
+ });
339
+
340
+ test("omits reasoning for a non-reasoning model even with a valid label", async () => {
341
+ const { complete, calls } = capturingComplete(assistantText("ok"));
342
+ await runInProcessReviewer(baseRequest({ thinking: "high" }), {
343
+ registry: fakeRegistry([fakeModel({ reasoning: false })]),
344
+ complete,
345
+ });
346
+ assert.ok(!("reasoning" in (calls[0]!.options ?? {})));
347
+ });
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // OpenCode session attribution headers
351
+ //
352
+ // Pi adds OpenCode attribution headers inside the main agent loop
353
+ // (provider-attribution.js#getSessionHeaders). This completion is an extension
354
+ // side-call that bypasses that loop, so it must add the same headers itself:
355
+ // provider opencode / opencode-go, or a baseUrl whose host is opencode.ai,
356
+ // carrying the live session id — and nothing for any other provider.
357
+ // ---------------------------------------------------------------------------
358
+
359
+ const OPENCODE_ATTRIBUTION_TESTS: Array<{ readonly name: string; readonly model: Partial<Model<Api>>; readonly selection: string }> = [
360
+ { name: "an opencode provider model", model: { provider: "opencode", id: "sonnet-4", baseUrl: "https://opencode.ai" }, selection: "opencode/sonnet-4" },
361
+ { name: "an opencode-go provider model", model: { provider: "opencode-go", id: "gpt-5", baseUrl: "https://opencode.ai" }, selection: "opencode-go/gpt-5" },
362
+ { name: "a custom provider whose baseUrl host is opencode.ai", model: { provider: "custom", id: "relay-model", baseUrl: "https://opencode.ai/v1" }, selection: "custom/relay-model" },
363
+ ];
364
+
365
+ for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
366
+ test(`${name} receives both attribution headers carrying the live session id`, async () => {
367
+ const { complete, calls } = capturingComplete(assistantText("ok"));
368
+ const outcome = await runInProcessReviewer(baseRequest({ selection, sessionId: "ses-live-1" }), {
369
+ registry: fakeRegistry([fakeModel(model)]),
370
+ complete,
371
+ });
372
+ expectText(outcome);
373
+ assert.equal(calls.length, 1);
374
+ assert.equal(calls[0]!.options?.headers?.["x-opencode-session"], "ses-live-1");
375
+ assert.equal(calls[0]!.options?.headers?.["x-opencode-client"], "pi");
376
+ });
377
+ }
378
+
379
+ for (const { name, model, selection } of OPENCODE_ATTRIBUTION_TESTS) {
380
+ test(`${name} without a session id adds no attribution header and still completes`, async () => {
381
+ const { complete, calls } = capturingComplete(assistantText("ok"));
382
+ const outcome = await runInProcessReviewer(baseRequest({ selection }), {
383
+ registry: fakeRegistry([fakeModel(model)]),
384
+ complete,
385
+ });
386
+ expectText(outcome);
387
+ assert.equal(calls.length, 1);
388
+ assert.ok(!("headers" in (calls[0]!.options ?? {})), "a missing session id must never invent a header");
389
+ });
390
+ }
391
+
392
+ test("a non-OpenCode model adds no attribution headers and leaves the options unchanged", async () => {
393
+ const { complete, calls } = capturingComplete(assistantText("ok"));
394
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "anthropic/claude-sonnet-4", sessionId: "ses-live-1" }), {
395
+ registry: fakeRegistry([fakeModel({ provider: "anthropic", id: "claude-sonnet-4", baseUrl: "https://api.anthropic.com" })]),
396
+ complete,
397
+ });
398
+ expectText(outcome);
399
+ assert.equal(calls.length, 1);
400
+ assert.ok(!("headers" in (calls[0]!.options ?? {})));
401
+ });
402
+
403
+ test("registry auth headers win over the attribution defaults and both survive the merge", async () => {
404
+ const { complete, calls } = capturingComplete(assistantText("ok"));
405
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "opencode/sonnet-4", sessionId: "ses-live-1" }), {
406
+ registry: fakeRegistry([fakeModel({ provider: "opencode", id: "sonnet-4" })], async () => ({
407
+ ok: true,
408
+ headers: { "x-api-key": "registry-key", "x-opencode-session": "registry-session" },
409
+ })),
410
+ complete,
411
+ });
412
+ expectText(outcome);
413
+ const headers = calls[0]!.options?.headers;
414
+ assert.equal(headers?.["x-api-key"], "registry-key", "registry auth headers must survive");
415
+ assert.equal(headers?.["x-opencode-session"], "registry-session", "explicit registry headers must not be clobbered by the attribution default");
416
+ assert.equal(headers?.["x-opencode-client"], "pi");
417
+ });
418
+
419
+ test("an unparseable baseUrl never throws: attribution follows the provider condition only", async () => {
420
+ const { complete, calls } = capturingComplete(assistantText("ok"));
421
+ const outcome = await runInProcessReviewer(baseRequest({ selection: "custom/relay-model", sessionId: "ses-live-1" }), {
422
+ registry: fakeRegistry([fakeModel({ provider: "custom", id: "relay-model", baseUrl: "not-a-url" })]),
423
+ complete,
424
+ });
425
+ expectText(outcome);
426
+ assert.ok(!("headers" in (calls[0]!.options ?? {})));
427
+ });
428
+
429
+ test("openCodeSessionAttributionHeaders mirrors pi's condition exactly", () => {
430
+ const opencode = fakeModel({ provider: "opencode", baseUrl: "https://example.com" });
431
+ assert.deepEqual(openCodeSessionAttributionHeaders(opencode, "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
432
+ assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "opencode-go" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
433
+ assert.deepEqual(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://opencode.ai/v1" }), "ses-1"), { "x-opencode-session": "ses-1", "x-opencode-client": "pi" });
434
+ assert.equal(openCodeSessionAttributionHeaders(fakeModel({ provider: "custom", baseUrl: "https://api.opencode.ai" }), "ses-1"), undefined, "a subdomain host is not opencode.ai");
435
+ assert.equal(openCodeSessionAttributionHeaders(fakeModel(), "ses-1"), undefined);
436
+ assert.equal(openCodeSessionAttributionHeaders(opencode, undefined), undefined, "no session id, no header");
437
+ assert.equal(openCodeSessionAttributionHeaders(opencode, ""), undefined, "an empty session id is no session id");
438
+ });
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Text concatenation ignoring thinking parts
442
+ // ---------------------------------------------------------------------------
443
+
444
+ test("concatenates only text parts, ignoring thinking parts, in order", async () => {
445
+ const assistant = assistantText("", {
446
+ content: [
447
+ { type: "thinking", thinking: "reasoning about the diff" },
448
+ { type: "text", text: "Part A " },
449
+ { type: "thinking", thinking: "more reasoning" },
450
+ { type: "text", text: "Part B" },
451
+ ],
452
+ });
453
+ const outcome = await runInProcessReviewer(baseRequest(), {
454
+ registry: fakeRegistry([fakeModel()]),
455
+ complete: async () => assistant,
456
+ });
457
+ const text = expectText(outcome);
458
+ assert.equal(text.text, "Part A Part B");
459
+ assert.equal(text.reviewerModel, "openai/gpt-5");
460
+ });