@junghanacs/entwurf 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +240 -0
- package/BASELINE.md +227 -0
- package/CHANGELOG.md +1210 -0
- package/CONTRIBUTING.md +63 -0
- package/DELIVERY.md +209 -0
- package/LICENSE +21 -0
- package/README.md +504 -0
- package/VERIFY.md +260 -0
- package/demo/README.md +188 -0
- package/demo/demo-baseline.sh +156 -0
- package/demo/demo.sh +183 -0
- package/docs/assets/entwurf-demo.gif +0 -0
- package/docs/assets/entwurf-doomemacs.gif +0 -0
- package/docs/assets/entwurf-entwurf.gif +0 -0
- package/docs/assets/entwurf-hero.jpg +0 -0
- package/docs/setup-clean-host.md +305 -0
- package/mcp/entwurf-bridge/src/index.ts +513 -0
- package/mcp/entwurf-bridge/start.sh +25 -0
- package/mcp/entwurf-bridge/test.sh +54 -0
- package/mcp/tsconfig.json +29 -0
- package/package.json +130 -0
- package/pi/entwurf-capabilities.json +9 -0
- package/pi/entwurf-targets.json +20 -0
- package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
- package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
- package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
- package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
- package/pi/settings.reference.json +43 -0
- package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
- package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
- package/pi-extensions/acp-provider.ts +63 -0
- package/pi-extensions/entwurf-control.ts +1692 -0
- package/pi-extensions/lib/acp/acp-client.ts +90 -0
- package/pi-extensions/lib/acp/augment.ts +238 -0
- package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
- package/pi-extensions/lib/acp/backend.ts +873 -0
- package/pi-extensions/lib/acp/config.ts +552 -0
- package/pi-extensions/lib/acp/context.ts +177 -0
- package/pi-extensions/lib/acp/engraving.ts +123 -0
- package/pi-extensions/lib/acp/event-mapper.ts +339 -0
- package/pi-extensions/lib/acp/models.ts +102 -0
- package/pi-extensions/lib/acp/overlay.ts +220 -0
- package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
- package/pi-extensions/lib/acp/session-store.ts +418 -0
- package/pi-extensions/lib/acp/tool-surface.ts +184 -0
- package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
- package/pi-extensions/lib/entwurf-core.ts +2033 -0
- package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
- package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
- package/pi-extensions/lib/entwurf-facts.ts +251 -0
- package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
- package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
- package/pi-extensions/lib/entwurf-preflight.ts +247 -0
- package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
- package/pi-extensions/lib/entwurf-self-address.ts +117 -0
- package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
- package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
- package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
- package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
- package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
- package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
- package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
- package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
- package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
- package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
- package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
- package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
- package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
- package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
- package/pi-extensions/lib/meta-session.ts +1951 -0
- package/pi-extensions/lib/project-trust-handler.ts +154 -0
- package/pi-extensions/lib/session-id.js +57 -0
- package/pi-extensions/lib/socket-discovery.ts +346 -0
- package/pi-extensions/lib/socket-probe.ts +92 -0
- package/pi-extensions/meta-bridge-hook.ts +250 -0
- package/pi-extensions/model-lock.ts +235 -0
- package/prompts/engraving.md +27 -0
- package/protocol.js +31 -0
- package/run.sh +2832 -0
- package/scripts/check-acp-backend-preflight.ts +130 -0
- package/scripts/check-acp-carrier-augment.ts +297 -0
- package/scripts/check-acp-config.ts +322 -0
- package/scripts/check-acp-event-mapper.ts +253 -0
- package/scripts/check-acp-overlay.ts +154 -0
- package/scripts/check-acp-prompt-builder.ts +191 -0
- package/scripts/check-acp-provider-surface.ts +159 -0
- package/scripts/check-acp-sdk-surface.ts +211 -0
- package/scripts/check-acp-session-reuse.ts +756 -0
- package/scripts/check-acp-session-store.ts +387 -0
- package/scripts/check-acp-tool-surface.ts +159 -0
- package/scripts/check-entwurf-bridge-boot.ts +199 -0
- package/scripts/check-entwurf-capabilities.ts +123 -0
- package/scripts/check-entwurf-control-rpc.ts +187 -0
- package/scripts/check-entwurf-deliverability.ts +158 -0
- package/scripts/check-entwurf-fact-provider.ts +316 -0
- package/scripts/check-entwurf-facts.ts +358 -0
- package/scripts/check-entwurf-mailbox-guard.ts +264 -0
- package/scripts/check-entwurf-peers-surface.ts +235 -0
- package/scripts/check-entwurf-resume-args.ts +149 -0
- package/scripts/check-entwurf-self-address.ts +209 -0
- package/scripts/check-entwurf-session-identity.ts +703 -0
- package/scripts/check-entwurf-v2-contract.ts +536 -0
- package/scripts/check-entwurf-v2-decider.ts +795 -0
- package/scripts/check-entwurf-v2-lock.ts +368 -0
- package/scripts/check-entwurf-v2-mailbox.ts +228 -0
- package/scripts/check-entwurf-v2-matrix.ts +437 -0
- package/scripts/check-entwurf-v2-production.ts +406 -0
- package/scripts/check-entwurf-v2-release.ts +245 -0
- package/scripts/check-entwurf-v2-runner.ts +452 -0
- package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
- package/scripts/check-entwurf-v2-send.ts +454 -0
- package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
- package/scripts/check-entwurf-v2-spawn.ts +398 -0
- package/scripts/check-entwurf-v2-surface.ts +352 -0
- package/scripts/check-keyset-overlap.py +120 -0
- package/scripts/check-mailbox-receipt-state.ts +170 -0
- package/scripts/check-meta-capability-source.ts +112 -0
- package/scripts/check-meta-dual-consumers.ts +154 -0
- package/scripts/check-meta-dual-read.ts +158 -0
- package/scripts/check-meta-listing.ts +138 -0
- package/scripts/check-meta-mailbox-state-write.ts +135 -0
- package/scripts/check-meta-migration.ts +212 -0
- package/scripts/check-meta-receiver-marker.ts +185 -0
- package/scripts/check-meta-record-v2.ts +191 -0
- package/scripts/check-meta-session.ts +673 -0
- package/scripts/check-model-lock.ts +408 -0
- package/scripts/check-package-source-routing.ts +253 -0
- package/scripts/check-pi-preflight.ts +304 -0
- package/scripts/check-project-trust-handler.ts +265 -0
- package/scripts/check-shell-quote.ts +121 -0
- package/scripts/check-socket-discovery.ts +428 -0
- package/scripts/check-socket-probe.ts +106 -0
- package/scripts/fixtures/probe-mcp-server.ts +33 -0
- package/scripts/gnew-rpc-drive.ts +211 -0
- package/scripts/lib/acp-child-cleanup.ts +116 -0
- package/scripts/meta-bridge-doctor.sh +315 -0
- package/scripts/meta-bridge-hook-log.sh +26 -0
- package/scripts/meta-bridge-install.sh +135 -0
- package/scripts/meta-bridge-prune.ts +199 -0
- package/scripts/meta-bridge-state.py +549 -0
- package/scripts/meta-bridge-statusline.sh +192 -0
- package/scripts/meta-bridge-store-doctor.ts +64 -0
- package/scripts/meta-bridge-uninstall.sh +39 -0
- package/scripts/new-session-id.ts +25 -0
- package/scripts/postinstall-chmod.cjs +58 -0
- package/scripts/raw-async-delivery/README.md +258 -0
- package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
- package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
- package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
- package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
- package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
- package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
- package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
- package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
- package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
- package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
- package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
- package/scripts/resolve-acp-bridge.ts +25 -0
- package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
- package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
- package/scripts/smoke-acp-mcp-live.ts +129 -0
- package/scripts/smoke-acp-memory-containment-live.ts +389 -0
- package/scripts/smoke-acp-overlay-live.ts +314 -0
- package/scripts/smoke-acp-provider-live.ts +162 -0
- package/scripts/smoke-acp-raw-turn-live.ts +261 -0
- package/scripts/smoke-acp-session-reuse-live.ts +172 -0
- package/scripts/smoke-acp-skill-live.ts +144 -0
- package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
- package/scripts/smoke-claude-native-resume-live.sh +198 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
- package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
- package/scripts/smoke-meta-async-drift.sh +171 -0
- package/scripts/smoke-meta-honesty.sh +147 -0
- package/scripts/smoke-meta-install-state.sh +403 -0
- package/scripts/smoke-meta-keyset-guard.sh +111 -0
- package/scripts/smoke-meta-prune.sh +174 -0
- package/scripts/smoke-resident-garden-guard.sh +433 -0
- package/scripts/smoke-session-id-name.ts +187 -0
- package/scripts/tsconfig.json +34 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-model-lock — deterministic unit test for `pi-extensions/model-lock.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Mocks `ExtensionAPI` + `ExtensionContext` and drives the
|
|
5
|
+
* `session_start` / `agent_start` / `model_select` handlers through every
|
|
6
|
+
* case in the policy matrix. No pi process, no network, no API cost. The
|
|
7
|
+
* harness shape mirrors pi-core's actual behavior (setModel mutates +
|
|
8
|
+
* emits synthetic model_select with source="set") so reentry-guard
|
|
9
|
+
* validation is realistic.
|
|
10
|
+
*
|
|
11
|
+
* Coverage (every assertion must pass):
|
|
12
|
+
* 1. native → native: no-op
|
|
13
|
+
* 2. native → entwurf: revert (warning notify)
|
|
14
|
+
* 3. entwurf → native: revert (warning notify)
|
|
15
|
+
* 4. entwurf/X → entwurf/Y (id differs): revert (warning notify)
|
|
16
|
+
* 5. entwurf → entwurf (same id): skip (changed=false)
|
|
17
|
+
* 6. undefined → any: skip (!from)
|
|
18
|
+
* 7. source="restore": skip
|
|
19
|
+
* 8. Reentry guard: revert's synthetic emit MUST NOT re-trigger revert
|
|
20
|
+
* 9. setModel returns false: notify level "error" instead of "warning"
|
|
21
|
+
* 10. session_start "startup" + entries=0: lock NOT engaged, model_select no-op
|
|
22
|
+
* 11. session_start "startup" + entries already have a message: lock engaged
|
|
23
|
+
* 12. session_start "startup" empty → agent_start → lock engaged
|
|
24
|
+
* 13. session_start "resume": lock engaged immediately
|
|
25
|
+
* 14. session_start "new" + entries=0: lock NOT engaged
|
|
26
|
+
* 15. session_start "reload" + entries have a message: lock engaged
|
|
27
|
+
* 16. session_start "fork": lock engaged immediately
|
|
28
|
+
* 17. session_start "reload" + entries=0 BUT prior sessionLocked=true: lock preserved
|
|
29
|
+
* 18. getEntries() throws: defensive `catch { return true }` engages the lock
|
|
30
|
+
*
|
|
31
|
+
* Scope (B-side, the extension hook):
|
|
32
|
+
* this drives the model-lock extension hook directly. Its former A-side
|
|
33
|
+
* companion `smoke-model-switch` exercised the ACP bridge guard
|
|
34
|
+
* (`ensureBridgeSession`) and was removed with the ACP purge (v2-only),
|
|
35
|
+
* so this B-side gate now stands alone as the policy verification surface.
|
|
36
|
+
*
|
|
37
|
+
* Module-level state note: model-lock.ts uses two module-level flags
|
|
38
|
+
* (`reverting`, `sessionLocked`) that persist across tests in the same
|
|
39
|
+
* Node process. Each `makeHarness` call explicitly establishes the
|
|
40
|
+
* starting state via `initial:` so tests do not implicitly depend on
|
|
41
|
+
* order.
|
|
42
|
+
*
|
|
43
|
+
* See AGENTS.md § session model lock, NEXT.md § Session model lock.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import assert from "node:assert/strict";
|
|
47
|
+
|
|
48
|
+
import modelLockExtension from "../pi-extensions/model-lock.ts";
|
|
49
|
+
|
|
50
|
+
type Provider = string;
|
|
51
|
+
type ModelLike = { provider: Provider; id: string };
|
|
52
|
+
type Source = "set" | "cycle" | "restore";
|
|
53
|
+
type SessionStartReason = "startup" | "reload" | "new" | "resume" | "fork";
|
|
54
|
+
|
|
55
|
+
interface ModelSelectEventLike {
|
|
56
|
+
type: "model_select";
|
|
57
|
+
model: ModelLike;
|
|
58
|
+
previousModel: ModelLike | undefined;
|
|
59
|
+
source: Source;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface SessionStartEventLike {
|
|
63
|
+
type: "session_start";
|
|
64
|
+
reason: SessionStartReason;
|
|
65
|
+
previousSessionFile?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface AgentStartEventLike {
|
|
69
|
+
type: "agent_start";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface EntryLike {
|
|
73
|
+
type: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface NotifyCall {
|
|
77
|
+
message: string;
|
|
78
|
+
level: "info" | "warning" | "error";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface Harness {
|
|
82
|
+
dispatchSessionStart(reason: SessionStartReason): Promise<void>;
|
|
83
|
+
dispatchAgentStart(): Promise<void>;
|
|
84
|
+
dispatch(event: ModelSelectEventLike): Promise<void>;
|
|
85
|
+
setEntries(entries: EntryLike[]): void;
|
|
86
|
+
setEntriesThrowing(throwing: boolean): void;
|
|
87
|
+
setModelCalls: ModelLike[];
|
|
88
|
+
notifyCalls: NotifyCall[];
|
|
89
|
+
emitsDuringSetModel(): number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* `initial` controls the starting state of module-level `sessionLocked`:
|
|
94
|
+
*
|
|
95
|
+
* "locked" — emit session_start "resume" (immediate lock). Default for
|
|
96
|
+
* tests 1-9 which predate the sessionLocked gate.
|
|
97
|
+
* "unlocked" — emit session_start "startup" with empty entries (no lock).
|
|
98
|
+
* For tests verifying pre-turn freedom.
|
|
99
|
+
* "manual" — emit nothing. Test code drives session_start /
|
|
100
|
+
* agent_start explicitly. For tests with multi-step
|
|
101
|
+
* state setup (e.g., test 17 reload-preserve).
|
|
102
|
+
*/
|
|
103
|
+
type InitialState = "locked" | "unlocked" | "manual";
|
|
104
|
+
|
|
105
|
+
interface MakeHarnessOpts {
|
|
106
|
+
setModelReturns?: boolean;
|
|
107
|
+
initial?: InitialState;
|
|
108
|
+
entries?: EntryLike[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function makeHarness(opts: MakeHarnessOpts = {}): Promise<Harness> {
|
|
112
|
+
const setModelReturns = opts.setModelReturns ?? true;
|
|
113
|
+
const initial: InitialState = opts.initial ?? "locked";
|
|
114
|
+
let entries: EntryLike[] = opts.entries ?? [];
|
|
115
|
+
let entriesThrowing = false;
|
|
116
|
+
|
|
117
|
+
const modelSelectHandlers: Array<(event: ModelSelectEventLike, ctx: unknown) => Promise<void> | void> = [];
|
|
118
|
+
const sessionStartHandlers: Array<(event: SessionStartEventLike, ctx: unknown) => Promise<void> | void> = [];
|
|
119
|
+
const agentStartHandlers: Array<(event: AgentStartEventLike, ctx: unknown) => Promise<void> | void> = [];
|
|
120
|
+
|
|
121
|
+
const setModelCalls: ModelLike[] = [];
|
|
122
|
+
const notifyCalls: NotifyCall[] = [];
|
|
123
|
+
let emitsDuringSetModel = 0;
|
|
124
|
+
|
|
125
|
+
const ctx = {
|
|
126
|
+
ui: {
|
|
127
|
+
notify(message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
128
|
+
notifyCalls.push({ message, level });
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
sessionManager: {
|
|
132
|
+
getEntries(): EntryLike[] {
|
|
133
|
+
if (entriesThrowing) throw new Error("simulated session-manager teardown");
|
|
134
|
+
return entries;
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const pi = {
|
|
140
|
+
on(event: string, handler: any): void {
|
|
141
|
+
if (event === "model_select") modelSelectHandlers.push(handler);
|
|
142
|
+
else if (event === "session_start") sessionStartHandlers.push(handler);
|
|
143
|
+
else if (event === "agent_start") agentStartHandlers.push(handler);
|
|
144
|
+
},
|
|
145
|
+
async setModel(model: ModelLike): Promise<boolean> {
|
|
146
|
+
setModelCalls.push(model);
|
|
147
|
+
// Simulate pi-core: AgentSession.setModel emits a synthetic
|
|
148
|
+
// model_select with source "set" AFTER mutating state. The
|
|
149
|
+
// reentry guard must catch this and skip.
|
|
150
|
+
const syntheticEvent: ModelSelectEventLike = {
|
|
151
|
+
type: "model_select",
|
|
152
|
+
model,
|
|
153
|
+
previousModel: { provider: "fake-prev", id: "synthetic" },
|
|
154
|
+
source: "set",
|
|
155
|
+
};
|
|
156
|
+
emitsDuringSetModel += 1;
|
|
157
|
+
for (const handler of modelSelectHandlers) {
|
|
158
|
+
await handler(syntheticEvent, ctx);
|
|
159
|
+
}
|
|
160
|
+
return setModelReturns;
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Register the extension. After this, handlers[] arrays hold the listeners.
|
|
165
|
+
modelLockExtension(pi as any);
|
|
166
|
+
|
|
167
|
+
const harness: Harness = {
|
|
168
|
+
async dispatchSessionStart(reason: SessionStartReason) {
|
|
169
|
+
for (const handler of sessionStartHandlers) {
|
|
170
|
+
await handler({ type: "session_start", reason }, ctx);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
async dispatchAgentStart() {
|
|
174
|
+
for (const handler of agentStartHandlers) {
|
|
175
|
+
await handler({ type: "agent_start" }, ctx);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
async dispatch(event: ModelSelectEventLike) {
|
|
179
|
+
for (const handler of modelSelectHandlers) {
|
|
180
|
+
await handler(event, ctx);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
setEntries(next: EntryLike[]) {
|
|
184
|
+
entries = next;
|
|
185
|
+
},
|
|
186
|
+
setEntriesThrowing(throwing: boolean) {
|
|
187
|
+
entriesThrowing = throwing;
|
|
188
|
+
},
|
|
189
|
+
setModelCalls,
|
|
190
|
+
notifyCalls,
|
|
191
|
+
emitsDuringSetModel: () => emitsDuringSetModel,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Establish the requested initial state via real handler dispatch so
|
|
195
|
+
// module-level `sessionLocked` reflects the choice deterministically,
|
|
196
|
+
// overriding whatever the previous test left behind.
|
|
197
|
+
if (initial === "locked") {
|
|
198
|
+
await harness.dispatchSessionStart("resume");
|
|
199
|
+
} else if (initial === "unlocked") {
|
|
200
|
+
// startup with empty entries → sessionLocked = false
|
|
201
|
+
entries = [];
|
|
202
|
+
await harness.dispatchSessionStart("startup");
|
|
203
|
+
}
|
|
204
|
+
// "manual": leave whatever module state is. Test must drive session_start
|
|
205
|
+
// explicitly before any model_select assertion.
|
|
206
|
+
|
|
207
|
+
return harness;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Test models.
|
|
211
|
+
const NATIVE_A: ModelLike = { provider: "openai-codex", id: "gpt-5.4" };
|
|
212
|
+
const NATIVE_B: ModelLike = { provider: "openai-codex", id: "gpt-5.5" };
|
|
213
|
+
const PSA_SONNET: ModelLike = { provider: "entwurf", id: "claude-sonnet-4-6" };
|
|
214
|
+
const PSA_OPUS: ModelLike = { provider: "entwurf", id: "claude-opus-4-8" };
|
|
215
|
+
|
|
216
|
+
let passed = 0;
|
|
217
|
+
let failed = 0;
|
|
218
|
+
|
|
219
|
+
async function run(name: string, body: () => Promise<void>): Promise<void> {
|
|
220
|
+
try {
|
|
221
|
+
await body();
|
|
222
|
+
console.log(` ok ${name}`);
|
|
223
|
+
passed += 1;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
226
|
+
console.error(` FAIL ${name}`);
|
|
227
|
+
console.error(` ${msg}`);
|
|
228
|
+
failed += 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
console.log("[check-model-lock] policy unit tests");
|
|
233
|
+
|
|
234
|
+
// =============================================================================
|
|
235
|
+
// Tests 1-9 — original policy matrix (sessionLocked=true via default "locked")
|
|
236
|
+
// =============================================================================
|
|
237
|
+
|
|
238
|
+
await run("1. native → native: no-op (touchesEntwurf=false)", async () => {
|
|
239
|
+
const h = await makeHarness();
|
|
240
|
+
await h.dispatch({ type: "model_select", model: NATIVE_B, previousModel: NATIVE_A, source: "set" });
|
|
241
|
+
assert.equal(h.setModelCalls.length, 0);
|
|
242
|
+
assert.equal(h.notifyCalls.length, 0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await run("2. native → entwurf: revert + warning notify", async () => {
|
|
246
|
+
const h = await makeHarness();
|
|
247
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
|
|
248
|
+
assert.equal(h.setModelCalls.length, 1);
|
|
249
|
+
assert.deepEqual(h.setModelCalls[0], NATIVE_A);
|
|
250
|
+
assert.equal(h.notifyCalls.length, 1);
|
|
251
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
252
|
+
assert.match(h.notifyCalls[0].message, /locked to openai-codex\/gpt-5\.4/);
|
|
253
|
+
assert.match(h.notifyCalls[0].message, /entwurf\/claude-sonnet-4-6/);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await run("3. entwurf → native: revert + warning notify", async () => {
|
|
257
|
+
const h = await makeHarness();
|
|
258
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
259
|
+
assert.equal(h.setModelCalls.length, 1);
|
|
260
|
+
assert.deepEqual(h.setModelCalls[0], PSA_SONNET);
|
|
261
|
+
assert.equal(h.notifyCalls.length, 1);
|
|
262
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
await run("4. entwurf/X → entwurf/Y (id differs): revert + warning notify", async () => {
|
|
266
|
+
const h = await makeHarness();
|
|
267
|
+
await h.dispatch({ type: "model_select", model: PSA_OPUS, previousModel: PSA_SONNET, source: "set" });
|
|
268
|
+
assert.equal(h.setModelCalls.length, 1, "B reverts same-provider mismatch BEFORE next prompt");
|
|
269
|
+
assert.deepEqual(h.setModelCalls[0], PSA_SONNET);
|
|
270
|
+
assert.equal(h.notifyCalls.length, 1);
|
|
271
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await run("5. entwurf → entwurf (same id): no-op (changed=false)", async () => {
|
|
275
|
+
const h = await makeHarness();
|
|
276
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: PSA_SONNET, source: "set" });
|
|
277
|
+
assert.equal(h.setModelCalls.length, 0);
|
|
278
|
+
assert.equal(h.notifyCalls.length, 0);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
await run("6. undefined → entwurf (first selection): no-op (!from)", async () => {
|
|
282
|
+
const h = await makeHarness();
|
|
283
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: undefined, source: "set" });
|
|
284
|
+
assert.equal(h.setModelCalls.length, 0);
|
|
285
|
+
assert.equal(h.notifyCalls.length, 0);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
await run('7. source="restore": no-op (skip restore)', async () => {
|
|
289
|
+
const h = await makeHarness();
|
|
290
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "restore" });
|
|
291
|
+
assert.equal(h.setModelCalls.length, 0);
|
|
292
|
+
assert.equal(h.notifyCalls.length, 0);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await run("8. Reentry guard: synthetic model_select inside setModel must NOT re-trigger revert", async () => {
|
|
296
|
+
const h = await makeHarness();
|
|
297
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
|
|
298
|
+
assert.equal(h.setModelCalls.length, 1, "setModel must be called exactly once (no reentry)");
|
|
299
|
+
assert.equal(h.emitsDuringSetModel(), 1, "exactly one synthetic emit happened (sanity check)");
|
|
300
|
+
assert.equal(h.notifyCalls.length, 1, "notify must be called exactly once (no reentry)");
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
await run("9. setModel returns false (auth missing): notify level error", async () => {
|
|
304
|
+
const h = await makeHarness({ setModelReturns: false });
|
|
305
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
306
|
+
assert.equal(h.setModelCalls.length, 1);
|
|
307
|
+
assert.equal(h.notifyCalls.length, 1);
|
|
308
|
+
assert.equal(h.notifyCalls[0].level, "error");
|
|
309
|
+
assert.match(h.notifyCalls[0].message, /failed to revert/);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
// =============================================================================
|
|
313
|
+
// Tests 10-18 — session lifecycle gate (sessionLocked)
|
|
314
|
+
// =============================================================================
|
|
315
|
+
|
|
316
|
+
await run('10. session_start "startup" + entries=0: lock NOT engaged, model_select no-op', async () => {
|
|
317
|
+
const h = await makeHarness({ initial: "unlocked" });
|
|
318
|
+
// Pre-turn model change (CLI --model, settings.json default, etc.) — must be free.
|
|
319
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
|
|
320
|
+
assert.equal(h.setModelCalls.length, 0, "lock must NOT fire before first agent_start");
|
|
321
|
+
assert.equal(h.notifyCalls.length, 0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await run('11. session_start "startup" + entries have a message: lock engaged', async () => {
|
|
325
|
+
// Future-proof guard: pi-mono currently distinguishes resume from
|
|
326
|
+
// startup, but if a future pi version loads a saved session under
|
|
327
|
+
// "startup" the entries-based check engages the lock.
|
|
328
|
+
const h = await makeHarness({ initial: "manual", entries: [{ type: "message" }] });
|
|
329
|
+
await h.dispatchSessionStart("startup");
|
|
330
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
331
|
+
assert.equal(h.setModelCalls.length, 1, "saved-session-on-startup engages the lock");
|
|
332
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
await run('12. session_start "startup" empty → agent_start → lock engaged', async () => {
|
|
336
|
+
const h = await makeHarness({ initial: "unlocked" });
|
|
337
|
+
// Pre-turn: still unlocked, change allowed.
|
|
338
|
+
await h.dispatch({ type: "model_select", model: PSA_OPUS, previousModel: PSA_SONNET, source: "set" });
|
|
339
|
+
assert.equal(h.setModelCalls.length, 0, "pre-turn no-op");
|
|
340
|
+
|
|
341
|
+
// First prompt arrives → agent_start fires → lock engaged.
|
|
342
|
+
await h.dispatchAgentStart();
|
|
343
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
344
|
+
assert.equal(h.setModelCalls.length, 1, "post agent_start lock engaged");
|
|
345
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await run('13. session_start "resume": lock engaged immediately', async () => {
|
|
349
|
+
const h = await makeHarness({ initial: "manual" });
|
|
350
|
+
await h.dispatchSessionStart("resume");
|
|
351
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
352
|
+
assert.equal(h.setModelCalls.length, 1, "resume engages lock without needing agent_start");
|
|
353
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await run('14. session_start "new" + entries=0: lock NOT engaged', async () => {
|
|
357
|
+
const h = await makeHarness({ initial: "manual" });
|
|
358
|
+
await h.dispatchSessionStart("new");
|
|
359
|
+
await h.dispatch({ type: "model_select", model: PSA_SONNET, previousModel: NATIVE_A, source: "set" });
|
|
360
|
+
assert.equal(h.setModelCalls.length, 0, "new session pre-turn must be free");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
await run('15. session_start "reload" + entries have a message: lock engaged', async () => {
|
|
364
|
+
const h = await makeHarness({ initial: "manual", entries: [{ type: "message" }] });
|
|
365
|
+
await h.dispatchSessionStart("reload");
|
|
366
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
367
|
+
assert.equal(h.setModelCalls.length, 1, "reload of an active session engages lock");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await run('16. session_start "fork": lock engaged immediately', async () => {
|
|
371
|
+
const h = await makeHarness({ initial: "manual" });
|
|
372
|
+
await h.dispatchSessionStart("fork");
|
|
373
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
374
|
+
assert.equal(h.setModelCalls.length, 1, "fork engages lock immediately");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
await run('17. session_start "reload" + entries=0 BUT prior sessionLocked=true: lock preserved', async () => {
|
|
378
|
+
// This is the GPT-flagged edge case: a session that already locked
|
|
379
|
+
// (via earlier agent_start or resume) must survive a reload even
|
|
380
|
+
// if message entries are not visible at the reload instant. The
|
|
381
|
+
// guard `sessionLocked = sessionLocked || hasStartedConversation(ctx)`
|
|
382
|
+
// keeps the prior `true` instead of resetting to `false`.
|
|
383
|
+
const h = await makeHarness({ initial: "manual" });
|
|
384
|
+
await h.dispatchSessionStart("resume"); // sessionLocked = true
|
|
385
|
+
h.setEntries([]); // simulate entries momentarily empty at reload boundary
|
|
386
|
+
await h.dispatchSessionStart("reload"); // sessionLocked = true || false = true (preserved)
|
|
387
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
388
|
+
assert.equal(h.setModelCalls.length, 1, "reload must NOT release a prior lock");
|
|
389
|
+
assert.equal(h.notifyCalls[0].level, "warning");
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
await run("18. getEntries() throws: defensive catch engages the lock", async () => {
|
|
393
|
+
const h = await makeHarness({ initial: "manual" });
|
|
394
|
+
h.setEntriesThrowing(true);
|
|
395
|
+
// Without the try/catch fallback, this dispatch would propagate the
|
|
396
|
+
// throw and the module would be in an indeterminate state. With the
|
|
397
|
+
// `catch { return true }` defensive probe, the lock engages.
|
|
398
|
+
await h.dispatchSessionStart("startup");
|
|
399
|
+
await h.dispatch({ type: "model_select", model: NATIVE_A, previousModel: PSA_SONNET, source: "set" });
|
|
400
|
+
assert.equal(h.setModelCalls.length, 1, "throwing probe must conservatively engage lock");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
console.log("");
|
|
404
|
+
console.log(`[check-model-lock] ${passed} passed, ${failed} failed`);
|
|
405
|
+
|
|
406
|
+
if (failed > 0) {
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* check-package-source-routing — deterministic gate for #29 (package-installed
|
|
3
|
+
* Entwurf ACP routing). No backend, no spawn, no API cost.
|
|
4
|
+
*
|
|
5
|
+
* Pins resolveExplicitExtensionSpec()'s package-source → install-root mapping and
|
|
6
|
+
* the fail-fast routing contract, exercised through the two public routing
|
|
7
|
+
* surfaces the spawn/resume paths actually call:
|
|
8
|
+
*
|
|
9
|
+
* - getRegistryRouting(target, isRemote) — spawn path
|
|
10
|
+
* - getEntwurfExplicitExtensions(model, isRemote, recordedProvider) — resume path
|
|
11
|
+
*
|
|
12
|
+
* The original bug: a `git:` / `npm:` Pi settings package source returned null,
|
|
13
|
+
* so `provider=entwurf` spawned a `--no-extensions` child that died with
|
|
14
|
+
* `Unknown provider "entwurf"`. This gate covers the install matrix:
|
|
15
|
+
*
|
|
16
|
+
* local path / git user / npm user (+ version spec) / install-missing /
|
|
17
|
+
* project-scope (unseen → unresolved) / no-source, across local + remote,
|
|
18
|
+
* plus the self-root fallback property and the resume fail-fast signal.
|
|
19
|
+
*
|
|
20
|
+
* Self-root note: for LOCAL entwurf resolution the parent module's own path
|
|
21
|
+
* is a valid bridge root, so local resolution always succeeds even when settings
|
|
22
|
+
* has no source — that IS the desired "local always works" property. Fail-fast is
|
|
23
|
+
* therefore asserted on the REMOTE path, where a local self-root cannot cross SSH.
|
|
24
|
+
*
|
|
25
|
+
* Env-isolation: PI_CODING_AGENT_DIR / PI_SETTINGS_PATH are set to temp paths
|
|
26
|
+
* before the resolver is imported (directly or in the subprocess env-check), so
|
|
27
|
+
* the module-level AGENT_DIR / PI_SETTINGS_PATH consts capture them. The real
|
|
28
|
+
* ~/.pi/agent is never read or written; os.homedir() is only used to build the
|
|
29
|
+
* EXPECTED remote path string (proving the local override does not leak remote).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import * as fs from "node:fs";
|
|
35
|
+
import * as os from "node:os";
|
|
36
|
+
import * as path from "node:path";
|
|
37
|
+
import { fileURLToPath } from "node:url";
|
|
38
|
+
import type { ResolvedTarget } from "../pi-extensions/lib/entwurf-core.ts";
|
|
39
|
+
|
|
40
|
+
// --- Isolated agent dir (must be set BEFORE importing entwurf-core) ----------
|
|
41
|
+
const tmpAgent = fs.mkdtempSync(path.join(os.tmpdir(), "psa-route-"));
|
|
42
|
+
process.env.PI_CODING_AGENT_DIR = tmpAgent;
|
|
43
|
+
delete process.env.ENTWURF_ACP_FOR_CODEX; // control wantsCodexBridge per-case
|
|
44
|
+
|
|
45
|
+
// Dynamic import so the resolver's AGENT_DIR / PI_SETTINGS_PATH consts capture
|
|
46
|
+
// the env above. A static import would evaluate them against the real ~/.pi.
|
|
47
|
+
const core = await import("../pi-extensions/lib/entwurf-core.ts");
|
|
48
|
+
const { getRegistryRouting, getEntwurfExplicitExtensions, EntwurfRoutingError } = core;
|
|
49
|
+
|
|
50
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
51
|
+
const SETTINGS_PATH = path.join(tmpAgent, "settings.json");
|
|
52
|
+
const REMOTE_AGENT = path.posix.join(os.homedir(), ".pi", "agent");
|
|
53
|
+
|
|
54
|
+
let passed = 0;
|
|
55
|
+
function check(label: string, fn: () => void): void {
|
|
56
|
+
fn();
|
|
57
|
+
passed += 1;
|
|
58
|
+
console.log(`[check-package-source-routing] ${label}: ok`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Fixture helpers ---------------------------------------------------------
|
|
62
|
+
function setSource(source: string | null): void {
|
|
63
|
+
const packages = source ? [source] : [];
|
|
64
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify({ packages }, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function makeInstallDir(...segs: string[]): string {
|
|
68
|
+
const dir = path.join(tmpAgent, ...segs);
|
|
69
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function rmInstall(...segs: string[]): void {
|
|
74
|
+
fs.rmSync(path.join(tmpAgent, ...segs), { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const ACP: ResolvedTarget = { provider: "entwurf", model: "claude-sonnet-4-6", explicitOnly: false };
|
|
78
|
+
const eArg = (r: { args: string[] }): string | undefined => {
|
|
79
|
+
const i = r.args.indexOf("-e");
|
|
80
|
+
return i >= 0 ? r.args[i + 1] : undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// =============================================================================
|
|
84
|
+
// LOCAL spawn (getRegistryRouting, isRemote=false)
|
|
85
|
+
// =============================================================================
|
|
86
|
+
|
|
87
|
+
// 1. Local path package source resolves to <agentDir>/<source>.
|
|
88
|
+
check("local-path source resolves to agentDir-relative root", () => {
|
|
89
|
+
const root = makeInstallDir("local-pkgs", "entwurf");
|
|
90
|
+
setSource("local-pkgs/entwurf");
|
|
91
|
+
const r = getRegistryRouting(ACP, false);
|
|
92
|
+
assert.equal(r.provider, "entwurf");
|
|
93
|
+
assert.equal(eArg(r), root);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// 2. git: user source → <agentDir>/git/<host>/<path>.
|
|
97
|
+
check("git: source resolves to agentDir/git/<host>/<path>", () => {
|
|
98
|
+
const root = makeInstallDir("git", "github.com", "junghan0611", "entwurf");
|
|
99
|
+
setSource("git:github.com/junghan0611/entwurf");
|
|
100
|
+
const r = getRegistryRouting(ACP, false);
|
|
101
|
+
assert.equal(eArg(r), root);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// 3. npm: user source → <agentDir>/npm/node_modules/<name>.
|
|
105
|
+
check("npm: source resolves to agentDir/npm/node_modules/<name>", () => {
|
|
106
|
+
const root = makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
|
|
107
|
+
setSource("npm:@junghanacs/entwurf");
|
|
108
|
+
const r = getRegistryRouting(ACP, false);
|
|
109
|
+
assert.equal(eArg(r), root);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// 4. npm: source WITH a version spec — root keys on the bare name, version stripped.
|
|
113
|
+
check("npm: source with @version strips the version from the install root", () => {
|
|
114
|
+
const root = makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
|
|
115
|
+
setSource("npm:@junghanacs/entwurf@0.8.0");
|
|
116
|
+
const r = getRegistryRouting(ACP, false);
|
|
117
|
+
assert.equal(eArg(r), root);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// 5. Self-root fallback — settings source absent, but LOCAL resolution still
|
|
121
|
+
// succeeds via the loaded module's own root (the "local always works" property).
|
|
122
|
+
check("local self-root fallback resolves when settings has no source", () => {
|
|
123
|
+
rmInstall("git");
|
|
124
|
+
rmInstall("npm");
|
|
125
|
+
rmInstall("local-pkgs");
|
|
126
|
+
setSource(null);
|
|
127
|
+
const r = getRegistryRouting(ACP, false);
|
|
128
|
+
assert.equal(eArg(r), REPO_ROOT, "self-root should be the entwurf-core package root");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// 6. Native provider passes through untouched — no bridge, no throw.
|
|
132
|
+
check("native provider target passes through with no -e and no throw", () => {
|
|
133
|
+
setSource(null);
|
|
134
|
+
const r = getRegistryRouting({ provider: "openai-codex", model: "gpt-5.4", explicitOnly: false }, false);
|
|
135
|
+
assert.equal(r.provider, "openai-codex");
|
|
136
|
+
assert.equal(eArg(r), undefined);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// =============================================================================
|
|
140
|
+
// REMOTE spawn (getRegistryRouting, isRemote=true) — self-root excluded
|
|
141
|
+
// =============================================================================
|
|
142
|
+
|
|
143
|
+
// 7. git: remote → remotePath uses the plain ~/.pi/agent layout, NOT the local
|
|
144
|
+
// PI_CODING_AGENT_DIR override (no local-env leak into the SSH path).
|
|
145
|
+
check("git: remote resolves to ~/.pi/agent/git/... (no local-env leak)", () => {
|
|
146
|
+
makeInstallDir("git", "github.com", "junghan0611", "entwurf"); // local mirror gates existence
|
|
147
|
+
setSource("git:github.com/junghan0611/entwurf");
|
|
148
|
+
const r = getRegistryRouting(ACP, true);
|
|
149
|
+
assert.equal(eArg(r), path.posix.join(REMOTE_AGENT, "git", "github.com", "junghan0611", "entwurf"));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 8. npm: remote with version → ~/.pi/agent/npm/node_modules/<name>.
|
|
153
|
+
check("npm: remote with @version resolves to ~/.pi/agent/npm/node_modules/<name>", () => {
|
|
154
|
+
makeInstallDir("npm", "node_modules", "@junghanacs", "entwurf");
|
|
155
|
+
setSource("npm:@junghanacs/entwurf@0.8.0");
|
|
156
|
+
const r = getRegistryRouting(ACP, true);
|
|
157
|
+
assert.equal(eArg(r), path.posix.join(REMOTE_AGENT, "npm", "node_modules", "@junghanacs", "entwurf"));
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// 9. Install dir missing, remote → fail-fast (EntwurfRoutingError), NOT warn-and-spawn.
|
|
161
|
+
check("remote git: with missing install dir throws EntwurfRoutingError", () => {
|
|
162
|
+
rmInstall("git");
|
|
163
|
+
setSource("git:github.com/junghan0611/entwurf");
|
|
164
|
+
assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// 10. No source in settings, remote → fail-fast.
|
|
168
|
+
check("remote with no settings source throws EntwurfRoutingError", () => {
|
|
169
|
+
setSource(null);
|
|
170
|
+
assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 10b. PI_SETTINGS_PATH override must be honored independently from
|
|
174
|
+
// PI_CODING_AGENT_DIR/settings.json. This is run in a subprocess because
|
|
175
|
+
// entwurf-core captures the env at module import time.
|
|
176
|
+
check("PI_SETTINGS_PATH env override is honored", () => {
|
|
177
|
+
const agent = fs.mkdtempSync(path.join(os.tmpdir(), "psa-route-settings-"));
|
|
178
|
+
try {
|
|
179
|
+
const root = path.join(agent, "git", "github.com", "junghan0611", "entwurf");
|
|
180
|
+
fs.mkdirSync(root, { recursive: true });
|
|
181
|
+
const settingsPath = path.join(agent, "custom-settings.json");
|
|
182
|
+
fs.writeFileSync(settingsPath, JSON.stringify({ packages: ["git:github.com/junghan0611/entwurf"] }));
|
|
183
|
+
const out = execFileSync(
|
|
184
|
+
process.execPath,
|
|
185
|
+
["--experimental-strip-types", path.join(REPO_ROOT, "scripts", "resolve-acp-bridge.ts"), "remote"],
|
|
186
|
+
{
|
|
187
|
+
env: { ...process.env, PI_CODING_AGENT_DIR: agent, PI_SETTINGS_PATH: settingsPath },
|
|
188
|
+
encoding: "utf8",
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
assert.equal(out, path.posix.join(REMOTE_AGENT, "git", "github.com", "junghan0611", "entwurf"));
|
|
192
|
+
} finally {
|
|
193
|
+
fs.rmSync(agent, { recursive: true, force: true });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// 11. Project-scope (-l) sources live in cwd/.pi, never user settings.json, so the
|
|
198
|
+
// resolver never sees them — they resolve to nothing and fail-fast on remote
|
|
199
|
+
// rather than silently emitting Unknown provider.
|
|
200
|
+
check("project-scope source (unseen in user settings) fails fast on remote", () => {
|
|
201
|
+
// A project install would record under ./.pi; user settings stays empty.
|
|
202
|
+
setSource(null);
|
|
203
|
+
assert.throws(() => getRegistryRouting(ACP, true), EntwurfRoutingError);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// =============================================================================
|
|
207
|
+
// RESUME path (getEntwurfExplicitExtensions) — unresolvedAcpIntent signal
|
|
208
|
+
// =============================================================================
|
|
209
|
+
|
|
210
|
+
// 12. recorded provider=entwurf + remote + no source → unresolvedAcpIntent
|
|
211
|
+
// (resume callers fail-fast; no -e injected).
|
|
212
|
+
check("resume recorded entwurf + remote + no source → unresolvedAcpIntent", () => {
|
|
213
|
+
setSource(null);
|
|
214
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", true, "entwurf");
|
|
215
|
+
assert.equal(r.unresolvedAcpIntent, true);
|
|
216
|
+
assert.equal(eArg(r), undefined);
|
|
217
|
+
assert.ok(r.warnings.length > 0, "should warn about the unresolved bridge");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// 13. recorded provider=entwurf + LOCAL → self-root resolves, no fail-fast.
|
|
221
|
+
check("resume recorded entwurf + local resolves via self-root", () => {
|
|
222
|
+
setSource(null);
|
|
223
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", false, "entwurf");
|
|
224
|
+
assert.ok(!r.unresolvedAcpIntent, "local self-root should resolve");
|
|
225
|
+
assert.equal(eArg(r), REPO_ROOT);
|
|
226
|
+
assert.equal(r.provider, "entwurf");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
// 14. opt-in Codex-via-ACP + remote + no source → unresolvedAcpIntent (fail-fast).
|
|
230
|
+
check("resume Codex-via-ACP opt-in + remote + no source → unresolvedAcpIntent", () => {
|
|
231
|
+
setSource(null);
|
|
232
|
+
process.env.ENTWURF_ACP_FOR_CODEX = "1";
|
|
233
|
+
try {
|
|
234
|
+
const r = getEntwurfExplicitExtensions("gpt-5.4", true, undefined);
|
|
235
|
+
assert.equal(r.unresolvedAcpIntent, true);
|
|
236
|
+
} finally {
|
|
237
|
+
delete process.env.ENTWURF_ACP_FOR_CODEX;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// 15. Claude-only heuristic (no recorded ACP signal) stays warning-only — NOT
|
|
242
|
+
// fail-fast — because the legacy pi-claude-code-use bridge may exist.
|
|
243
|
+
check("resume Claude-only heuristic + remote + no source stays warning-only", () => {
|
|
244
|
+
setSource(null);
|
|
245
|
+
const r = getEntwurfExplicitExtensions("claude-sonnet-4-6", true, undefined);
|
|
246
|
+
assert.ok(!r.unresolvedAcpIntent, "Claude heuristic must not fail-fast");
|
|
247
|
+
assert.ok(r.warnings.length > 0, "should warn");
|
|
248
|
+
assert.equal(eArg(r), undefined);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// --- Cleanup + summary -------------------------------------------------------
|
|
252
|
+
fs.rmSync(tmpAgent, { recursive: true, force: true });
|
|
253
|
+
console.log(`[check-package-source-routing] ${passed} assertions ok`);
|