@junghanacs/entwurf 0.13.0 → 0.13.1
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 +5 -3
- package/BASELINE.md +57 -203
- package/CHANGELOG.md +19 -0
- package/CONTRIBUTING.md +1 -1
- package/DELIVERY.md +117 -261
- package/README.md +68 -408
- package/VERIFY.md +58 -87
- package/demo/README.md +1 -1
- package/docs/acp-backend-rail.md +231 -0
- package/docs/external-mcp-host.md +132 -0
- package/docs/fresh-cut-policy.md +99 -0
- package/docs/setup-clean-host.md +123 -328
- package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +21 -48
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
- package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +3 -3
- package/mcp/entwurf-bridge/src/index.ts +21 -48
- package/package.json +12 -9
- package/pi-extensions/entwurf-control.ts +20 -49
- package/pi-extensions/lib/acp/acp-client.ts +22 -0
- package/pi-extensions/lib/acp/augment.ts +42 -3
- package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
- package/pi-extensions/lib/acp/backend.ts +305 -36
- package/pi-extensions/lib/acp/config.ts +1 -1
- package/pi-extensions/lib/acp/engraving.ts +43 -1
- package/pi-extensions/lib/acp/event-mapper.ts +16 -13
- package/pi-extensions/lib/acp/models.ts +4 -4
- package/pi-extensions/lib/acp/overlay.ts +3 -3
- package/prompts/engraving.md +13 -5
- package/run.sh +244 -108
- package/scripts/check-acp-carrier-augment.ts +246 -10
- package/scripts/check-acp-cortex.ts +5 -5
- package/scripts/check-acp-prompt-lifecycle.ts +565 -0
- package/scripts/check-acp-sdk-surface.ts +60 -21
- package/scripts/check-acp-stop-reason.ts +342 -0
- package/scripts/check-entwurf-v2-surface.ts +41 -0
- package/scripts/check-gate-qualification.ts +6 -3
- package/scripts/check-probe-ordering.ts +39 -7
- package/scripts/check-release-gate-outcomes.ts +332 -0
- package/scripts/lib/live-skip.ts +33 -0
- package/scripts/lib/probe-acp-turn.ts +33 -12
- package/scripts/lib/step-outcome.sh +88 -0
- package/scripts/mutants/acp-augment.json +106 -0
- package/scripts/mutants/acp-cortex.json +2 -2
- package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
- package/scripts/mutants/acp-stop-reason.json +80 -0
- package/scripts/mutants/probe-ordering.json +27 -5
- package/scripts/mutants/release-gate.json +105 -0
- package/scripts/mutants/v2-surface.json +26 -15
- package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
- package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
- package/scripts/smoke-acp-cortex-live.ts +8 -14
- package/scripts/smoke-acp-long-turn-live.ts +185 -0
- package/scripts/smoke-acp-mcp-live.ts +2 -2
- package/scripts/smoke-acp-memory-containment-live.ts +2 -2
- package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
- package/scripts/smoke-acp-overlay-live.ts +2 -2
- package/scripts/smoke-acp-provider-live.ts +2 -2
- package/scripts/smoke-acp-raw-turn-live.ts +3 -3
- package/scripts/smoke-acp-session-reuse-live.ts +2 -2
- package/scripts/smoke-acp-skill-live.ts +2 -2
- package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
- package/scripts/smoke-acp-v2-send-live.ts +4 -3
- package/scripts/smoke-agy-native-push-live.ts +4 -3
- package/scripts/smoke-claude-native-resume-live.sh +13 -3
- package/scripts/smoke-entwurf-chain-live.ts +352 -0
- package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
- package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
- package/scripts/smoke-meta-install-state.sh +4 -0
|
@@ -4,23 +4,62 @@
|
|
|
4
4
|
// the peer-resolution that makes the Claude ACP adapter satisfiable:
|
|
5
5
|
//
|
|
6
6
|
// @agentclientprotocol/sdk 1.3.0 wire SDK (acp-bridge import source)
|
|
7
|
-
// @agentclientprotocol/claude-agent-acp 0.
|
|
7
|
+
// @agentclientprotocol/claude-agent-acp 0.64.0 Claude adapter (spawn binary)
|
|
8
8
|
// @anthropic-ai/sdk 0.100.1 peer-resolution pin ONLY (see below)
|
|
9
9
|
//
|
|
10
|
-
// 2026-07-
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
10
|
+
// 2026-07-31 bump 0.63.0 → 0.64.0 — a ONE-FEATURE adapter release. Measured
|
|
11
|
+
// against the upstream checkout (release commit 9cc5a09): the entire functional
|
|
12
|
+
// delta is `src/elicitation.ts` +14 lines (commit d7a65ce), which stamps the
|
|
13
|
+
// AskUserQuestion form's "Other" free-text field with a shared
|
|
14
|
+
// `_meta._askUserQuestionCustomAnswer` marker so ACP clients can recognize a
|
|
15
|
+
// custom-answer companion across Codex/Claude bridges. Published package:
|
|
16
|
+
// unpackedSize 529,638 → 530,740 B, fileCount 24 → 24. Runtime deps are
|
|
17
|
+
// IDENTICAL (`@agentclientprotocol/sdk` 1.3.0, `@anthropic-ai/claude-agent-sdk`
|
|
18
|
+
// 0.3.220) — the upstream lock's `@modelcontextprotocol/sdk` 1.29.0 → 1.30.0 is
|
|
19
|
+
// THEIR dev tree; our resolution stays 1.29.0 because our runtime dep set did
|
|
20
|
+
// not move. Re-measured: the anthropic peer still resolves 0.100.1.
|
|
21
|
+
//
|
|
22
|
+
// Reachability is STRONGER than "off by default", and it is measured, not
|
|
23
|
+
// assumed. `backend.ts` sends `clientCapabilities: {}` — a hardcoded literal with
|
|
24
|
+
// no operator-config seam — so upstream computes `elicitationSupport.form =
|
|
25
|
+
// false`. Two independent upstream gates then close the new marker off:
|
|
26
|
+
// `acp-agent.ts:5358` disallows `AskUserQuestion` outright, and `:5449` composes
|
|
27
|
+
// the final list by CONCATENATION (`[...userProvidedOptions.disallowedTools,
|
|
28
|
+
// ...disallowedTools]`), so no operator `disallowedTools` can remove it; and the
|
|
29
|
+
// form-elicitation emission branch at `:4558` is gated on the same flag. The only
|
|
30
|
+
// lever is `clientCapabilities`, which entwurf does not expose to configuration —
|
|
31
|
+
// so this marker cannot reach our wire without an entwurf SOURCE change. That is
|
|
32
|
+
// the honest claim for THIS delta; do not generalize it to other axes.
|
|
33
|
+
//
|
|
34
|
+
// HISTORY — 2026-07-30 bump 0.62.0 → 0.63.0 — an ADAPTER-CODE release, not a dependency
|
|
35
|
+
// refresh. Measured on the packed tarballs: 137,294 → 142,084 bytes, with
|
|
36
|
+
// `dist/acp-agent.js`, `dist/tools.js` and the `dist/acp-agent.d.ts` surface all
|
|
37
|
+
// moved. Three upstream fixes ride it (#923 denied-tool resolution, #916
|
|
38
|
+
// tool_progress heartbeat keying, #917 Bash terminal metas keyed off the
|
|
39
|
+
// announced tool_use id) plus claude-agent-sdk 0.3.219 → 0.3.220.
|
|
40
|
+
//
|
|
41
|
+
// Reachability onto OUR surface is narrower than that list, and is measured, not
|
|
42
|
+
// assumed: `backend.ts` sends `clientCapabilities: {}`, and the adapter gates
|
|
43
|
+
// terminal metas on `clientCapabilities?._meta?.["terminal_output"] === true`
|
|
44
|
+
// and subagent transcript on the matching capability — so both stay off for
|
|
45
|
+
// every backend we ship. What CAN still reach the wire is opt-in-free metadata
|
|
46
|
+
// (`_meta.claudeCode.title` / `.subagent`) and #916's re-keyed heartbeats; the
|
|
47
|
+
// mapper ignores those, so no visible state changes. Do not write this up as
|
|
48
|
+
// "0.63.0 does not reach us" — write what each axis was measured to do.
|
|
49
|
+
//
|
|
50
|
+
// HISTORY — 2026-07-27 bump 0.61.0 → 0.62.0: that one WAS a pure dependency
|
|
51
|
+
// refresh; the adapter's `dist/` was byte-identical across the two releases and
|
|
52
|
+
// only its declared deps moved (claude-agent-sdk 0.3.217 → 0.3.219). It was
|
|
53
|
+
// specifically NOT a fix for the bundled-MCP readiness race (ROADMAP 🔴), and
|
|
54
|
+
// 0.63.0 adds no explicit readiness fence either. Effective runtime behavior is
|
|
55
|
+
// NOT claimed identical across either bump: the transitive SDK moved both times,
|
|
56
|
+
// and MCP startup lives inside it, so timing may differ — what is established is
|
|
57
|
+
// only that no new fence was added.
|
|
19
58
|
//
|
|
20
59
|
// The anthropic SDK is NOT an API client / auth surface here. It is a direct
|
|
21
|
-
// dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.
|
|
22
|
-
// (>=0.93.0 — re-measured at 0.3.
|
|
23
|
-
// pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
|
|
60
|
+
// dep solely to satisfy @anthropic-ai/claude-agent-sdk@0.3.220's peer floor
|
|
61
|
+
// (>=0.93.0 — re-measured at 0.3.220, unchanged from 0.3.219 and 0.3.217, so the
|
|
62
|
+
// 0.100.1 pin stands rather than rising mechanically); drop it and the tree resolves a stale 0.91.1 so the peer goes
|
|
24
63
|
// unmet — a failure that would only surface at the first raw turn. The
|
|
25
64
|
// lockfile proves the same shape. Source-level import / API-client
|
|
26
65
|
// instantiation / credential use stays forbidden — asserted in layer (4).
|
|
@@ -54,7 +93,7 @@ const pkg = JSON.parse(read("package.json")) as { dependencies?: Record<string,
|
|
|
54
93
|
const deps = pkg.dependencies ?? {};
|
|
55
94
|
const PINS: Record<string, string> = {
|
|
56
95
|
"@agentclientprotocol/sdk": "1.3.0",
|
|
57
|
-
"@agentclientprotocol/claude-agent-acp": "0.
|
|
96
|
+
"@agentclientprotocol/claude-agent-acp": "0.64.0",
|
|
58
97
|
[ANTHROPIC_SDK]: "0.100.1",
|
|
59
98
|
};
|
|
60
99
|
for (const [name, ver] of Object.entries(PINS)) {
|
|
@@ -74,13 +113,13 @@ for (const [name, ver] of Object.entries(PINS)) {
|
|
|
74
113
|
const lock = read("pnpm-lock.yaml");
|
|
75
114
|
assert.match(
|
|
76
115
|
lock,
|
|
77
|
-
/@agentclientprotocol\/claude-agent-acp@0\.
|
|
78
|
-
"pnpm-lock: claude-agent-acp@0.
|
|
116
|
+
/@agentclientprotocol\/claude-agent-acp@0\.64\.0\(@anthropic-ai\/sdk@0\.100\.1/,
|
|
117
|
+
"pnpm-lock: claude-agent-acp@0.64.0 must peer-resolve @anthropic-ai/sdk@0.100.1 (peer-pin), not the stale 0.91.1",
|
|
79
118
|
);
|
|
80
119
|
assert.match(
|
|
81
120
|
lock,
|
|
82
|
-
/@anthropic-ai\/claude-agent-sdk@0\.3\.
|
|
83
|
-
"pnpm-lock: claude-agent-sdk@0.3.
|
|
121
|
+
/@anthropic-ai\/claude-agent-sdk@0\.3\.220\(@anthropic-ai\/sdk@0\.100\.1/,
|
|
122
|
+
"pnpm-lock: claude-agent-sdk@0.3.220 must peer-resolve @anthropic-ai/sdk@0.100.1 — else its >=0.93.0 peer floor is unmet",
|
|
84
123
|
);
|
|
85
124
|
|
|
86
125
|
// ---------------------------------------------------------------------------
|
|
@@ -119,8 +158,8 @@ const casEntry = adapterRequire.resolve("@anthropic-ai/claude-agent-sdk");
|
|
|
119
158
|
const casInfo = pkgInfoFromEntry(casEntry);
|
|
120
159
|
assert.equal(
|
|
121
160
|
casInfo.version,
|
|
122
|
-
"0.3.
|
|
123
|
-
`@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.
|
|
161
|
+
"0.3.220",
|
|
162
|
+
`@anthropic-ai/claude-agent-sdk must runtime-resolve to 0.3.220 from the adapter context (got ${casInfo.version})`,
|
|
124
163
|
);
|
|
125
164
|
const casRequire = createRequire(resolve(casInfo.dir, "package.json"));
|
|
126
165
|
const sdkEntry = casRequire.resolve(ANTHROPIC_SDK);
|
|
@@ -163,7 +202,7 @@ const mcpEntry = casRequire.resolve("@modelcontextprotocol/sdk/server/index.js")
|
|
|
163
202
|
const mcpInfo = pkgInfoFromEntry(mcpEntry);
|
|
164
203
|
assert.ok(
|
|
165
204
|
mcpInfo.version.startsWith("1.29."),
|
|
166
|
-
`@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.
|
|
205
|
+
`@modelcontextprotocol/sdk must runtime-resolve to 1.29.x from the claude-agent-sdk context (got ${mcpInfo.version}) — claude-agent-sdk 0.3.220 declares a ^1.29.0 peer`,
|
|
167
206
|
);
|
|
168
207
|
|
|
169
208
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
// Deterministic gate for the ACP stop-reason contract (backend.ts + event-mapper.ts).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THIS EXISTS TO STOP. `mapPromptStopReason` used to be a bare
|
|
4
|
+
// `switch` returning a StopReason with `default: return "stop"`. The ACP terminal
|
|
5
|
+
// set is closed — `@agentclientprotocol/sdk` 1.3.0 `schema/types.gen` declares
|
|
6
|
+
// `end_turn | max_tokens | max_turn_requests | refusal | cancelled` — so that
|
|
7
|
+
// default silently collapsed THREE distinct non-success outcomes into a clean
|
|
8
|
+
// successful turn:
|
|
9
|
+
//
|
|
10
|
+
// refusal the model declined; the answer is absent
|
|
11
|
+
// max_turn_requests the backend's per-turn budget ran out; the answer is cut
|
|
12
|
+
// <anything new> a reason this build has never seen
|
|
13
|
+
//
|
|
14
|
+
// plus a fourth: a prompt result carrying NO stopReason at all. pi rendered all
|
|
15
|
+
// four as a finished assistant message. pi 0.83 closed the identical hole in its
|
|
16
|
+
// own providers (#7272 — unmapped terminal reasons surface as provider errors,
|
|
17
|
+
// never successful stops) and added `AssistantMessage.rawStopReason` so the wire
|
|
18
|
+
// value survives mapping; this gate pins our side of that same contract.
|
|
19
|
+
//
|
|
20
|
+
// A pure-function check of the mapper is NOT enough — the defect people actually
|
|
21
|
+
// feel is "pi showed a truncated answer as done". So every cell is driven
|
|
22
|
+
// BEHAVIORALLY through `streamAcpTurn` against a fake ACP connection, and each
|
|
23
|
+
// asserts all four observable facts together:
|
|
24
|
+
//
|
|
25
|
+
// (a) the emitted event KIND done vs error
|
|
26
|
+
// (b) the emitted event REASON stop / length / aborted / error
|
|
27
|
+
// (c) the final message stopReason
|
|
28
|
+
// (d) rawStopReason + errorMessage on that same final message
|
|
29
|
+
//
|
|
30
|
+
// SEED CLAIM (separate, and deliberately not folded into the cells above).
|
|
31
|
+
// `createAcpStreamState` seeds `stopReason: "pending"` — pi 0.83's sentinel for
|
|
32
|
+
// "no terminal reason observed yet". Seeding "stop" would pre-claim success for
|
|
33
|
+
// the entire in-flight turn. That claim is asserted on the value returned by
|
|
34
|
+
// `createAcpStreamState` DIRECTLY: `state.output` is a mutable object that every
|
|
35
|
+
// terminal path overwrites, so reading it after a turn would only ever show the
|
|
36
|
+
// terminal value and could never fail. Reading it late is how this assertion
|
|
37
|
+
// would quietly stop testing anything.
|
|
38
|
+
//
|
|
39
|
+
// backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
|
|
40
|
+
// convention), so — like check-acp-session-reuse — we tsc-emit the project and
|
|
41
|
+
// import the COMPILED backend.js whose `.js` imports resolve to real siblings.
|
|
42
|
+
|
|
43
|
+
import { strict as assert } from "node:assert";
|
|
44
|
+
import { execFileSync } from "node:child_process";
|
|
45
|
+
import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
|
|
46
|
+
import { tmpdir } from "node:os";
|
|
47
|
+
import { resolve } from "node:path";
|
|
48
|
+
import { pathToFileURL } from "node:url";
|
|
49
|
+
import type { Api, AssistantMessageEvent, Context, Model } from "@earendil-works/pi-ai";
|
|
50
|
+
|
|
51
|
+
const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
|
|
52
|
+
|
|
53
|
+
type Stream = AsyncIterable<AssistantMessageEvent> & {
|
|
54
|
+
result: () => Promise<{ stopReason: string; errorMessage?: string }>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
|
|
58
|
+
const events: AssistantMessageEvent[] = [];
|
|
59
|
+
for await (const ev of stream) events.push(ev);
|
|
60
|
+
return events;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function makeFakeChild() {
|
|
64
|
+
const pipe = () => ({ destroy() {}, unref() {} });
|
|
65
|
+
return {
|
|
66
|
+
pid: undefined as number | undefined,
|
|
67
|
+
exitCode: null as number | null,
|
|
68
|
+
signalCode: null as NodeJS.Signals | null,
|
|
69
|
+
stdin: pipe(),
|
|
70
|
+
stdout: pipe(),
|
|
71
|
+
stderr: { on() {}, destroy() {}, unref() {} },
|
|
72
|
+
kill() {
|
|
73
|
+
return true;
|
|
74
|
+
},
|
|
75
|
+
unref() {},
|
|
76
|
+
once() {},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
|
|
81
|
+
const DEFAULT_RESOLVED_CONFIG: any = {
|
|
82
|
+
settingSources: [],
|
|
83
|
+
strictMcpConfig: true,
|
|
84
|
+
showToolNotifications: true,
|
|
85
|
+
mcpServers: [],
|
|
86
|
+
mcpServersHash: EMPTY_MCP_HASH,
|
|
87
|
+
tools: ["Read"],
|
|
88
|
+
skillPlugins: [],
|
|
89
|
+
permissionAllow: ["Read(*)"],
|
|
90
|
+
disallowedTools: [],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A harness whose fake ACP `prompt` resolves with EXACTLY the terminal shape the
|
|
95
|
+
* cell is about. `wireReason === null` models a result object with no stopReason
|
|
96
|
+
* key at all (the "absent" cell) rather than a literal `undefined` value.
|
|
97
|
+
*/
|
|
98
|
+
function makeHarness(recordDir: string, wireReason: string | null) {
|
|
99
|
+
return {
|
|
100
|
+
resolveLaunch: () => ({ command: "node", args: ["fake"] }),
|
|
101
|
+
ensureOverlay: () => {},
|
|
102
|
+
spawnChild: () => makeFakeChild(),
|
|
103
|
+
createConnection: (_child: any, handlers: any) => ({
|
|
104
|
+
initialize: async () => ({ agentCapabilities: {} }),
|
|
105
|
+
newSession: async () => ({ sessionId: "ACP-1" }),
|
|
106
|
+
setSessionConfigOption: async () => ({}),
|
|
107
|
+
prompt: async ({ sessionId }: any) => {
|
|
108
|
+
await handlers.sessionUpdate({
|
|
109
|
+
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "PARTIAL" } },
|
|
110
|
+
sessionId,
|
|
111
|
+
});
|
|
112
|
+
return wireReason === null ? {} : { stopReason: wireReason };
|
|
113
|
+
},
|
|
114
|
+
}),
|
|
115
|
+
lifecyclePolicy: () => "process-scoped",
|
|
116
|
+
loadConfig: () => DEFAULT_RESOLVED_CONFIG,
|
|
117
|
+
now: () => "2026-07-30T00:00:00Z",
|
|
118
|
+
sessionDir: recordDir,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const TMP_EMIT = ".tmp-verify/acp-stop-reason";
|
|
123
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
124
|
+
const recordDir = mkdtempSync(resolve(tmpdir(), "acp-stop-rec-"));
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* One driven turn, described by everything an operator could observe.
|
|
128
|
+
*
|
|
129
|
+
* `claim` is the qualification token this cell's SEALING assertions carry. The
|
|
130
|
+
* mutant harness does not accept "the gate went red" — it requires the gate to
|
|
131
|
+
* go red *at the token of the claim under test*, so a cell that guards a claim
|
|
132
|
+
* must print that claim's `[QK:…]` in the very assertion the mutant trips.
|
|
133
|
+
* Cells whose sealing no mutant targets carry no token.
|
|
134
|
+
*/
|
|
135
|
+
type Cell = {
|
|
136
|
+
wire: string | null;
|
|
137
|
+
kind: "done" | "error";
|
|
138
|
+
reason: string;
|
|
139
|
+
stopReason: string;
|
|
140
|
+
rawStopReason?: string;
|
|
141
|
+
/** Substring the errorMessage must carry; absent = there must be no errorMessage. */
|
|
142
|
+
errorContains?: string;
|
|
143
|
+
claim?: string;
|
|
144
|
+
why: string;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** Qualification token for the claim that the raw ACP reason survives mapping. */
|
|
148
|
+
const RAW_CLAIM = "[QK:STOP-RAW-PRESERVED]";
|
|
149
|
+
|
|
150
|
+
/** Prefix an assertion message with a claim token when the cell guards one. */
|
|
151
|
+
function tag(cell: Cell, message: string): string {
|
|
152
|
+
return cell.claim ? `${cell.claim} ${message}` : message;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const CELLS: Cell[] = [
|
|
156
|
+
{
|
|
157
|
+
wire: "end_turn",
|
|
158
|
+
kind: "done",
|
|
159
|
+
reason: "stop",
|
|
160
|
+
stopReason: "stop",
|
|
161
|
+
rawStopReason: "end_turn",
|
|
162
|
+
why: "a normal completion is the only clean success",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
wire: "max_tokens",
|
|
166
|
+
kind: "done",
|
|
167
|
+
reason: "length",
|
|
168
|
+
stopReason: "length",
|
|
169
|
+
rawStopReason: "max_tokens",
|
|
170
|
+
why: "output-cap truncation is honestly reported as length, not stop",
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
wire: "cancelled",
|
|
174
|
+
kind: "error",
|
|
175
|
+
reason: "aborted",
|
|
176
|
+
stopReason: "aborted",
|
|
177
|
+
rawStopReason: "cancelled",
|
|
178
|
+
why: "a cancelled turn is an abort, never a done",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
wire: "refusal",
|
|
182
|
+
kind: "error",
|
|
183
|
+
reason: "error",
|
|
184
|
+
stopReason: "error",
|
|
185
|
+
rawStopReason: "refusal",
|
|
186
|
+
errorContains: "refusal",
|
|
187
|
+
claim: "[QK:STOP-REFUSAL-NOT-SUCCESS]",
|
|
188
|
+
why: "a model refusal has no answer to show — reporting it as done is the original defect",
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
wire: "max_turn_requests",
|
|
192
|
+
kind: "error",
|
|
193
|
+
reason: "error",
|
|
194
|
+
stopReason: "error",
|
|
195
|
+
rawStopReason: "max_turn_requests",
|
|
196
|
+
errorContains: "max_turn_requests",
|
|
197
|
+
claim: "[QK:STOP-TURNBUDGET-NOT-SUCCESS]",
|
|
198
|
+
why: "an exhausted per-turn request budget leaves the answer cut short",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
wire: "teapot",
|
|
202
|
+
kind: "error",
|
|
203
|
+
reason: "error",
|
|
204
|
+
stopReason: "error",
|
|
205
|
+
rawStopReason: "teapot",
|
|
206
|
+
errorContains: "teapot",
|
|
207
|
+
claim: "[QK:STOP-UNKNOWN-NOT-SUCCESS]",
|
|
208
|
+
why: "a reason this build has never seen must fail loud, carrying the raw value",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
wire: null,
|
|
212
|
+
kind: "error",
|
|
213
|
+
reason: "error",
|
|
214
|
+
stopReason: "error",
|
|
215
|
+
errorContains: "without a stop reason",
|
|
216
|
+
claim: "[QK:STOP-ABSENT-NOT-SUCCESS]",
|
|
217
|
+
why: "a prompt result with no stopReason at all is not a successful turn",
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
|
|
223
|
+
stdio: "pipe",
|
|
224
|
+
});
|
|
225
|
+
const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
|
|
226
|
+
mkdirSync(promptsOut, { recursive: true });
|
|
227
|
+
copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
|
|
228
|
+
|
|
229
|
+
const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
|
|
230
|
+
const eventUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/event-mapper.js")).href;
|
|
231
|
+
const backend = (await import(backendUrl)) as any;
|
|
232
|
+
const eventMod = (await import(eventUrl)) as any;
|
|
233
|
+
|
|
234
|
+
// ----------------------------------------------------------------------
|
|
235
|
+
// (1) SEED — asserted on the freshly returned state, never after a turn.
|
|
236
|
+
// ----------------------------------------------------------------------
|
|
237
|
+
{
|
|
238
|
+
const seeded = eventMod.createAcpStreamState(
|
|
239
|
+
{ push() {}, end() {} },
|
|
240
|
+
{
|
|
241
|
+
api: "entwurf",
|
|
242
|
+
provider: "entwurf",
|
|
243
|
+
model: sonnet.id,
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
assert.equal(
|
|
247
|
+
seeded.output.stopReason,
|
|
248
|
+
"pending",
|
|
249
|
+
"[QK:STOP-PENDING-SEED] a freshly created ACP stream state must seed stopReason 'pending' (pi 0.83's " +
|
|
250
|
+
"no-terminal-reason-yet sentinel) — seeding 'stop' pre-claims success for the whole in-flight turn",
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ----------------------------------------------------------------------
|
|
255
|
+
// (2) The seven terminal cells, driven end to end. Each cell's qualification
|
|
256
|
+
// token lives in its `claim` field above — exactly one occurrence per
|
|
257
|
+
// token in this file, which is what the mutant manifest requires.
|
|
258
|
+
// ----------------------------------------------------------------------
|
|
259
|
+
for (const cell of CELLS) {
|
|
260
|
+
const label = cell.wire === null ? "<absent>" : cell.wire;
|
|
261
|
+
const events = await collect(
|
|
262
|
+
backend.streamAcpTurn(
|
|
263
|
+
sonnet,
|
|
264
|
+
{ messages: [{ role: "user", content: `drive ${label}`, timestamp: 0 }] } as Context,
|
|
265
|
+
{ sessionId: `stop-${label}` },
|
|
266
|
+
makeHarness(recordDir, cell.wire),
|
|
267
|
+
) as Stream,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const done = events.find((e) => e.type === "done") as any;
|
|
271
|
+
const errored = events.find((e) => e.type === "error") as any;
|
|
272
|
+
|
|
273
|
+
if (cell.kind === "done") {
|
|
274
|
+
assert.ok(done, tag(cell, `[${label}] must seal as a done event — ${cell.why}`));
|
|
275
|
+
assert.ok(!errored, tag(cell, `[${label}] must not also emit an error event`));
|
|
276
|
+
} else {
|
|
277
|
+
assert.ok(errored, tag(cell, `[${label}] must seal as an ERROR event, not a done — ${cell.why}`));
|
|
278
|
+
assert.ok(
|
|
279
|
+
!done,
|
|
280
|
+
tag(cell, `[${label}] must NOT emit a done event — that is exactly the collapse this gate forbids`),
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sealed = cell.kind === "done" ? done : errored;
|
|
285
|
+
assert.equal(sealed.reason, cell.reason, tag(cell, `[${label}] sealed event reason must be "${cell.reason}"`));
|
|
286
|
+
|
|
287
|
+
const final = cell.kind === "done" ? sealed.message : sealed.error;
|
|
288
|
+
assert.equal(
|
|
289
|
+
final.stopReason,
|
|
290
|
+
cell.stopReason,
|
|
291
|
+
tag(cell, `[${label}] final message stopReason must be "${cell.stopReason}"`),
|
|
292
|
+
);
|
|
293
|
+
// Deliberately carries no claim token: the seed mutant is caught by the
|
|
294
|
+
// direct assertion in (1), and this one is the standing guarantee that no
|
|
295
|
+
// terminal path ever leaks the sentinel out to a consumer.
|
|
296
|
+
assert.notEqual(
|
|
297
|
+
final.stopReason,
|
|
298
|
+
"pending",
|
|
299
|
+
`[${label}] the seed must never survive a sealed turn — every terminal path overwrites it`,
|
|
300
|
+
);
|
|
301
|
+
assert.equal(
|
|
302
|
+
final.rawStopReason,
|
|
303
|
+
cell.rawStopReason,
|
|
304
|
+
`${RAW_CLAIM} [${label}] rawStopReason must preserve the wire value (${cell.rawStopReason ?? "absent"}) so ` +
|
|
305
|
+
"the raw ACP reason survives the mapping",
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
if (cell.errorContains) {
|
|
309
|
+
assert.ok(
|
|
310
|
+
typeof final.errorMessage === "string" && final.errorMessage.includes(cell.errorContains),
|
|
311
|
+
tag(
|
|
312
|
+
cell,
|
|
313
|
+
`[${label}] errorMessage must name the reason (expected to contain "${cell.errorContains}", got ` +
|
|
314
|
+
`${JSON.stringify(final.errorMessage)})`,
|
|
315
|
+
),
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
assert.equal(final.errorMessage, undefined, tag(cell, `[${label}] a clean end must not carry an errorMessage`));
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
rmSync(TMP_EMIT, { recursive: true, force: true });
|
|
323
|
+
try {
|
|
324
|
+
// The qualification harness's work-surface hash walks ignored paths too, so
|
|
325
|
+
// a leftover EMPTY parent dir reads as IMPURE tree drift even though git
|
|
326
|
+
// porcelain is clean. Remove it when empty; a concurrent sibling gate's
|
|
327
|
+
// emit keeps it alive and this rmdir simply fails.
|
|
328
|
+
rmdirSync(".tmp-verify");
|
|
329
|
+
} catch {
|
|
330
|
+
// non-empty or already gone — fine either way
|
|
331
|
+
}
|
|
332
|
+
rmSync(recordDir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(
|
|
336
|
+
"[check-acp-stop-reason] ok — the ACP terminal set is driven end to end through streamAcpTurn: end_turn→done/stop " +
|
|
337
|
+
"and max_tokens→done/length are the only clean seals; cancelled→error/aborted; refusal, max_turn_requests, an " +
|
|
338
|
+
"unrecognized reason, and an ABSENT reason each seal as an ERROR event with no done (the collapse-to-stop defect " +
|
|
339
|
+
"pi 0.83 #7272 closed on its own providers); rawStopReason preserves the wire value on every cell that carried " +
|
|
340
|
+
"one; and the stream state seeds 'pending' (asserted on the fresh state, not after a turn) so no in-flight turn " +
|
|
341
|
+
"pre-claims success",
|
|
342
|
+
);
|
|
@@ -162,6 +162,45 @@ function assertRailSemantics(tag: string, longDescRaw: string, intentDescRaw: st
|
|
|
162
162
|
* parameter marker in the long-description slice: if the slice ever swallows the schema
|
|
163
163
|
* again, this fails instead of silently satisfying the schema's own text.
|
|
164
164
|
*/
|
|
165
|
+
/**
|
|
166
|
+
* HOST TRUNCATION CAP. Measured 2026-07-30 on both Claude ACP and stock Claude Code: a tool
|
|
167
|
+
* description longer than this is cut mid-sentence and the tail is replaced with `… [truncated]`.
|
|
168
|
+
* The shipped entwurf_v2 text was 4,022 chars, so everything from the INTENT contract onward —
|
|
169
|
+
* the reject taxonomy, the three-valued probe, the lock scope — never reached the model that had
|
|
170
|
+
* to choose an intent. Every other assertion in this file checks that a sentence is PRESENT in
|
|
171
|
+
* source; none of them can see that the host threw half of it away. This one can.
|
|
172
|
+
*
|
|
173
|
+
* Judged on `modelText` (the rendered string), not the source slice: pi-native wraps inside one
|
|
174
|
+
* template literal and the MCP surface `+`-concatenates, so only the normalized text has the
|
|
175
|
+
* length the host actually measures.
|
|
176
|
+
*/
|
|
177
|
+
const HOST_DESCRIPTION_CAP = 2048;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The string the HOST measures, recovered from either surface's source scaffolding: drop the
|
|
181
|
+
* `description:` key and the outer template-literal / string-literal fence, and unescape `\"`.
|
|
182
|
+
* Counting the raw slice instead would charge the text for punctuation the model never sees —
|
|
183
|
+
* and the two surfaces carry DIFFERENT punctuation, so the cap would mean two different things.
|
|
184
|
+
*/
|
|
185
|
+
function renderedDescription(raw: string): string {
|
|
186
|
+
const flat = modelText(raw);
|
|
187
|
+
// Anchor on the description's own first word rather than on scaffolding: the two surfaces put
|
|
188
|
+
// DIFFERENT preludes in front of it (the MCP slice starts at the tool name), and counting those
|
|
189
|
+
// would report a number the host never measures — a cap gate that cites the wrong length gets
|
|
190
|
+
// quoted as if it were the real one.
|
|
191
|
+
const from = flat.indexOf("CANONICAL");
|
|
192
|
+
return (from >= 0 ? flat.slice(from) : flat).replace(/[`"],?$/, "").replace(/\\"/g, '"');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertDescriptionFitsHostCap(tag: string, longDescRaw: string): void {
|
|
196
|
+
const rendered = renderedDescription(longDescRaw);
|
|
197
|
+
ok(
|
|
198
|
+
`${tag} — long description fits the host's ${HOST_DESCRIPTION_CAP}-char cap (${rendered.length}) ` +
|
|
199
|
+
"[QK:V2SURF-DESC-FITS-HOST-CAP]",
|
|
200
|
+
rendered.length <= HOST_DESCRIPTION_CAP,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
165
204
|
function assertLongDescExcludesParams(tag: string, longDesc: string, markers: readonly string[]): void {
|
|
166
205
|
for (const marker of markers) {
|
|
167
206
|
ok(`${tag} — long description slice excludes the \`${marker}\` parameter marker`, !longDesc.includes(marker));
|
|
@@ -551,6 +590,7 @@ async function main(): Promise<void> {
|
|
|
551
590
|
]);
|
|
552
591
|
const piModeDesc = sliceDescription(piV2Block, "mode: Type.Optional(", "wants_reply:");
|
|
553
592
|
assertRailSemantics("4: pi-native", piLongDesc, piIntentDesc, piModeDesc);
|
|
593
|
+
assertDescriptionFitsHostCap("4: pi-native", piLongDesc);
|
|
554
594
|
}
|
|
555
595
|
|
|
556
596
|
// ── 5: MCP bridge wiring guard ────────────────────────────────────────────
|
|
@@ -635,6 +675,7 @@ async function main(): Promise<void> {
|
|
|
635
675
|
assertLongDescExcludesParams("5: MCP", mcpLongDesc, ["target: z", "intent: z", "mode: z", "wants_reply: z"]);
|
|
636
676
|
const mcpModeDesc = sliceDescription(v2Block, "mode: z", "wants_reply: z");
|
|
637
677
|
assertRailSemantics("5: MCP", mcpLongDesc, mcpIntentDesc, mcpModeDesc);
|
|
678
|
+
assertDescriptionFitsHostCap("5: MCP", mcpLongDesc);
|
|
638
679
|
|
|
639
680
|
// entwurf_peers is a FACT surface, but it still told the model what `unsupported` means —
|
|
640
681
|
// and "does NOT mean unreachable" is false for a record whose backend has no adapter on
|
|
@@ -718,13 +718,16 @@ console.log(`\n[gate-qualification] self-test: ${passed} checks passed`);
|
|
|
718
718
|
// manifest lost in a merge must name itself here — "0/0 killed" is not a pass.
|
|
719
719
|
// Extend this inventory and the manifests TOGETHER, never silently.
|
|
720
720
|
const EXPECTED_LANE_MUTANTS: Record<string, number> = {
|
|
721
|
-
"acp-augment":
|
|
721
|
+
"acp-augment": 10,
|
|
722
722
|
"acp-cortex": 12,
|
|
723
|
+
"acp-prompt-lifecycle": 8,
|
|
724
|
+
"acp-stop-reason": 6,
|
|
723
725
|
"agy-permission": 6,
|
|
724
726
|
"meta-identity": 1,
|
|
725
|
-
"probe-ordering":
|
|
727
|
+
"probe-ordering": 85,
|
|
728
|
+
"release-gate": 8,
|
|
726
729
|
"self-address": 3,
|
|
727
|
-
"v2-surface":
|
|
730
|
+
"v2-surface": 5,
|
|
728
731
|
};
|
|
729
732
|
const laneTally: Record<string, number> = {};
|
|
730
733
|
for (const man of manifests) laneTally[man.lane] = (laneTally[man.lane] ?? 0) + man.mutants.length;
|
|
@@ -94,6 +94,7 @@ import { terminateChild } from "./lib/acp-child-cleanup.ts";
|
|
|
94
94
|
import {
|
|
95
95
|
driveProbeTurn,
|
|
96
96
|
PROBE_PHASE_TIMEOUTS,
|
|
97
|
+
PROBE_PROMPT_OBSERVATION_MS,
|
|
97
98
|
type ProbeAdapterSeam,
|
|
98
99
|
type ProbeMcpEnricher,
|
|
99
100
|
ProbePhaseError,
|
|
@@ -290,7 +291,7 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
|
|
|
290
291
|
`set-model start/end are marked between newSession end and prompt start (got ${eventOrder.join(",")})`,
|
|
291
292
|
);
|
|
292
293
|
|
|
293
|
-
//
|
|
294
|
+
// Carrier-less shape: an undefined session meta omits the `_meta` KEY entirely.
|
|
294
295
|
const calls2: RecordedCall[] = [];
|
|
295
296
|
await driveProbeTurn(makeRecordingConnection(calls2), {
|
|
296
297
|
cwd: "/scratch/gate2",
|
|
@@ -305,7 +306,7 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
|
|
|
305
306
|
});
|
|
306
307
|
assert.ok(
|
|
307
308
|
!Object.hasOwn(calls2.find((c) => c.method === "newSession")?.params as object, "_meta"),
|
|
308
|
-
"undefined session meta omits the _meta KEY entirely (
|
|
309
|
+
"undefined session meta omits the _meta KEY entirely (carrier-less shape)",
|
|
309
310
|
);
|
|
310
311
|
|
|
311
312
|
// --- production source pins ---------------------------------------------
|
|
@@ -319,15 +320,37 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
|
|
|
319
320
|
initializeMs: PROBE_PHASE_TIMEOUTS.initializeMs,
|
|
320
321
|
newSessionMs: PROBE_PHASE_TIMEOUTS.newSessionMs,
|
|
321
322
|
setModelMs: PROBE_PHASE_TIMEOUTS.setModelMs,
|
|
322
|
-
promptMs: PROBE_PHASE_TIMEOUTS.promptMs,
|
|
323
323
|
},
|
|
324
324
|
{
|
|
325
325
|
initializeMs: timeoutOf("INITIALIZE_TIMEOUT_MS"),
|
|
326
326
|
newSessionMs: timeoutOf("NEW_SESSION_TIMEOUT_MS"),
|
|
327
327
|
setModelMs: timeoutOf("SET_MODEL_TIMEOUT_MS"),
|
|
328
|
-
promptMs: timeoutOf("PROMPT_TIMEOUT_MS"),
|
|
329
328
|
},
|
|
330
|
-
"probe
|
|
329
|
+
"probe BOOTSTRAP timeouts EQUAL backend.ts's — a bootstrap-phase D is only readable against production " +
|
|
330
|
+
"boundaries [QK:PROBE-BOOTSTRAP-TIMEOUTS-MATCH-PRODUCTION]",
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
// --- the prompt phase has NO production boundary to match --------------
|
|
334
|
+
//
|
|
335
|
+
// backend.ts ends a prompt on lifecycle events only. The probe still needs a
|
|
336
|
+
// bounded observation horizon so a measurement RUN cannot hang forever, but
|
|
337
|
+
// that horizon is the harness's own: pinning it to a production number (or
|
|
338
|
+
// letting production grow one back) would make the probe report a
|
|
339
|
+
// harness-invented cutoff as if it were the turn contract.
|
|
340
|
+
const promptCutoffTraces = [/\bPROMPT_TIMEOUT_MS\b/, /withTimeout\(\s*\n?\s*"prompt"/].filter((re) =>
|
|
341
|
+
re.test(BACKEND_SRC),
|
|
342
|
+
);
|
|
343
|
+
assert.equal(
|
|
344
|
+
promptCutoffTraces.length,
|
|
345
|
+
0,
|
|
346
|
+
"[QK:NO-PRODUCTION-PROMPT-CUTOFF] backend.ts carries NO prompt wall-clock — neither a PROMPT_TIMEOUT_MS constant " +
|
|
347
|
+
'nor a withTimeout("prompt", …) race. A running turn is not a failed turn for being long, and the old 600s ' +
|
|
348
|
+
`cutoff also fed pi's transient-retry dictionary. Found: ${promptCutoffTraces.join(", ")}`,
|
|
349
|
+
);
|
|
350
|
+
assert.ok(
|
|
351
|
+
PROBE_PROMPT_OBSERVATION_MS > 0 && !("promptMs" in PROBE_PHASE_TIMEOUTS),
|
|
352
|
+
"[QK:PROMPT-HORIZON-NOT-PRODUCTION] the prompt horizon lives OUTSIDE the production-pinned bootstrap set — it is " +
|
|
353
|
+
"the harness's own observation bound, and folding it back in would report a harness number as the turn contract",
|
|
331
354
|
);
|
|
332
355
|
|
|
333
356
|
const idx = (needle: string): number => {
|
|
@@ -338,11 +361,19 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
|
|
|
338
361
|
const iInit = idx("connection.initialize({");
|
|
339
362
|
const iNew = idx("connection.newSession(newSessionArgs)");
|
|
340
363
|
const iEnforce = idx("adapter.enforceModel({");
|
|
341
|
-
|
|
364
|
+
// The prompt no longer sits inline: runNewTurn hands the wire call to
|
|
365
|
+
// awaitAcpPromptTurn (the lifecycle-bounded driver), so the ordering pin
|
|
366
|
+
// follows the dispatch, and the driver's own `session.connection.prompt(...)`
|
|
367
|
+
// is the single place the wire call lives.
|
|
368
|
+
const iPrompt = BACKEND_SRC.indexOf("await awaitAcpPromptTurn(", iEnforce);
|
|
342
369
|
assert.ok(
|
|
343
370
|
iInit < iNew && iNew < iEnforce && iEnforce < iPrompt && iPrompt !== -1,
|
|
344
371
|
"backend.ts runNewTurn keeps initialize → newSession → enforceModel → prompt; the probe mirrors THIS sequence",
|
|
345
372
|
);
|
|
373
|
+
assert.ok(
|
|
374
|
+
BACKEND_SRC.includes("await Promise.race([session.connection.prompt(promptArgs), lifecycle])"),
|
|
375
|
+
"the prompt driver races the wire call against LIFECYCLE endings only — the probe mirrors that same wire call",
|
|
376
|
+
);
|
|
346
377
|
assert.ok(
|
|
347
378
|
BACKEND_SRC.includes('clientInfo: { name: "entwurf", version: "s2d" }'),
|
|
348
379
|
"backend.ts still sends the clientInfo the probe mirrors",
|
|
@@ -490,7 +521,8 @@ function makeRecordingConnection(calls: RecordedCall[]): AcpConnectionLike {
|
|
|
490
521
|
await driveProbeTurn(makeRecordingConnection(calls), {
|
|
491
522
|
...base,
|
|
492
523
|
adapter: { buildSessionMeta: () => undefined, enforceModel: () => new Promise<never>(() => {}) },
|
|
493
|
-
timeouts: { initializeMs: 1000, newSessionMs: 1000, setModelMs: 50
|
|
524
|
+
timeouts: { initializeMs: 1000, newSessionMs: 1000, setModelMs: 50 },
|
|
525
|
+
promptObservationMs: 1000,
|
|
494
526
|
});
|
|
495
527
|
assert.fail("hung set-model must time the turn out");
|
|
496
528
|
} catch (err) {
|