@pi-archimedes/session-name 2.10.4 → 2.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ After installing Pi, choose one installation command above, then `cd` into your
41
41
  | Setting | Type | Default | Description |
42
42
  |---------|------|---------|-------------|
43
43
  | `model` | string | _(current model)_ | Model used for title generation (e.g. `openai/gpt-4o-mini`). Canonical `provider/id`, bare IDs, and thinking-suffix forms are all resolved. Empty = current model. |
44
- | `reasoning` | string \| null | `minimal` | Thinking level for the title request: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Set `null` to omit the option entirely — that's the fix for a provider that rejects thinking levels outright, which shows up as the naming warning plus a `400 Invalid request parameters` line in the log. Case and stray spaces are tolerated (`" Minimal "` works). Anything unrecognised is ignored in favour of the default and logged as `[archimedes] session-name: ignoring unrecognized reasoning <value>` — it is never sent to the provider, because what an unknown level *means* is provider-dependent: some read it as more thinking, some reject the request, and we'd rather not depend on which. |
44
+ | `reasoning` | string \| null | `minimal` | Thinking level for the title request: `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `off`. Resolved to the nearest level the model supports (warns on change); the option is omitted only when that resolves to `"off"`. `off`/`null` therefore request no thinking but can still send a level on models that exclude it — use `"minimal"` for bounded-small everywhere. Case and stray spaces are tolerated (`" Minimal "` works). Anything unrecognised is ignored in favour of the default and logged. Only supported levels reach the provider: the setting resolves through pi's clamp to the nearest level the model supports. |
45
45
 
46
46
  `model` is also editable in the `/archimedes` panel (**Model for naming** — Enter to type, blank to go back to the current model); `reasoning` is JSON-only.
47
47
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/session-name",
3
- "version": "2.10.4",
3
+ "version": "2.11.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -18,7 +18,7 @@
18
18
  ".": "./src/index.ts"
19
19
  },
20
20
  "dependencies": {
21
- "@pi-archimedes/core": "2.10.4"
21
+ "@pi-archimedes/core": "2.11.1"
22
22
  },
23
23
  "peerDependencies": {
24
24
  "@earendil-works/pi-coding-agent": ">=0.86.0",
@@ -26,9 +26,9 @@
26
26
  "@earendil-works/pi-tui": ">=0.86.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-ai": "^0.87.0",
30
- "@earendil-works/pi-coding-agent": "^0.87.0",
31
- "@earendil-works/pi-tui": "^0.87.0",
29
+ "@earendil-works/pi-ai": "1.2.0",
30
+ "@earendil-works/pi-coding-agent": "1.2.0",
31
+ "@earendil-works/pi-tui": "1.2.0",
32
32
  "typescript": "^6.0.0"
33
33
  },
34
34
  "pi": {
package/src/index.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { loadConfig } from "@pi-archimedes/core/settings-io";
2
+ import { diag } from "@pi-archimedes/core/log";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import { thinkingBudgetForLevel } from "@earendil-works/pi-ai/api/simple-options";
4
5
  import { generateTitle, registerSessionName, resolveTitleReasoning } from "./index.js";
@@ -39,7 +40,9 @@ function createMockCtx(options: {
39
40
  },
40
41
  ];
41
42
 
42
- const defaultModel = { provider: "anthropic", id: "claude-3-5-sonnet" };
43
+ // Reasoning model without a level map: every named level passes clamp
44
+ // through, so existing assertions on forwarded levels keep holding.
45
+ const defaultModel = { provider: "anthropic", id: "claude-3-5-sonnet", reasoning: true };
43
46
 
44
47
  return {
45
48
  sessionManager: {
@@ -66,6 +69,16 @@ vi.mock("@pi-archimedes/core/settings-io", () => ({
66
69
  loadConfig: vi.fn((_namespace, defaults) => defaults),
67
70
  }));
68
71
 
72
+ // Diagnostics never go to the console (see packages/core/src/log.ts): a write
73
+ // there smears a fullscreen frame and is discarded inside a subagent child. So
74
+ // every "it said something / it said nothing" assertion below watches `diag`.
75
+ vi.mock("@pi-archimedes/core/log", async (importOriginal) => {
76
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
77
+ return { ...real, diag: vi.fn() };
78
+ });
79
+
80
+ const clearDiag = () => vi.mocked(diag).mockClear();
81
+
69
82
  describe("generateTitle", () => {
70
83
  it("generates title, trims quotes and whitespace, and calls pi.setSessionName", async () => {
71
84
  const pi = createMockPi();
@@ -121,8 +134,10 @@ describe("generateTitle", () => {
121
134
  // anything. A non-level must never reach the provider — see the pin at the
122
135
  // end of this block for what that costs.
123
136
  describe("reasoning validation", () => {
124
- it("passes every real thinking level through untouched", async () => {
125
- for (const reasoning of ["minimal", "low", "medium", "high", "xhigh", "max"]) {
137
+ it("passes every supported thinking level through untouched", async () => {
138
+ // The mock model has no level map, like the real title model — so only
139
+ // minimal..high pass through; xhigh/max step down (covered below).
140
+ for (const reasoning of ["minimal", "low", "medium", "high"]) {
126
141
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning });
127
142
  const ctx = createMockCtx({});
128
143
  await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
@@ -132,54 +147,71 @@ describe("generateTitle", () => {
132
147
  }
133
148
  });
134
149
 
135
- it.each(["Minimal", " high ", "MAX"])("tolerates casing and padding: %s", async (raw) => {
150
+ it.each([["xhigh", "high"], ["MAX", "high"]])("clamps %s to nearest supported %s, warning", async (raw, clamped) => {
136
151
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
137
152
  const ctx = createMockCtx({});
138
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
139
- try {
140
- await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
141
- expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(
142
- raw.trim().toLowerCase(),
143
- );
144
- expect(log).not.toHaveBeenCalled();
145
- } finally {
146
- log.mockRestore();
147
- }
153
+ clearDiag();
154
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
155
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(clamped);
156
+ expect(diag).toHaveBeenCalledWith(
157
+ "session-name",
158
+ expect.stringContaining(`using "${clamped}"`),
159
+ );
148
160
  });
149
161
 
150
- it.each([["ultra"], ["off"], [""], [42], [true], [{}], [[]]])(
162
+ it.each(["Minimal", " high "])("tolerates casing and padding: %s", async (raw) => {
163
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
164
+ const ctx = createMockCtx({});
165
+ clearDiag();
166
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
167
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(
168
+ raw.trim().toLowerCase(),
169
+ );
170
+ expect(diag).not.toHaveBeenCalled();
171
+ });
172
+
173
+ it.each([["ultra"], [""], [42], [true], [{}], [[]]])(
151
174
  "never forwards a non-level: %s",
152
175
  async (raw) => {
153
176
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
154
177
  const ctx = createMockCtx({});
155
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
156
- try {
157
- await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
158
-
159
- const sent = (ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning;
160
- // Invalid config behaves like absent config: the documented default.
161
- expect(sent).toBe("minimal");
162
- expect(log).toHaveBeenCalledWith(
163
- "[archimedes] session-name: ignoring unrecognized reasoning",
164
- JSON.stringify(raw),
165
- );
166
- } finally {
167
- log.mockRestore();
168
- }
178
+ clearDiag();
179
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
180
+
181
+ const sent = (ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning;
182
+ // Invalid config behaves like absent config: the documented default.
183
+ expect(sent).toBe("minimal");
184
+ // The typo is invisible unless it is reported, and the report is the
185
+ // single log line naming the rejected value.
186
+ expect(diag).toHaveBeenCalledWith(
187
+ "session-name",
188
+ `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`,
189
+ );
169
190
  },
170
191
  );
171
192
 
172
- it("sends no reasoning key when the value is null, and says nothing", async () => {
173
- vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: null });
193
+ it.each([["off"], [null]])("omits the key for %s on models that take it", async (reasoning) => {
194
+ // Default mock has no level map, so "off" is supported and omitted silently.
195
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning });
174
196
  const ctx = createMockCtx({});
175
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
176
- try {
177
- await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
178
- expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2]).not.toHaveProperty("reasoning");
179
- expect(log).not.toHaveBeenCalled();
180
- } finally {
181
- log.mockRestore();
182
- }
197
+ clearDiag();
198
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
199
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2]).not.toHaveProperty("reasoning");
200
+ expect(diag).not.toHaveBeenCalled();
201
+ });
202
+
203
+ it("clamps off to minimal with a warning where the model excludes it", async () => {
204
+ // Muse-spark shape: thinking model whose map excludes "off" (null).
205
+ const model = { provider: "op", id: "spark", reasoning: true, thinkingLevelMap: { off: null } };
206
+ vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: "off" });
207
+ const ctx = createMockCtx({ model });
208
+ clearDiag();
209
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
210
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe("minimal");
211
+ expect(diag).toHaveBeenCalledWith(
212
+ "session-name",
213
+ expect.stringContaining('using "minimal"'),
214
+ );
183
215
  });
184
216
 
185
217
  it("keeps the default when the setting is absent entirely, and says nothing", async () => {
@@ -187,26 +219,25 @@ describe("generateTitle", () => {
187
219
  // "minimal" — the common path must stay silent. A regression that warned
188
220
  // on every default-configured session would otherwise pass the suite.
189
221
  const ctx = createMockCtx({});
190
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
191
- try {
192
- await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
193
- expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe("minimal");
194
- expect(log).not.toHaveBeenCalled();
195
- } finally {
196
- log.mockRestore();
197
- }
222
+ clearDiag();
223
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
224
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe("minimal");
225
+ expect(diag).not.toHaveBeenCalled();
198
226
  });
199
227
 
200
228
  it("treats undefined like absent config, without warning", () => {
201
229
  // Direct call: the generateTitle path can't produce this, since
202
230
  // loadConfig fills the default in, but the helper is exported.
203
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
204
- try {
205
- expect(resolveTitleReasoning(undefined)).toBe("minimal");
206
- expect(log).not.toHaveBeenCalled();
207
- } finally {
208
- log.mockRestore();
209
- }
231
+ clearDiag();
232
+ expect(resolveTitleReasoning(undefined)).toBe("minimal");
233
+ expect(diag).not.toHaveBeenCalled();
234
+ });
235
+
236
+ it("accepts off for the call site to clamp per model", () => {
237
+ clearDiag();
238
+ expect(resolveTitleReasoning("off")).toBe("off");
239
+ expect(resolveTitleReasoning(" Off ")).toBe("off");
240
+ expect(diag).not.toHaveBeenCalled();
210
241
  });
211
242
 
212
243
  // Pin the invariant against the arithmetic that makes this dangerous. Of
@@ -228,15 +259,18 @@ describe("generateTitle", () => {
228
259
  for (const level of levels) {
229
260
  expect(thinkingBudgetForLevel(level)).toBeTypeOf("number");
230
261
  }
231
- const junk: unknown[] = ["ultra", "MINIMAL", "off", "", "Max ", 42, true, {}, []];
262
+ const junk: unknown[] = ["ultra", "MINIMAL", "", "Max ", 42, true, {}, []];
232
263
  for (const value of junk) {
233
264
  expect(thinkingBudgetForLevel(value as never)).toBeUndefined();
234
265
  }
235
266
 
236
- // Whatever the setting holds, the value we forward is always safe to send.
267
+ // Whatever the setting holds, the value reaching clamp is a real level
268
+ // or "off" — never raw input. "off" is resolved per model at the call
269
+ // site (nearest supported, warn on change), never forwarded verbatim.
237
270
  for (const raw of [...levels, ...junk, null, undefined]) {
238
271
  const resolved = resolveTitleReasoning(raw);
239
272
  if (resolved === null) continue; // omitted → no level requested
273
+ if (resolved === "off") continue; // clamped per model at the call site
240
274
  expect(levels).toContain(resolved);
241
275
  expect(thinkingBudgetForLevel(resolved)).toBeTypeOf("number");
242
276
  }
@@ -270,16 +304,16 @@ describe("generateTitle", () => {
270
304
  pi.getSessionName.mockImplementation(() => { throw new Error(message); });
271
305
  const onSuccess = vi.fn();
272
306
  const onFailure = vi.fn();
273
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
274
- try {
275
- await generateTitle(pi as any, createMockCtx({}) as any, onSuccess, onFailure);
276
- expect(onFailure).toHaveBeenCalledTimes(failures);
277
- expect(log).toHaveBeenCalledTimes(failures);
278
- expect(onSuccess).not.toHaveBeenCalled();
279
- expect(pi.setSessionName).not.toHaveBeenCalled();
280
- } finally {
281
- log.mockRestore();
307
+ clearDiag();
308
+ await generateTitle(pi as any, createMockCtx({}) as any, onSuccess, onFailure);
309
+ expect(onFailure).toHaveBeenCalledTimes(failures);
310
+ // A swallowed failure is only acceptable if it left a trace.
311
+ expect(diag).toHaveBeenCalledTimes(failures);
312
+ if (failures === 1) {
313
+ expect(diag).toHaveBeenCalledWith("session-name", expect.stringContaining(message));
282
314
  }
315
+ expect(onSuccess).not.toHaveBeenCalled();
316
+ expect(pi.setSessionName).not.toHaveBeenCalled();
283
317
  });
284
318
 
285
319
  it("does not call onFailure when streamSimple returns stopReason === 'aborted'", async () => {
@@ -356,29 +390,28 @@ describe("registerSessionName", () => {
356
390
  const error = { stopReason: "error", content: [], errorMessage: "400 boom" };
357
391
  const notify = vi.fn();
358
392
  const ctx = { ...createMockCtx({ streamSimpleResult: error }), hasUI: scenario !== "no UI", ui: { notify } };
359
- const log = vi.spyOn(console, "error").mockImplementation(() => {});
360
- try {
361
- await agentEnd({}, ctx);
362
- await agentEnd({}, ctx);
363
- let finish!: (response: any) => void;
364
- const pending = new Promise((resolve) => { finish = resolve; });
365
- ctx.modelRegistry.streamSimple.mockReturnValueOnce({ result: vi.fn(() => pending) });
366
- await agentEnd({}, ctx);
367
- // Change the session/UI while the third request is still pending.
368
- if (scenario === "named") pi.setSessionName("Manual name");
369
- if (scenario === "stale") pi.getSessionName.mockImplementation(() => { throw new Error("stale context"); });
370
- if (scenario === "stale UI") Object.defineProperty(ctx, "hasUI", { get: () => { throw new Error("stale UI"); } });
371
- if (scenario === "broken UI") notify.mockImplementation(() => { throw new Error("UI failed"); });
372
- finish(error);
373
- await pending;
374
- await agentEnd({}, ctx);
375
- expect(ctx.modelRegistry.streamSimple).toHaveBeenCalledTimes(3);
376
- expect(log).toHaveBeenCalledTimes(3);
377
- expect(log).toHaveBeenCalledWith("[archimedes] session-name failed:", "400 boom");
378
- expect(notify).toHaveBeenCalledTimes(scenario === "UI" || scenario === "broken UI" ? 1 : 0);
379
- if (scenario === "UI") expect(notify).toHaveBeenCalledWith(expect.stringContaining("/name"), "warning");
380
- } finally {
381
- log.mockRestore();
382
- }
393
+ clearDiag();
394
+ await agentEnd({}, ctx);
395
+ await agentEnd({}, ctx);
396
+ let finish!: (response: any) => void;
397
+ const pending = new Promise((resolve) => { finish = resolve; });
398
+ ctx.modelRegistry.streamSimple.mockReturnValueOnce({ result: vi.fn(() => pending) });
399
+ await agentEnd({}, ctx);
400
+ // Change the session/UI while the third request is still pending.
401
+ if (scenario === "named") pi.setSessionName("Manual name");
402
+ if (scenario === "stale") pi.getSessionName.mockImplementation(() => { throw new Error("stale context"); });
403
+ if (scenario === "stale UI") Object.defineProperty(ctx, "hasUI", { get: () => { throw new Error("stale UI"); } });
404
+ if (scenario === "broken UI") notify.mockImplementation(() => { throw new Error("UI failed"); });
405
+ finish(error);
406
+ await pending;
407
+ await agentEnd({}, ctx);
408
+ expect(ctx.modelRegistry.streamSimple).toHaveBeenCalledTimes(3);
409
+ // Every attempt is diagnosable…
410
+ expect(diag).toHaveBeenCalledTimes(3);
411
+ expect(diag).toHaveBeenCalledWith("session-name", "[archimedes] session-name failed: 400 boom");
412
+ // …but the user is told once, at the retry-budget boundary: three lines of
413
+ // console noise (or three notifications) is not a report, it is spam.
414
+ expect(notify).toHaveBeenCalledTimes(scenario === "UI" || scenario === "broken UI" ? 1 : 0);
415
+ if (scenario === "UI") expect(notify).toHaveBeenCalledWith(expect.stringContaining("/name"), "warning");
383
416
  });
384
417
  });
package/src/index.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
2
+ import { clampThinkingLevel } from "@earendil-works/pi-ai";
3
+ import type { ModelThinkingLevel, ThinkingLevel } from "@earendil-works/pi-ai";
3
4
  import { loadConfig } from "@pi-archimedes/core/settings-io";
5
+ import { diag, describeError } from "@pi-archimedes/core/log";
4
6
  import type { SettingItem } from "@earendil-works/pi-tui";
5
7
 
6
8
  // ── Config ──────────────────────────────────────────────────────────────────
@@ -9,7 +11,7 @@ export interface SessionNameSettings {
9
11
  // suite-managed by meta's plugin gate (archimedes.sessionName.enabled — see ADR 0012); session-name never reads this
10
12
  enabled?: boolean | undefined;
11
13
  model?: string | undefined;
12
- reasoning?: ThinkingLevel | null | undefined;
14
+ reasoning?: ThinkingLevel | "off" | null | undefined;
13
15
  }
14
16
 
15
17
  const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
@@ -19,14 +21,16 @@ const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
19
21
 
20
22
  const NAMESPACE = "archimedes.sessionName";
21
23
 
22
- /** The thinking levels we accept — pi-ai's set, minus `off` (see below). */
23
- const REASONING_LEVELS: readonly ThinkingLevel[] = [
24
+ /** The thinking levels we accept — pi-ai's set, plus `off` (resolved per
25
+ * model at the call site: omitting is the only portable spelling of none). */
26
+ const REASONING_LEVELS: readonly (ThinkingLevel | "off")[] = [
24
27
  "minimal",
25
28
  "low",
26
29
  "medium",
27
30
  "high",
28
31
  "xhigh",
29
32
  "max",
33
+ "off",
30
34
  ];
31
35
 
32
36
  /**
@@ -45,14 +49,15 @@ const REASONING_LEVELS: readonly ThinkingLevel[] = [
45
49
  * budget-based Claude it instead misses the four-key budget table, and the
46
50
  * resulting `undefined` reaches the provider as an invalid `max_tokens`.
47
51
  *
48
- * Outcomes: a real level (casing/padding tolerated) is forwarded; `null` means
49
- * omit the option entirely; anything else — including absent — is treated like
50
- * an absent setting and gets the default, with a log line so the user can find
51
- * the typo. `"off"` is deliberately not accepted: it would be a fourth
52
- * spelling of "unset" next to `null` and an absent key, and pi accepts `off` as
53
- * no-effort anyway wherever it maps one.
52
+ * Outcomes: a real level (casing/padding tolerated) is returned for the call
53
+ * site to resolve per model; `null` means omit the option entirely; anything
54
+ * else — including absent — is treated like an absent setting and gets the
55
+ * default, with a log line so the user can find the typo. `"off"` is
56
+ * accepted but never forwarded raw: it resolves through pi's
57
+ * `clampThinkingLevel` like every other level (nearest supported, warn on
58
+ * change), because unclamped providers map unknown levels to maximum effort.
54
59
  */
55
- export function resolveTitleReasoning(raw: unknown): ThinkingLevel | null {
60
+ export function resolveTitleReasoning(raw: unknown): ThinkingLevel | "off" | null {
56
61
  if (raw === null) return null;
57
62
  if (typeof raw === "string") {
58
63
  const value = raw.trim().toLowerCase();
@@ -60,10 +65,10 @@ export function resolveTitleReasoning(raw: unknown): ThinkingLevel | null {
60
65
  if (match) return match;
61
66
  }
62
67
  if (raw !== undefined) {
63
- console.error(
64
- "[archimedes] session-name: ignoring unrecognized reasoning",
65
- JSON.stringify(raw),
66
- );
68
+ // A typo in a hand-edited settings.json is invisible unless reported, but
69
+ // the report is for whoever debugs the config — the log file, not the
70
+ // console (which smears a fullscreen frame and is discarded in a child).
71
+ diag("session-name", `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`);
67
72
  }
68
73
  return DEFAULT_SESSION_NAME_CONFIG.reasoning ?? null;
69
74
  }
@@ -205,8 +210,17 @@ export async function generateTitle(
205
210
  if (!ctx.modelRegistry.hasConfiguredAuth(model)) return;
206
211
 
207
212
  // 5. Stream simple with built-in auth resolution.
208
- // Reasoning is resolved, not forwarded raw: see resolveTitleReasoning().
209
- const reasoning = resolveTitleReasoning(settings.reasoning);
213
+ // Reasoning is resolved per model, not forwarded raw: see
214
+ // resolveTitleReasoning(). `clampThinkingLevel` maps to the nearest level
215
+ // the model supports (warn on change); only an `off` result omits the
216
+ // option, which is the single portable spelling of none.
217
+ const resolved = resolveTitleReasoning(settings.reasoning);
218
+ const requested: ModelThinkingLevel = resolved === null ? "off" : (resolved ?? "minimal");
219
+ const clamped = clampThinkingLevel(model, requested);
220
+ if (clamped !== requested) {
221
+ diag("session-name", `[archimedes] session-name: reasoning "${requested}" is not supported — using "${clamped}"`);
222
+ }
223
+ const reasoning = clamped === "off" ? undefined : clamped;
210
224
  const stream = ctx.modelRegistry.streamSimple(
211
225
  model,
212
226
  {
@@ -219,7 +233,7 @@ export async function generateTitle(
219
233
  ],
220
234
  },
221
235
  {
222
- ...(reasoning !== null ? { reasoning } : {}),
236
+ ...(reasoning !== undefined ? { reasoning } : {}),
223
237
  cacheRetention: "none",
224
238
  sessionId: crypto.randomUUID(),
225
239
  },
@@ -231,7 +245,9 @@ export async function generateTitle(
231
245
  return;
232
246
  }
233
247
  if (response.stopReason === "error") {
234
- console.error("[archimedes] session-name failed:", response.errorMessage ?? "title request failed");
248
+ // The user's notice is the single one at the retry-budget boundary (see
249
+ // registerSessionName); one line per attempt goes to the log.
250
+ diag("session-name", `[archimedes] session-name failed: ${response.errorMessage ?? "title request failed"}`);
235
251
  onFailure();
236
252
  return;
237
253
  }
@@ -260,7 +276,7 @@ export async function generateTitle(
260
276
  } catch (e) {
261
277
  // Session replacement or print-mode teardown makes an in-flight title irrelevant.
262
278
  if (e instanceof Error && e.message.includes("stale after session replacement")) return;
263
- console.error("[archimedes] session-name failed:", e);
279
+ diag("session-name", `[archimedes] session-name failed: ${describeError(e)}`);
264
280
  onFailure();
265
281
  }
266
282
  }