@pi-archimedes/session-name 2.10.4 → 2.11.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.
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.0",
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.0"
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";
@@ -66,6 +67,16 @@ vi.mock("@pi-archimedes/core/settings-io", () => ({
66
67
  loadConfig: vi.fn((_namespace, defaults) => defaults),
67
68
  }));
68
69
 
70
+ // Diagnostics never go to the console (see packages/core/src/log.ts): a write
71
+ // there smears a fullscreen frame and is discarded inside a subagent child. So
72
+ // every "it said something / it said nothing" assertion below watches `diag`.
73
+ vi.mock("@pi-archimedes/core/log", async (importOriginal) => {
74
+ const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
75
+ return { ...real, diag: vi.fn() };
76
+ });
77
+
78
+ const clearDiag = () => vi.mocked(diag).mockClear();
79
+
69
80
  describe("generateTitle", () => {
70
81
  it("generates title, trims quotes and whitespace, and calls pi.setSessionName", async () => {
71
82
  const pi = createMockPi();
@@ -135,16 +146,12 @@ describe("generateTitle", () => {
135
146
  it.each(["Minimal", " high ", "MAX"])("tolerates casing and padding: %s", async (raw) => {
136
147
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
137
148
  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
- }
149
+ clearDiag();
150
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
151
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning).toBe(
152
+ raw.trim().toLowerCase(),
153
+ );
154
+ expect(diag).not.toHaveBeenCalled();
148
155
  });
149
156
 
150
157
  it.each([["ultra"], ["off"], [""], [42], [true], [{}], [[]]])(
@@ -152,34 +159,28 @@ describe("generateTitle", () => {
152
159
  async (raw) => {
153
160
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: raw });
154
161
  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
- }
162
+ clearDiag();
163
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
164
+
165
+ const sent = (ctx.modelRegistry.streamSimple as any).mock.calls[0][2].reasoning;
166
+ // Invalid config behaves like absent config: the documented default.
167
+ expect(sent).toBe("minimal");
168
+ // The typo is invisible unless it is reported, and the report is the
169
+ // single log line naming the rejected value.
170
+ expect(diag).toHaveBeenCalledWith(
171
+ "session-name",
172
+ `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`,
173
+ );
169
174
  },
170
175
  );
171
176
 
172
177
  it("sends no reasoning key when the value is null, and says nothing", async () => {
173
178
  vi.mocked(loadConfig).mockReturnValueOnce({ reasoning: null });
174
179
  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
- }
180
+ clearDiag();
181
+ await generateTitle(createMockPi() as any, ctx as any, vi.fn(), vi.fn());
182
+ expect((ctx.modelRegistry.streamSimple as any).mock.calls[0][2]).not.toHaveProperty("reasoning");
183
+ expect(diag).not.toHaveBeenCalled();
183
184
  });
184
185
 
185
186
  it("keeps the default when the setting is absent entirely, and says nothing", async () => {
@@ -187,26 +188,18 @@ describe("generateTitle", () => {
187
188
  // "minimal" — the common path must stay silent. A regression that warned
188
189
  // on every default-configured session would otherwise pass the suite.
189
190
  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
- }
191
+ clearDiag();
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(diag).not.toHaveBeenCalled();
198
195
  });
199
196
 
200
197
  it("treats undefined like absent config, without warning", () => {
201
198
  // Direct call: the generateTitle path can't produce this, since
202
199
  // 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
- }
200
+ clearDiag();
201
+ expect(resolveTitleReasoning(undefined)).toBe("minimal");
202
+ expect(diag).not.toHaveBeenCalled();
210
203
  });
211
204
 
212
205
  // Pin the invariant against the arithmetic that makes this dangerous. Of
@@ -270,16 +263,16 @@ describe("generateTitle", () => {
270
263
  pi.getSessionName.mockImplementation(() => { throw new Error(message); });
271
264
  const onSuccess = vi.fn();
272
265
  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();
266
+ clearDiag();
267
+ await generateTitle(pi as any, createMockCtx({}) as any, onSuccess, onFailure);
268
+ expect(onFailure).toHaveBeenCalledTimes(failures);
269
+ // A swallowed failure is only acceptable if it left a trace.
270
+ expect(diag).toHaveBeenCalledTimes(failures);
271
+ if (failures === 1) {
272
+ expect(diag).toHaveBeenCalledWith("session-name", expect.stringContaining(message));
282
273
  }
274
+ expect(onSuccess).not.toHaveBeenCalled();
275
+ expect(pi.setSessionName).not.toHaveBeenCalled();
283
276
  });
284
277
 
285
278
  it("does not call onFailure when streamSimple returns stopReason === 'aborted'", async () => {
@@ -356,29 +349,28 @@ describe("registerSessionName", () => {
356
349
  const error = { stopReason: "error", content: [], errorMessage: "400 boom" };
357
350
  const notify = vi.fn();
358
351
  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
- }
352
+ clearDiag();
353
+ await agentEnd({}, ctx);
354
+ await agentEnd({}, ctx);
355
+ let finish!: (response: any) => void;
356
+ const pending = new Promise((resolve) => { finish = resolve; });
357
+ ctx.modelRegistry.streamSimple.mockReturnValueOnce({ result: vi.fn(() => pending) });
358
+ await agentEnd({}, ctx);
359
+ // Change the session/UI while the third request is still pending.
360
+ if (scenario === "named") pi.setSessionName("Manual name");
361
+ if (scenario === "stale") pi.getSessionName.mockImplementation(() => { throw new Error("stale context"); });
362
+ if (scenario === "stale UI") Object.defineProperty(ctx, "hasUI", { get: () => { throw new Error("stale UI"); } });
363
+ if (scenario === "broken UI") notify.mockImplementation(() => { throw new Error("UI failed"); });
364
+ finish(error);
365
+ await pending;
366
+ await agentEnd({}, ctx);
367
+ expect(ctx.modelRegistry.streamSimple).toHaveBeenCalledTimes(3);
368
+ // Every attempt is diagnosable…
369
+ expect(diag).toHaveBeenCalledTimes(3);
370
+ expect(diag).toHaveBeenCalledWith("session-name", "[archimedes] session-name failed: 400 boom");
371
+ // …but the user is told once, at the retry-budget boundary: three lines of
372
+ // console noise (or three notifications) is not a report, it is spam.
373
+ expect(notify).toHaveBeenCalledTimes(scenario === "UI" || scenario === "broken UI" ? 1 : 0);
374
+ if (scenario === "UI") expect(notify).toHaveBeenCalledWith(expect.stringContaining("/name"), "warning");
383
375
  });
384
376
  });
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-ai";
3
3
  import { loadConfig } from "@pi-archimedes/core/settings-io";
4
+ import { diag, describeError } from "@pi-archimedes/core/log";
4
5
  import type { SettingItem } from "@earendil-works/pi-tui";
5
6
 
6
7
  // ── Config ──────────────────────────────────────────────────────────────────
@@ -60,10 +61,10 @@ export function resolveTitleReasoning(raw: unknown): ThinkingLevel | null {
60
61
  if (match) return match;
61
62
  }
62
63
  if (raw !== undefined) {
63
- console.error(
64
- "[archimedes] session-name: ignoring unrecognized reasoning",
65
- JSON.stringify(raw),
66
- );
64
+ // A typo in a hand-edited settings.json is invisible unless reported, but
65
+ // the report is for whoever debugs the config — the log file, not the
66
+ // console (which smears a fullscreen frame and is discarded in a child).
67
+ diag("session-name", `[archimedes] session-name: ignoring unrecognized reasoning ${JSON.stringify(raw)}`);
67
68
  }
68
69
  return DEFAULT_SESSION_NAME_CONFIG.reasoning ?? null;
69
70
  }
@@ -231,7 +232,9 @@ export async function generateTitle(
231
232
  return;
232
233
  }
233
234
  if (response.stopReason === "error") {
234
- console.error("[archimedes] session-name failed:", response.errorMessage ?? "title request failed");
235
+ // The user's notice is the single one at the retry-budget boundary (see
236
+ // registerSessionName); one line per attempt goes to the log.
237
+ diag("session-name", `[archimedes] session-name failed: ${response.errorMessage ?? "title request failed"}`);
235
238
  onFailure();
236
239
  return;
237
240
  }
@@ -260,7 +263,7 @@ export async function generateTitle(
260
263
  } catch (e) {
261
264
  // Session replacement or print-mode teardown makes an in-flight title irrelevant.
262
265
  if (e instanceof Error && e.message.includes("stale after session replacement")) return;
263
- console.error("[archimedes] session-name failed:", e);
266
+ diag("session-name", `[archimedes] session-name failed: ${describeError(e)}`);
264
267
  onFailure();
265
268
  }
266
269
  }