@junghanacs/entwurf 0.23.0 → 0.24.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 +3 -1
- package/BASELINE.md +2 -0
- package/CHANGELOG.md +307 -0
- package/FAQ.md +47 -0
- package/README.md +5 -3
- package/VERIFY.md +43 -2
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +97 -18
- package/docs/setup-clean-host.md +8 -7
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +33 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/context.js +19 -5
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +2 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/callback-env.js +68 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +2 -2
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +54 -22
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +7 -3
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +13 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +19 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-omp.js +35 -42
- package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
- package/mcp/entwurf-bridge/src/index.ts +41 -1
- package/mcp/entwurf-bridge/test.sh +2 -2
- package/package.json +15 -13
- package/pi-extensions/entwurf-control.ts +56 -0
- package/pi-extensions/lib/acp/acp-client.ts +3 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +3 -3
- package/pi-extensions/lib/acp/backend.ts +20 -10
- package/pi-extensions/lib/acp/context.ts +30 -10
- package/pi-extensions/lib/acp/event-mapper.ts +5 -5
- package/pi-extensions/lib/acp/session-store.ts +2 -2
- package/pi-extensions/lib/acp/tool-surface.ts +2 -1
- package/pi-extensions/lib/callback-env.ts +78 -0
- package/pi-extensions/lib/compaction-send-guard.ts +4 -1
- package/pi-extensions/lib/entwurf-v2-contract.ts +2 -2
- package/pi-extensions/lib/entwurf-v2-lock.ts +1 -1
- package/pi-extensions/lib/entwurf-v2-runner.ts +1 -1
- package/pi-extensions/lib/fresh-call-composition.ts +56 -22
- package/pi-extensions/lib/herdr-fresh-call.ts +12 -4
- package/pi-extensions/lib/herdr-placement.ts +13 -4
- package/pi-extensions/lib/mux-fresh-call.ts +23 -8
- package/pi-extensions/meta-bridge-omp.ts +35 -48
- package/run.sh +327 -246
- package/scripts/check-acp-backend-preflight.ts +117 -9
- package/scripts/check-acp-carrier-augment.ts +6 -4
- package/scripts/check-acp-event-mapper.ts +17 -6
- package/scripts/check-acp-prompt-builder.ts +17 -9
- package/scripts/check-acp-prompt-lifecycle.ts +23 -6
- package/scripts/check-acp-session-reuse.ts +64 -51
- package/scripts/check-acp-session-store.ts +31 -8
- package/scripts/check-acp-stop-reason.ts +20 -2
- package/scripts/check-acp-stream-hooks.ts +22 -5
- package/scripts/check-acp-usage-accounting.ts +42 -18
- package/scripts/check-entwurf-bridge-boot.ts +16 -1
- package/scripts/check-fresh-call-dispatch.ts +4 -5
- package/scripts/check-gate-qualification.ts +45 -5
- package/scripts/check-herdr-activation.ts +73 -0
- package/scripts/check-herdr-fresh-call.ts +22 -6
- package/scripts/check-herdr-sandbox.ts +14 -2
- package/scripts/check-install-container.sh +1 -1
- package/scripts/check-pi-launch.ts +361 -0
- package/scripts/check-release-gate-outcomes.ts +7 -0
- package/scripts/inventory-verification-surface.ts +86 -10
- package/scripts/lib/mutation-qualify.ts +1 -1
- package/scripts/mutants/acp-backend-preflight.json +20 -0
- package/scripts/mutants/bridge-boot-resume.json +26 -0
- package/scripts/mutants/herdr-activation.json +16 -0
- package/scripts/mutants/herdr-fresh-call.json +11 -0
- package/scripts/mutants/herdr-plugin-build.json +1 -1
- package/scripts/mutants/mux-fresh-call.json +21 -8
- package/scripts/mutants/omp-fresh.json +5 -9
- package/scripts/mutants/pack-install.json +3 -3
- package/scripts/mutants/pi-launch.json +113 -0
- package/scripts/mutants/resume-args.json +6 -6
- package/scripts/mutants/v2-surface.json +2 -2
- package/scripts/mutants/v2-visible-resume.json +17 -17
- package/scripts/probe-bridge-command.ts +1 -0
- package/scripts/smoke-acp-raw-turn-live.ts +1 -1
- package/scripts/smoke-acp-session-reuse-live.ts +23 -5
- package/scripts/smoke-agy-install-state.sh +2 -2
- package/scripts/smoke-copilot-mcp-state.sh +1 -1
- package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
- package/scripts/smoke-herdr-fresh-call-live.ts +56 -28
- package/scripts/smoke-herdr-raw-install-live.sh +877 -0
- package/scripts/smoke-omp-fresh-live.ts +4 -2
- package/scripts/smoke-omp-mcp-state.sh +1 -1
- package/scripts/smoke-pi-provider-state.sh +2 -2
- package/scripts/tsconfig.json +5 -0
- package/scripts/check-entwurf-facts.ts +0 -391
- package/scripts/check-entwurf-resume-args.ts +0 -122
- package/scripts/check-entwurf-v2-decider.ts +0 -657
- package/scripts/check-entwurf-v2-lock.ts +0 -371
- package/scripts/check-entwurf-v2-mailbox.ts +0 -261
- package/scripts/check-entwurf-v2-matrix.ts +0 -401
- package/scripts/check-entwurf-v2-native-push.ts +0 -221
- package/scripts/check-entwurf-v2-release.ts +0 -151
- package/scripts/check-entwurf-v2-runner.ts +0 -448
- package/scripts/check-entwurf-v2-send-fallback.ts +0 -350
- package/scripts/check-entwurf-v2-send.ts +0 -458
- package/scripts/check-entwurf-v2-visible-resume.ts +0 -445
- package/scripts/check-socket-discovery.ts +0 -401
|
@@ -16,23 +16,42 @@
|
|
|
16
16
|
|
|
17
17
|
import { strict as assert } from "node:assert";
|
|
18
18
|
import { execFileSync } from "node:child_process";
|
|
19
|
-
import { readFileSync, rmSync } from "node:fs";
|
|
20
|
-
import { resolve } from "node:path";
|
|
19
|
+
import { readdirSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import { join, resolve } from "node:path";
|
|
21
21
|
import { pathToFileURL } from "node:url";
|
|
22
|
-
import type { Api, AssistantMessageEvent,
|
|
22
|
+
import type { Api, AssistantMessageEvent, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
23
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
23
24
|
|
|
24
25
|
const model = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
25
26
|
|
|
26
27
|
// Declared tools exclude `read`, but the Claude child always exposes Read →
|
|
27
28
|
// declared != actual → the preflight must reject before spawning.
|
|
28
|
-
|
|
29
|
+
//
|
|
30
|
+
// Built through `normalizeContext` — NOT as a `Context` literal. Since pi 0.86
|
|
31
|
+
// a custom provider is handed a TranscriptContext (model-runtime.ts calls
|
|
32
|
+
// `normalizeContext(context)` before `provider.streamSimple`), so `context.tools`
|
|
33
|
+
// is ALWAYS undefined on the provider path and the declared surface lives in the
|
|
34
|
+
// leading system message's `toolsAdded`. Feeding a 0.85-shaped literal straight
|
|
35
|
+
// into streamShellAcp would bypass the fold and let this gate stay green over a
|
|
36
|
+
// preflight that can no longer see any tools at all.
|
|
37
|
+
const context: TranscriptContext = normalizeContext({
|
|
29
38
|
messages: [{ role: "user", content: "hi", timestamp: 0 }],
|
|
30
39
|
tools: [
|
|
31
40
|
{ name: "bash", description: "", parameters: {} as never },
|
|
32
41
|
{ name: "edit", description: "", parameters: {} as never },
|
|
33
42
|
{ name: "write", description: "", parameters: {} as never },
|
|
34
43
|
],
|
|
35
|
-
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Premise guard: the fixture must really carry its tools as transcript state. If
|
|
47
|
+
// a future pi stops folding, this fails loudly instead of leaving the exclude-
|
|
48
|
+
// tools assertion below testing an empty surface for the wrong reason.
|
|
49
|
+
assert.equal(context.messages[0].role, "system", "normalizeContext folded the tools into a leading system message");
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
(context.messages[0] as { toolsAdded?: { name: string }[] }).toolsAdded?.map((t) => t.name),
|
|
52
|
+
["bash", "edit", "write"],
|
|
53
|
+
"the declared surface rides the system message's toolsAdded",
|
|
54
|
+
);
|
|
36
55
|
|
|
37
56
|
const TMP_EMIT = ".tmp-verify/acp-backend-preflight";
|
|
38
57
|
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
@@ -44,7 +63,7 @@ try {
|
|
|
44
63
|
const mod = (await import(backendUrl)) as {
|
|
45
64
|
streamShellAcp: (
|
|
46
65
|
m: Model<Api>,
|
|
47
|
-
c:
|
|
66
|
+
c: TranscriptContext,
|
|
48
67
|
) => AsyncIterable<AssistantMessageEvent> & {
|
|
49
68
|
result: () => Promise<{ stopReason: string; errorMessage?: string }>;
|
|
50
69
|
};
|
|
@@ -101,8 +120,25 @@ try {
|
|
|
101
120
|
for await (const ev of stream) events.push(ev);
|
|
102
121
|
|
|
103
122
|
const types = events.map((e) => e.type);
|
|
104
|
-
assert.ok(!types.includes("done"), `a tool-surface lie must NOT complete as done (got ${types.join(",")})`);
|
|
105
123
|
const errorEvent = events.find((e): e is Extract<AssistantMessageEvent, { type: "error" }> => e.type === "error");
|
|
124
|
+
|
|
125
|
+
// The whole claim in ONE assertion, placed BEFORE the diagnostic breakdown so
|
|
126
|
+
// a mutant always dies here and carries the signature. The breakdown below
|
|
127
|
+
// still runs on a green tree and says WHICH half broke.
|
|
128
|
+
const preflightFired =
|
|
129
|
+
!types.includes("done") &&
|
|
130
|
+
/cannot honor --exclude-tools \(read\)/.test(String(errorEvent?.error.errorMessage ?? ""));
|
|
131
|
+
assert.ok(
|
|
132
|
+
preflightFired,
|
|
133
|
+
"[QK:ACP-PREFLIGHT-REPLAYS-TRANSCRIPT-TOOLS] the runtime tool-surface preflight must read the active tools by " +
|
|
134
|
+
"REPLAYING the transcript's system messages (`getCurrentTools(context.messages)`), which is where pi 0.86 puts " +
|
|
135
|
+
"the declared surface. Reading a `context.tools` field instead yields undefined on every 0.86 provider call; " +
|
|
136
|
+
"with a fallback to the full builtin set that makes assertExcludeToolsHonored unfireable — the turn reaches a " +
|
|
137
|
+
"spawn and the operator is told a tool is excluded while the backend can still run it. " +
|
|
138
|
+
`Got events [${types.join(",")}], error=${JSON.stringify(errorEvent?.error.errorMessage ?? null)}`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
assert.ok(!types.includes("done"), `a tool-surface lie must NOT complete as done (got ${types.join(",")})`);
|
|
106
142
|
assert.ok(errorEvent, `expected an error event (got ${types.join(",")})`);
|
|
107
143
|
assert.equal(errorEvent.reason, "error", "tool-surface divergence is a hard error, not aborted");
|
|
108
144
|
assert.equal(errorEvent.error.stopReason, "error", "final message stopReason must be error");
|
|
@@ -123,8 +159,80 @@ try {
|
|
|
123
159
|
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
124
160
|
}
|
|
125
161
|
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Provider-path fixture sweep — the cast that got past typecheck twice
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// The behavioral cells above prove ONE gate feeds the 0.86 shape. This proves the
|
|
166
|
+
// others cannot quietly stop doing so. When the pi 0.86 pin landed, the branded
|
|
167
|
+
// `TranscriptContext` named every gate that built a provider-path fixture as a
|
|
168
|
+
// `Context` LITERAL — and named none of the four that wrote `as Context`, because a
|
|
169
|
+
// cast is exactly the operation that silences the brand. Those four then failed in
|
|
170
|
+
// the full floor with `'error' !== 'done'`, a message that says nothing about the
|
|
171
|
+
// cause. A fixture cast to `Context` declares no tools, `getCurrentTools` replays
|
|
172
|
+
// none, and every turn seals as an exclude-tools rejection before the prompt is
|
|
173
|
+
// sent — which reads like broken streaming and is actually a stale fixture.
|
|
174
|
+
//
|
|
175
|
+
// So: no provider-path source may cast to `Context` at all. Static by nature —
|
|
176
|
+
// there is no production subject here and nothing to drive, the claim IS the
|
|
177
|
+
// absence of a token in a file set — so this carries no mutant. Its kill-proof is
|
|
178
|
+
// the sweep itself: delete the assertion and nothing else changes, which is true of
|
|
179
|
+
// any absence check and is why it lives beside the capability it protects rather
|
|
180
|
+
// than in a lane of its own.
|
|
181
|
+
{
|
|
182
|
+
// The SMOKES are in scope, not only the deterministic gates. Leaving them out is how this
|
|
183
|
+
// guard missed `smoke-acp-session-reuse-live.ts` in the 0.23.2 release gate: the gates it
|
|
184
|
+
// did sweep were all green, and the one provider-path fixture it could not see failed an
|
|
185
|
+
// hour into a LIVE run instead.
|
|
186
|
+
const swept = [
|
|
187
|
+
...readdirSync(resolve("scripts"))
|
|
188
|
+
.filter((f) => (f.startsWith("check-acp-") || f.startsWith("smoke-")) && f.endsWith(".ts"))
|
|
189
|
+
.map((f) => join("scripts", f)),
|
|
190
|
+
...readdirSync(resolve("pi-extensions", "lib"), { recursive: true, encoding: "utf8" })
|
|
191
|
+
.filter((f) => f.endsWith(".test.ts"))
|
|
192
|
+
.map((f) => join("pi-extensions", "lib", f)),
|
|
193
|
+
];
|
|
194
|
+
// Guard the sweep: a glob that matches nothing passes vacuously and says so to nobody.
|
|
195
|
+
assert.ok(swept.length >= 10, `provider-path sweep matched only ${swept.length} files — the globs stopped resolving`);
|
|
196
|
+
|
|
197
|
+
// BOTH spellings, because the hazard is the TYPE, not one syntax for reaching it. The four
|
|
198
|
+
// gates found in the full floor wrote `as Context`; the live smoke found in the release gate
|
|
199
|
+
// wrote `const turn1: Context = {…}`. A guard that names one spelling only teaches the next
|
|
200
|
+
// author which spelling to use.
|
|
201
|
+
//
|
|
202
|
+
// `Context[...]` and `Context<...>` are NOT the hazard and are excluded by the trailing
|
|
203
|
+
// lookahead: `messages: Context["messages"]` is the legitimate INPUT to `normalizeContext`,
|
|
204
|
+
// which takes a real Context by contract. Flagging it would push authors to re-type pi's own
|
|
205
|
+
// message array by hand, which is a worse fixture than the one this rule exists to prevent.
|
|
206
|
+
const RAW_CONTEXT = /(?:\bas\s+Context|:\s*Context)\b(?![[<])/;
|
|
207
|
+
const offenders: string[] = [];
|
|
208
|
+
for (const file of swept) {
|
|
209
|
+
readFileSync(file, "utf8")
|
|
210
|
+
.split("\n")
|
|
211
|
+
.forEach((line, i) => {
|
|
212
|
+
// Prose about the rule is not a violation of it.
|
|
213
|
+
if (line.trimStart().startsWith("//") || line.trimStart().startsWith("*")) return;
|
|
214
|
+
if (RAW_CONTEXT.test(line)) offenders.push(`${file}:${i + 1}: ${line.trim()}`);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
assert.deepEqual(
|
|
218
|
+
offenders,
|
|
219
|
+
[],
|
|
220
|
+
"[QK:ACP-FIXTURE-NO-CONTEXT-CAST] a provider-path fixture may not be typed as the raw `Context` — neither by " +
|
|
221
|
+
"annotation nor by cast. Since pi 0.86 a custom provider receives a branded TranscriptContext, and the brand is " +
|
|
222
|
+
"the only thing that names a stale fixture at typecheck time; either spelling silences it, so the fixture " +
|
|
223
|
+
"declares no tools, the exclude-tools preflight rejects every turn before the prompt is sent, and the failure " +
|
|
224
|
+
"surfaces as `'error' !== 'done'` with nothing pointing at the cause. Build it with " +
|
|
225
|
+
"`normalizeContext({ tools, messages })` instead. Found:\n" +
|
|
226
|
+
offenders.join("\n"),
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
126
230
|
console.log(
|
|
127
|
-
"[check-acp-backend-preflight] ok — streamShellAcp
|
|
231
|
+
"[check-acp-backend-preflight] ok — streamShellAcp replays the 0.86 transcript's system messages for the active tool " +
|
|
232
|
+
"surface and runs assertExcludeToolsHonored before spawn; a declared-vs-actual " +
|
|
128
233
|
"tool-surface lie fails fast into the stream as an error event (no backend launched, no done); " +
|
|
129
|
-
"actionableAcpBackendHint (A-c) classifies a context-window 400 into an actionable hint without misclassifying
|
|
234
|
+
"actionableAcpBackendHint (A-c) classifies a context-window 400 into an actionable hint without misclassifying " +
|
|
235
|
+
"unrelated failures; and no provider-path fixture in the acp gates, the smokes or the lib tests is typed as the raw " +
|
|
236
|
+
"`Context` (annotation or cast), so the " +
|
|
237
|
+
"TranscriptContext brand still names a stale one at typecheck time",
|
|
130
238
|
);
|
|
@@ -19,7 +19,7 @@ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
20
|
import { join } from "node:path";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
-
import
|
|
22
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
23
23
|
import {
|
|
24
24
|
buildPiContextAugment,
|
|
25
25
|
prependNewPromptAugment,
|
|
@@ -163,8 +163,10 @@ const BRIDGE_MARK = "operating through entwurf";
|
|
|
163
163
|
// ===========================================================================
|
|
164
164
|
// helper: a context whose first user message is `firstUser`
|
|
165
165
|
// ===========================================================================
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
// Built through `normalizeContext` (pi 0.86 provider-path shape): a custom
|
|
167
|
+
// provider receives a TranscriptContext, never a raw Context.
|
|
168
|
+
function ctxWith(firstUser: string) {
|
|
169
|
+
return normalizeContext({
|
|
168
170
|
messages: [
|
|
169
171
|
{ role: "user", content: firstUser, timestamp: 0 },
|
|
170
172
|
{
|
|
@@ -186,7 +188,7 @@ function ctxWith(firstUser: string): Context {
|
|
|
186
188
|
},
|
|
187
189
|
{ role: "user", content: "latest turn", timestamp: 0 },
|
|
188
190
|
],
|
|
189
|
-
};
|
|
191
|
+
});
|
|
190
192
|
}
|
|
191
193
|
|
|
192
194
|
// ===========================================================================
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
// Pure/deterministic — IN pnpm check.
|
|
12
12
|
|
|
13
13
|
import { strict as assert } from "node:assert";
|
|
14
|
-
import type { AssistantMessageEvent
|
|
15
|
-
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
14
|
+
import type { AssistantMessageEvent } from "@earendil-works/pi-ai";
|
|
15
|
+
import { createAssistantMessageEventStream, normalizeContext } from "@earendil-works/pi-ai";
|
|
16
16
|
import { contextToAcpPrompt, contextTranscript } from "../pi-extensions/lib/acp/context.ts";
|
|
17
17
|
import {
|
|
18
18
|
type AcpPiStreamState,
|
|
@@ -183,7 +183,14 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
|
|
|
183
183
|
// 6) context conversion — transcript passthrough, no systemPrompt, single block
|
|
184
184
|
// ---------------------------------------------------------------------------
|
|
185
185
|
{
|
|
186
|
-
|
|
186
|
+
// `normalizeContext` is the 0.86 provider-path shape: the systemPrompt and the
|
|
187
|
+
// declared tools are FOLDED INTO a leading `role:"system"` message rather than
|
|
188
|
+
// staying on their own Context fields. That is exactly why the no-leak
|
|
189
|
+
// assertion below matters now — the secret is INSIDE `messages`, so the only
|
|
190
|
+
// thing keeping it out of the ACP child's transcript is renderMessage's
|
|
191
|
+
// explicit `case "system"` drop. A hand-built Context literal here would have
|
|
192
|
+
// proved nothing: the transcript loop never saw those fields to begin with.
|
|
193
|
+
const context = normalizeContext({
|
|
187
194
|
systemPrompt: "SECRET-SYSTEM-PROMPT-DO-NOT-LEAK",
|
|
188
195
|
tools: [{ name: "x", description: "d", parameters: {} as never }],
|
|
189
196
|
messages: [
|
|
@@ -226,9 +233,13 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
|
|
|
226
233
|
timestamp: 0,
|
|
227
234
|
},
|
|
228
235
|
],
|
|
229
|
-
};
|
|
236
|
+
});
|
|
237
|
+
// The fixture really does carry the prompt as a transcript message — assert the
|
|
238
|
+
// premise so a future pi that stops folding cannot turn the no-leak assertion
|
|
239
|
+
// into a vacuous one.
|
|
240
|
+
assert.equal(context.messages[0].role, "system", "normalizeContext put the systemPrompt in a leading system message");
|
|
230
241
|
const transcript = contextTranscript(context);
|
|
231
|
-
assert.ok(!transcript.includes("SECRET-SYSTEM-PROMPT"), "transcript must NOT leak
|
|
242
|
+
assert.ok(!transcript.includes("SECRET-SYSTEM-PROMPT"), "transcript must NOT leak the folded system prompt");
|
|
232
243
|
assert.ok(!transcript.includes("private reasoning"), "transcript must NOT include assistant thinking");
|
|
233
244
|
assert.match(transcript, /User: first question/, "includes prior user turn");
|
|
234
245
|
assert.match(transcript, /Assistant: an answer/, "includes assistant text");
|
|
@@ -243,7 +254,7 @@ const deltas = (events: AssistantMessageEvent[]): string[] =>
|
|
|
243
254
|
assert.equal(prompt[0].type, "text", "prompt block is text");
|
|
244
255
|
assert.equal(prompt[0].text, transcript, "prompt text equals the transcript");
|
|
245
256
|
|
|
246
|
-
assert.deepEqual(contextToAcpPrompt({ messages: [] }), [], "empty history → empty prompt array");
|
|
257
|
+
assert.deepEqual(contextToAcpPrompt(normalizeContext({ messages: [] })), [], "empty history → empty prompt array");
|
|
247
258
|
}
|
|
248
259
|
|
|
249
260
|
console.log(
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// pure scope lock over it. Pure/deterministic — IN pnpm check.
|
|
17
17
|
|
|
18
18
|
import { strict as assert } from "node:assert";
|
|
19
|
-
import type {
|
|
19
|
+
import type { TranscriptContext } from "@earendil-works/pi-ai";
|
|
20
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
20
21
|
import {
|
|
21
22
|
type AcpBootstrapPath,
|
|
22
23
|
buildAcpPrompt,
|
|
@@ -28,8 +29,15 @@ const REUSE_PATHS: AcpBootstrapPath[] = ["reuse", "resume", "load"];
|
|
|
28
29
|
|
|
29
30
|
// A multi-turn context: a prior turn, an assistant answer, the real latest user
|
|
30
31
|
// turn (with an image), and a SessionStart hook user-message appended AFTER it.
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
//
|
|
33
|
+
// Built through `normalizeContext` (pi-ai) because that is what pi 0.86 hands a
|
|
34
|
+
// custom provider: `systemPrompt`/`tools` are folded into a LEADING
|
|
35
|
+
// `role:"system"` message, never their own fields. A hand-built `Context`
|
|
36
|
+
// literal here would test a shape production no longer receives — the
|
|
37
|
+
// systemPrompt-leak assertions below only mean something when the prompt is
|
|
38
|
+
// actually inside `messages`.
|
|
39
|
+
function multiTurn(): TranscriptContext {
|
|
40
|
+
return normalizeContext({
|
|
33
41
|
systemPrompt: "SECRET-SYSTEM-PROMPT",
|
|
34
42
|
tools: [{ name: "x", description: "d", parameters: {} as never }],
|
|
35
43
|
messages: [
|
|
@@ -63,7 +71,7 @@ function multiTurn(): Context {
|
|
|
63
71
|
// must NOT pick this as the latest user delta.
|
|
64
72
|
{ role: "user", content: "device=thinkpad time_kst=20260618T160000", timestamp: 0 },
|
|
65
73
|
],
|
|
66
|
-
};
|
|
74
|
+
});
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
// ---------------------------------------------------------------------------
|
|
@@ -120,12 +128,12 @@ for (const path of REUSE_PATHS) {
|
|
|
120
128
|
// 4) no assistant yet → first user of the whole context is the delta
|
|
121
129
|
// ---------------------------------------------------------------------------
|
|
122
130
|
{
|
|
123
|
-
const ctx
|
|
131
|
+
const ctx = normalizeContext({
|
|
124
132
|
messages: [
|
|
125
133
|
{ role: "user", content: "only question", timestamp: 0 },
|
|
126
134
|
{ role: "user", content: "device=thinkpad time_kst=x", timestamp: 0 },
|
|
127
135
|
],
|
|
128
|
-
};
|
|
136
|
+
});
|
|
129
137
|
const delta = latestUserDelta(ctx);
|
|
130
138
|
assert.equal(delta.length, 1, "delta exists when there is a user turn");
|
|
131
139
|
assert.equal(delta[0].text, "only question", "first user wins (hook message skipped) even with no assistant");
|
|
@@ -135,7 +143,7 @@ for (const path of REUSE_PATHS) {
|
|
|
135
143
|
// 5) trailing message is assistant (no user after it) → empty delta
|
|
136
144
|
// ---------------------------------------------------------------------------
|
|
137
145
|
{
|
|
138
|
-
const ctx
|
|
146
|
+
const ctx = normalizeContext({
|
|
139
147
|
messages: [
|
|
140
148
|
{ role: "user", content: "q", timestamp: 0 },
|
|
141
149
|
{
|
|
@@ -156,7 +164,7 @@ for (const path of REUSE_PATHS) {
|
|
|
156
164
|
timestamp: 0,
|
|
157
165
|
},
|
|
158
166
|
],
|
|
159
|
-
};
|
|
167
|
+
});
|
|
160
168
|
assert.deepEqual(latestUserDelta(ctx), [], "no user after the last assistant → empty delta");
|
|
161
169
|
}
|
|
162
170
|
|
|
@@ -164,7 +172,7 @@ for (const path of REUSE_PATHS) {
|
|
|
164
172
|
// 6) empty context → empty for every path
|
|
165
173
|
// ---------------------------------------------------------------------------
|
|
166
174
|
{
|
|
167
|
-
const empty
|
|
175
|
+
const empty = normalizeContext({ messages: [] });
|
|
168
176
|
assert.deepEqual(buildAcpPrompt(empty, "new"), [], "empty context → empty new prompt");
|
|
169
177
|
for (const path of REUSE_PATHS) {
|
|
170
178
|
assert.deepEqual(buildAcpPrompt(empty, path), [], `empty context → empty ${path} prompt`);
|
|
@@ -36,7 +36,8 @@ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs
|
|
|
36
36
|
import { tmpdir } from "node:os";
|
|
37
37
|
import { resolve } from "node:path";
|
|
38
38
|
import { pathToFileURL } from "node:url";
|
|
39
|
-
import type { Api, AssistantMessageEvent,
|
|
39
|
+
import type { Api, AssistantMessageEvent, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
40
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
40
41
|
import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
|
|
41
42
|
|
|
42
43
|
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
@@ -215,7 +216,7 @@ function makeHarness(recordDir: string) {
|
|
|
215
216
|
/** Start a turn and collect its events in the background. */
|
|
216
217
|
function startTurn(
|
|
217
218
|
backend: any,
|
|
218
|
-
context:
|
|
219
|
+
context: TranscriptContext,
|
|
219
220
|
options: Record<string, unknown>,
|
|
220
221
|
deps: unknown,
|
|
221
222
|
): { events: AssistantMessageEvent[]; done: Promise<void> } {
|
|
@@ -230,11 +231,27 @@ function startTurn(
|
|
|
230
231
|
const sealed = (events: AssistantMessageEvent[]) =>
|
|
231
232
|
events.filter((e) => e.type === "done" || e.type === "error") as any[];
|
|
232
233
|
|
|
233
|
-
|
|
234
|
+
// The pi built-ins the Claude child also exposes natively. pi declares its tool surface on
|
|
235
|
+
// every turn, and since 0.86 that declaration reaches a provider as the leading system
|
|
236
|
+
// message's `toolsAdded` (`normalizeContext`), which backend.ts replays for the exclude-tools
|
|
237
|
+
// truthfulness preflight. A fixture that declares NO tools is therefore not "a turn with the
|
|
238
|
+
// defaults" — it is a turn where the operator excluded everything, and the preflight rejects it
|
|
239
|
+
// before the prompt is ever sent. So every provider-path context here is built through
|
|
240
|
+
// `normalizeContext`, never cast `as Context`: the cast is exactly what hid this shape from
|
|
241
|
+
// typecheck when the 0.86 pin landed.
|
|
242
|
+
const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
|
|
243
|
+
name,
|
|
244
|
+
description: "",
|
|
245
|
+
parameters: {} as never,
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
const userCtx = (text: string): TranscriptContext =>
|
|
249
|
+
normalizeContext({ tools: PI_DECLARED_TOOLS, messages: [{ role: "user", content: text, timestamp: 0 }] });
|
|
234
250
|
|
|
235
251
|
/** A reuse-shaped context: prior user, assistant, new user. */
|
|
236
|
-
function reuseCtx(prior: string, latest: string):
|
|
237
|
-
return {
|
|
252
|
+
function reuseCtx(prior: string, latest: string): TranscriptContext {
|
|
253
|
+
return normalizeContext({
|
|
254
|
+
tools: PI_DECLARED_TOOLS,
|
|
238
255
|
messages: [
|
|
239
256
|
{ role: "user", content: prior, timestamp: 0 },
|
|
240
257
|
{
|
|
@@ -256,7 +273,7 @@ function reuseCtx(prior: string, latest: string): Context {
|
|
|
256
273
|
} as unknown as Message,
|
|
257
274
|
{ role: "user", content: latest, timestamp: 0 },
|
|
258
275
|
],
|
|
259
|
-
}
|
|
276
|
+
});
|
|
260
277
|
}
|
|
261
278
|
|
|
262
279
|
const TMP_EMIT = ".tmp-verify/acp-prompt-lifecycle";
|
|
@@ -30,7 +30,26 @@ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmdirSync, rmSync,
|
|
|
30
30
|
import { tmpdir } from "node:os";
|
|
31
31
|
import { resolve } from "node:path";
|
|
32
32
|
import { pathToFileURL } from "node:url";
|
|
33
|
-
import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
|
|
33
|
+
import type { Api, AssistantMessageEvent, Context, Message, Model, TranscriptContext } from "@earendil-works/pi-ai";
|
|
34
|
+
import { normalizeContext } from "@earendil-works/pi-ai";
|
|
35
|
+
|
|
36
|
+
// The pi built-ins the Claude child also exposes natively. pi declares its tool
|
|
37
|
+
// surface on every turn; since 0.86 that declaration reaches a provider as the
|
|
38
|
+
// leading system message's `toolsAdded` (normalizeContext), and backend.ts
|
|
39
|
+
// replays it for the exclude-tools truthfulness preflight. A fixture that
|
|
40
|
+
// declares NO tools is therefore not "a turn with the defaults" — it is a turn
|
|
41
|
+
// where the operator excluded everything, and the preflight rejects it before
|
|
42
|
+
// any spawn. So every provider-path context in this gate is built through `tctx`.
|
|
43
|
+
const PI_DECLARED_TOOLS = ["read", "bash", "edit", "write"].map((name) => ({
|
|
44
|
+
name,
|
|
45
|
+
description: "",
|
|
46
|
+
parameters: {} as never,
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
/** A provider-path context in the 0.86 shape: tools folded into a leading system message. */
|
|
50
|
+
function tctx(messages: Context["messages"]): TranscriptContext {
|
|
51
|
+
return normalizeContext({ tools: PI_DECLARED_TOOLS, messages });
|
|
52
|
+
}
|
|
34
53
|
|
|
35
54
|
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
36
55
|
const opus = { id: "claude-opus-5" } as unknown as Model<Api>;
|
|
@@ -222,30 +241,28 @@ function mkAssistant(content: Array<{ type: "text"; text: string; textSignature?
|
|
|
222
241
|
}
|
|
223
242
|
|
|
224
243
|
// A multi-turn (reuse-shaped) context: a prior user, an assistant, a new user.
|
|
225
|
-
function reuseCtx(prior: string, latest: string):
|
|
226
|
-
return
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
242
|
-
},
|
|
243
|
-
stopReason: "stop",
|
|
244
|
-
timestamp: 0,
|
|
244
|
+
function reuseCtx(prior: string, latest: string): TranscriptContext {
|
|
245
|
+
return tctx([
|
|
246
|
+
{ role: "user", content: prior, timestamp: 0 },
|
|
247
|
+
{
|
|
248
|
+
role: "assistant",
|
|
249
|
+
content: [{ type: "text", text: "ok" }],
|
|
250
|
+
api: "x",
|
|
251
|
+
provider: "x",
|
|
252
|
+
model: "x",
|
|
253
|
+
usage: {
|
|
254
|
+
input: 0,
|
|
255
|
+
output: 0,
|
|
256
|
+
cacheRead: 0,
|
|
257
|
+
cacheWrite: 0,
|
|
258
|
+
totalTokens: 0,
|
|
259
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
245
260
|
},
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
261
|
+
stopReason: "stop",
|
|
262
|
+
timestamp: 0,
|
|
263
|
+
},
|
|
264
|
+
{ role: "user", content: latest, timestamp: 0 },
|
|
265
|
+
]);
|
|
249
266
|
}
|
|
250
267
|
|
|
251
268
|
const TMP_EMIT = ".tmp-verify/acp-session-reuse";
|
|
@@ -282,7 +299,7 @@ try {
|
|
|
282
299
|
|
|
283
300
|
// Pre-write a COMPATIBLE persisted record at a DIFFERENT acp id — if the
|
|
284
301
|
// backend (wrongly) resumed it, turn 1 would prompt "OLD-RESUME-ID".
|
|
285
|
-
const turn1Ctx
|
|
302
|
+
const turn1Ctx = tctx([{ role: "user", content: "remember NONCE-AAA", timestamp: 0 }]);
|
|
286
303
|
store.writeSessionRecord(
|
|
287
304
|
store.buildSessionRecord(
|
|
288
305
|
{
|
|
@@ -350,7 +367,7 @@ try {
|
|
|
350
367
|
await collect(
|
|
351
368
|
backend.streamAcpTurn(
|
|
352
369
|
sonnet,
|
|
353
|
-
|
|
370
|
+
tctx([{ role: "user", content: "first", timestamp: 0 }]),
|
|
354
371
|
{ sessionId: "gate-B" },
|
|
355
372
|
h.deps,
|
|
356
373
|
) as Stream,
|
|
@@ -384,7 +401,7 @@ try {
|
|
|
384
401
|
const h = makeHarness(recordDir);
|
|
385
402
|
let release!: () => void;
|
|
386
403
|
h.setBlock(new Promise<void>((r) => (release = r)));
|
|
387
|
-
const ctx
|
|
404
|
+
const ctx = tctx([{ role: "user", content: "only", timestamp: 0 }]);
|
|
388
405
|
// turn A: first turn, blocks in prompt (still in flight, not yet retained).
|
|
389
406
|
const aDone = collect(backend.streamAcpTurn(sonnet, ctx, { sessionId: "gate-D" }, h.deps) as Stream);
|
|
390
407
|
await new Promise((r) => setTimeout(r, 20)); // let turn A claim the key + spawn
|
|
@@ -410,7 +427,7 @@ try {
|
|
|
410
427
|
await collect(
|
|
411
428
|
backend.streamAcpTurn(
|
|
412
429
|
sonnet,
|
|
413
|
-
|
|
430
|
+
tctx([{ role: "user", content: "x", timestamp: 0 }]),
|
|
414
431
|
{ sessionId: "gate-E1", cwd: "/w1" },
|
|
415
432
|
h.deps,
|
|
416
433
|
) as Stream,
|
|
@@ -421,7 +438,7 @@ try {
|
|
|
421
438
|
await collect(
|
|
422
439
|
backend.streamAcpTurn(
|
|
423
440
|
sonnet,
|
|
424
|
-
|
|
441
|
+
tctx([{ role: "user", content: "y", timestamp: 0 }]),
|
|
425
442
|
{ sessionId: "gate-E1", cwd: "/w2" },
|
|
426
443
|
h.deps,
|
|
427
444
|
) as Stream,
|
|
@@ -434,7 +451,7 @@ try {
|
|
|
434
451
|
await collect(
|
|
435
452
|
backend.streamAcpTurn(
|
|
436
453
|
sonnet,
|
|
437
|
-
|
|
454
|
+
tctx([{ role: "user", content: "x", timestamp: 0 }]),
|
|
438
455
|
{ sessionId: "gate-E2" },
|
|
439
456
|
h2.deps,
|
|
440
457
|
) as Stream,
|
|
@@ -481,7 +498,7 @@ try {
|
|
|
481
498
|
const t1 = await collect(
|
|
482
499
|
backend.streamAcpTurn(
|
|
483
500
|
sonnet,
|
|
484
|
-
|
|
501
|
+
tctx([{ role: "user", content: "hi NONCE-F", timestamp: 0 }]),
|
|
485
502
|
{ sessionId: "gate-F" },
|
|
486
503
|
h.deps,
|
|
487
504
|
) as Stream,
|
|
@@ -513,15 +530,13 @@ try {
|
|
|
513
530
|
|
|
514
531
|
// F4: a `new` full-transcript rebuild DROPS a lifecycle-marked assistant block
|
|
515
532
|
// (context.ts filter) while keeping the real transcript text.
|
|
516
|
-
const ctxWithNotice
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
],
|
|
524
|
-
};
|
|
533
|
+
const ctxWithNotice = tctx([
|
|
534
|
+
{ role: "user", content: "real user line", timestamp: 0 },
|
|
535
|
+
mkAssistant([
|
|
536
|
+
{ type: "text", text: "\n[acp: session ready model=claude-sonnet-5]\n", textSignature: MARKER },
|
|
537
|
+
{ type: "text", text: "real assistant line" },
|
|
538
|
+
]),
|
|
539
|
+
]);
|
|
525
540
|
const built = ctxMod
|
|
526
541
|
.buildAcpPrompt(ctxWithNotice, "new")
|
|
527
542
|
.map((b: { text: string }) => b.text)
|
|
@@ -534,12 +549,10 @@ try {
|
|
|
534
549
|
|
|
535
550
|
// F5: a lifecycle-marked block does NOT change the reuse-compat signature —
|
|
536
551
|
// the per-message signature is identical with and without the notice.
|
|
537
|
-
const ctxNoNotice
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
],
|
|
542
|
-
};
|
|
552
|
+
const ctxNoNotice = tctx([
|
|
553
|
+
{ role: "user", content: "real user line", timestamp: 0 },
|
|
554
|
+
mkAssistant([{ type: "text", text: "real assistant line" }]),
|
|
555
|
+
]);
|
|
543
556
|
assert.deepEqual(
|
|
544
557
|
store.contextMessageSignatures(ctxWithNotice),
|
|
545
558
|
store.contextMessageSignatures(ctxNoNotice),
|
|
@@ -639,7 +652,7 @@ try {
|
|
|
639
652
|
await collect(
|
|
640
653
|
backend.streamAcpTurn(
|
|
641
654
|
sonnet,
|
|
642
|
-
|
|
655
|
+
tctx([{ role: "user", content: "hi NONCE-G", timestamp: 0 }]),
|
|
643
656
|
{ sessionId: "gate-G" },
|
|
644
657
|
h.deps,
|
|
645
658
|
) as Stream,
|
|
@@ -704,7 +717,7 @@ try {
|
|
|
704
717
|
await collect(
|
|
705
718
|
backend.streamAcpTurn(
|
|
706
719
|
sonnet,
|
|
707
|
-
|
|
720
|
+
tctx([{ role: "user", content: "hi NONCE-H", timestamp: 0 }]),
|
|
708
721
|
{ sessionId: "gate-H-off" },
|
|
709
722
|
hOff.deps,
|
|
710
723
|
) as Stream,
|
|
@@ -720,7 +733,7 @@ try {
|
|
|
720
733
|
await collect(
|
|
721
734
|
backend.streamAcpTurn(
|
|
722
735
|
sonnet,
|
|
723
|
-
|
|
736
|
+
tctx([{ role: "user", content: "hi NONCE-H2", timestamp: 0 }]),
|
|
724
737
|
{ sessionId: "gate-H-on" },
|
|
725
738
|
hOn.deps,
|
|
726
739
|
) as Stream,
|
|
@@ -761,7 +774,7 @@ try {
|
|
|
761
774
|
await collect(
|
|
762
775
|
backend.streamAcpTurn(
|
|
763
776
|
sonnet,
|
|
764
|
-
|
|
777
|
+
tctx([{ role: "user", content: "hi NONCE-I1", timestamp: 0 }]),
|
|
765
778
|
{ sessionId: "gate-I-one" },
|
|
766
779
|
h.deps,
|
|
767
780
|
) as Stream,
|
|
@@ -769,7 +782,7 @@ try {
|
|
|
769
782
|
await collect(
|
|
770
783
|
backend.streamAcpTurn(
|
|
771
784
|
sonnet,
|
|
772
|
-
|
|
785
|
+
tctx([{ role: "user", content: "hi NONCE-I2", timestamp: 0 }]),
|
|
773
786
|
{ sessionId: "gate-I-two" },
|
|
774
787
|
h.deps,
|
|
775
788
|
) as Stream,
|