@mycadet/assistant 0.12.6-dev.202610072225.d7acd73 → 0.12.6-dev.202610080102.d7d1bec

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.
@@ -134,7 +134,7 @@ describe("package boundary", () => {
134
134
  const forbidden = [
135
135
  "@mycadet/assistant",
136
136
  "@mycadet/ces",
137
- "@mycadet/cadet-gateway",
137
+ "@mycadet/gateway",
138
138
  "@mycadet/assistant-client",
139
139
  "@mycadet/ces-client",
140
140
  "@mycadet/ces-contracts",
@@ -129,7 +129,7 @@ describe("package boundary", () => {
129
129
  const forbidden = Object.keys(allDeps).filter((dep) =>
130
130
  [
131
131
  "@mycadet/assistant",
132
- "@mycadet/cadet-gateway",
132
+ "@mycadet/gateway",
133
133
  "@mycadet/credential-storage",
134
134
  "@mycadet/egress-proxy",
135
135
  ].includes(dep),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycadet/assistant",
3
- "version": "0.12.6-dev.202610072225.d7acd73",
3
+ "version": "0.12.6-dev.202610080102.d7d1bec",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -5,12 +5,14 @@
5
5
  * carries the x-cadet-principal-type: svc_gateway header (injected by
6
6
  * the HTTP adapter from AuthContext).
7
7
  */
8
- import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
9
-
10
- mock.module("../config/env.js", () => ({
11
- isHttpAuthDisabled: () => true,
12
- getAssistantDomain: () => "mycadet.bot",
13
- }));
8
+ import {
9
+ afterAll,
10
+ beforeAll,
11
+ beforeEach,
12
+ describe,
13
+ expect,
14
+ test,
15
+ } from "bun:test";
14
16
 
15
17
  import { initializeDb } from "../persistence/db-init.js";
16
18
  import {
@@ -52,9 +54,16 @@ describe("attachment upload — trustedSource flag", () => {
52
54
  // initializeDb runs the full migration chain (hundreds of steps); under
53
55
  // parallel CI load it can exceed bun's default 5s hook timeout, so allow more.
54
56
  beforeAll(async () => {
57
+ // Real env gate: the route only honors trustedSource when HTTP auth is
58
+ // disabled (the gateway-fronted deployment posture).
59
+ process.env.DISABLE_HTTP_AUTH = "true";
55
60
  await initializeDb();
56
61
  }, 30_000);
57
62
 
63
+ afterAll(() => {
64
+ delete process.env.DISABLE_HTTP_AUTH;
65
+ });
66
+
58
67
  beforeEach(() => {
59
68
  // Each test uploads a fresh attachment with unique filename; no per-test
60
69
  // cleanup needed since the staging directory is recreated lazily.
@@ -10,7 +10,7 @@
10
10
  * `conversations` is emptied: an append that beat it is a row it deletes, and
11
11
  * one that follows it has no conversation to key itself to.
12
12
  */
13
- import { describe, expect, mock, test } from "bun:test";
13
+ import { beforeAll, describe, expect, mock, spyOn, test } from "bun:test";
14
14
 
15
15
  // Keep the rest of the module real; only the Qdrant collection drop is
16
16
  // replaced, so `clearAll` does not reach for a lexical index that is not
@@ -34,34 +34,16 @@ mock.module("../persistence/job-handlers/message-lexical.js", () => ({
34
34
  let appendDuringWipe: { onStatement: string; run: () => void } | null = null;
35
35
 
36
36
  // Every bulk delete in `clearAll` goes through `runAsyncSqlite`, which spawns
37
- // a sqlite3 subprocess and yields the event loop. The wrapper runs the real
38
- // statement and stands in for whatever else the daemon would have done while
39
- // it was in flight.
40
- const actualAsyncQuery = await import("../persistence/db-async-query.js");
41
- const realRunAsyncSqlite = actualAsyncQuery.runAsyncSqlite;
42
- mock.module("../persistence/db-async-query.js", () => ({
43
- ...actualAsyncQuery,
44
- runAsyncSqlite: async (
45
- sql: string,
46
- label: string,
47
- options?: Parameters<typeof actualAsyncQuery.runAsyncSqlite>[2],
48
- ) => {
49
- const result = await realRunAsyncSqlite(sql, label, options);
50
- if (appendDuringWipe && appendDuringWipe.onStatement === sql) {
51
- const pending = appendDuringWipe;
52
- appendDuringWipe = null;
53
- pending.run();
54
- }
55
- return result;
56
- },
57
- }));
58
-
37
+ // a sqlite3 subprocess and yields the event loop. A pass-through spy on the
38
+ // live module namespace lets the real subprocess run while the race hook below
39
+ // schedules an append onto the statement it is keyed to.
59
40
  import {
60
41
  clearAll,
61
42
  createConversation,
62
43
  deleteConversation,
63
44
  deleteConversationGently,
64
45
  } from "../persistence/conversation-crud.js";
46
+ import * as dbAsyncQuery from "../persistence/db-async-query.js";
65
47
  import { getDb } from "../persistence/db-connection.js";
66
48
  import { initializeDb } from "../persistence/db-init.js";
67
49
  import { attachments } from "../persistence/schema/conversations.js";
@@ -73,7 +55,25 @@ import {
73
55
  renderWatchTimeline,
74
56
  } from "../watch/watch-timeline.js";
75
57
 
76
- await initializeDb();
58
+ beforeAll(async () => {
59
+ await initializeDb();
60
+ const realRunAsyncSqlite = dbAsyncQuery.runAsyncSqlite;
61
+ spyOn(dbAsyncQuery, "runAsyncSqlite").mockImplementation(
62
+ async (
63
+ sql: string,
64
+ label: string,
65
+ options?: Parameters<typeof realRunAsyncSqlite>[2],
66
+ ) => {
67
+ const result = await realRunAsyncSqlite(sql, label, options);
68
+ if (appendDuringWipe && appendDuringWipe.onStatement === sql) {
69
+ const pending = appendDuringWipe;
70
+ appendDuringWipe = null;
71
+ pending.run();
72
+ }
73
+ return result;
74
+ },
75
+ );
76
+ });
77
77
 
78
78
  /** A 1x1 JPEG, the shape the host hands over on every observe. */
79
79
  const SCREENSHOT_BASE64 =
@@ -253,8 +253,10 @@ describe("discardLastAssistantDisplayTurn keeps the memory-retrospective cursor
253
253
 
254
254
  expect(getMessages(CONV_ID).some((m) => m.id === assistantId)).toBe(false);
255
255
  // The hook chain is fire-and-forget from the delete primitive, so the
256
- // stamp lands shortly after the discard returns.
257
- const deadline = Date.now() + 2_000;
256
+ // stamp lands shortly after the discard returns — but hook discovery is
257
+ // async and unbounded under CI's 8-workers-on-2-cores contention, so the
258
+ // budget needs CI headroom (same family as message-deleted-hook-dispatch).
259
+ const deadline = Date.now() + 10_000;
258
260
  let stamped =
259
261
  getRetrospectiveState(CONV_ID)?.lastProcessedCreatedAt ?? null;
260
262
  while (stamped === null && Date.now() < deadline) {
@@ -1,19 +1,12 @@
1
- import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
-
3
- process.env.DISABLE_HTTP_AUTH = "true";
4
-
5
- const mockMarkConversationUnread = mock((_conversationId: string) => true);
6
-
7
- mock.module("../persistence/conversation-attention-store.js", () => ({
8
- getAttentionStateByConversationIds: () => new Map(),
9
- recordConversationSeenSignal: () => ({}),
10
- markConversationUnread: mockMarkConversationUnread,
11
- }));
12
-
13
- mock.module("../persistence/conversation-key-store.js", () => ({
14
- resolveConversationId: (id: string) => id,
15
- }));
16
-
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+
3
+ import { getAttentionStateByConversationIds } from "../persistence/conversation-attention-store.js";
4
+ import {
5
+ addMessage,
6
+ ensureConversationExists,
7
+ } from "../persistence/conversation-crud.js";
8
+ import { initializeDb } from "../persistence/db-init.js";
9
+ import { RuntimeHttpServer } from "../runtime/http-server.js";
17
10
  import { ROUTES } from "../runtime/routes/index.js";
18
11
 
19
12
  /**
@@ -27,18 +20,20 @@ function routePolicy(endpoint: string, method?: string) {
27
20
  );
28
21
  return route?.policy ?? null;
29
22
  }
30
- import { RuntimeHttpServer } from "../runtime/http-server.js";
31
- import { UserError } from "../util/errors.js";
32
23
 
33
24
  describe("POST /v1/conversations/unread", () => {
34
25
  let server: RuntimeHttpServer;
35
26
  let port: number;
36
27
 
37
- beforeEach(() => {
38
- mockMarkConversationUnread.mockClear();
28
+ beforeAll(async () => {
29
+ await initializeDb();
30
+ // The HTTP adapter only serves unauthenticated local requests when HTTP
31
+ // auth is disabled (the gateway-fronted deployment posture).
32
+ process.env.DISABLE_HTTP_AUTH = "true";
39
33
  });
40
34
 
41
35
  afterAll(async () => {
36
+ delete process.env.DISABLE_HTTP_AUTH;
42
37
  await server?.stop();
43
38
  });
44
39
 
@@ -79,12 +74,14 @@ describe("POST /v1/conversations/unread", () => {
79
74
  message: "Missing conversationId",
80
75
  },
81
76
  });
82
- expect(mockMarkConversationUnread).not.toHaveBeenCalled();
83
77
 
84
78
  await stopServer();
85
79
  });
86
80
 
87
81
  test("marks a conversation unread", async () => {
82
+ ensureConversationExists("conv-123", "telegram");
83
+ addMessage("conv-123", "assistant", "the only assistant reply");
84
+
88
85
  await startServer();
89
86
 
90
87
  const res = await fetch(unreadUrl(), {
@@ -95,18 +92,22 @@ describe("POST /v1/conversations/unread", () => {
95
92
 
96
93
  expect(res.status).toBe(200);
97
94
  expect(await res.json()).toEqual({ ok: true });
98
- expect(mockMarkConversationUnread).toHaveBeenCalledTimes(1);
99
- expect(mockMarkConversationUnread).toHaveBeenCalledWith("conv-123");
95
+
96
+ // Unread = seen cursor behind the latest assistant message. With a single
97
+ // assistant message there is no previous one, so the seen cursor is null.
98
+ const state = getAttentionStateByConversationIds(["conv-123"]).get(
99
+ "conv-123",
100
+ );
101
+ expect(state?.latestAssistantMessageId).not.toBeNull();
102
+ expect(state?.lastSeenAssistantMessageId).toBeNull();
100
103
 
101
104
  await stopServer();
102
105
  });
103
106
 
104
107
  test("returns UNPROCESSABLE_ENTITY when the conversation has no assistant message", async () => {
105
- mockMarkConversationUnread.mockImplementationOnce(() => {
106
- throw new UserError(
107
- "Conversation has no assistant message to mark unread",
108
- );
109
- });
108
+ // Real store semantics: no assistant message means no cursor to mark
109
+ // unread against, and markConversationUnread throws this exact UserError.
110
+ ensureConversationExists("conv-no-assistant", "telegram");
110
111
 
111
112
  await startServer();
112
113
 
@@ -6,31 +6,32 @@
6
6
  * behavior that disables caching entirely.
7
7
  */
8
8
 
9
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
10
-
11
- // ---------------------------------------------------------------------------
12
- // Mocks — defined before importing the module under test
13
- // ---------------------------------------------------------------------------
14
-
15
- const checkpointStore = new Map<string, string>();
16
-
17
- mock.module("../persistence/checkpoints.js", () => ({
18
- getMemoryCheckpoint: (key: string) => checkpointStore.get(key) ?? null,
19
- setMemoryCheckpoint: (key: string, value: string) => {
20
- checkpointStore.set(key, value);
21
- },
22
- }));
23
-
24
- // ---------------------------------------------------------------------------
25
- // Imports (after mocks)
26
- // ---------------------------------------------------------------------------
9
+ import {
10
+ afterEach,
11
+ beforeAll,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ test,
16
+ } from "bun:test";
27
17
 
18
+ import {
19
+ getMemoryCheckpoint,
20
+ setMemoryCheckpoint,
21
+ } from "../persistence/checkpoints.js";
22
+ import { getSqlite } from "../persistence/db-connection.js";
23
+ import { initializeDb } from "../persistence/db-init.js";
28
24
  import {
29
25
  getCachedEmptyStateGreeting,
30
26
  setCachedEmptyStateGreeting,
31
27
  } from "../runtime/routes/empty-state-greeting-cache.js";
32
28
  import { setConfig } from "./helpers/set-config.js";
33
29
 
30
+ // The checkpoint store is real: rows live in the per-process workspace DB.
31
+ beforeAll(async () => {
32
+ await initializeDb();
33
+ });
34
+
34
35
  /** Seed the cache TTL into the real workspace config. */
35
36
  function setCacheTtlMs(ttlMs: number): void {
36
37
  setConfig("ui", { emptyStateGreetingCacheTtlMs: ttlMs });
@@ -43,7 +44,7 @@ beforeEach(() => {
43
44
  });
44
45
 
45
46
  afterEach(() => {
46
- checkpointStore.clear();
47
+ getSqlite().run("DELETE FROM memory_checkpoints");
47
48
  });
48
49
 
49
50
  describe("empty-state greeting cache", () => {
@@ -67,7 +68,7 @@ describe("empty-state greeting cache", () => {
67
68
 
68
69
  test("returns null once the TTL is exceeded", () => {
69
70
  setCachedEmptyStateGreeting("stale");
70
- checkpointStore.set(
71
+ setMemoryCheckpoint(
71
72
  TIMESTAMP_KEY,
72
73
  String(Date.now() - (4 * 60 + 1) * 60 * 1000),
73
74
  );
@@ -76,7 +77,7 @@ describe("empty-state greeting cache", () => {
76
77
 
77
78
  test("returns the cached value just within the TTL", () => {
78
79
  setCachedEmptyStateGreeting("fresh enough");
79
- checkpointStore.set(
80
+ setMemoryCheckpoint(
80
81
  TIMESTAMP_KEY,
81
82
  String(Date.now() - (3 * 60 + 59) * 60 * 1000),
82
83
  );
@@ -86,7 +87,7 @@ describe("empty-state greeting cache", () => {
86
87
  test("TTL of 0 disables caching: writes are skipped and reads miss", () => {
87
88
  setCacheTtlMs(0);
88
89
  setCachedEmptyStateGreeting("should not persist");
89
- expect(checkpointStore.size).toBe(0);
90
+ expect(getMemoryCheckpoint(TIMESTAMP_KEY)).toBeNull();
90
91
  expect(getCachedEmptyStateGreeting()).toBeNull();
91
92
  });
92
93
 
@@ -97,7 +98,7 @@ describe("empty-state greeting cache", () => {
97
98
  });
98
99
 
99
100
  test("returns null when the timestamp checkpoint is missing", () => {
100
- checkpointStore.set("empty_state:greeting:text", "orphaned");
101
+ setMemoryCheckpoint("empty_state:greeting:text", "orphaned");
101
102
  expect(getCachedEmptyStateGreeting()).toBeNull();
102
103
  });
103
104
  });
@@ -8,8 +8,21 @@
8
8
  * queue), and what the repaired history looks like.
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
- import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
11
+ import {
12
+ afterEach,
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ spyOn,
18
+ test,
19
+ } from "bun:test";
12
20
 
21
+ import {
22
+ clearCachedOverrides,
23
+ setCachedOverrides,
24
+ } from "../config/feature-flag-cache.js";
25
+ import { INTERRUPT_ON_SEND_FLAG_KEY } from "../config/interrupt-on-send-flag.js";
13
26
  import type { Conversation } from "../daemon/conversation.js";
14
27
  import {
15
28
  deleteConversation,
@@ -26,37 +39,18 @@ import {
26
39
  } from "../util/abort-reasons.js";
27
40
 
28
41
  // ---------------------------------------------------------------------------
29
- // Mocks must precede the module imports so Bun applies them at load time.
42
+ // Real persistence, observed. The repair writes land in the per-process
43
+ // workspace DB; a pass-through spy on the live module namespace records each
44
+ // call so the timing/failure scaffolding below still works.
30
45
  // ---------------------------------------------------------------------------
31
46
 
32
- let flagEnabled = true;
33
- mock.module("../config/interrupt-on-send-gate.js", () => ({
34
- isInterruptOnSendEnabled: () => flagEnabled,
35
- }));
36
-
37
47
  const persisted: Array<{ role: string; content: string }> = [];
38
48
  let persistGate: Promise<void> = Promise.resolve();
39
49
  let persistShouldFail = false;
40
50
 
41
- // Only the persist seam is replaced; every other CRUD export stays real so the
42
- // repair module's import graph resolves as it does in production.
43
- const realConversationCrud =
44
- await import("../persistence/conversation-crud.js");
45
- mock.module("../persistence/conversation-crud.js", () => ({
46
- ...realConversationCrud,
47
- addMessage: async (
48
- _conversationId: string,
49
- role: string,
50
- content: string,
51
- ) => {
52
- await persistGate;
53
- if (persistShouldFail) {
54
- throw new Error("simulated persist failure");
55
- }
56
- persisted.push({ role, content });
57
- return { id: `msg-${persisted.length}` };
58
- },
59
- }));
51
+ import * as conversationCrud from "../persistence/conversation-crud.js";
52
+ import { ensureConversationExists } from "../persistence/conversation-crud.js";
53
+ import { initializeDb } from "../persistence/db-init.js";
60
54
 
61
55
  const {
62
56
  classifyInterruptEligibility,
@@ -79,6 +73,23 @@ const {
79
73
 
80
74
  const CONV = "interrupt-on-send-conv";
81
75
 
76
+ beforeAll(async () => {
77
+ await initializeDb();
78
+ ensureConversationExists(CONV, "telegram");
79
+ const realAddMessage = conversationCrud.addMessage;
80
+ spyOn(conversationCrud, "addMessage").mockImplementation(
81
+ async (...args: Parameters<typeof realAddMessage>) => {
82
+ await persistGate;
83
+ if (persistShouldFail) {
84
+ throw new Error("simulated persist failure");
85
+ }
86
+ const result = await realAddMessage(...args);
87
+ persisted.push({ role: args[1], content: args[2] });
88
+ return result;
89
+ },
90
+ );
91
+ });
92
+
82
93
  const assistantWithToolUse = (...ids: string[]): Message => ({
83
94
  role: "assistant",
84
95
  content: ids.map((id) => ({
@@ -276,7 +287,12 @@ function registerBusyTurn(
276
287
  }
277
288
 
278
289
  beforeEach(() => {
279
- flagEnabled = true;
290
+ setCachedOverrides(
291
+ { [INTERRUPT_ON_SEND_FLAG_KEY]: true },
292
+ {
293
+ fromGateway: false,
294
+ },
295
+ );
280
296
  persisted.length = 0;
281
297
  persistGate = Promise.resolve();
282
298
  persistShouldFail = false;
@@ -288,10 +304,9 @@ afterEach(() => {
288
304
  deleteConversation(CONV);
289
305
  pendingInteractions.clear();
290
306
  resetTurnFinalizationsForTesting();
291
- // `mock.module` is process-wide, so this stub outlives the file. Leave it
292
- // reading off, which is the flag's shipped state, so a later file's
293
- // flag-off expectations are not answered by this file's last setting.
294
- flagEnabled = false;
307
+ // Drop the override so the flag reads its shipped (off) state — a later
308
+ // file's flag-off expectations must not be answered by this file's setting.
309
+ clearCachedOverrides();
295
310
  });
296
311
 
297
312
  describe("interruptRunningTurn", () => {
@@ -496,7 +511,12 @@ describe("interruptRunningTurn", () => {
496
511
  });
497
512
 
498
513
  test("declines when the flag is off, touching nothing", async () => {
499
- flagEnabled = false;
514
+ setCachedOverrides(
515
+ { [INTERRUPT_ON_SEND_FLAG_KEY]: false },
516
+ {
517
+ fromGateway: false,
518
+ },
519
+ );
500
520
  const turn = registerBusyTurn({
501
521
  messages: [assistantWithToolUse("tool-1")],
502
522
  });
@@ -787,7 +807,12 @@ describe("classifyInterruptEligibility", () => {
787
807
  });
788
808
 
789
809
  test("names the flag when it is off", () => {
790
- flagEnabled = false;
810
+ setCachedOverrides(
811
+ { [INTERRUPT_ON_SEND_FLAG_KEY]: false },
812
+ {
813
+ fromGateway: false,
814
+ },
815
+ );
791
816
  const turn = registerBusyTurn();
792
817
  expect(classifyInterruptEligibility(turn.conversation, opts)).toBe(
793
818
  "flag_off",
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
 
3
+ import { ModelPricingOverrideSchema } from "../config/schemas/inference.js";
3
4
  import { LLMConfigBase, LLMSchema } from "../config/schemas/llm.js";
4
5
 
5
6
  // A legacy `llm.default` blob as older configs persisted it. The schema has
@@ -391,3 +392,74 @@ describe("LLMSchema", () => {
391
392
  });
392
393
  });
393
394
  });
395
+
396
+ // `llm.pricingOverrides` entries are validated by `PricingOverrideSchema`
397
+ // inside `LLMSchema`, while `ModelPricingOverrideSchema` types the same entries
398
+ // for consumers such as `resolvePricingForUsageWithOverrides`. Both shapes are
399
+ // asserted here so they stay in step.
400
+ describe("pricing override cache rates", () => {
401
+ const baseOverride = {
402
+ provider: "openai-compatible",
403
+ modelPattern: "claude-opus-4-6",
404
+ inputPer1M: 10,
405
+ outputPer1M: 20,
406
+ };
407
+
408
+ test("LLMSchema keeps optional cache rates on a pricing override", () => {
409
+ const parsed = LLMSchema.parse({
410
+ pricingOverrides: [
411
+ { ...baseOverride, cacheReadPer1M: 1, cacheWritePer1M: 12.5 },
412
+ ],
413
+ });
414
+ expect(parsed.pricingOverrides[0]).toEqual({
415
+ ...baseOverride,
416
+ cacheReadPer1M: 1,
417
+ cacheWritePer1M: 12.5,
418
+ });
419
+ });
420
+
421
+ test("LLMSchema accepts a pricing override that omits cache rates", () => {
422
+ const parsed = LLMSchema.parse({ pricingOverrides: [baseOverride] });
423
+ expect(parsed.pricingOverrides[0]).toEqual(baseOverride);
424
+ });
425
+
426
+ test.each(["cacheReadPer1M", "cacheWritePer1M"] as const)(
427
+ "LLMSchema rejects a negative %s",
428
+ (field) => {
429
+ const result = LLMSchema.safeParse({
430
+ pricingOverrides: [{ ...baseOverride, [field]: -1 }],
431
+ });
432
+ expect(result.success).toBe(false);
433
+ },
434
+ );
435
+
436
+ test("ModelPricingOverrideSchema parses optional cache rates", () => {
437
+ const parsed = ModelPricingOverrideSchema.parse({
438
+ ...baseOverride,
439
+ cacheReadPer1M: 1,
440
+ cacheWritePer1M: 12.5,
441
+ });
442
+ expect(parsed.cacheReadPer1M).toBe(1);
443
+ expect(parsed.cacheWritePer1M).toBe(12.5);
444
+ });
445
+
446
+ test("ModelPricingOverrideSchema leaves cache rates undefined when omitted", () => {
447
+ const parsed = ModelPricingOverrideSchema.parse(baseOverride);
448
+ expect(parsed.cacheReadPer1M).toBeUndefined();
449
+ expect(parsed.cacheWritePer1M).toBeUndefined();
450
+ });
451
+
452
+ test.each([
453
+ ["cacheReadPer1M", "pricingOverrides[].cacheReadPer1M"],
454
+ ["cacheWritePer1M", "pricingOverrides[].cacheWritePer1M"],
455
+ ])("ModelPricingOverrideSchema rejects a negative %s", (field, label) => {
456
+ const result = ModelPricingOverrideSchema.safeParse({
457
+ ...baseOverride,
458
+ [field]: -1,
459
+ });
460
+ expect(result.success).toBe(false);
461
+ expect(result.error?.issues[0]?.message).toBe(
462
+ `${label} must be a non-negative number`,
463
+ );
464
+ });
465
+ });
@@ -8,8 +8,17 @@ import {
8
8
  } from "node:fs";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join, sep } from "node:path";
11
- import { afterEach, describe, expect, mock, test } from "bun:test";
11
+ import { afterEach, describe, expect, test } from "bun:test";
12
12
 
13
+ import { getBundledSkillsDir } from "../config/skills.js";
14
+ import {
15
+ getBundledSkillsRoot,
16
+ getManagedSkillsRoot,
17
+ getSkillRoots,
18
+ isSkillSourcePath,
19
+ normalizeDirPath,
20
+ normalizeFilePath,
21
+ } from "../skills/path-classifier.js";
13
22
  import {
14
23
  hostPolicy,
15
24
  resolveRealPath,
@@ -17,30 +26,13 @@ import {
17
26
  sandboxPolicyWithHostFallback,
18
27
  sandboxReadPolicy,
19
28
  } from "../tools/shared/filesystem/path-policy.js";
20
- import { getDotEnvPath } from "../util/platform.js";
21
-
22
- // ── Mock setup for skill path classifier ────────────────────────────────────
23
- // The classifier imports getWorkspaceSkillsDir and getBundledSkillsDir, which
24
- // need stable test directories to avoid depending on the real home directory.
25
- // We create and realpath the root eagerly so that macOS's /tmp -> /private/tmp
26
- // symlink doesn't cause prefix mismatches between normalized roots and paths.
29
+ import { getDotEnvPath, getWorkspaceSkillsDir } from "../util/platform.js";
27
30
 
28
- const CLASSIFIER_TEST_ROOT = process.env.CADET_WORKSPACE_DIR!;
29
- const MOCK_MANAGED_DIR = join(CLASSIFIER_TEST_ROOT, "skills");
30
- const MOCK_BUNDLED_DIR = join(CLASSIFIER_TEST_ROOT, "bundled-skills");
31
-
32
- mock.module("../config/skills.js", () => ({
33
- getBundledSkillsDir: () => MOCK_BUNDLED_DIR,
34
- }));
35
-
36
- const {
37
- isSkillSourcePath,
38
- normalizeDirPath,
39
- normalizeFilePath,
40
- getSkillRoots,
41
- getManagedSkillsRoot,
42
- getBundledSkillsRoot,
43
- } = await import("../skills/path-classifier.js");
31
+ // The classifier reads REAL roots: managed = the per-process workspace skills
32
+ // dir (writable, from the test preload), bundled = the repo's bundled-skills
33
+ // dir (read-only — tests only classify existing paths under it).
34
+ const MOCK_MANAGED_DIR = getWorkspaceSkillsDir();
35
+ const MOCK_BUNDLED_DIR = getBundledSkillsDir();
44
36
 
45
37
  const testDirs: string[] = [];
46
38
 
@@ -360,12 +352,7 @@ describe("sandboxPolicyWithHostFallback", () => {
360
352
  const boundary = makeTempDir();
361
353
 
362
354
  const result = sandboxPolicyWithHostFallback(
363
- join(
364
- process.env.HOME!,
365
- ".cadet",
366
- "protected",
367
- "actor-token-signing-key",
368
- ),
355
+ join(process.env.HOME!, ".cadet", "protected", "actor-token-signing-key"),
369
356
  boundary,
370
357
  { mustExist: false },
371
358
  );
@@ -882,8 +869,8 @@ describe("isSkillSourcePath", () => {
882
869
  });
883
870
 
884
871
  test("returns true for path inside bundled skills dir", () => {
885
- mkdirSync(MOCK_BUNDLED_DIR, { recursive: true });
886
- const filePath = join(MOCK_BUNDLED_DIR, "web-search", "SKILL.md");
872
+ // The real bundled dir ships skills — classify one instead of creating it.
873
+ const filePath = join(MOCK_BUNDLED_DIR, "acp", "SKILL.md");
887
874
  expect(isSkillSourcePath(filePath)).toBe(true);
888
875
  });
889
876