@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.
- package/node_modules/@mycadet/egress-proxy/src/__tests__/package-boundary.test.ts +1 -1
- package/node_modules/@mycadet/gateway-client/src/__tests__/package-boundary.test.ts +1 -1
- package/package.json +1 -1
- package/src/__tests__/attachment-upload-trusted-source.test.ts +15 -6
- package/src/__tests__/conversation-delete-watch-timeline.test.ts +24 -24
- package/src/__tests__/conversation-retry-discard.test.ts +4 -2
- package/src/__tests__/conversation-unread-route.test.ts +29 -28
- package/src/__tests__/empty-state-greeting-cache.test.ts +24 -23
- package/src/__tests__/interrupt-on-send.test.ts +58 -33
- package/src/__tests__/llm-schema.test.ts +72 -0
- package/src/__tests__/path-policy.test.ts +19 -32
- package/src/__tests__/playbook-execution.test.ts +5 -7
- package/src/__tests__/playbook-tools.test.ts +5 -8
- package/src/__tests__/plugin-api-webhook-url.test.ts +3 -13
- package/src/__tests__/pricing.test.ts +96 -0
- package/src/__tests__/resolve-trust-class.test.ts +6 -20
- package/src/__tests__/scheduler-result-notification.test.ts +10 -1
- package/src/__tests__/secret-ingress-channel.test.ts +99 -71
- package/src/__tests__/skill-load-plugin-scope.test.ts +92 -124
- package/src/__tests__/test-preload.ts +3 -0
- package/src/__tests__/title-generate-hook.test.ts +37 -81
- package/src/config/schemas/inference.ts +21 -1
- package/src/config/schemas/llm.ts +11 -0
- package/src/util/pricing.ts +15 -8
|
@@ -129,7 +129,7 @@ describe("package boundary", () => {
|
|
|
129
129
|
const forbidden = Object.keys(allDeps).filter((dep) =>
|
|
130
130
|
[
|
|
131
131
|
"@mycadet/assistant",
|
|
132
|
-
"@mycadet/
|
|
132
|
+
"@mycadet/gateway",
|
|
133
133
|
"@mycadet/credential-storage",
|
|
134
134
|
"@mycadet/egress-proxy",
|
|
135
135
|
].includes(dep),
|
package/package.json
CHANGED
|
@@ -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 {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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.
|
|
38
|
-
//
|
|
39
|
-
// it
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
886
|
-
const filePath = join(MOCK_BUNDLED_DIR, "
|
|
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
|
|