mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
fix(runner): admit Pi MCP names and bound native questions
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
97217c5313
commit
bb00c85b69
19 files changed
+244
-45
No files matched your search
@@ -1,8 +1,31 @@
|
||||
# Pi rich ACP runtime
|
||||
|
||||
Status: implementation candidate, 2026-09-29. Profile version 6 adds the native
|
||||
Status: implementation candidate, 2026-09-29. Current profile version 7 repairs
|
||||
MCP tool-name admission and native question labels. MCP names containing colons
|
||||
or periods, or exceeding model name limits, receive deterministic collision-checked
|
||||
aliases of at most 64 characters; authenticated calls retain the exact original
|
||||
name. Titles and select labels admit at most 1,000 UTF-16 code units, matching
|
||||
the canonical question contract. Invalid owned arguments fail before a UI promise;
|
||||
unsupported external UI requests emit an explicit error notice and stop the session.
|
||||
The editor draft requires the shared `initialText` normalization and editable UI
|
||||
roundtrip. These repairs change the native closure and require fresh qualification;
|
||||
no v7 paid Product or Runner proof exists yet. The canonical
|
||||
[v7 declaration](../../packages/paperclip-runner/test-fixtures/pi-acp/profile-v7-identity.json)
|
||||
binds the exact wrapper, helper, extension and three target closures.
|
||||
|
||||
Credential-free regressions run the pinned SDK with synthetic model streams to
|
||||
verify all four question methods and original MCP call names behind aliases. The
|
||||
actual patched wrapper proves visible unsupported-dialog failure; the real form
|
||||
normalizer verifies the 1,000-unit ASCII, CJK and surrogate-pair boundaries. The
|
||||
closed snapshot admission test exercises the candidate assets without inference.
|
||||
|
||||
Profile version 6 added the native
|
||||
question tool, the authenticated agent-files root, and matching SDK path
|
||||
normalization. Its authenticated Product and Runner qualification is pending.
|
||||
The later v6 Product hello on source `97217c531` passed with one observed attempt
|
||||
and an exclusive-key cost delta of $0.00065884. Its launcher omitted the explicit
|
||||
zero-retry CLI flag, so it is retained as `passed_with_retry_policy_gap`, never
|
||||
as v7 qualification.
|
||||
The first frozen v6 Product hello attempt fails before provider startup because
|
||||
the shared native-execution parser still admits only profile versions 1–5.
|
||||
Both assignment and automatic recovery runs reject the same input; no usage
|
||||
@@ -135,7 +158,7 @@ lifecycle correlation and stable, disjoint historical replay identities.
|
||||
The historical v5 command digest is
|
||||
`sha256:020d96ccbd3c45c3f62680814776394ed5a56d9572a1a4dccda56a74d16c7803`.
|
||||
Versions 1–4 cannot reopen under this identity. Paid and image proofs below remain
|
||||
evidence of their recorded versions, not v6 qualification. The historical v4
|
||||
evidence of their recorded versions, not v7 qualification. The historical v4
|
||||
digest is `sha256:2324d9b47650c12b16f8e2c44dc33637d52f1b22ba8e914623eac4049e7e1991`.
|
||||
|
||||
## Lifetime and recovery
|
||||
@@ -176,7 +199,7 @@ Credential-free tests exercise all four methods through the actual pinned Pi
|
||||
AgentSession tool dispatcher and separately through the installed ACP wrapper's
|
||||
form transport. They do not establish browser persistence, reconnect delivery,
|
||||
provider-death expiry, or live model behavior. Those remain distinct Product
|
||||
qualification requirements for version 6.
|
||||
qualification requirements for version 7.
|
||||
|
||||
## Verified launch and packaging contract
|
||||
|
||||
@@ -397,7 +420,7 @@ that those targets have executed successfully.
|
||||
|
||||
### Installation authority and token semantics
|
||||
|
||||
`verifyPiInstallation(profile)` admits only profile version 6 and the source-owned
|
||||
`verifyPiInstallation(profile)` admits only profile version 7 and the source-owned
|
||||
Pi identity. It resolves `provider-assets/pi/<platform>-<arch>` inside the verified
|
||||
Runner package, checks the complete runtime against source-pinned closure hashes,
|
||||
and opens a guarded immutable native snapshot. The snapshot bootstrap binds Node,
|
||||
|
||||
@@ -170,7 +170,7 @@ impl AcpxProviderDescriptor {
|
||||
"0.0.33",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
|
||||
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
|
||||
),
|
||||
"cursor" => (
|
||||
self.model.as_str(),
|
||||
@@ -2740,7 +2740,7 @@ mod tests {
|
||||
"pi",
|
||||
"pi-acp",
|
||||
"0.0.33",
|
||||
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
|
||||
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
|
||||
Some("@earendil-works/pi-coding-agent"),
|
||||
Some("0.84.2"),
|
||||
"openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ use sha2::{Digest, Sha256};
|
||||
const CODEX_ACPX_DIGEST: &str =
|
||||
"sha256:c4538599d1ab767db5dff50934f13bb5ba313a59d9c4a83e993fac4617ea63d3";
|
||||
const PI_ACPX_DIGEST: &str =
|
||||
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f";
|
||||
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761";
|
||||
|
||||
fn temporary_directory(label: &str) -> PathBuf {
|
||||
let nonce = SystemTime::now()
|
||||
|
||||
@@ -19,8 +19,8 @@ const patchPath = join(workspaceRoot, "patches/pi-acp@0.0.33.patch");
|
||||
const supportedTargets = new Set(["darwin-arm64", "darwin-x64", "linux-x64"]);
|
||||
export const PI_DISTRIBUTION_PINS = Object.freeze({
|
||||
wrapper: "0.0.33", runtime: "0.84.2", sdk: "0.26.0", zod: "3.25.76", nodeVersion: PI_NODE_VERSION, undici: "8.10.2", nodeBundledUndici: "7.29.1",
|
||||
wrapperSha256: "dc4786faff30942e82106c87a8984a75fb979a925ff1d62648500a642b920cac",
|
||||
helperSha256: "2df24aee67ca6a5cc813e1f87324f51a72f6a104757daed97a09cbbcb63626d4",
|
||||
wrapperSha256: "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095",
|
||||
helperSha256: "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d",
|
||||
});
|
||||
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
||||
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
})).toThrow("eventExpiryDays");
|
||||
});
|
||||
|
||||
it.each([1, 2, 3, 4, 5, 6] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
it.each([1, 2, 3, 4, 5, 6, 7] as const)("accepts only a closed ACPX profile matching the driver and agent at profile version %s", (agentProfileVersion) => {
|
||||
const provider = {
|
||||
kind: "acpx",
|
||||
agent: "pi",
|
||||
@@ -328,7 +328,7 @@ describe("NativeExecutionInputV1", () => {
|
||||
profile: provider.profile,
|
||||
});
|
||||
expect(parseNativeExecutionInput(parsed)).toEqual(parsed);
|
||||
for (const unsupportedVersion of [0, 7, 1.5, "6", null]) {
|
||||
for (const unsupportedVersion of [0, 8, 1.5, "7", null]) {
|
||||
expect(() => parseNativeExecutionInput({
|
||||
...input,
|
||||
session: { ...input.session, driverKind: "acpx_runtime" },
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface NativeAcpxProfileSnapshot {
|
||||
protocolVersion: 1;
|
||||
acpxVersion: "0.13.1";
|
||||
agent: NativeAcpxAgent;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
agentProfileVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7;
|
||||
agentServerPackage: string;
|
||||
agentServerVersion: string;
|
||||
agentRuntimePackage: string | null;
|
||||
@@ -601,7 +601,7 @@ export function parseNativeExecutionInput(value: unknown): NativeExecutionInput
|
||||
|| profile.protocolVersion !== 1
|
||||
|| profile.acpxVersion !== "0.13.1"
|
||||
|| profile.agent !== provider.agent
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6)
|
||||
|| (profile.agentProfileVersion !== 1 && profile.agentProfileVersion !== 2 && profile.agentProfileVersion !== 3 && profile.agentProfileVersion !== 4 && profile.agentProfileVersion !== 5 && profile.agentProfileVersion !== 6 && profile.agentProfileVersion !== 7)
|
||||
) {
|
||||
throw new NativeExecutionInputError("input.provider.profile does not match the qualified ACPX v1 profile");
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from "node:fs/promis
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeAcpFormElicitation } from "./acp-question-adapter.js";
|
||||
import { createPiLaunchSpec, PiToolIdentities, PiRpcFrames, PiTurnUsage, PiUiBridge } from "./pi-acp-runtime.js";
|
||||
|
||||
const temporary: string[] = [];
|
||||
@@ -68,6 +69,38 @@ describe("Pi ACP bridge", () => {
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q1", ...expected });
|
||||
});
|
||||
|
||||
it("retains blank editor prefill and distinguishes an accepted empty string from cancellation", async () => {
|
||||
const f = fixture(); f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "" } });
|
||||
await f.bridge.handle({ id: "blank", method: "editor", title: "Draft", prefill: "" });
|
||||
expect(f.connection.unstable_createElicitation.mock.calls[0]![0]).toMatchObject({ requestedSchema: { properties: { answer: { default: "" } } } });
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "blank", value: "" });
|
||||
f.connection.unstable_createElicitation.mockResolvedValue({ action: "cancel" });
|
||||
await f.bridge.handle({ id: "dismissed", method: "editor", title: "Draft", prefill: "" });
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "dismissed", cancelled: true });
|
||||
});
|
||||
|
||||
it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("passes the canonical character limit through the real form normalizer", async (label) => {
|
||||
const f = fixture();
|
||||
f.connection.unstable_createElicitation.mockImplementation(async request => {
|
||||
const normalized = normalizeAcpFormElicitation(request)!;
|
||||
expect(normalized.questionSet.title).toBe(label);
|
||||
expect(normalized.questionSet.questions[0]!.options![0]!.label).toBe(label);
|
||||
return normalized.accept({ schema: "paperclip.question_response.v1", answers: { [normalized.questionSet.questions[0]!.id]: { selectedOptionIds: ["option-1"] } } });
|
||||
});
|
||||
await f.bridge.handle({ id: "boundary", method: "select", title: label, options: [label] });
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledWith({ id: "boundary", value: label });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ title: "a".repeat(1001), options: ["ok"] },
|
||||
{ title: "ok", options: ["界".repeat(1001)] },
|
||||
])("reports external unsupported questions without presenting or silently cancelling them", async extra => {
|
||||
const f = fixture();
|
||||
await expect(f.bridge.handle({ id: "oversize", method: "select", ...extra })).rejects.toThrow("unsupported or invalid");
|
||||
expect(f.connection.unstable_createElicitation).not.toHaveBeenCalled();
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "oversize", cancelled: true });
|
||||
});
|
||||
|
||||
it("sends only offered native permission choices and keeps questions separate", async () => {
|
||||
const f = fixture(); f.connection.requestPermission.mockResolvedValue({ outcome: { outcome: "selected", optionId: "allow_always" } });
|
||||
const event = { id: "p1", method: "select", title: 'paperclip.pi.permission.v1:{"toolCallId":"tool-a","nativeToolCallId":"native-a","modelIteration":1,"toolName":"bash","input":{"command":"pwd"}}' };
|
||||
@@ -92,10 +125,10 @@ describe("Pi ACP bridge", () => {
|
||||
f.connection.unstable_createElicitation.mockImplementation(() => new Promise((resolve) => { answer = resolve; }));
|
||||
const request = f.bridge.handle({ id: "q", method: "select", title: "Question", options: ["A"], timeout: 100 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
answer({ action: "accept", content: { answer: "A" } }); await request;
|
||||
answer({ action: "accept", content: { answer: "not-an-offered-option" } }); await request;
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenCalledExactlyOnceWith({ id: "q", cancelled: true });
|
||||
f.connection.unstable_createElicitation.mockResolvedValue({ action: "accept", content: { answer: "other" } });
|
||||
await f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] });
|
||||
await expect(f.bridge.handle({ id: "q2", method: "select", title: "Question", options: ["A"] })).rejects.toThrow("unsupported or invalid");
|
||||
expect(f.process.sendExtensionUiResponse).toHaveBeenLastCalledWith({ id: "q2", cancelled: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -227,6 +227,13 @@ export function createPiLaunchSpec(
|
||||
return { command, args, invocationNamespace, env };
|
||||
}
|
||||
|
||||
// Same UTF-16 character bound as the canonical question-set title/header/label.
|
||||
export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000;
|
||||
export function piQuestionLabel(value: unknown): string {
|
||||
if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized");
|
||||
return value;
|
||||
}
|
||||
|
||||
interface UiConnection {
|
||||
requestPermission(params: RecordValue): Promise<unknown>;
|
||||
unstable_createElicitation(params: RecordValue): Promise<unknown>;
|
||||
@@ -282,10 +289,11 @@ export class PiUiBridge {
|
||||
await finish(selected ? { value: selected.name } : { cancelled: true });
|
||||
return;
|
||||
}
|
||||
piQuestionLabel(title);
|
||||
const property: RecordValue = { type: method === "confirm" ? "boolean" : "string", title };
|
||||
if (method === "select") {
|
||||
if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
|
||||
property.enum = event.options.map((option) => text(option, 4096));
|
||||
property.enum = event.options.map((option) => piQuestionLabel(option));
|
||||
if (new Set(property.enum as string[]).size !== event.options.length) throw new Error("Ambiguous Pi question options");
|
||||
}
|
||||
if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
|
||||
@@ -305,7 +313,11 @@ export class PiUiBridge {
|
||||
if (method === "select" && !(property.enum as string[]).includes(value)) throw new Error("Invalid Pi question response");
|
||||
await finish({ value });
|
||||
}
|
||||
} catch { await finish({ cancelled: true }).catch(() => {}); }
|
||||
} catch {
|
||||
if (done) return; // An expired request cannot fail a later live session.
|
||||
await finish({ cancelled: true }).catch(() => {});
|
||||
throw new Error("Pi structured question is unsupported or invalid");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* three pins. Never accept a digest supplied only by an installed manifest.
|
||||
*/
|
||||
export const PI_DISTRIBUTION_CLOSURE_SHA256 = Object.freeze({
|
||||
"darwin-arm64": "b2ce6bb2d9b93c4265b2c363cbf1433e06bb3349327f59b6f8eb3fdbd1b03d6a",
|
||||
"darwin-x64": "a9269f6e0675ad1b50b24e76d47b971d4238ff25ddd57074b707db2a9bc83a74",
|
||||
"linux-x64": "05eb422d9ce9e32b081cb2d2a3433dc113d66a55f993125b74b1666002cf3b67",
|
||||
"darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128",
|
||||
"darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12",
|
||||
"linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2",
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { QUALIFIED_ACPX_PROFILES, type QualifiedAcpxProfile } from "./qualified-
|
||||
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { vi.unstubAllEnvs(); await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))); });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 6 });
|
||||
const candidate = (): QualifiedAcpxProfile => ({ ...QUALIFIED_ACPX_PROFILES.pi, agentProfileVersion: 7 });
|
||||
|
||||
async function fixture() {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-installation-test-")); roots.push(root);
|
||||
@@ -30,7 +30,7 @@ describe("Pi installation factory", () => {
|
||||
const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]`
|
||||
: value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value);
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v6-identity.json", import.meta.url), "utf8"));
|
||||
const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v7-identity.json", import.meta.url), "utf8"));
|
||||
const declaration = identity.declaration;
|
||||
expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`);
|
||||
expect(QUALIFIED_ACPX_PROFILES.pi.commandDigest).toBe(identity.commandDigest);
|
||||
@@ -46,18 +46,19 @@ describe("Pi installation factory", () => {
|
||||
const materializer = await readFile(new URL("../../../scripts/materialize-pi-distribution.mjs", import.meta.url), "utf8");
|
||||
expect(materializer).toContain(`wrapperSha256: "${declaration.wrapperSha256}"`);
|
||||
expect(materializer).toContain(`helperSha256: "${declaration.helperSha256}"`);
|
||||
// This exact patch produced the independently verified v6 wrapper/closure.
|
||||
// This exact patch produced the independently verified v7 wrapper/closure.
|
||||
// Pin it separately so wrapper-only edits cannot keep an unchanged declaration.
|
||||
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("bae0f2d9c866852a8d72883ddb7cc83af3e157d1638f9aa494670304913f6aea");
|
||||
expect(hash(await readFile(new URL("../../../../../patches/pi-acp@0.0.33.patch", import.meta.url)))).toBe("4f316d89e63ff47854f93faf5805420cd8b21e730b46a025406db87df86fa5fa");
|
||||
});
|
||||
|
||||
it("rejects legacy profiles and caller-selected identities", () => {
|
||||
expect(() => assertPiInstallationProfile(candidate())).not.toThrow();
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 6");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 6");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 6");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 6");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 6");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 7");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 7");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 7");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 7");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 7");
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 7");
|
||||
for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { reportedModelId: "different" }, { agentRuntimePackage: "ambient-pi" }]) {
|
||||
expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration");
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ function record(value: unknown): Record<string, unknown> {
|
||||
/** Candidate identity is build-owned; model selection cannot substitute a CLI. */
|
||||
export function assertPiInstallationProfile(profile: QualifiedAcpxProfile): void {
|
||||
const trusted = QUALIFIED_ACPX_PROFILES.pi;
|
||||
if (profile.agentProfileVersion !== 6) throw new Error("Pi rich ACP requires profile version 6; reopen the previous session");
|
||||
if (profile.agentProfileVersion !== 7) throw new Error("Pi rich ACP requires profile version 7; reopen the previous session");
|
||||
if (profile.agent !== "pi" || profile.driverKind !== trusted.driverKind || profile.protocolVersion !== trusted.protocolVersion || profile.acpxVersion !== trusted.acpxVersion || profile.agentServerPackage !== "pi-acp" || profile.agentServerVersion !== "0.0.33" || profile.agentRuntimePackage !== "@earendil-works/pi-coding-agent" || profile.agentRuntimeVersion !== "0.84.2" || profile.commandDigest !== trusted.commandDigest || profile.permissionPolicy !== "interactive" || profile.qualificationModel !== PI_MODEL || profile.reportedModelId !== PI_MODEL) throw new Error("Pi distribution profile differs from its trusted declaration");
|
||||
}
|
||||
|
||||
|
||||
@@ -97,12 +97,44 @@ describe("owned Pi runtime extension", () => {
|
||||
{ method: "input", title: "Question", command: "touch forbidden" },
|
||||
{ method: "editor", title: "Question", prefill: "x".repeat(16385) },
|
||||
{ method: "multi-select", title: "Question" },
|
||||
{ method: "input", title: "x".repeat(1001) },
|
||||
{ method: "select", title: "Question", options: [{ id: "a", label: "界".repeat(1001) }] },
|
||||
];
|
||||
for (const [index, args] of invalid.entries()) await expect(h.nativeTools[0]!.execute(`invalid-${index}`, args, undefined, undefined, context)).rejects.toThrow();
|
||||
expect(Object.values(ui).every(fn => fn.mock.calls.length === 0)).toBe(true);
|
||||
const abort = new AbortController(); abort.abort();
|
||||
expect((await h.nativeTools[0]!.execute("cancelled", { method: "input", title: "Question" }, abort.signal, undefined, context)).details).toEqual({ status: "cancelled" });
|
||||
});
|
||||
it("aliases MCP punctuation and long names without changing authenticated call identity", async () => {
|
||||
const { config } = await workspace(); const h = harness();
|
||||
const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)];
|
||||
const request = vi.fn(async (_server, method) => method === "tools/list"
|
||||
? { tools: names.map(name => ({ name, inputSchema: { type: "object", properties: {} } })) }
|
||||
: { content: [{ type: "text", text: "recorded" }] });
|
||||
const assigned = { ...config, servers: [{ type: "http" as const, name: "paperclip", url: "http://127.0.0.1:1234", headers: [] }] };
|
||||
await installPiRuntimeExtension(h.api, assigned, request); h.handlers.get("turn_start")!();
|
||||
expect(h.tools.map(tool => tool.name).every(name => /^[A-Za-z0-9_-]{1,64}$/.test(name))).toBe(true);
|
||||
expect(new Set(h.tools.map(tool => tool.name)).size).toBe(names.length);
|
||||
for (const [index, tool] of h.tools.entries()) {
|
||||
await tool.execute(`call-${index}`, { index });
|
||||
expect(request).toHaveBeenLastCalledWith(assigned.servers[0], "tools/call", { name: names[index], arguments: { index } }, expect.any(String), undefined);
|
||||
}
|
||||
const again = harness(); await installPiRuntimeExtension(again.api, assigned, request);
|
||||
expect(again.tools.map(tool => tool.name)).toEqual(h.tools.map(tool => tool.name));
|
||||
const duplicate = harness();
|
||||
await expect(installPiRuntimeExtension(duplicate.api, assigned, async (_server, method) => method === "tools/list"
|
||||
? { tools: [names[0], names[0]].map(name => ({ name, inputSchema: { type: "object" } })) } : {})).rejects.toThrow("ambiguous");
|
||||
expect(duplicate.api.registerCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["a".repeat(1000), "界".repeat(1000), "🌒".repeat(500)])("admits the exact shared label boundary before native UI", async label => {
|
||||
const { config } = await workspace(); const h = harness();
|
||||
await installPiRuntimeExtension(h.api, config); h.handlers.get("turn_start")!();
|
||||
const select = vi.fn().mockResolvedValue(label);
|
||||
expect((await h.nativeTools[0]!.execute("boundary", { method: "select", title: label, options: [{ id: "choice", label }] }, undefined, undefined, { cwd: config.workspace, ui: { select } })).details).toEqual({ status: "answered", optionId: "choice" });
|
||||
expect(select).toHaveBeenCalledWith(label, [label], expect.any(Object));
|
||||
});
|
||||
|
||||
it("requires explicit assigned configuration, authenticated HTTPS or numeric loopback HTTP", () => {
|
||||
expect(() => readPiRuntimeConfiguration({})).toThrow("missing");
|
||||
const value = { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: "/work/project", readOnly: false, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1234/mcp", headers: [{ name: "Authorization", value: "Bearer 1234567890123456" }] }] };
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* this immutable module explicitly; project/global extension discovery is off.
|
||||
* Structural types keep this module independent of Pi's optional UI packages.
|
||||
*/
|
||||
import { PiToolIdentities } from "./pi-acp-runtime.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { PI_QUESTION_LABEL_MAX_LENGTH, piQuestionLabel, PiToolIdentities } from "./pi-acp-runtime.js";
|
||||
import { lstat, realpath } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
@@ -205,7 +206,7 @@ async function askPiNativeQuestion(args: Record<string, unknown>, ui: PiUi, sign
|
||||
if (typeof value !== "string" || Buffer.byteLength(value) > limit) throw new Error("Pi question text is invalid or oversized");
|
||||
return value;
|
||||
};
|
||||
const title = boundedText(args.title, 4096);
|
||||
const title = piQuestionLabel(args.title);
|
||||
if (!title.trim() || title.startsWith(PI_PERMISSION_TITLE_PREFIX)) throw new Error("Pi question title is invalid");
|
||||
const method = args.method;
|
||||
const permitted = ["method", "title", ...(method === "select" ? ["options"] : method === "confirm" ? ["message"] : method === "input" ? ["placeholder"] : method === "editor" ? ["prefill"] : [])];
|
||||
@@ -214,7 +215,7 @@ async function askPiNativeQuestion(args: Record<string, unknown>, ui: PiUi, sign
|
||||
if (method === "select") {
|
||||
if (!Array.isArray(args.options) || args.options.length < 1 || args.options.length > 128) throw new Error("Pi question options are invalid");
|
||||
const options = args.options.map(value => {
|
||||
const option = asRecord(value); const id = boundedText(option.id, 128); const label = boundedText(option.label, 4096);
|
||||
const option = asRecord(value); const id = boundedText(option.id, 128); const label = piQuestionLabel(option.label);
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(id) || !label.trim() || Object.keys(option).some(key => !["id", "label"].includes(key))) throw new Error("Pi question option is invalid");
|
||||
return { id, label };
|
||||
});
|
||||
@@ -370,8 +371,8 @@ export async function installPiRuntimeExtension(
|
||||
name: PI_NATIVE_QUESTION_TOOL, label: "Ask a native question",
|
||||
description: "Ask the human a native Pi select, confirm, input, or editor question. Select is single-choice and returns the supplied stable option ID. This cannot approve tools or a Paperclip Plan. For durable task questions or Plan approval use the assigned Paperclip semantic tools. Cancellation is not an answer; confirm false means No or dismissal.",
|
||||
parameters: { type: "object", additionalProperties: false, required: ["method", "title"], properties: {
|
||||
method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, title: { type: "string", maxLength: 4096 },
|
||||
options: { type: "array", minItems: 1, maxItems: 128, items: { type: "object", additionalProperties: false, required: ["id", "label"], properties: { id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" }, label: { type: "string", maxLength: 4096 } } } },
|
||||
method: { type: "string", enum: ["select", "confirm", "input", "editor"] }, title: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH },
|
||||
options: { type: "array", minItems: 1, maxItems: 128, items: { type: "object", additionalProperties: false, required: ["id", "label"], properties: { id: { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" }, label: { type: "string", maxLength: PI_QUESTION_LABEL_MAX_LENGTH } } } },
|
||||
message: { type: "string", maxLength: 16384 }, placeholder: { type: "string", maxLength: 16384 }, prefill: { type: "string", maxLength: 16384 },
|
||||
} },
|
||||
async execute(callId, args, signal, _onUpdate, context) {
|
||||
@@ -389,8 +390,13 @@ export async function installPiRuntimeExtension(
|
||||
if (!Array.isArray(catalog.tools) || catalog.nextCursor) throw new Error("Pi MCP catalog is incomplete");
|
||||
for (const rawTool of catalog.tools) {
|
||||
const tool = asRecord(rawTool);
|
||||
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
|
||||
const nativeName = `mcp__${server.name}__${tool.name}`;
|
||||
if (typeof tool.name !== "string" || !/^[A-Za-z0-9_.:-]{1,128}$/.test(tool.name)) throw new Error("Pi MCP tool name is invalid");
|
||||
const readableName = `mcp__${server.name}__${tool.name}`;
|
||||
// MCP names may contain colons/periods and exceed model tool-name limits.
|
||||
// Preserve the exact source identity only in the authenticated call closure;
|
||||
// a framed digest avoids lossy replacement collisions (a:b versus a_b).
|
||||
const nativeName = /^[A-Za-z0-9_-]{1,64}$/.test(readableName) ? readableName
|
||||
: `mcp__${createHash("sha256").update(JSON.stringify([server.name, tool.name])).digest("hex").slice(0, 59)}`;
|
||||
if (bridgeTools.has(nativeName) || ++count > MAX_TOOLS) throw new Error("Pi MCP catalog is ambiguous or oversized");
|
||||
const schema = asRecord(tool.inputSchema);
|
||||
if (schema.type !== "object") throw new Error("Pi MCP tool parameters must be an object");
|
||||
|
||||
@@ -47,14 +47,14 @@ export const QUALIFIED_ACPX_PROFILES: Readonly<
|
||||
protocolVersion: ACPX_DRIVER_PROTOCOL_VERSION,
|
||||
acpxVersion: QUALIFIED_ACPX_VERSION,
|
||||
agent: "pi",
|
||||
agentProfileVersion: 6,
|
||||
agentProfileVersion: 7,
|
||||
qualificationStatus: "pending",
|
||||
agentServerPackage: "pi-acp",
|
||||
agentServerVersion: "0.0.33",
|
||||
agentRuntimePackage: "@earendil-works/pi-coding-agent",
|
||||
agentRuntimeVersion: "0.84.2",
|
||||
commandDigest:
|
||||
"sha256:d2b470e940115a1cd8a31cd5d2ddde2837912e965d3475deede09d67bff6346f",
|
||||
"sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
|
||||
qualificationModel: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
reportedModelId: "openrouter/deepseek/deepseek-v4-flash-0731",
|
||||
permissionPolicy: "interactive",
|
||||
|
||||
@@ -75,6 +75,7 @@ createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
}
|
||||
if (request.message === "auto-compact" || request.message === "retry-compact") { output({ type: "compaction_start", reason: "threshold" }); if (request.message === "retry-compact") output({ type: "summarization_retry_scheduled" }); output({ type: "compaction_end", reason: "threshold", result: compaction(), aborted: false, willRetry: true }); finish("compacted"); return; }
|
||||
if (request.message === "question") { output({ type: "extension_ui_request", id: "question-id", method: "input", title: "Project name", placeholder: "Name" }); return; }
|
||||
if (request.message === "oversized-question") { output({ type: "extension_ui_request", id: "oversized", method: "select", title: "Native question", options: ["x".repeat(1001)] }); return; }
|
||||
if (request.message.startsWith("native-question-")) {
|
||||
const method = request.message.slice("native-question-".length);
|
||||
output({ type: "extension_ui_request", id: `native-${method}`, method, title: "Native question", options: ["Red", "Blue"], message: "Continue?", placeholder: "Name", prefill: "Old\ntext" }); return;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"commandDigest": "sha256:fec5b1448f4135710887133a9192747c476a825a56c255b52b17b1780ccb3761",
|
||||
"declaration": {
|
||||
"schema": "paperclip.acpx_profile_declaration.v1",
|
||||
"agent": "pi",
|
||||
"agentProfileVersion": 7,
|
||||
"acpxVersion": "0.13.1",
|
||||
"agentServerVersion": "0.0.33",
|
||||
"agentRuntimeVersion": "0.84.2",
|
||||
"nodeVersion": "24.21.0",
|
||||
"closure": {
|
||||
"darwin-arm64": "3703361965d4e0b641fcd5eac4079e9b6c0de47b4b245a5b31b7da7b52c94128",
|
||||
"darwin-x64": "885d9fde187bd1a877838ac309aa68df4b928e0727ca341b3b8f3b1e7467ad12",
|
||||
"linux-x64": "425afafe8a1419c86333a6eaa93bf56f0e78a4dc919dcef491f13e451550c5e2"
|
||||
},
|
||||
"wrapperSha256": "2b590e8e12133a77ee71cba2a688bf1e2bc73cfb771235a823ed9cdc3cd8c095",
|
||||
"helperSha256": "153caaa32b93313a81636a28af32ca8dcce295c200853f5897e0387f79a0d92d",
|
||||
"extensionSha256": "2b74a4a2e6a42def3d9918a46732e0e1b8c618791d2b22970cfb3e9d285e55ad",
|
||||
"agentFilesBinding": "registered-runtime-context-v1",
|
||||
"nativeQuestions": "select-confirm-input-editor-v2",
|
||||
"pathPolicy": "sdk-normalization-v1",
|
||||
"mcpToolNames": "provider-safe-exact-source-v1"
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,13 @@ for (const [method, answer, expected] of [["select", "Blue", { value: "Blue" }],
|
||||
});
|
||||
}
|
||||
|
||||
test("actual patched ACP reports unsupported external UI as an error and stops the session", async t => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
await assert.rejects(f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "oversized-question" }] }), /exited/);
|
||||
assert.equal(f.requests.length, 0);
|
||||
assert.ok(f.notifications.some(event => event.params?.update?._meta?.paperclipPi?.notice?.message === "Pi structured question is unsupported or invalid; the session was stopped"));
|
||||
});
|
||||
|
||||
test("native steering is explicit and does not replace the active ACP turn", async (t) => {
|
||||
const f = await fixture(t); const session = await f.call("session/new", { cwd: join(f.root, "workspace"), mcpServers: [] });
|
||||
const prompt = f.call("session/prompt", { sessionId: session.sessionId, prompt: [{ type: "text", text: "long" }] });
|
||||
|
||||
@@ -64,7 +64,7 @@ for (const brokenCatalog of [false, true]) {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
const result = rpc.method === "initialize"
|
||||
? { protocolVersion: "2025-03-26", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
|
||||
: { tools: [{ name: brokenCatalog ? "invalid/name" : "report_progress", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] };
|
||||
: { tools: [{ name: brokenCatalog ? "invalid/name" : "connection:search", description: "Report progress", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] };
|
||||
response.end(JSON.stringify({ jsonrpc: "2.0", id: rpc.id, result }));
|
||||
});
|
||||
server.listen(0, "127.0.0.1"); await once(server, "listening");
|
||||
@@ -220,6 +220,49 @@ test("real pinned Pi executes all four owned native question methods without per
|
||||
} finally { session?.dispose(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("real pinned Pi dispatches aliased MCP tools with their exact original names", { timeout: 20_000 }, async () => {
|
||||
const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href);
|
||||
const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all(
|
||||
["sdk", "resource-loader", "model-runtime", "session-manager", "settings-manager", "auth-storage"].map(load),
|
||||
);
|
||||
const root = await realpath(await mkdtemp(join(tmpdir(), "paperclip-pi-mcp-alias-")));
|
||||
let session;
|
||||
try {
|
||||
await writeFile(join(root, "extension.mjs"), stripTypeScriptTypes(extensionSource));
|
||||
await writeFile(join(root, "pi-acp-runtime.js"), stripTypeScriptTypes(await readFile(new URL("../src/drivers/acpx/pi-acp-runtime.ts", import.meta.url), "utf8")));
|
||||
const { installPiRuntimeExtension } = await import(pathToFileURL(join(root, "extension.mjs")).href);
|
||||
const names = ["connection:search", "connection_search", "connection.search", "x".repeat(128)];
|
||||
const calls = [];
|
||||
const request = async (_server, method, params) => method === "tools/list"
|
||||
? { tools: names.map(name => ({ name, description: name, inputSchema: { type: "object", properties: { index: { type: "number" } } } })) }
|
||||
: method === "tools/call" ? (calls.push(params), { content: [{ type: "text", text: "recorded" }] }) : {};
|
||||
const settings = SettingsManager.inMemory({ compaction: { enabled: false }, retry: { enabled: false } });
|
||||
const modelRuntime = await ModelRuntime.create({ credentials: AuthStorage.inMemory({}), modelsPath: null, refreshOnCreate: false, allowModelNetwork: false });
|
||||
const model = { id: "fixture", name: "Fixture", api: "openai-completions", provider: "fixture", baseUrl: "http://127.0.0.1:1", reasoning: false, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 64 };
|
||||
const resourceLoader = new DefaultResourceLoader({ cwd: root, agentDir: root, settingsManager: settings, noExtensions: true, noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true,
|
||||
extensionFactories: [pi => installPiRuntimeExtension(pi, { invocationNamespace: "00000000-0000-4000-8000-000000000000", workspace: root, readOnly: true, readRoots: [], protectedRoots: [], instructions: "", servers: [{ type: "http", name: "paperclip", url: "http://127.0.0.1:1", headers: [] }] }, request)] });
|
||||
await resourceLoader.reload();
|
||||
({ session } = await createAgentSession({ cwd: root, agentDir: root, model, modelRuntime, settingsManager: settings, sessionManager: SessionManager.inMemory(root), resourceLoader, noTools: "builtin", thinkingLevel: "off" }));
|
||||
const errors = []; const results = [];
|
||||
await session.bindExtensions({ onError: error => errors.push(error.event) });
|
||||
session.subscribe(event => { if (event.type === "tool_execution_end") results.push(event); });
|
||||
let streams = 0;
|
||||
session.agent.streamFunction = (_model, context) => {
|
||||
const index = streams++; const original = names[index];
|
||||
const exposed = context.tools.filter(tool => names.includes(tool.description));
|
||||
assert.equal(exposed.length, names.length);
|
||||
assert.equal(new Set(exposed.map(tool => tool.name)).size, names.length);
|
||||
assert.ok(exposed.every(tool => /^[A-Za-z0-9_-]{1,64}$/.test(tool.name)));
|
||||
const name = exposed.find(tool => tool.description === original)?.name;
|
||||
const message = { role: "assistant", content: name ? [{ type: "toolCall", id: "call_0", name, arguments: { index } }] : [{ type: "text", text: "done" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, stopReason: name ? "toolUse" : "stop", timestamp: streams };
|
||||
return { async *[Symbol.asyncIterator]() { yield { type: "start", partial: message }; yield { type: "done", reason: message.stopReason, message }; }, result: async () => message };
|
||||
};
|
||||
await session.agent.prompt("fixture aliases"); await session.agent.waitForIdle();
|
||||
assert.deepEqual(errors, []); assert.equal(results.length, names.length); assert.ok(results.every(event => !event.isError));
|
||||
assert.deepEqual(calls, names.map((name, index) => ({ name, arguments: { index } })));
|
||||
} finally { session?.dispose(); await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("real pinned Pi tool dispatch admits registered agent files and rejects unassigned roots", { timeout: 20_000 }, async () => {
|
||||
const load = (name) => import(pathToFileURL(join(packageRoot, `dist/core/${name}.js`)).href);
|
||||
const [{ createAgentSession }, { DefaultResourceLoader }, { ModelRuntime }, { SessionManager }, { SettingsManager }, { AuthStorage }] = await Promise.all(
|
||||
|
||||
@@ -313,7 +313,7 @@
|
||||
if (snapshot.oldText === null || newText !== snapshot.oldText) {
|
||||
hasStructuredDiff = true;
|
||||
content = [
|
||||
@@ -1154,14 +1199,11 @@
|
||||
@@ -1154,14 +1199,16 @@
|
||||
break;
|
||||
}
|
||||
case "extension_ui_request": {
|
||||
@@ -328,7 +328,12 @@
|
||||
+ sessionUpdate: "session_info_update",
|
||||
+ _meta: { paperclipPi: { notice: { message: typeof ev.message === "string" ? ev.message.slice(0, 4096) : "Pi notification", level: ev.notifyType } } }
|
||||
});
|
||||
+ void this.uiBridge.handle(ev).catch(() => { this.proc.dispose("SIGKILL"); });
|
||||
+ void this.uiBridge.handle(ev).catch(() => {
|
||||
+ this.emit({ sessionUpdate: "session_info_update", _meta: { paperclipPi: { notice: {
|
||||
+ message: "Pi structured question is unsupported or invalid; the session was stopped", level: "error"
|
||||
+ } } } });
|
||||
+ this.proc.dispose("SIGKILL");
|
||||
+ });
|
||||
break;
|
||||
}
|
||||
case "auto_retry_start": {
|
||||
@@ -675,7 +680,7 @@
|
||||
try {
|
||||
--- /dev/null
|
||||
+++ b/dist/paperclip-runtime.js
|
||||
@@ -0,0 +1,382 @@
|
||||
@@ -0,0 +1,394 @@
|
||||
+/** Source for the helper embedded in patches/pi-acp@0.0.33.patch. */
|
||||
+import { createHash, randomUUID } from "node:crypto";
|
||||
+import { StringDecoder } from "node:string_decoder";
|
||||
@@ -905,6 +910,13 @@
|
||||
+ return { command, args, invocationNamespace, env };
|
||||
+}
|
||||
+
|
||||
+// Same UTF-16 character bound as the canonical question-set title/header/label.
|
||||
+export const PI_QUESTION_LABEL_MAX_LENGTH = 1_000;
|
||||
+export function piQuestionLabel(value ) {
|
||||
+ if (typeof value !== "string" || !value.trim() || value.length > PI_QUESTION_LABEL_MAX_LENGTH) throw new Error("Pi question label is invalid or oversized");
|
||||
+ return value;
|
||||
+}
|
||||
+
|
||||
+
|
||||
+
|
||||
+
|
||||
@@ -960,10 +972,11 @@
|
||||
+ await finish(selected ? { value: selected.name } : { cancelled: true });
|
||||
+ return;
|
||||
+ }
|
||||
+ piQuestionLabel(title);
|
||||
+ const property = { type: method === "confirm" ? "boolean" : "string", title };
|
||||
+ if (method === "select") {
|
||||
+ if (!Array.isArray(event.options) || event.options.length < 1 || event.options.length > 128) throw new Error("Invalid Pi question options");
|
||||
+ property.enum = event.options.map((option) => text(option, 4096));
|
||||
+ property.enum = event.options.map((option) => piQuestionLabel(option));
|
||||
+ if (new Set(property.enum ).size !== event.options.length) throw new Error("Ambiguous Pi question options");
|
||||
+ }
|
||||
+ if (method === "input" && typeof event.placeholder === "string") property.description = text(event.placeholder);
|
||||
@@ -983,7 +996,11 @@
|
||||
+ if (method === "select" && !(property.enum ).includes(value)) throw new Error("Invalid Pi question response");
|
||||
+ await finish({ value });
|
||||
+ }
|
||||
+ } catch { await finish({ cancelled: true }).catch(() => {}); }
|
||||
+ } catch {
|
||||
+ if (done) return; // An expired request cannot fail a later live session.
|
||||
+ await finish({ cancelled: true }).catch(() => {});
|
||||
+ throw new Error("Pi structured question is unsupported or invalid");
|
||||
+ }
|
||||
+ }
|
||||
+}
|
||||
+
|
||||
|
||||
Reference in new issue
Block a user