@north-light/crouter 0.3.161 → 0.3.163
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/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/modelauth.d.ts +32 -0
- package/dist/api/dto/modelauth.js +2 -1
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/00-runtime-base.md +1 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -0
- package/dist/builtin-memory/init.md +38 -0
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +0 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +88 -396
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +215 -419
- package/dist/clients/attach/__tests__/crtr-output.test.js +14 -9
- package/dist/clients/attach/__tests__/edit-diff.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/edit-diff.test.js +34 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/editor-frame-title.test.js +19 -0
- package/dist/clients/attach/render/chat-view.js +8 -3
- package/dist/clients/attach/render/crtr-output.js +31 -2
- package/dist/clients/attach/render/edit-diff.js +26 -2
- package/dist/clients/attach/render/tool-calls.d.ts +7 -2
- package/dist/clients/attach/render/tool-calls.js +25 -4
- package/dist/clients/attach/session/connection.d.ts +1 -1
- package/dist/clients/attach/session/editor-frame.d.ts +5 -1
- package/dist/clients/attach/session/editor-frame.js +21 -15
- package/dist/clients/attach/session/identity.d.ts +1 -1
- package/dist/clients/attach/viewer.js +441 -439
- package/dist/commands/__tests__/api-canvas-source.test.d.ts +1 -0
- package/dist/commands/__tests__/api-canvas-source.test.js +37 -0
- package/dist/commands/__tests__/human.test.js +2 -0
- package/dist/commands/__tests__/search-contents.test.d.ts +1 -0
- package/dist/commands/__tests__/search-contents.test.js +52 -0
- package/dist/commands/api-client.js +1 -0
- package/dist/commands/human/prompts.js +2 -2
- package/dist/commands/human/shared.d.ts +1 -0
- package/dist/commands/human/shared.js +7 -4
- package/dist/commands/memory/lint.d.ts +6 -0
- package/dist/commands/memory/lint.js +177 -35
- package/dist/commands/memory/read.js +22 -1
- package/dist/commands/memory/write.js +18 -4
- package/dist/commands/memory.js +1 -1
- package/dist/commands/search/contents.js +2 -2
- package/dist/commands/sys/__tests__/sync-deps.test.js +12 -21
- package/dist/commands/sys/__tests__/sync-import.test.js +30 -29
- package/dist/commands/sys/setup-wizard.d.ts +8 -1
- package/dist/commands/sys/setup-wizard.js +209 -58
- package/dist/commands/sys/sync-deps.js +14 -18
- package/dist/commands/sys/sync-project-guidance.js +16 -19
- package/dist/core/__tests__/broker-launch-candidates.test.js +13 -3
- package/dist/core/__tests__/broker-sdk-wiring.test.js +53 -25
- package/dist/core/__tests__/full/broker-provider-retry.test.js +6 -122
- package/dist/core/__tests__/migration.test.js +8 -3
- package/dist/core/__tests__/model-pin-durability.test.js +7 -5
- package/dist/core/__tests__/model-routes.test.d.ts +1 -0
- package/dist/core/__tests__/model-routes.test.js +41 -0
- package/dist/core/__tests__/on-read-crouter-home-fence.test.js +7 -10
- package/dist/core/__tests__/on-read-dedup-resume.test.js +13 -25
- package/dist/core/__tests__/on-read-identity.test.js +8 -15
- package/dist/core/__tests__/review-model-floor.test.js +2 -2
- package/dist/core/__tests__/revive.test.js +2 -2
- package/dist/core/__tests__/tmux-surface.test.js +10 -4
- package/dist/core/__tests__/worktree.test.js +3 -3
- package/dist/core/auth-file.d.ts +3 -2
- package/dist/core/auth-file.js +5 -3
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +18 -11
- package/dist/core/canvas/labels.d.ts +4 -5
- package/dist/core/canvas/labels.js +9 -9
- package/dist/core/canvas/types.d.ts +6 -1
- package/dist/core/command-manifests/schema.js +18 -5
- package/dist/core/command.js +17 -7
- package/dist/core/config.d.ts +3 -1
- package/dist/core/config.js +88 -2
- package/dist/core/configured-clis/invoker.js +2 -0
- package/dist/core/help.d.ts +3 -1
- package/dist/core/help.js +5 -2
- package/dist/core/keybindings/__tests__/resolve.test.js +5 -2
- package/dist/core/keybindings/catalog.d.ts +3 -2
- package/dist/core/keybindings/catalog.js +34 -25
- package/dist/core/keybindings/index.d.ts +1 -1
- package/dist/core/keybindings/resolve.js +8 -0
- package/dist/core/keybindings/types.d.ts +6 -1
- package/dist/core/memory/doc-link-grammar.d.ts +20 -0
- package/dist/core/memory/doc-link-grammar.js +110 -0
- package/dist/core/memory-resolver.d.ts +5 -0
- package/dist/core/memory-resolver.js +12 -1
- package/dist/core/model-routes.d.ts +37 -0
- package/dist/core/model-routes.js +140 -0
- package/dist/core/runtime/bearings.d.ts +8 -8
- package/dist/core/runtime/bearings.js +20 -16
- package/dist/core/runtime/broker.d.ts +0 -4
- package/dist/core/runtime/broker.js +119 -385
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/launch.d.ts +10 -12
- package/dist/core/runtime/launch.js +16 -22
- package/dist/core/runtime/managed-provider-cooling.d.ts +0 -14
- package/dist/core/runtime/managed-provider-cooling.js +0 -19
- package/dist/core/runtime/model-swap.js +7 -1
- package/dist/core/runtime/promote.js +1 -0
- package/dist/core/runtime/tmux.js +136 -100
- package/dist/core/scope.js +4 -5
- package/dist/core/subscription-state.d.ts +12 -36
- package/dist/core/subscription-state.js +19 -203
- package/dist/core/substrate/ceiling.d.ts +3 -6
- package/dist/core/substrate/ceiling.js +13 -15
- package/dist/core/substrate/index.d.ts +2 -3
- package/dist/core/substrate/index.js +2 -2
- package/dist/core/substrate/on-read.d.ts +4 -13
- package/dist/core/substrate/on-read.js +142 -262
- package/dist/core/substrate/render.js +4 -4
- package/dist/core/substrate/schema.d.ts +3 -2
- package/dist/core/substrate/schema.js +5 -5
- package/dist/daemon/api/__tests__/full/api-server.test.js +9 -6
- package/dist/daemon/api/__tests__/full/b10-attach-modelauth.test.js +123 -1
- package/dist/daemon/api/handlers/modelauth.js +51 -2
- package/dist/daemon/api/handlers/nodes.js +2 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-tool-guide.test.js +96 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +7 -8
- package/dist/pi-extensions/canvas-tool-guide.d.ts +14 -0
- package/dist/pi-extensions/canvas-tool-guide.js +74 -0
- package/dist/types.d.ts +17 -0
- package/dist/web-client/assets/{index-B76ZKfT_.js → index-NIuSCOHM.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/crouter-help.ts +0 -95
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
//
|
|
19
19
|
// These run offline (no network/auth): registration + system-prompt assembly +
|
|
20
20
|
// dialog routing are all local. No tmux, no broker process — direct unit drives.
|
|
21
|
-
import { test } from 'node:test';
|
|
21
|
+
import { after, before, test } from 'node:test';
|
|
22
22
|
import assert from 'node:assert/strict';
|
|
23
23
|
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
24
24
|
import { spawnSync } from 'node:child_process';
|
|
@@ -27,7 +27,6 @@ import { join, dirname } from 'node:path';
|
|
|
27
27
|
import { fileURLToPath } from 'node:url';
|
|
28
28
|
import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
|
|
29
29
|
import { buildBrokerSession, makeBrokerUiContext, isLeadingEngineCommand, chooseGuidanceDeliveryMode, promptWithNextTurnGuidance, isHiddenJsonlBranchEntry, stripHiddenEntriesFromJsonlExport, engineLeadingCommandTokens, } from '../runtime/broker.js';
|
|
30
|
-
import { readManagedProviderRotationPolicy } from '../runtime/managed-provider-cooling.js';
|
|
31
30
|
import { CANVAS_EXTENSIONS } from '../runtime/launch.js';
|
|
32
31
|
import { buildRefInventory } from '../memory/inline-ref-inventory.js';
|
|
33
32
|
import { buildGuidance } from '../memory/inline-ref-guidance.js';
|
|
@@ -45,6 +44,32 @@ const realEngine = {
|
|
|
45
44
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
46
45
|
const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
|
|
47
46
|
const C5_EXT = join(HERE, 'fixtures', 'c5-command-boundary-ext.ts');
|
|
47
|
+
const priorModelIntent = process.env['CRTR_MODEL_INTENT'];
|
|
48
|
+
const priorNodeCwd = process.env['CRTR_NODE_CWD'];
|
|
49
|
+
const priorProfileId = process.env['CRTR_PROFILE_ID'];
|
|
50
|
+
before(() => {
|
|
51
|
+
// buildBrokerSession normally receives its environment through buildBrokerEnv,
|
|
52
|
+
// which strips the launcher's CRTR_* values before applying the target recipe.
|
|
53
|
+
// These direct SDK tests bypass that boundary, so clear the ambient node's
|
|
54
|
+
// routing scope and intent to exercise only each fixture's explicit config.
|
|
55
|
+
delete process.env['CRTR_MODEL_INTENT'];
|
|
56
|
+
delete process.env['CRTR_NODE_CWD'];
|
|
57
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
58
|
+
});
|
|
59
|
+
after(() => {
|
|
60
|
+
if (priorModelIntent === undefined)
|
|
61
|
+
delete process.env['CRTR_MODEL_INTENT'];
|
|
62
|
+
else
|
|
63
|
+
process.env['CRTR_MODEL_INTENT'] = priorModelIntent;
|
|
64
|
+
if (priorNodeCwd === undefined)
|
|
65
|
+
delete process.env['CRTR_NODE_CWD'];
|
|
66
|
+
else
|
|
67
|
+
process.env['CRTR_NODE_CWD'] = priorNodeCwd;
|
|
68
|
+
if (priorProfileId === undefined)
|
|
69
|
+
delete process.env['CRTR_PROFILE_ID'];
|
|
70
|
+
else
|
|
71
|
+
process.env['CRTR_PROFILE_ID'] = priorProfileId;
|
|
72
|
+
});
|
|
48
73
|
function brokerExts() {
|
|
49
74
|
return [...CANVAS_EXTENSIONS, C3_EXT];
|
|
50
75
|
}
|
|
@@ -138,7 +163,7 @@ function runIsolatedManagedLaunch(pool) {
|
|
|
138
163
|
const script = `
|
|
139
164
|
const { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION } = await import(${JSON.stringify(new URL('../runtime/broker-sdk.ts', import.meta.url).href)});
|
|
140
165
|
const { buildBrokerSession } = await import(${JSON.stringify(new URL('../runtime/broker.ts', import.meta.url).href)});
|
|
141
|
-
const { writeSubscriptionPool
|
|
166
|
+
const { writeSubscriptionPool } = await import(${JSON.stringify(new URL('../subscription-state.ts', import.meta.url).href)});
|
|
142
167
|
await writeSubscriptionPool('anthropic', ${JSON.stringify(pool)});
|
|
143
168
|
const realEngine = { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION };
|
|
144
169
|
const engine = {
|
|
@@ -160,7 +185,7 @@ function runIsolatedManagedLaunch(pool) {
|
|
|
160
185
|
},
|
|
161
186
|
};
|
|
162
187
|
const { session } = await buildBrokerSession(engine, { cwd: ${JSON.stringify(cwd)}, extensionPaths: [${JSON.stringify(C3_EXT)}], model: 'anthropic/cooling-model' });
|
|
163
|
-
console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id
|
|
188
|
+
console.log(JSON.stringify({ provider: session.model?.provider, id: session.model?.id }));
|
|
164
189
|
session.dispose();
|
|
165
190
|
`;
|
|
166
191
|
try {
|
|
@@ -177,8 +202,23 @@ function runIsolatedManagedLaunch(pool) {
|
|
|
177
202
|
rmSync(cwd, { recursive: true, force: true });
|
|
178
203
|
}
|
|
179
204
|
}
|
|
180
|
-
test('C3f —
|
|
205
|
+
test('C3f — a logical request can reach a non-managed raw-registry fallback when no managed provider is authenticated', async () => {
|
|
181
206
|
const cwd = mkdtempSync(join(tmpdir(), 'crtr-c3c-'));
|
|
207
|
+
const priorIntent = process.env['CRTR_MODEL_INTENT'];
|
|
208
|
+
const priorNodeCwd = process.env['CRTR_NODE_CWD'];
|
|
209
|
+
process.env['CRTR_MODEL_INTENT'] = 'custom/strong';
|
|
210
|
+
process.env['CRTR_NODE_CWD'] = cwd;
|
|
211
|
+
mkdirSync(join(cwd, '.crouter'), { recursive: true });
|
|
212
|
+
writeFileSync(join(cwd, '.crouter', 'config.json'), `${JSON.stringify({
|
|
213
|
+
modelRoutes: {
|
|
214
|
+
c3: {
|
|
215
|
+
family: 'custom',
|
|
216
|
+
credentialSource: 'user',
|
|
217
|
+
providerId: 'c3prov',
|
|
218
|
+
models: { strong: 'c3prov/c3model' },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
})}\n`);
|
|
182
222
|
try {
|
|
183
223
|
const deterministicEngine = {
|
|
184
224
|
...realEngine,
|
|
@@ -209,6 +249,14 @@ test('C3f — broker launch falls back to a non-managed provider when no managed
|
|
|
209
249
|
}
|
|
210
250
|
}
|
|
211
251
|
finally {
|
|
252
|
+
if (priorIntent === undefined)
|
|
253
|
+
delete process.env['CRTR_MODEL_INTENT'];
|
|
254
|
+
else
|
|
255
|
+
process.env['CRTR_MODEL_INTENT'] = priorIntent;
|
|
256
|
+
if (priorNodeCwd === undefined)
|
|
257
|
+
delete process.env['CRTR_NODE_CWD'];
|
|
258
|
+
else
|
|
259
|
+
process.env['CRTR_NODE_CWD'] = priorNodeCwd;
|
|
212
260
|
rmSync(cwd, { recursive: true, force: true });
|
|
213
261
|
}
|
|
214
262
|
});
|
|
@@ -218,7 +266,6 @@ test('C3g — an explicit managed subscription pool authenticates a registered r
|
|
|
218
266
|
]);
|
|
219
267
|
assert.equal(selected.provider, 'anthropic');
|
|
220
268
|
assert.equal(selected.id, 'cooling-model');
|
|
221
|
-
assert.equal(selected.preferred, undefined);
|
|
222
269
|
});
|
|
223
270
|
test('C3h — a cooling managed launch target boots model-less rather than falling back to a non-managed provider', () => {
|
|
224
271
|
// The managed anthropic target is registered + pool-authenticated but COOLING
|
|
@@ -233,7 +280,6 @@ test('C3h — a cooling managed launch target boots model-less rather than falli
|
|
|
233
280
|
]);
|
|
234
281
|
assert.equal(selected.provider, 'unknown');
|
|
235
282
|
assert.equal(selected.id, 'unknown');
|
|
236
|
-
assert.equal(selected.preferred, undefined);
|
|
237
283
|
});
|
|
238
284
|
// (C4 — project guidance now comes from the document substrate: crouter keeps
|
|
239
285
|
// `noContextFiles: true` in buildBrokerSession, and buildContextIntro's
|
|
@@ -271,24 +317,6 @@ test('C2 — zero-writable UI context resolves dialogs to deny/cancel immediatel
|
|
|
271
317
|
assert.equal(await ctx.input('name'), undefined, 'C2: input() cancels (undefined)');
|
|
272
318
|
assert.equal(await ctx.editor('edit'), undefined, 'C2: editor() cancels (undefined)');
|
|
273
319
|
});
|
|
274
|
-
test('broker UI exposes the live provider pin policy to package extensions', () => {
|
|
275
|
-
let pinned = false;
|
|
276
|
-
const ctx = makeBrokerUiContext({
|
|
277
|
-
writable: () => [],
|
|
278
|
-
forward: () => { },
|
|
279
|
-
pending: new Map(),
|
|
280
|
-
broadcast: () => { },
|
|
281
|
-
providerRotationPolicy: () => pinned
|
|
282
|
-
? { allowCrossProviderFallback: false, reason: 'provider-pinned' }
|
|
283
|
-
: { allowCrossProviderFallback: true },
|
|
284
|
-
});
|
|
285
|
-
assert.deepEqual(readManagedProviderRotationPolicy(ctx), { allowCrossProviderFallback: true });
|
|
286
|
-
pinned = true;
|
|
287
|
-
assert.deepEqual(readManagedProviderRotationPolicy(ctx), {
|
|
288
|
-
allowCrossProviderFallback: false,
|
|
289
|
-
reason: 'provider-pinned',
|
|
290
|
-
}, 'the same running UI context observes a live set_model pin change');
|
|
291
|
-
});
|
|
292
320
|
// ===========================================================================
|
|
293
321
|
// M2 (review mq5wkqep / T4) — REPLACES the Wave-0 M-1 cancel-on-detach. A dialog
|
|
294
322
|
// forwarded to a writable viewer that then DETACHES must NOT be cancelled: it
|
|
@@ -5,14 +5,13 @@
|
|
|
5
5
|
// exercises runBroker's live retry handler rather than its helpers in isolation.
|
|
6
6
|
import { test, before, after, afterEach } from 'node:test';
|
|
7
7
|
import assert from 'node:assert/strict';
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
11
11
|
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
12
12
|
import { defaultModelLaddersConfig } from '../../../types.js';
|
|
13
13
|
import { readFault } from '../../runtime/fault.js';
|
|
14
14
|
import { normalizeModel } from '../../runtime/launch.js';
|
|
15
|
-
import { setModelLive } from '../../runtime/model-swap.js';
|
|
16
15
|
const MODEL_LADDERS = defaultModelLaddersConfig();
|
|
17
16
|
function resolvedModel(spec) {
|
|
18
17
|
const normalized = normalizeModel(spec, MODEL_LADDERS);
|
|
@@ -21,9 +20,8 @@ function resolvedModel(spec) {
|
|
|
21
20
|
assert.notEqual(slash, -1, `${spec} must normalize to a qualified provider/model spec`);
|
|
22
21
|
const provider = modelSpec.slice(0, slash);
|
|
23
22
|
const id = modelSpec.slice(slash + 1);
|
|
24
|
-
return { spec: normalized, model: { provider, id }
|
|
23
|
+
return { spec: normalized, model: { provider, id } };
|
|
25
24
|
}
|
|
26
|
-
const ANTHROPIC_STRONG = resolvedModel('anthropic/strong');
|
|
27
25
|
const OPENAI_STRONG = resolvedModel('openai/strong');
|
|
28
26
|
let h;
|
|
29
27
|
const kit = createAttachKit(() => h);
|
|
@@ -39,20 +37,6 @@ function seedPool(provider, cooling) {
|
|
|
39
37
|
rateLimitedUntil: cooling ? Date.now() + 60_000 : 0, lastAttemptAt: 0, lastRateLimitedAt: 0,
|
|
40
38
|
}]));
|
|
41
39
|
}
|
|
42
|
-
function rotationPath() {
|
|
43
|
-
return join(h.agentHome, '.pi', 'agent', 'provider-rotation.json');
|
|
44
|
-
}
|
|
45
|
-
function clearRotation() {
|
|
46
|
-
rmSync(rotationPath(), { force: true });
|
|
47
|
-
}
|
|
48
|
-
function preferred() {
|
|
49
|
-
try {
|
|
50
|
-
return JSON.parse(readFileSync(rotationPath(), 'utf8')).preferredModel;
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
40
|
function model(id) {
|
|
57
41
|
return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.model.json'), 'utf8'));
|
|
58
42
|
}
|
|
@@ -76,28 +60,6 @@ function agentStartCount(id) {
|
|
|
76
60
|
return 0;
|
|
77
61
|
}
|
|
78
62
|
}
|
|
79
|
-
async function autoRetry(id, expected) {
|
|
80
|
-
h.fakeCmd(id, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
|
|
81
|
-
await h.waitFor(() => {
|
|
82
|
-
try {
|
|
83
|
-
return assert.deepEqual(model(id), expected) === undefined ? true : false;
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}, { label: `auto-retry switch for ${id}` });
|
|
89
|
-
}
|
|
90
|
-
// The auto-retry receipt file's timestamp, so re-firing auto_retry a second/
|
|
91
|
-
// third time in one test doesn't false-positive on a STALE receipt that
|
|
92
|
-
// already existed from an earlier fire in the same test.
|
|
93
|
-
function autoRetryAt(id) {
|
|
94
|
-
try {
|
|
95
|
-
return JSON.parse(readFileSync(join(h.home, 'nodes', id, 'fake-pi.auto-retry.json'), 'utf8')).at;
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
return 0;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
63
|
before(async () => {
|
|
102
64
|
h = await createHeadlessHarness({ sessionPrefix: 'crtr-provider-retry' });
|
|
103
65
|
});
|
|
@@ -108,61 +70,12 @@ after(async () => {
|
|
|
108
70
|
afterEach(() => {
|
|
109
71
|
kit.closeAll();
|
|
110
72
|
});
|
|
111
|
-
test('broker auto-retry preserves the launch requested pin, clears it on restoration, and never persists the temporary fallback', { timeout: 30_000 }, async () => {
|
|
112
|
-
const parent = h.spawnRoot('provider retry parent');
|
|
113
|
-
seedPool('anthropic', true);
|
|
114
|
-
seedPool('openai-codex', false);
|
|
115
|
-
clearRotation();
|
|
116
|
-
const child = await h.spawnHeadlessChild(parent, 'launch fallback then retry', { model: ANTHROPIC_STRONG.spec });
|
|
117
|
-
assert.deepEqual(model(child), OPENAI_STRONG.model, 'a cooling requested managed launch starts its viable equivalent');
|
|
118
|
-
assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'launch retains the requested managed model as its restoration pin');
|
|
119
|
-
const before = h.node(child);
|
|
120
|
-
seedPool('anthropic', false);
|
|
121
|
-
await autoRetry(child, ANTHROPIC_STRONG.model);
|
|
122
|
-
await h.waitFor(() => preferred() === undefined, { label: 'launch preferred pin cleared after restoration' });
|
|
123
|
-
assert.deepEqual(preferred(), undefined, 'reaching the launch-requested provider fulfils and clears its temporary pin');
|
|
124
|
-
const after = h.node(child);
|
|
125
|
-
assert.equal(after.model_override, before.model_override, 'turn-time fallback never overwrites the durable model override');
|
|
126
|
-
assert.equal(after.launch?.model, before.launch?.model, 'turn-time fallback never overwrites the durable launch recipe');
|
|
127
|
-
});
|
|
128
|
-
test('broker auto-retry records its current managed model only when no restoration pin exists', { timeout: 30_000 }, async () => {
|
|
129
|
-
const parent = h.spawnRoot('normal retry parent');
|
|
130
|
-
seedPool('anthropic', false);
|
|
131
|
-
seedPool('openai-codex', false);
|
|
132
|
-
clearRotation();
|
|
133
|
-
// Establish the exact qualified OpenAI ladder cell, then explicitly release
|
|
134
|
-
// its structural pin so this test reaches the retry resolver by construction.
|
|
135
|
-
const child = await h.spawnHeadlessChild(parent, 'normal retry', { model: OPENAI_STRONG.spec });
|
|
136
|
-
await setModelLive(child, OPENAI_STRONG.spec, false);
|
|
137
|
-
clearRotation();
|
|
138
|
-
const before = h.node(child);
|
|
139
|
-
assert.deepEqual(model(child), OPENAI_STRONG.model);
|
|
140
|
-
await autoRetry(child, ANTHROPIC_STRONG.model);
|
|
141
|
-
await h.waitFor(() => {
|
|
142
|
-
try {
|
|
143
|
-
assert.deepEqual(preferred(), OPENAI_STRONG.pin);
|
|
144
|
-
return true;
|
|
145
|
-
}
|
|
146
|
-
catch {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
}, { label: 'normal retry preferred model recorded' });
|
|
150
|
-
assert.deepEqual(preferred(), OPENAI_STRONG.pin, 'a normal automatic switch records its failed managed model for later restoration');
|
|
151
|
-
const after = h.node(child);
|
|
152
|
-
assert.equal(after.model_override, before.model_override);
|
|
153
|
-
assert.equal(after.launch?.model, before.launch?.model);
|
|
154
|
-
});
|
|
155
73
|
test('a managed-cooling terminal authors a rate-limit auto fault whose deadline drives exactly one broker re-drive', { timeout: 30_000 }, async () => {
|
|
156
74
|
const parent = h.spawnRoot('cooling terminal parent');
|
|
157
75
|
// The terminal fault path is pool-independent — the stophook classifies purely
|
|
158
76
|
// off the errorMessage text + the cooling diagnostic, so pool state is inert.
|
|
159
|
-
// A qualified openai-codex id now pins structurally, but that has no effect
|
|
160
|
-
// here either way: this test never triggers retry/not-found policy (only the
|
|
161
|
-
// stophook fault path + launch-time pinnedToFirst, which is a no-op because
|
|
162
|
-
// the pool isn't cooling at spawn).
|
|
163
77
|
seedPool('anthropic', false);
|
|
164
78
|
seedPool('openai-codex', false);
|
|
165
|
-
clearRotation();
|
|
166
79
|
const child = await h.spawnHeadlessChild(parent, 'cooling terminal', { model: OPENAI_STRONG.spec });
|
|
167
80
|
await h.waitFor(() => agentStartCount(child) >= 1, { label: 'initial cooling fixture turn started' });
|
|
168
81
|
const baselineStarts = agentStartCount(child);
|
|
@@ -332,47 +245,18 @@ test('raw overflow settling without compaction terminates instead of hanging', {
|
|
|
332
245
|
await h.tick();
|
|
333
246
|
assert.equal(h.status(child), 'dead', 'settled raw overflow cannot leave the child hanging');
|
|
334
247
|
});
|
|
335
|
-
test(
|
|
336
|
-
const parent = h.spawnRoot('live pin policy parent');
|
|
337
|
-
seedPool('anthropic', false);
|
|
338
|
-
seedPool('openai-codex', false);
|
|
339
|
-
clearRotation();
|
|
340
|
-
// Establish the desired qualified Anthropic ladder cell while explicitly
|
|
341
|
-
// keeping its live retry policy unpinned, independent of ambient defaults.
|
|
342
|
-
const child = await h.spawnHeadlessChild(parent, 'live pin policy', { model: ANTHROPIC_STRONG.spec });
|
|
343
|
-
await setModelLive(child, ANTHROPIC_STRONG.spec, false);
|
|
344
|
-
clearRotation();
|
|
345
|
-
assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'explicit setup selects the current Anthropic strong model');
|
|
346
|
-
// false → true, LIVE: an explicit raw qualified re-pick pins the SAME
|
|
347
|
-
// resolved model — no model value changes, only the live pin decision. The
|
|
348
|
-
// running broker's retry-fallback policy (broker.ts's cfg.modelProviderPinned
|
|
349
|
-
// read) enforces it on the VERY NEXT retry, with no revive in between: the
|
|
350
|
-
// auto_retry below is suppressed rather than allowed to rotate the model.
|
|
351
|
-
await setModelLive(child, ANTHROPIC_STRONG.spec, true);
|
|
352
|
-
assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'the explicit re-pick keeps the same resolved model');
|
|
353
|
-
const priorAt1 = autoRetryAt(child);
|
|
354
|
-
h.fakeCmd(child, { cmd: 'auto_retry', errorMessage: 'rate limit exceeded' });
|
|
355
|
-
await h.waitFor(() => autoRetryAt(child) > priorAt1, { label: 'pinned auto-retry processed' });
|
|
356
|
-
// Settle past the fallback's own async continuation window.
|
|
357
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
358
|
-
assert.deepEqual(model(child), ANTHROPIC_STRONG.model, 'a live pin suppresses turn-time provider rotation immediately, without revive');
|
|
359
|
-
assert.deepEqual(preferred(), undefined, 'a suppressed retry records no restoration pin');
|
|
360
|
-
// true → false, LIVE: an explicit re-pick of the same qualified model with
|
|
361
|
-
// pinned=false releases the pin. The next retry must now be free to rotate.
|
|
362
|
-
await setModelLive(child, ANTHROPIC_STRONG.spec, false);
|
|
363
|
-
await autoRetry(child, OPENAI_STRONG.model);
|
|
364
|
-
assert.deepEqual(preferred(), ANTHROPIC_STRONG.pin, 'the released pin lets the next retry rotate and record a restoration pin');
|
|
365
|
-
});
|
|
366
|
-
test('a picker-originated set_model frame with no pinned field derives the pin from the real registry provider namespace; an internal no-raw-token path preserves it', { timeout: 30_000 }, async () => {
|
|
248
|
+
test('an explicit picker choice pins and clears prior logical routing; an internal no-raw-token path preserves that decision', { timeout: 30_000 }, async () => {
|
|
367
249
|
const parent = h.spawnRoot('picker frame parent');
|
|
368
250
|
seedPool('anthropic', false);
|
|
369
251
|
seedPool('openai-codex', false);
|
|
370
|
-
const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'strong' }); //
|
|
252
|
+
const child = await h.spawnHeadlessChild(parent, 'picker frame child', { model: 'openai/strong' }); // logical route boot
|
|
371
253
|
const ctrl = await kit.attach(child, 'controller', 'picker-test');
|
|
372
254
|
ctrl.send({ type: 'set_model', model: OPENAI_STRONG.spec }); // exactly what buildModelPicker sends — no `pinned`
|
|
373
255
|
await ctrl.waitFrame((f) => f.type === 'ack' && f.for === 'set_model', 'picker set_model acked');
|
|
374
256
|
let after = h.node(child);
|
|
375
257
|
assert.equal(after.launch?.modelProviderPinned, true, 'the omitted-pinned frame derives pinned from the real openai-codex registry namespace');
|
|
258
|
+
assert.equal(after.launch?.modelIntent, undefined, 'the explicit concrete choice clears the logical launch intent');
|
|
259
|
+
assert.equal(after.launch?.env.CRTR_MODEL_INTENT, undefined, 'a revive cannot restore the stale logical route intent');
|
|
376
260
|
// `cycle_ladder` — an internal no-raw-token path (no `model`/`pinned` on the
|
|
377
261
|
// frame at all) — reuses the SAME resolve+setModel machinery `set_model` does
|
|
378
262
|
// (real `nextLadderModel`/`resolveModelQuery`, the fake engine's real
|
|
@@ -7,12 +7,17 @@ import { fileURLToPath } from 'node:url';
|
|
|
7
7
|
import { DatabaseSync } from 'node:sqlite';
|
|
8
8
|
import { openDb, closeDb, migrate, MIGRATIONS } from '../canvas/db.js';
|
|
9
9
|
import { canvasDbPath, ensureHome } from '../canvas/paths.js';
|
|
10
|
-
import {
|
|
10
|
+
import { customExtensionPaths } from '../canvas/extensions.js';
|
|
11
11
|
import { updateNode } from '../canvas/canvas.js';
|
|
12
12
|
import { spawn, spawnSync } from 'node:child_process';
|
|
13
13
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const RUNTIME_COLUMNS = ['intent', 'pi_pid', 'window', 'tmux_session'];
|
|
15
15
|
const RACE_FIXTURE = join(HERE, 'fixtures', 'metadata-migration-race.ts');
|
|
16
|
+
const PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES = [
|
|
17
|
+
'canvas-inbox-watcher', 'canvas-stophook', 'canvas-recap',
|
|
18
|
+
'canvas-goal-capture', 'canvas-passive-context', 'canvas-context-intro',
|
|
19
|
+
'canvas-doc-substrate', 'canvas-structured-output', 'canvas-bash-valve',
|
|
20
|
+
];
|
|
16
21
|
let home;
|
|
17
22
|
async function waitForPath(path) {
|
|
18
23
|
for (;;) {
|
|
@@ -76,7 +81,7 @@ test('a v14 canvas normalizes historical canvas paths while retaining custom lau
|
|
|
76
81
|
node_id: 'saved', name: 'saved', created: '2026-01-01T00:00:00.000Z', cwd: '/tmp',
|
|
77
82
|
kind: 'developer', mode: 'base', lifecycle: 'terminal', status: 'idle',
|
|
78
83
|
launch: { extensions: [
|
|
79
|
-
...
|
|
84
|
+
...PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`),
|
|
80
85
|
'/plugins/canvas-inbox-watcher.js',
|
|
81
86
|
] },
|
|
82
87
|
}));
|
|
@@ -102,7 +107,7 @@ test('a v14 migration is protected against concurrent writeback by normal update
|
|
|
102
107
|
lifecycle: 'terminal',
|
|
103
108
|
status: 'idle',
|
|
104
109
|
launch: {
|
|
105
|
-
extensions: [...
|
|
110
|
+
extensions: [...PRE_TOOL_GUIDE_CANVAS_EXTENSION_NAMES.map((name) => `/old-generation/pi-extensions/${name}.js`), '/plugins/canvas-inbox-watcher.js'],
|
|
106
111
|
},
|
|
107
112
|
}));
|
|
108
113
|
ensureHome();
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// `modelProviderPinned` is durable node state with ONE derivation rule: it is
|
|
4
4
|
// decided exactly once, at an explicit user model change, from that change's
|
|
5
|
-
// RAW pre-normalize token — a
|
|
6
|
-
// `<
|
|
5
|
+
// RAW pre-normalize token — a concrete `<provider>/<model-id>` token pins;
|
|
6
|
+
// logical `<family>/<tier>` refs, bare tiers (`ultra`), and family aliases
|
|
7
7
|
// (`opus`/`sonnet`/`haiku`) unpins. Every other rebuild or persist (a
|
|
8
8
|
// polymorph/lifecycle change with no new `--model`, a dormant model write whose
|
|
9
9
|
// spec happens to normalize to a concrete cell, cycle_model/cycle_ladder,
|
|
@@ -77,11 +77,11 @@ test('fixture: a bare tier is unpinned; its normalized concrete cell reads as pi
|
|
|
77
77
|
assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier is unpinned');
|
|
78
78
|
assert.equal(isProviderPinnedModelToken(ULTRA_CELL), true, 'a qualified concrete cell reads as pinned');
|
|
79
79
|
});
|
|
80
|
-
test('
|
|
80
|
+
test('only concrete provider/id tokens pin; logical family/tier refs remain preferences', () => {
|
|
81
81
|
assert.equal(isProviderPinnedModelToken('anthropic/claude-fable-5:high'), true, 'a concrete anthropic id pins');
|
|
82
82
|
assert.equal(isProviderPinnedModelToken('openai-codex/gpt-5.6-sol:max'), true, 'the real openai-codex registry namespace pins, not just the family alias `openai`');
|
|
83
83
|
assert.equal(isProviderPinnedModelToken('amazon-bedrock/some-model-id'), true, 'any qualified provider/id pins — no undocumented exception for a provider outside the three built-in families');
|
|
84
|
-
assert.equal(isProviderPinnedModelToken('openai/ultra'),
|
|
84
|
+
assert.equal(isProviderPinnedModelToken('openai/ultra'), false, 'a qualified family/tier ref is logical and unpinned');
|
|
85
85
|
assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
|
|
86
86
|
assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
|
|
87
87
|
});
|
|
@@ -114,11 +114,13 @@ test('a deliberately tier-run node is not wrongly re-pinned when a rebuild or pe
|
|
|
114
114
|
// the concrete spec ULTRA_CELL normalizes to.
|
|
115
115
|
createNode(node('tier-node', {
|
|
116
116
|
model_override: ULTRA_CELL,
|
|
117
|
-
launch: { extensions: [], env: {}, model: ULTRA_CELL, modelProviderPinned: true },
|
|
117
|
+
launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'openai/ultra' }, model: ULTRA_CELL, modelIntent: { family: 'openai', strength: 'ultra' }, modelProviderPinned: true },
|
|
118
118
|
}));
|
|
119
119
|
persistDormantModel('tier-node', ULTRA_CELL, false);
|
|
120
120
|
const after = getNode('tier-node');
|
|
121
121
|
assert.equal(after?.launch?.model, ULTRA_CELL, 'the concrete spec is persisted as the launch model');
|
|
122
122
|
assert.equal(after?.model_override, ULTRA_CELL, 'the concrete spec is persisted as the override');
|
|
123
|
+
assert.equal(after?.launch?.modelIntent, undefined, 'an explicit concrete choice clears the prior logical route intent');
|
|
124
|
+
assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined, 'the revived broker does not inherit the prior logical route intent');
|
|
123
125
|
assert.equal(after?.launch?.modelProviderPinned, false, 'an explicit bare-tier decision clears a prior pin — the raw-token decision wins over the concrete spec shape');
|
|
124
126
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { after, before, test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { expandModelCandidates } from '../model-routes.js';
|
|
7
|
+
const previousHome = process.env.HOME;
|
|
8
|
+
let home = '';
|
|
9
|
+
before(() => {
|
|
10
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-model-routes-'));
|
|
11
|
+
process.env.HOME = home;
|
|
12
|
+
mkdirSync(join(home, '.crouter'), { recursive: true });
|
|
13
|
+
writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({
|
|
14
|
+
modelRoutes: {
|
|
15
|
+
'anthropic-user': { family: 'anthropic', credentialSource: 'user', providerId: 'anthropic', models: { strong: 'anthropic/a-strong', medium: 'anthropic/a-medium', light: 'anthropic/a-light' } },
|
|
16
|
+
'openai-user': { family: 'openai', credentialSource: 'user', providerId: 'openai-codex', models: { strong: 'openai-codex/o-strong', medium: 'openai-codex/o-medium', light: 'openai-codex/o-light' } },
|
|
17
|
+
'anthropic-funded': { family: 'anthropic', credentialSource: 'northlight', providerId: 'funded-anthropic', models: { strong: 'funded-anthropic/a-strong', medium: 'funded-anthropic/a-medium', light: 'funded-anthropic/a-light' } },
|
|
18
|
+
'openai-funded': { family: 'openai', credentialSource: 'northlight', providerId: 'funded-openai', models: { strong: 'funded-openai/o-strong', medium: 'funded-openai/o-medium', light: 'funded-openai/o-light' } },
|
|
19
|
+
},
|
|
20
|
+
modelRouting: { credentialSourceOrder: ['user', 'northlight'], familyOrder: ['anthropic', 'openai'], strengthFallback: ['medium', 'light'] },
|
|
21
|
+
}));
|
|
22
|
+
});
|
|
23
|
+
after(() => {
|
|
24
|
+
if (previousHome === undefined)
|
|
25
|
+
delete process.env.HOME;
|
|
26
|
+
else
|
|
27
|
+
process.env.HOME = previousHome;
|
|
28
|
+
rmSync(home, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
test('declared routes expand credential source, strength, then family preference', () => {
|
|
31
|
+
const candidates = expandModelCandidates({ kind: 'logical', family: 'openai', strength: 'strong', spec: 'openai-codex/o-strong' }, home, null);
|
|
32
|
+
assert.deepEqual(candidates.map((candidate) => `${candidate.credentialSource}/${candidate.family}/${candidate.strength}`), [
|
|
33
|
+
'user/openai/strong', 'user/anthropic/strong', 'user/openai/medium', 'user/anthropic/medium', 'user/openai/light', 'user/anthropic/light',
|
|
34
|
+
'northlight/openai/strong', 'northlight/anthropic/strong', 'northlight/openai/medium', 'northlight/anthropic/medium', 'northlight/openai/light', 'northlight/anthropic/light',
|
|
35
|
+
]);
|
|
36
|
+
assert.deepEqual(candidates.map((candidate) => candidate.rank), [...Array(12).keys()]);
|
|
37
|
+
});
|
|
38
|
+
test('concrete routes retain provider/model first and only move to same-model credential sources', () => {
|
|
39
|
+
const candidates = expandModelCandidates({ kind: 'concrete', spec: 'anthropic/a-strong' }, home, null);
|
|
40
|
+
assert.deepEqual(candidates.map((candidate) => `${candidate.providerId}/${candidate.modelId}`), ['anthropic/a-strong', 'funded-anthropic/a-strong']);
|
|
41
|
+
});
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
//
|
|
2
|
-
// the user scope root) as a project ancestor. Canvas runtime
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// cwd resolved its private project store there. That leaks one node's private
|
|
7
|
-
// memory into every unrelated node that reads a canvas file. This test pins the
|
|
8
|
-
// fence: reading a canvas artifact must not surface a store nested in the
|
|
9
|
-
// crouter home.
|
|
1
|
+
// Explicit file-context discovery never treats the crouter home
|
|
2
|
+
// (`~/.crouter`, the user scope root) as a project ancestor. Canvas runtime
|
|
3
|
+
// artifacts live below that root, while a nested `~/.crouter/.crouter/memory/`
|
|
4
|
+
// store can belong to a differently scoped node. The user-root fence keeps its
|
|
5
|
+
// explicitly routed docs out of unrelated canvas-file reads.
|
|
10
6
|
//
|
|
11
7
|
// Run: node --import tsx/esm --test src/core/__tests__/on-read-crouter-home-fence.test.ts
|
|
12
8
|
import { test, before, beforeEach, after } from 'node:test';
|
|
@@ -54,7 +50,8 @@ test('a store nested in the crouter home does not leak into a canvas-artifact re
|
|
|
54
50
|
mkdirSync(leakedStore, { recursive: true });
|
|
55
51
|
writeFileSync(join(leakedStore, 'private-thing.md'), '---\nkind: knowledge\n' +
|
|
56
52
|
"when-and-why-to-read: When X, read this because Y.\n" +
|
|
57
|
-
'file-read-visibility: content\n
|
|
53
|
+
'file-read-visibility: content\n' +
|
|
54
|
+
'applies-to: "**/*.md"\n---\n' +
|
|
58
55
|
'PRIVATE ASSISTANT MEMORY MUST NOT LEAK\n');
|
|
59
56
|
// A canvas runtime artifact under the crouter home — the shape of every node
|
|
60
57
|
// report/context file the harness auto-reads.
|
|
@@ -1,17 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// conversation. That dedup set USED to live only in the pi process heap (cleared
|
|
5
|
-
// on session_start). But a node's logical session — the .jsonl transcript —
|
|
6
|
-
// spans MULTIPLE pi processes: a dormancy → revive(resume) cycle exits the old
|
|
7
|
-
// process and launches a fresh `pi --session` that REUSES the same transcript.
|
|
8
|
-
// The fresh process started with an empty set, so any doc already injected
|
|
9
|
-
// before dormancy got injected AGAIN on the next read.
|
|
10
|
-
//
|
|
11
|
-
// The fix persists the set to nodes/<id>/injected-docs.json: a resume rehydrates
|
|
12
|
-
// it (dedup holds), and only the FRESH-transcript launch paths clear it. These
|
|
13
|
-
// tests lock in both halves: the store round-trip, and the dedup surviving a
|
|
14
|
-
// simulated revive(resume) while re-surfacing after a fresh launch.
|
|
1
|
+
// On-read context surfaces at most once per conversation. The dedup set lives
|
|
2
|
+
// at nodes/<id>/injected-docs.json so a process resumed onto the same transcript
|
|
3
|
+
// rehydrates it, while a fresh transcript clears it.
|
|
15
4
|
//
|
|
16
5
|
// Run: node --import tsx/esm --test src/core/__tests__/on-read-dedup-resume.test.ts
|
|
17
6
|
import { test, before, beforeEach, after } from 'node:test';
|
|
@@ -64,7 +53,7 @@ test('injected-store round-trips and clears', () => {
|
|
|
64
53
|
assert.ok(!existsSync(injectedDocsPath(node)), 'clear removes the file');
|
|
65
54
|
assert.deepEqual([...loadInjectedDocs(node)], [], 'load after clear is empty');
|
|
66
55
|
});
|
|
67
|
-
test('user
|
|
56
|
+
test('project and user memory docs without applies-to do not fire on file reads', () => {
|
|
68
57
|
const fakeHome = mkdtempSync(join(tmpdir(), 'crtr-onread-user-home-'));
|
|
69
58
|
process.env['HOME'] = fakeHome;
|
|
70
59
|
resetScopeCache();
|
|
@@ -72,33 +61,32 @@ test('user-global memory docs do not fire positionally for every file under home
|
|
|
72
61
|
const userMemDir = join(fakeHome, '.crouter', 'memory');
|
|
73
62
|
mkdirSync(userMemDir, { recursive: true });
|
|
74
63
|
writeFileSync(join(userMemDir, 'noisy-global.md'), '---\nkind: preference\n' +
|
|
75
|
-
'when-and-why-to-read: When reading any file, this preference should be read because this fixture must not fire
|
|
64
|
+
'when-and-why-to-read: When reading any file, this preference should be read because this fixture must not fire without an explicit route\n' +
|
|
76
65
|
'file-read-visibility: content\n---\n' +
|
|
77
66
|
'NOISY USER BODY\n');
|
|
78
67
|
const projectMemDir = join(fakeHome, 'work', '.crouter', 'memory');
|
|
79
68
|
mkdirSync(projectMemDir, { recursive: true });
|
|
80
69
|
writeFileSync(join(projectMemDir, 'local.md'), '---\nkind: knowledge\n' +
|
|
81
|
-
'when-and-why-to-read: When reading project files, this reference should be read because
|
|
70
|
+
'when-and-why-to-read: When reading project files, this reference should be read because this fixture proves explicit file routing\n' +
|
|
82
71
|
'file-read-visibility: content\n---\n' +
|
|
83
72
|
'LOCAL PROJECT BODY\n');
|
|
84
73
|
const readFile = join(fakeHome, 'work', 'ideas.md');
|
|
85
74
|
writeFileSync(readFile, 'turn off xml closer\n');
|
|
86
75
|
const rendered = renderOnReadDocs(node, readFile, new Set());
|
|
87
|
-
assert.ok(!rendered.includes('NOISY USER BODY'), 'user-global doc
|
|
88
|
-
assert.ok(rendered.includes('LOCAL PROJECT BODY'), 'project doc
|
|
89
|
-
assert.
|
|
90
|
-
assert.ok(!rendered.includes('triggered-by='), 'read target is not repeated on child items');
|
|
91
|
-
assert.ok(!rendered.includes('<auto-loaded-context file='), 'read target is not repeated on the wrapper');
|
|
76
|
+
assert.ok(!rendered.includes('NOISY USER BODY'), 'user-global doc has no explicit file route');
|
|
77
|
+
assert.ok(!rendered.includes('LOCAL PROJECT BODY'), 'project doc also has no implicit file route');
|
|
78
|
+
assert.equal(rendered, '', 'no file context renders without an explicit trigger');
|
|
92
79
|
rmSync(fakeHome, { recursive: true, force: true });
|
|
93
80
|
});
|
|
94
81
|
test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfaces after a fresh launch', () => {
|
|
95
82
|
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
96
|
-
// A
|
|
83
|
+
// A file-routed substrate doc in a `.crouter/memory/` ancestor.
|
|
97
84
|
const memDir = join(work, '.crouter', 'memory');
|
|
98
85
|
mkdirSync(memDir, { recursive: true });
|
|
99
86
|
writeFileSync(join(memDir, 'onread-fixture.md'), '---\nkind: knowledge\n' +
|
|
100
87
|
'when-and-why-to-read: When reading work files, this reference should be read because it is the on-read regression fixture\n' +
|
|
101
|
-
'file-read-visibility: content\n
|
|
88
|
+
'file-read-visibility: content\n' +
|
|
89
|
+
'applies-to: "src/**"\n---\n' +
|
|
102
90
|
`${FIXTURE_BODY}\n`);
|
|
103
91
|
const readFile = join(work, 'src', 'file.ts');
|
|
104
92
|
mkdirSync(join(work, 'src'), { recursive: true });
|
|
@@ -113,7 +101,7 @@ test('on-read doc surfaces once, stays deduped across a revive(resume), re-surfa
|
|
|
113
101
|
// --- dormancy → revive(resume): NEW process rehydrates the set from disk ---
|
|
114
102
|
const seenAfterRevive = loadInjectedDocs(node);
|
|
115
103
|
assert.deepEqual(seenAfterRevive, seen, 'revive(resume) rehydrates the persisted dedup set');
|
|
116
|
-
assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does
|
|
104
|
+
assert.ok(!renderOnReadDocs(node, readFile, seenAfterRevive).includes(FIXTURE_BODY), 'resumed process does not re-inject the doc');
|
|
117
105
|
// --- fresh launch (revive resume=false, e.g. a refresh-yield) clears the set ---
|
|
118
106
|
clearInjectedDocs(node);
|
|
119
107
|
const seenFresh = loadInjectedDocs(node);
|
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
// The
|
|
1
|
+
// The explicit file-context loader resolves a doc's identity with the same
|
|
2
2
|
// explicit-name-then-path-fallback rule the boot resolver uses (schema.ts's
|
|
3
3
|
// resolveDocName), never deriving `name` from the physical path alone.
|
|
4
4
|
//
|
|
5
|
-
// A migrated AGENTS.md doc (`crtr sys sync project-guidance`'s CLAUDE.md/AGENTS.md conversion)
|
|
6
|
-
// is written at `file-read-visibility: content` with an explicit frontmatter
|
|
7
|
-
// `name` (the project label, e.g. `crouter`) so sibling project roots do not
|
|
8
|
-
// collapse onto the shared `AGENTS` leaf. `loadPositionalDoc` must inject the
|
|
9
|
-
// doc under that explicit name, not the path-derived `AGENTS`.
|
|
10
|
-
//
|
|
11
5
|
// Run: node --import tsx/esm --test src/core/__tests__/on-read-identity.test.ts
|
|
12
6
|
import { test, before, beforeEach, after } from 'node:test';
|
|
13
7
|
import assert from 'node:assert/strict';
|
|
@@ -47,22 +41,21 @@ after(() => {
|
|
|
47
41
|
else
|
|
48
42
|
process.env['HOME'] = prevHomeEnv;
|
|
49
43
|
});
|
|
50
|
-
test('
|
|
44
|
+
test('an explicitly routed doc injects on-read under its frontmatter name, not the path-derived one', () => {
|
|
51
45
|
const node = spawnNode({ kind: 'general', cwd: work, parent: null }).node_id;
|
|
52
|
-
// Shape of a `crtr sys sync project-guidance` CLAUDE.md/AGENTS.md migration: physical filename
|
|
53
|
-
// stays AGENTS.md, but frontmatter carries the project's explicit identity.
|
|
54
46
|
const memDir = join(work, '.crouter', 'memory');
|
|
55
47
|
mkdirSync(memDir, { recursive: true });
|
|
56
|
-
writeFileSync(join(memDir, '
|
|
48
|
+
writeFileSync(join(memDir, 'physical-guide.md'), '---\nkind: knowledge\n' +
|
|
57
49
|
'name: acme-project\n' +
|
|
58
|
-
'when-and-why-to-read: When
|
|
59
|
-
'file-read-visibility: content\n
|
|
50
|
+
'when-and-why-to-read: When reading acme source, this knowledge should be read because it carries the project-specific implementation constraints.\n' +
|
|
51
|
+
'file-read-visibility: content\n' +
|
|
52
|
+
'applies-to: "src/**"\n---\n' +
|
|
60
53
|
'ACME PROJECT OPERATING GUIDE\n');
|
|
61
54
|
const readFile = join(work, 'src', 'file.ts');
|
|
62
55
|
mkdirSync(join(work, 'src'), { recursive: true });
|
|
63
56
|
writeFileSync(readFile, 'export const x = 1;\n');
|
|
64
57
|
const rendered = renderOnReadDocs(node, readFile, new Set());
|
|
65
|
-
assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the
|
|
58
|
+
assert.ok(rendered.includes('ACME PROJECT OPERATING GUIDE'), 'the matching explicit route fires');
|
|
66
59
|
assert.ok(rendered.includes('<memory kind="knowledge" name="acme-project"'), `expected explicit frontmatter name "acme-project" in the injected envelope, got: ${rendered}`);
|
|
67
|
-
assert.ok(!rendered.includes('name="
|
|
60
|
+
assert.ok(!rendered.includes('name="physical-guide"'), 'must not fall back to the path-derived name when frontmatter sets one');
|
|
68
61
|
});
|