@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,322 @@
|
|
|
1
|
+
// Deterministic gate for the S2g operator provider-config loader. Pure +
|
|
2
|
+
// temp-dir settings I/O — IN pnpm check. No child, no spawn.
|
|
3
|
+
//
|
|
4
|
+
// Locks the GPT `…2f9325` config-passthrough invariants:
|
|
5
|
+
// 1. global/project merge: scalar override (project-defined only) + mcpServers
|
|
6
|
+
// per-name shallow merge with project override.
|
|
7
|
+
// 2. defaults: strictMcpConfig true, settingSources [], tools/permission/
|
|
8
|
+
// disallowed baseline, skillPlugins [].
|
|
9
|
+
// 3. invalid mcpServers (root / entry / args / env / headers / type / url) →
|
|
10
|
+
// McpServerConfigError naming the offending server.
|
|
11
|
+
// 4. invalid skillPlugins (relative / missing dir / missing manifest) → fail-loud.
|
|
12
|
+
// 5. nonempty skillPlugins → Skill tool + Skill(*) permission auto-added.
|
|
13
|
+
// 6. appendSystemPrompt: true → fail-loud (unsupported).
|
|
14
|
+
// 7. strictMcpConfig: false → fail-loud (Hard Rule #4).
|
|
15
|
+
// 8. normalized mcp hash: deterministic + sorted; a command/env/header/url
|
|
16
|
+
// change changes the hash (NOT just a name change). The envelope enrich
|
|
17
|
+
// injects PI_SESSION_ID/PI_AGENT_ID into entwurf-bridge only, filters stale
|
|
18
|
+
// values, leaves http/sse untouched, and runs AFTER the hash is taken.
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from "node:assert";
|
|
21
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join, resolve } from "node:path";
|
|
24
|
+
import type { AcpAdapterSettingsParams, AcpBackendAdapter } from "../pi-extensions/lib/acp/backend-adapter.ts";
|
|
25
|
+
import {
|
|
26
|
+
type AcpMcpServer,
|
|
27
|
+
enrichMcpServersWithEnvelope,
|
|
28
|
+
McpServerConfigError,
|
|
29
|
+
mcpServerNames,
|
|
30
|
+
normalizeMcpServers,
|
|
31
|
+
resolveProviderConfig,
|
|
32
|
+
} from "../pi-extensions/lib/acp/config.ts";
|
|
33
|
+
import {
|
|
34
|
+
DEFAULT_CLAUDE_DISALLOWED_TOOLS,
|
|
35
|
+
DEFAULT_CLAUDE_PERMISSION_ALLOW,
|
|
36
|
+
DEFAULT_CLAUDE_TOOLS,
|
|
37
|
+
} from "../pi-extensions/lib/acp/tool-surface.ts";
|
|
38
|
+
|
|
39
|
+
// A minimal fake adapter — config.ts only ever calls `resolveAdapterSettings`, so the
|
|
40
|
+
// other methods are never exercised here (cast through unknown). Keeps this gate from
|
|
41
|
+
// loading the real backend-adapter.js (and its .js sibling value imports) at runtime;
|
|
42
|
+
// the type import above is erased by strip-types.
|
|
43
|
+
function fakeAdapter(resolveAdapterSettings: (p: AcpAdapterSettingsParams) => unknown): AcpBackendAdapter {
|
|
44
|
+
return { backend: "fake", resolveAdapterSettings } as unknown as AcpBackendAdapter;
|
|
45
|
+
}
|
|
46
|
+
const claudeLikeFake = fakeAdapter(() => undefined);
|
|
47
|
+
|
|
48
|
+
const root = mkdtempSync(resolve(tmpdir(), "acp-config-gate-"));
|
|
49
|
+
const NONE = join(root, "__absent__.json");
|
|
50
|
+
|
|
51
|
+
/** Write a settings file with the given entwurfProvider block; return path. */
|
|
52
|
+
function settings(name: string, block: unknown): string {
|
|
53
|
+
const p = join(root, name);
|
|
54
|
+
writeFileSync(p, JSON.stringify({ entwurfProvider: block }));
|
|
55
|
+
return p;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveWith(globalPath: string, projectPath: string, adapter: AcpBackendAdapter = claudeLikeFake) {
|
|
59
|
+
return resolveProviderConfig({
|
|
60
|
+
cwd: root,
|
|
61
|
+
modelId: "claude-sonnet-4-6",
|
|
62
|
+
adapter,
|
|
63
|
+
globalSettingsPath: globalPath,
|
|
64
|
+
projectSettingsPath: projectPath,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const DEFAULT_TOOLS = ["Read", "Bash", "Edit", "Write"];
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
// ----------------------------------------------------------------------
|
|
72
|
+
// 1) defaults (no settings files at all)
|
|
73
|
+
// ----------------------------------------------------------------------
|
|
74
|
+
{
|
|
75
|
+
const c = resolveWith(NONE, NONE);
|
|
76
|
+
assert.deepEqual(c.settingSources, [], "settingSources defaults to []");
|
|
77
|
+
assert.equal(c.strictMcpConfig, true, "strictMcpConfig defaults to true");
|
|
78
|
+
assert.equal(c.showToolNotifications, true, "showToolNotifications defaults to true");
|
|
79
|
+
assert.deepEqual(c.mcpServers, [], "mcpServers defaults to []");
|
|
80
|
+
assert.deepEqual(c.tools, DEFAULT_TOOLS, "tools defaults to the pi baseline");
|
|
81
|
+
assert.ok(c.permissionAllow.includes("mcp__*"), "permissionAllow defaults include mcp__*");
|
|
82
|
+
assert.ok(c.disallowedTools.includes("WebSearch"), "disallowedTools defaults applied");
|
|
83
|
+
assert.deepEqual(c.skillPlugins, [], "skillPlugins defaults to []");
|
|
84
|
+
// Amber drift-lock (GPT `…2f9325`): config.ts mirrors the defaults as local
|
|
85
|
+
// constants (strip-types value-import ban). Assert they stay in lockstep with
|
|
86
|
+
// tool-surface.ts — the SSOT — so a one-sided edit fails this gate.
|
|
87
|
+
assert.deepEqual(
|
|
88
|
+
[...c.tools],
|
|
89
|
+
[...DEFAULT_CLAUDE_TOOLS],
|
|
90
|
+
"resolved default tools == tool-surface DEFAULT_CLAUDE_TOOLS",
|
|
91
|
+
);
|
|
92
|
+
assert.deepEqual(
|
|
93
|
+
[...c.permissionAllow],
|
|
94
|
+
[...DEFAULT_CLAUDE_PERMISSION_ALLOW],
|
|
95
|
+
"resolved default permissionAllow == tool-surface DEFAULT_CLAUDE_PERMISSION_ALLOW",
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(
|
|
98
|
+
[...c.disallowedTools],
|
|
99
|
+
[...DEFAULT_CLAUDE_DISALLOWED_TOOLS],
|
|
100
|
+
"resolved default disallowedTools == tool-surface DEFAULT_CLAUDE_DISALLOWED_TOOLS",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ----------------------------------------------------------------------
|
|
105
|
+
// 2) merge: project overrides global for defined keys; mcpServers merge per name
|
|
106
|
+
// ----------------------------------------------------------------------
|
|
107
|
+
{
|
|
108
|
+
const g = settings("g2.json", {
|
|
109
|
+
settingSources: ["user"],
|
|
110
|
+
mcpServers: { alpha: { command: "a" }, shared: { command: "global-shared" } },
|
|
111
|
+
});
|
|
112
|
+
const p = settings("p2.json", {
|
|
113
|
+
mcpServers: { beta: { type: "http", url: "https://b.test" }, shared: { command: "project-shared" } },
|
|
114
|
+
});
|
|
115
|
+
const c = resolveWith(g, p);
|
|
116
|
+
// project did NOT set settingSources → global value survives (not nuked by undefined).
|
|
117
|
+
assert.deepEqual(c.settingSources, ["user"], "absent project key keeps the global value");
|
|
118
|
+
// mcpServers: union of names, project wins on `shared`.
|
|
119
|
+
assert.deepEqual(mcpServerNames(c), ["alpha", "beta", "shared"], "mcpServers merge is the union, name-sorted");
|
|
120
|
+
const shared = c.mcpServers.find((s) => s.name === "shared") as Extract<AcpMcpServer, { command: string }>;
|
|
121
|
+
assert.equal(shared.command, "project-shared", "project overrides the global server of the same name");
|
|
122
|
+
}
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => normalizeMcpServers([] as unknown as Record<string, unknown>),
|
|
125
|
+
McpServerConfigError,
|
|
126
|
+
"array root rejected",
|
|
127
|
+
);
|
|
128
|
+
assert.throws(
|
|
129
|
+
() => normalizeMcpServers({ x: { command: "" } }),
|
|
130
|
+
/x: stdio server requires/,
|
|
131
|
+
"empty command rejected",
|
|
132
|
+
);
|
|
133
|
+
assert.throws(() => normalizeMcpServers({ x: { command: "c", args: [1] } }), /x: "args/, "non-string arg rejected");
|
|
134
|
+
assert.throws(
|
|
135
|
+
() => normalizeMcpServers({ x: { command: "c", env: [{ name: "A" }] } }),
|
|
136
|
+
/x: "env/,
|
|
137
|
+
"env entry without value rejected",
|
|
138
|
+
);
|
|
139
|
+
assert.throws(
|
|
140
|
+
() => normalizeMcpServers({ x: { type: "http" } }),
|
|
141
|
+
/x: http server requires/,
|
|
142
|
+
"http without url rejected",
|
|
143
|
+
);
|
|
144
|
+
assert.throws(
|
|
145
|
+
() => normalizeMcpServers({ x: { type: "bogus" } }),
|
|
146
|
+
/x: unsupported "type"/,
|
|
147
|
+
"unsupported type rejected",
|
|
148
|
+
);
|
|
149
|
+
// the error carries structured issues naming each bad server.
|
|
150
|
+
try {
|
|
151
|
+
normalizeMcpServers({ bad1: { command: "" }, bad2: { type: "sse" } });
|
|
152
|
+
assert.fail("expected McpServerConfigError");
|
|
153
|
+
} catch (e) {
|
|
154
|
+
assert.ok(e instanceof McpServerConfigError, "is a McpServerConfigError");
|
|
155
|
+
assert.deepEqual(e.issues.map((i) => i.server).sort(), ["bad1", "bad2"], "issues name every offending server");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ----------------------------------------------------------------------
|
|
159
|
+
// 4) invalid skillPlugins → fail-loud
|
|
160
|
+
// ----------------------------------------------------------------------
|
|
161
|
+
{
|
|
162
|
+
assert.throws(
|
|
163
|
+
() => resolveWith(NONE, settings("p4a.json", { skillPlugins: ["relative/path"] })),
|
|
164
|
+
/must be an absolute path/,
|
|
165
|
+
"relative skillPlugin path rejected",
|
|
166
|
+
);
|
|
167
|
+
assert.throws(
|
|
168
|
+
() => resolveWith(NONE, settings("p4b.json", { skillPlugins: [join(root, "nope")] })),
|
|
169
|
+
/does not exist/,
|
|
170
|
+
"missing skillPlugin dir rejected",
|
|
171
|
+
);
|
|
172
|
+
const noManifest = join(root, "nomanifest");
|
|
173
|
+
mkdirSync(noManifest, { recursive: true });
|
|
174
|
+
assert.throws(
|
|
175
|
+
() => resolveWith(NONE, settings("p4c.json", { skillPlugins: [noManifest] })),
|
|
176
|
+
/missing \.claude-plugin\/plugin\.json/,
|
|
177
|
+
"skillPlugin without manifest rejected",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ----------------------------------------------------------------------
|
|
182
|
+
// 5) nonempty skillPlugins → Skill + Skill(*) auto-added
|
|
183
|
+
// ----------------------------------------------------------------------
|
|
184
|
+
{
|
|
185
|
+
const skill = join(root, "okskill");
|
|
186
|
+
mkdirSync(join(skill, ".claude-plugin"), { recursive: true });
|
|
187
|
+
writeFileSync(join(skill, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "okskill" }));
|
|
188
|
+
const c = resolveWith(NONE, settings("p5.json", { skillPlugins: [skill] }));
|
|
189
|
+
assert.ok(c.tools.includes("Skill"), "Skill tool auto-added");
|
|
190
|
+
assert.ok(c.permissionAllow.includes("Skill(*)"), "Skill(*) permission auto-added");
|
|
191
|
+
assert.deepEqual(c.skillPlugins, [skill], "the plugin path is carried through");
|
|
192
|
+
// idempotent: an operator who already listed Skill is not duplicated.
|
|
193
|
+
const c2 = resolveWith(NONE, settings("p5b.json", { skillPlugins: [skill], tools: ["Read", "Skill"] }));
|
|
194
|
+
assert.equal(c2.tools.filter((t) => t === "Skill").length, 1, "Skill is not duplicated");
|
|
195
|
+
}
|
|
196
|
+
assert.throws(
|
|
197
|
+
() => resolveWith(NONE, settings("p6.json", { appendSystemPrompt: true })),
|
|
198
|
+
/appendSystemPrompt: true is unsupported/,
|
|
199
|
+
"appendSystemPrompt:true rejected (rich context = first-user augment)",
|
|
200
|
+
);
|
|
201
|
+
// false / absent is fine.
|
|
202
|
+
assert.doesNotThrow(() => resolveWith(NONE, settings("p6b.json", { appendSystemPrompt: false })), "false is allowed");
|
|
203
|
+
assert.throws(
|
|
204
|
+
() => resolveWith(NONE, settings("p7.json", { strictMcpConfig: false })),
|
|
205
|
+
/strictMcpConfig: false is unsupported/,
|
|
206
|
+
"strictMcpConfig:false rejected (Hard Rule #4: no ambient MCP)",
|
|
207
|
+
);
|
|
208
|
+
assert.doesNotThrow(() => resolveWith(NONE, settings("p7b.json", { strictMcpConfig: true })), "true is allowed");
|
|
209
|
+
|
|
210
|
+
// backend is a DIAGNOSTIC field, parsed SYNTACTICALLY only — config does NOT
|
|
211
|
+
// whitelist values (the adapter registry owns valid backends; the model-id
|
|
212
|
+
// prefix is the routing authority). The declared-vs-routed mismatch guard lives
|
|
213
|
+
// in backend.ts, NOT here. So config accepts ANY backend string and surfaces it.
|
|
214
|
+
{
|
|
215
|
+
const claudeBackend = resolveWith(NONE, settings("p7c.json", { backend: "claude" }));
|
|
216
|
+
assert.equal(claudeBackend.backend, "claude", "backend string is parsed and surfaced");
|
|
217
|
+
// A fictional, unregistered backend name — config must accept ANY string (the
|
|
218
|
+
// registry, not config, owns the valid set). NOT a real backend: this gate never
|
|
219
|
+
// implements one; a real backend brings its own adapter PR + gates.
|
|
220
|
+
const future = resolveWith(NONE, settings("p7d.json", { backend: "demobackend" }));
|
|
221
|
+
assert.equal(future.backend, "demobackend", "config does NOT whitelist backend values (no claude-only gate)");
|
|
222
|
+
const absent = resolveWith(NONE, settings("p7e.json", {}));
|
|
223
|
+
assert.equal(absent.backend, undefined, "absent backend → undefined");
|
|
224
|
+
assert.throws(
|
|
225
|
+
() => resolveWith(NONE, settings("p7f.json", { backend: 123 })),
|
|
226
|
+
/backend must be a string/,
|
|
227
|
+
"non-string backend rejected (syntactic guard)",
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// adapterSettings seam (generic plumbing — uses a FICTIONAL backend key, implements
|
|
232
|
+
// NO real backend): the routed adapter's resolveAdapterSettings receives the RAW
|
|
233
|
+
// blocks, and its opaque return lands on config.adapterSettings. A backend-specific
|
|
234
|
+
// key (`demoSetting` here) rides the raw block to the hook but NEVER appears on the
|
|
235
|
+
// common ResolvedAcpConfig. Real backends bring their own adapter PR + gates; this
|
|
236
|
+
// gate only proves the rail carries arbitrary backend settings without polluting common.
|
|
237
|
+
{
|
|
238
|
+
const sentinel = { ok: true };
|
|
239
|
+
let captured: AcpAdapterSettingsParams | undefined;
|
|
240
|
+
const recording = fakeAdapter((p) => {
|
|
241
|
+
captured = p;
|
|
242
|
+
return sentinel;
|
|
243
|
+
});
|
|
244
|
+
const block = { backend: "demobackend", demoSetting: "demo-val", tools: ["Read"] };
|
|
245
|
+
const cfg = resolveWith(NONE, settings("p7g.json", block), recording);
|
|
246
|
+
assert.equal(cfg.adapterSettings, sentinel, "adapterSettings = the opaque value the adapter returned");
|
|
247
|
+
assert.ok(captured, "resolveAdapterSettings was called");
|
|
248
|
+
assert.equal(captured?.mergedBlock.demoSetting, "demo-val", "raw mergedBlock carries backend keys to the hook");
|
|
249
|
+
assert.equal(captured?.projectBlock.demoSetting, "demo-val", "raw projectBlock passed for error attribution");
|
|
250
|
+
assert.ok(
|
|
251
|
+
!Object.hasOwn(cfg as object, "demoSetting"),
|
|
252
|
+
"backend-specific key NEVER lands on the common ResolvedAcpConfig (no fat-bridge regression)",
|
|
253
|
+
);
|
|
254
|
+
const claudeLike = resolveWith(NONE, settings("p7h.json", {}));
|
|
255
|
+
assert.equal(claudeLike.adapterSettings, undefined, "a backend with no own settings → adapterSettings undefined");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ----------------------------------------------------------------------
|
|
259
|
+
// 8) hash determinism + sensitivity; envelope enrich semantics
|
|
260
|
+
// ----------------------------------------------------------------------
|
|
261
|
+
{
|
|
262
|
+
const a = normalizeMcpServers({ z: { command: "c", args: ["1"] }, a: { command: "c" } });
|
|
263
|
+
const b = normalizeMcpServers({ a: { command: "c" }, z: { command: "c", args: ["1"] } });
|
|
264
|
+
assert.equal(a.hash, b.hash, "hash is order-independent (servers sorted before hashing)");
|
|
265
|
+
assert.deepEqual(
|
|
266
|
+
a.servers.map((s) => s.name),
|
|
267
|
+
["a", "z"],
|
|
268
|
+
"servers are name-sorted",
|
|
269
|
+
);
|
|
270
|
+
// a command / arg / env change changes the hash (not just a name change).
|
|
271
|
+
assert.notEqual(
|
|
272
|
+
normalizeMcpServers({ a: { command: "c" } }).hash,
|
|
273
|
+
normalizeMcpServers({ a: { command: "DIFFERENT" } }).hash,
|
|
274
|
+
"command change changes the hash",
|
|
275
|
+
);
|
|
276
|
+
assert.notEqual(
|
|
277
|
+
normalizeMcpServers({ a: { command: "c" } }).hash,
|
|
278
|
+
normalizeMcpServers({ a: { command: "c", env: { K: "V" } } }).hash,
|
|
279
|
+
"env change changes the hash",
|
|
280
|
+
);
|
|
281
|
+
assert.notEqual(
|
|
282
|
+
normalizeMcpServers({ a: { type: "http", url: "https://x.test" } }).hash,
|
|
283
|
+
normalizeMcpServers({ a: { type: "http", url: "https://y.test", headers: { H: "V" } } }).hash,
|
|
284
|
+
"url/header change changes the hash",
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// enrich: PI_SESSION_ID/PI_AGENT_ID into entwurf-bridge only; stale filtered;
|
|
288
|
+
// http untouched; other stdio untouched.
|
|
289
|
+
const servers = normalizeMcpServers({
|
|
290
|
+
"entwurf-bridge": { command: "node", env: { PI_SESSION_ID: "STALE", FOO: "bar" } },
|
|
291
|
+
weather: { type: "http", url: "https://w.test" },
|
|
292
|
+
other: { command: "x" },
|
|
293
|
+
}).servers;
|
|
294
|
+
const enriched = enrichMcpServersWithEnvelope(servers, { modelId: "claude-sonnet-4-6", piSessionId: "LIVE-1" });
|
|
295
|
+
const bridge = enriched.find((s) => s.name === "entwurf-bridge") as Extract<AcpMcpServer, { command: string }>;
|
|
296
|
+
const env = Object.fromEntries(bridge.env.map((e) => [e.name, e.value]));
|
|
297
|
+
assert.equal(env.PI_SESSION_ID, "LIVE-1", "live PI_SESSION_ID wins over the stale operator value");
|
|
298
|
+
assert.equal(env.PI_AGENT_ID, "entwurf/claude-sonnet-4-6", "PI_AGENT_ID injected from the model id");
|
|
299
|
+
assert.equal(env.FOO, "bar", "operator env preserved");
|
|
300
|
+
const weather = enriched.find((s) => s.name === "weather");
|
|
301
|
+
assert.ok(!("env" in (weather ?? {})), "http server not enriched (no env carrier)");
|
|
302
|
+
const other = enriched.find((s) => s.name === "other") as Extract<AcpMcpServer, { command: string }>;
|
|
303
|
+
assert.deepEqual(other.env, [], "a non-bridge stdio server keeps its (empty) env untouched");
|
|
304
|
+
// enrich does NOT mutate the normalized list (hash taken earlier stays valid).
|
|
305
|
+
assert.equal(
|
|
306
|
+
normalizeMcpServers({ "entwurf-bridge": { command: "node", env: { PI_SESSION_ID: "STALE", FOO: "bar" } } })
|
|
307
|
+
.servers[0].name,
|
|
308
|
+
"entwurf-bridge",
|
|
309
|
+
"enrich is a pure map — the source normalized list is unchanged",
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
console.log(
|
|
314
|
+
"[check-acp-config] ok — defaults (strict-mcp on, [] sources, baseline tools); project overrides global only for " +
|
|
315
|
+
"defined keys; mcpServers merge per-name with project win; invalid mcpServers/skillPlugins fail loud naming the " +
|
|
316
|
+
"offender; nonempty skillPlugins auto-add Skill+Skill(*); appendSystemPrompt:true and strictMcpConfig:false are " +
|
|
317
|
+
"rejected; mcp hash is sorted+deterministic and sensitive to command/args/env/url/headers; envelope enrich injects " +
|
|
318
|
+
"PI_SESSION_ID/PI_AGENT_ID into entwurf-bridge only (stale filtered), leaves http/other stdio untouched, post-hash",
|
|
319
|
+
);
|
|
320
|
+
} finally {
|
|
321
|
+
rmSync(root, { recursive: true, force: true });
|
|
322
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// Deterministic gate for the S2c ACP→pi event mapper + context conversion.
|
|
2
|
+
//
|
|
3
|
+
// Feeds synthetic ACP session_notification updates through the mapper and
|
|
4
|
+
// asserts the emitted pi AssistantMessageEvent sequence, including the critical
|
|
5
|
+
// boundary: ACP tool_call / tool_call_update render as TEXT NOTICES, never as
|
|
6
|
+
// structured toolcall_* events (the ACP child already executed the tool — a
|
|
7
|
+
// structured pi ToolCall would trigger re-execution). Also locks the S2c
|
|
8
|
+
// context→ACP-prompt conversion: transcript passthrough that excludes
|
|
9
|
+
// systemPrompt and never replays structured tools.
|
|
10
|
+
//
|
|
11
|
+
// Pure/deterministic — IN pnpm check.
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from "node:assert";
|
|
14
|
+
import type { AssistantMessageEvent, Context } from "@earendil-works/pi-ai";
|
|
15
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
16
|
+
import { contextToAcpPrompt, contextTranscript } from "../pi-extensions/lib/acp/context.ts";
|
|
17
|
+
import {
|
|
18
|
+
type AcpPiStreamState,
|
|
19
|
+
applyAcpSessionUpdate,
|
|
20
|
+
createAcpStreamState,
|
|
21
|
+
finalizeAcpStreamState,
|
|
22
|
+
} from "../pi-extensions/lib/acp/event-mapper.ts";
|
|
23
|
+
|
|
24
|
+
const IDENTITY = { api: "entwurf", provider: "entwurf", model: "claude-sonnet-4-6" };
|
|
25
|
+
|
|
26
|
+
// Drive a set of synthetic updates through a fresh mapper and collect every
|
|
27
|
+
// emitted event (push is synchronous; the terminal done drains the queue).
|
|
28
|
+
async function drive(
|
|
29
|
+
updates: Array<Record<string, unknown>>,
|
|
30
|
+
): Promise<{ events: AssistantMessageEvent[]; state: AcpPiStreamState }> {
|
|
31
|
+
const stream = createAssistantMessageEventStream();
|
|
32
|
+
const state = createAcpStreamState(stream, IDENTITY, { timestamp: 0 });
|
|
33
|
+
stream.push({ type: "start", partial: state.output });
|
|
34
|
+
for (const u of updates) applyAcpSessionUpdate(state, u);
|
|
35
|
+
finalizeAcpStreamState(state);
|
|
36
|
+
stream.push({ type: "done", reason: "stop", message: state.output });
|
|
37
|
+
const events: AssistantMessageEvent[] = [];
|
|
38
|
+
for await (const ev of stream) events.push(ev);
|
|
39
|
+
return { events, state };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const types = (events: AssistantMessageEvent[]): string[] => events.map((e) => e.type);
|
|
43
|
+
const deltas = (events: AssistantMessageEvent[]): string[] =>
|
|
44
|
+
events
|
|
45
|
+
.filter((e): e is Extract<AssistantMessageEvent, { type: "text_delta" }> => e.type === "text_delta")
|
|
46
|
+
.map((e) => e.delta);
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// 1) agent_message_chunk → text_start / text_delta(s) / text_end
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
{
|
|
52
|
+
const { events } = await drive([
|
|
53
|
+
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello" } },
|
|
54
|
+
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: " world" } },
|
|
55
|
+
]);
|
|
56
|
+
assert.deepEqual(
|
|
57
|
+
types(events),
|
|
58
|
+
["start", "text_start", "text_delta", "text_delta", "text_end", "done"],
|
|
59
|
+
"text chunk event sequence",
|
|
60
|
+
);
|
|
61
|
+
assert.deepEqual(deltas(events), ["Hello", " world"], "text deltas accumulate in order");
|
|
62
|
+
const end = events.find((e) => e.type === "text_end") as Extract<AssistantMessageEvent, { type: "text_end" }>;
|
|
63
|
+
assert.equal(end.content, "Hello world", "text_end carries the full accumulated text");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// 2) agent_thought_chunk → thinking_*; switching text↔thinking closes the prior
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
{
|
|
70
|
+
const { events } = await drive([
|
|
71
|
+
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "pre" } },
|
|
72
|
+
{ sessionUpdate: "agent_thought_chunk", content: { type: "text", text: "hmm" } },
|
|
73
|
+
{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "post" } },
|
|
74
|
+
]);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
types(events),
|
|
77
|
+
[
|
|
78
|
+
"start",
|
|
79
|
+
"text_start",
|
|
80
|
+
"text_delta",
|
|
81
|
+
"text_end",
|
|
82
|
+
"thinking_start",
|
|
83
|
+
"thinking_delta",
|
|
84
|
+
"thinking_end",
|
|
85
|
+
"text_start",
|
|
86
|
+
"text_delta",
|
|
87
|
+
"text_end",
|
|
88
|
+
"done",
|
|
89
|
+
],
|
|
90
|
+
"text→thinking→text closes each block before opening the next",
|
|
91
|
+
);
|
|
92
|
+
const tDelta = events.find((e) => e.type === "thinking_delta") as Extract<
|
|
93
|
+
AssistantMessageEvent,
|
|
94
|
+
{ type: "thinking_delta" }
|
|
95
|
+
>;
|
|
96
|
+
assert.equal(tDelta.delta, "hmm", "thinking delta carried through");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// 3) tool_call / tool_call_update → TEXT NOTICE, never structured toolcall_*
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
{
|
|
103
|
+
const { events, state } = await drive([
|
|
104
|
+
{ sessionUpdate: "tool_call", toolCallId: "t1", title: "Bash" },
|
|
105
|
+
{
|
|
106
|
+
sessionUpdate: "tool_call_update",
|
|
107
|
+
toolCallId: "t1",
|
|
108
|
+
status: "completed",
|
|
109
|
+
rawOutput: [{ type: "text", text: "result body" }],
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
// The hard invariant: zero structured tool-call events.
|
|
113
|
+
for (const t of types(events)) {
|
|
114
|
+
assert.ok(!t.startsWith("toolcall"), `tool events must NOT surface as structured toolcall_* (saw ${t})`);
|
|
115
|
+
}
|
|
116
|
+
const noticeText = deltas(events).join("");
|
|
117
|
+
assert.match(noticeText, /\[tool:start\] Bash/, "tool_call renders a [tool:start] notice");
|
|
118
|
+
assert.match(
|
|
119
|
+
noticeText,
|
|
120
|
+
/\[tool:done\] Bash — result body/,
|
|
121
|
+
"completed tool_call_update renders a [tool:done] notice with summary",
|
|
122
|
+
);
|
|
123
|
+
// The running message must contain only text content blocks (no ToolCall).
|
|
124
|
+
for (const c of state.output.content) {
|
|
125
|
+
assert.equal((c as { type: string }).type, "text", "notices land as text content, never a ToolCall");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// 3b) MCP-level error (isError) on a completed call → [tool:failed]
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
{
|
|
133
|
+
const { events } = await drive([
|
|
134
|
+
{ sessionUpdate: "tool_call", toolCallId: "t2", title: "Write" },
|
|
135
|
+
{
|
|
136
|
+
sessionUpdate: "tool_call_update",
|
|
137
|
+
toolCallId: "t2",
|
|
138
|
+
status: "completed",
|
|
139
|
+
rawOutput: { isError: true, content: [{ type: "text", text: "nope" }] },
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
const noticeText = deltas(events).join("");
|
|
143
|
+
assert.match(noticeText, /\[tool:failed\] Write — nope/, "isError completed call renders [tool:failed]");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// 3c) cancelled status → [tool:cancelled]
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
{
|
|
150
|
+
const { events } = await drive([
|
|
151
|
+
{ sessionUpdate: "tool_call", toolCallId: "t3", title: "Bash" },
|
|
152
|
+
{ sessionUpdate: "tool_call_update", toolCallId: "t3", status: "cancelled" },
|
|
153
|
+
]);
|
|
154
|
+
const noticeText = deltas(events).join("");
|
|
155
|
+
assert.match(noticeText, /\[tool:cancelled\] Bash/, "cancelled tool_call_update renders [tool:cancelled]");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// 4) usage_update → mutates the running message usage
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
{
|
|
162
|
+
const { state } = await drive([{ sessionUpdate: "usage_update", used: 1234, cost: { amount: 0.05 } }]);
|
|
163
|
+
assert.equal(state.output.usage.totalTokens, 1234, "usage_update.used → totalTokens");
|
|
164
|
+
assert.equal(state.output.usage.cost.total, 0.05, "usage_update.cost.amount → cost.total");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// 5) notice suppression — showToolNotifications:false drops tool notices
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
{
|
|
171
|
+
const stream = createAssistantMessageEventStream();
|
|
172
|
+
const state = createAcpStreamState(stream, IDENTITY, { timestamp: 0, showToolNotifications: false });
|
|
173
|
+
stream.push({ type: "start", partial: state.output });
|
|
174
|
+
applyAcpSessionUpdate(state, { sessionUpdate: "tool_call", toolCallId: "t1", title: "Bash" });
|
|
175
|
+
finalizeAcpStreamState(state);
|
|
176
|
+
stream.push({ type: "done", reason: "stop", message: state.output });
|
|
177
|
+
const events: AssistantMessageEvent[] = [];
|
|
178
|
+
for await (const ev of stream) events.push(ev);
|
|
179
|
+
assert.deepEqual(types(events), ["start", "done"], "suppressed notices emit no text events");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// 6) context conversion — transcript passthrough, no systemPrompt, single block
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
{
|
|
186
|
+
const context: Context = {
|
|
187
|
+
systemPrompt: "SECRET-SYSTEM-PROMPT-DO-NOT-LEAK",
|
|
188
|
+
tools: [{ name: "x", description: "d", parameters: {} as never }],
|
|
189
|
+
messages: [
|
|
190
|
+
{ role: "user", content: "first question", timestamp: 0 },
|
|
191
|
+
{
|
|
192
|
+
role: "assistant",
|
|
193
|
+
content: [
|
|
194
|
+
{ type: "thinking", thinking: "private reasoning" },
|
|
195
|
+
{ type: "text", text: "an answer" },
|
|
196
|
+
{ type: "toolCall", id: "c1", name: "Bash", arguments: {} },
|
|
197
|
+
],
|
|
198
|
+
api: "x",
|
|
199
|
+
provider: "x",
|
|
200
|
+
model: "x",
|
|
201
|
+
usage: {
|
|
202
|
+
input: 0,
|
|
203
|
+
output: 0,
|
|
204
|
+
cacheRead: 0,
|
|
205
|
+
cacheWrite: 0,
|
|
206
|
+
totalTokens: 0,
|
|
207
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
208
|
+
},
|
|
209
|
+
stopReason: "stop",
|
|
210
|
+
timestamp: 0,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
role: "toolResult",
|
|
214
|
+
toolCallId: "c1",
|
|
215
|
+
toolName: "Bash",
|
|
216
|
+
content: [{ type: "text", text: "tool body" }],
|
|
217
|
+
isError: false,
|
|
218
|
+
timestamp: 0,
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
role: "user",
|
|
222
|
+
content: [
|
|
223
|
+
{ type: "image", data: "BASE64IMAGEDATA", mimeType: "image/png" },
|
|
224
|
+
{ type: "text", text: "follow up" },
|
|
225
|
+
],
|
|
226
|
+
timestamp: 0,
|
|
227
|
+
},
|
|
228
|
+
],
|
|
229
|
+
};
|
|
230
|
+
const transcript = contextTranscript(context);
|
|
231
|
+
assert.ok(!transcript.includes("SECRET-SYSTEM-PROMPT"), "transcript must NOT leak context.systemPrompt");
|
|
232
|
+
assert.ok(!transcript.includes("private reasoning"), "transcript must NOT include assistant thinking");
|
|
233
|
+
assert.match(transcript, /User: first question/, "includes prior user turn");
|
|
234
|
+
assert.match(transcript, /Assistant: an answer/, "includes assistant text");
|
|
235
|
+
assert.match(transcript, /Tool result \(Bash\): tool body/, "includes tool result as plain text");
|
|
236
|
+
assert.match(transcript, /follow up/, "includes the last user turn");
|
|
237
|
+
// image content is NOT silently dropped — it leaves a marker, never raw data.
|
|
238
|
+
assert.match(transcript, /\[image omitted: image\/png\]/, "image content leaves a text marker");
|
|
239
|
+
assert.ok(!transcript.includes("BASE64IMAGEDATA"), "raw image data must never enter the transcript");
|
|
240
|
+
|
|
241
|
+
const prompt = contextToAcpPrompt(context);
|
|
242
|
+
assert.equal(prompt.length, 1, "prompt is a single text block");
|
|
243
|
+
assert.equal(prompt[0].type, "text", "prompt block is text");
|
|
244
|
+
assert.equal(prompt[0].text, transcript, "prompt text equals the transcript");
|
|
245
|
+
|
|
246
|
+
assert.deepEqual(contextToAcpPrompt({ messages: [] }), [], "empty history → empty prompt array");
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
console.log(
|
|
250
|
+
"[check-acp-event-mapper] ok — text/thinking block lifecycle, tool_call→text-notice (no structured toolcall_*), " +
|
|
251
|
+
"isError→[tool:failed], cancelled→[tool:cancelled], usage mutation, notice suppression, and context transcript " +
|
|
252
|
+
"passthrough (no systemPrompt/thinking, image marker not silent-drop, single block)",
|
|
253
|
+
);
|