Implement ACPX outbound extension delivery

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 15:02:03 -05:00
1 parent 4237bc369a
commit 36e7121046
4 files changed
+63 -1

No files matched your search

@@ -42,6 +42,26 @@ prices are estimates. Unpriced usage must stay unpriced in the ledger and UI.
## Evidence so far
- At `4237bc369`, the native turn-binding correction advances the steering
journey past `steering_stale_turn`, but the provider boundary still rejects
delivery. The real patched ACPX client lacks `requestExtension`: its types
and runtime callers declare it, while its implementation omits it. A real
package regression fails with that exact TypeError before the correction,
then passes steering and follow-up during an unanswered prompt afterward.
This is a separate runtime correction and needs a fresh installed journey.
- The corrected Intel public package at `4237bc369` passes credential-free
closed startup in 37.8 seconds. Its CI typecheck, build, native tests and
browser shards pass. CI retains two failures: a resumed durability test
inherits the deliberately killed attempt's 500 ms timeout; the Linux public
Pi probe reaches `session_handshake_timeout`. The former gets an explicit
resumed-turn bound. The latter remains an admission blocker; accepting a
timeout as successful installation would weaken the release gate.
- The local Linux image build at `4237bc369` fails before a provider starts
because the committed lock does not match the source patch configuration.
The official image workflow already regenerates its private build lock;
local builds must do the same and record that resolved lock's digest.
No repository lockfile or workflow changes are required.
- `2b50801b2`: installed restart failed before native answer delivery. The
durable request turn ID differs from the provider turn ID. The browser's
issue-identifier route also differed from the matcher's UUID route.
@@ -1813,6 +1813,9 @@ describe("Capability live runnerd and Codex session", () => {
sessionId: binding.sessionId,
attemptId: "attempt-resumed",
resumeOf: "attempt-killed",
// Only the killed attempt deliberately uses the short timeout. Allow
// durable disk writes to complete during the resumed successful turn.
turnTimeoutMs: 5_000,
});
expect(resumed.snapshot().providerThreadId).toBe(state.threadId);
expect(resumed.snapshot().attempts).toMatchObject([
@@ -25,6 +25,8 @@ readline.createInterface({input:process.stdin}).on('line',(line)=>{
send({method:'fixture/ignored',params:{sessionId:'session-1'}});
send({method:'fixture/activity',params:{sessionId:'wrong-session'}});
send({id:0,method:mode==='unknown'?'fixture/not-enabled':'fixture/question',params:{sessionId:mode==='wrong-session'?'wrong-session':'session-1',value:'private-question'}});
} else if(message.method==='pi/steer' || message.method==='pi/follow_up') {
send({id:message.id,result:{accepted:true,sessionId:message.params.sessionId,disposition:'queued',message:message.params.message}});
} else if(message.method==='session/cancel') { send({id:promptId,result:{stopReason:'cancelled'}}); promptId=undefined; }
else if(message.id===0 && !message.method) {
send({method:'session/update',params:{sessionId:'session-1',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:JSON.stringify(message)}}}});
@@ -77,6 +79,29 @@ test("initialize retains mandatory capabilities while merging provider metadata"
});
});
test("real patched client sends Pi steering and follow-up while its original prompt awaits input", { timeout: 10000 }, async () => {
let observe; const started = new Promise(resolve => { observe = resolve; });
await withClient({ onExtensionRequest: async () => { observe(); return await new Promise(() => {}); } }, async client => {
const pending = client.prompt("session-1", "question");
await started;
try {
for (const method of ["pi/steer", "pi/follow_up"]) {
assert.deepEqual(await client.requestExtension(method, { sessionId: "session-1", message: "Keep the original turn" }), {
accepted: true, sessionId: "session-1", disposition: "queued", message: "Keep the original turn",
});
}
for (const method of ["session/prompt", "fs/read_text_file", "initialize", "bad method/name"]) {
await assert.rejects(client.requestExtension(method, {}), /extension method/);
}
await assert.rejects(client.requestExtension("pi/steer", []), /payload must be an object/);
await assert.rejects(client.requestExtension("pi/steer", { message: "x".repeat(256 * 1024) }), /bounded size/);
} finally {
await client.cancel("session-1");
assert.equal((await pending).stopReason, "cancelled");
}
});
});
test("unknown methods and cross-session requests never reach the host callback", { timeout: 10000 }, async () => {
let calls = 0;
await withClient({ onExtensionRequest: async () => { calls++; return {}; } }, async (client) => {
+15 -1
View File
@@ -523,7 +523,21 @@ index d454fd7c5..6eba74345 100644
}));
result = claudeAcp ? await withTimeout(createPromise, resolveClaudeAcpSessionCreateTimeoutMs()) : await createPromise;
} catch (error) {
@@ -4864,7 +5099,7 @@ var AcpClient = class {
@@ -4596,6 +4831,13 @@ var AcpClient = class {
}
}
+ async requestExtension(method, params) {
+ closedExtensionMethods([method]);
+ const payload = boundedExtensionRecord(params);
+ const connection = this.getConnection();
+ const result = await this.runConnectionRequest(() => connection.extMethod(method, payload));
+ return boundedExtensionRecord(result);
+ }
async setSessionModel(sessionId, modelId, controlOverride) {
const control = this.resolveModelControl(sessionId, controlOverride);
if (!control) throw new RequestedModelUnsupportedError(`Cannot set model "${modelId}": the ACP session did not advertise a model config option or legacy session/set_model support.`, "missing-capability");
const resolvedModelId = resolveRequestedModelId({
@@ -4864,7 +5106,7 @@ var AcpClient = class {
}
selectAuthMethod(methods) {
for (const method of methods) {