From 6b72a3c12790d5ee0bb28295786afde9321ea693 Mon Sep 17 00:00:00 2001 From: supermomonga <377137+supermomonga@users.noreply.github.com> Date: Sat, 24 Oct 2026 07:54:35 +0900 Subject: [PATCH] fix: deliver steers that ask to clear pending permissions Paseo marks every message typed in the app during a run with clearPendingPermissions. The provider failed such steers, so Paseo interrupted the turn and resent the prompt with the same clientMessageId, which the provider then rejected as a duplicate. Send these steers as native guidance and, once ZCode accepts them, decline pending permissions, questions and Plan approvals with their deny action until ZCode reads the message. Allow the clientMessageId of an input that was not admitted to be reused. Record the decision in ADR 22. --- README.md | 1 + ...34\344\273\230\343\201\221\343\202\213.md" | 2 + ...22\345\220\246\343\201\231\343\202\213.md" | 77 +++++++++ docs/adr/README.md | 1 + docs/verification.md | 30 ++++ scripts/check-upstream.mjs | 2 + server/presentation.test.ts | 49 ++++++ server/presentation.ts | 8 +- server/provider.ts | 10 +- server/session.ts | 35 +++- test/provider.test.ts | 154 ++++++++++++++++++ 11 files changed, 361 insertions(+), 8 deletions(-) create mode 100644 "docs/adr/0022-paseo\343\201\214\346\261\202\343\202\201\343\202\213\343\201\250\343\201\215\343\201\257\345\217\227\343\201\221\344\273\230\343\201\221\343\201\237steer\343\201\214\350\252\255\343\201\276\343\202\214\343\202\213\343\201\276\343\201\247\344\277\235\347\225\231\344\270\255\343\201\256\346\250\251\351\231\220\347\242\272\350\252\215\343\202\222\346\213\222\345\220\246\343\201\231\343\202\213.md" diff --git a/README.md b/README.md index 07bb989..d09e31a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ The source baseline is `3c5b227649371507ddc4eb46ac5133afe6db1cab` (ZCode 3.16.2, - Models and reasoning options come from ZCode. Editing modes are `build`, `edit`, `guarded` and `yolo`; `settings.plan_mode` is independent. `guarded` (ZCode's Autonomous mode, Server 3.15.1 or later) runs tools without approval but asks once, each time, before a command that ZCode's safety rules consider risky; it is not offered on older runtimes. - Text, thoughts, tools, subagent progress, usage and history use V4 snapshots and deltas. Tool output is marked when ZCode truncates it. Usage is cumulative; repeated snapshots do not add it again. - Text submitted during generation uses native guidance. Attachments use the native queue. Paseo reports one run across these native turns and completes it only after all accepted input has been consumed and foreground work has ended. +- Paseo sends every message typed in the app during a run as a steer that asks to clear pending permissions. The plugin queues it first, then declines pending permissions, questions and Plan approvals with their deny action until ZCode reads the message, including requests opened in the meantime. Requests that appear after it is read are left to the user. Declining a Plan approval in Plan mode ends ZCode's turn; the message then starts the next native turn in the same Paseo run (ADR 22). - Acceptance is reported once, after the native acknowledgement. Unknown delivery results are never automatically resent. Stop identifies the current native execution, disables automatic queue execution and removes pending input. A new explicit run enables the queue again. - As in ZCode Desktop, successful hooks are not shown. A finished hook invocation appears once, as a notification, only when a hook was blocked, failed, timed out or was cancelled; context compaction also appears once, when it ends. Paseo appends every notification it receives, so progress is not reported. - Native permission IDs, option values, questions and Plan approval retain their meanings. Question auto-resolution is disabled for each owned Server. Workspace hook trust is reviewed through the official CLI; it is not represented as “allow once.” diff --git "a/docs/adr/0009-v4\345\205\245\345\212\233\345\217\227\344\273\230\343\201\250\343\203\215\343\202\244\343\203\206\343\202\243\343\203\226\345\276\205\346\251\237\343\202\255\343\203\245\343\203\274\343\202\222\344\270\200\343\201\244\343\201\256paseo\345\256\237\350\241\214\343\201\270\345\257\276\345\277\234\344\273\230\343\201\221\343\202\213.md" "b/docs/adr/0009-v4\345\205\245\345\212\233\345\217\227\344\273\230\343\201\250\343\203\215\343\202\244\343\203\206\343\202\243\343\203\226\345\276\205\346\251\237\343\202\255\343\203\245\343\203\274\343\202\222\344\270\200\343\201\244\343\201\256paseo\345\256\237\350\241\214\343\201\270\345\257\276\345\277\234\344\273\230\343\201\221\343\202\213.md" index 5ee7e01..dcb183c 100644 --- "a/docs/adr/0009-v4\345\205\245\345\212\233\345\217\227\344\273\230\343\201\250\343\203\215\343\202\244\343\203\206\343\202\243\343\203\226\345\276\205\346\251\237\343\202\255\343\203\245\343\203\274\343\202\222\344\270\200\343\201\244\343\201\256paseo\345\256\237\350\241\214\343\201\270\345\257\276\345\277\234\344\273\230\343\201\221\343\202\213.md" +++ "b/docs/adr/0009-v4\345\205\245\345\212\233\345\217\227\344\273\230\343\201\250\343\203\215\343\202\244\343\203\206\343\202\243\343\203\226\345\276\205\346\251\237\343\202\255\343\203\245\343\203\274\343\202\222\344\270\200\343\201\244\343\201\256paseo\345\256\237\350\241\214\343\201\270\345\257\276\345\277\234\344\273\230\343\201\221\343\202\213.md" @@ -10,6 +10,8 @@ links: kind: amendedby - target: 13 kind: amendedby + - target: 22 + kind: amendedby --- # V4入力受付とネイティブ待機キューを一つのPaseo実行へ対応付ける diff --git "a/docs/adr/0022-paseo\343\201\214\346\261\202\343\202\201\343\202\213\343\201\250\343\201\215\343\201\257\345\217\227\343\201\221\344\273\230\343\201\221\343\201\237steer\343\201\214\350\252\255\343\201\276\343\202\214\343\202\213\343\201\276\343\201\247\344\277\235\347\225\231\344\270\255\343\201\256\346\250\251\351\231\220\347\242\272\350\252\215\343\202\222\346\213\222\345\220\246\343\201\231\343\202\213.md" "b/docs/adr/0022-paseo\343\201\214\346\261\202\343\202\201\343\202\213\343\201\250\343\201\215\343\201\257\345\217\227\343\201\221\344\273\230\343\201\221\343\201\237steer\343\201\214\350\252\255\343\201\276\343\202\214\343\202\213\343\201\276\343\201\247\344\277\235\347\225\231\344\270\255\343\201\256\346\250\251\351\231\220\347\242\272\350\252\215\343\202\222\346\213\222\345\220\246\343\201\231\343\202\213.md" new file mode 100644 index 0000000..2b72310 --- /dev/null +++ "b/docs/adr/0022-paseo\343\201\214\346\261\202\343\202\201\343\202\213\343\201\250\343\201\215\343\201\257\345\217\227\343\201\221\344\273\230\343\201\221\343\201\237steer\343\201\214\350\252\255\343\201\276\343\202\214\343\202\213\343\201\276\343\201\247\344\277\235\347\225\231\344\270\255\343\201\256\346\250\251\351\231\220\347\242\272\350\252\215\343\202\222\346\213\222\345\220\246\343\201\231\343\202\213.md" @@ -0,0 +1,77 @@ +--- +number: 22 +title: Paseoが求めるときは受け付けたSteerが読まれるまで保留中の権限確認を拒否する +status: accepted +date: 2026-10-23 +links: + - target: 9 + kind: amends +--- + +# Paseoが求めるときは受け付けたSteerが読まれるまで保留中の権限確認を拒否する + +## Context and Problem Statement + +Paseo は、アプリで入力したメッセージや音声入力を実行中のターンへ Steer するとき、常に `clearPendingPermissions: true` を付ける(Paseo の `session.ts`、#3585 で導入)。Paseo の Provider 仕様(`docs/providers.md`)では、このフラグ付きの Steer を受け付けた Provider は、まず入力をキューに入れ、配送を妨げている権限確認を拒否し、Steer が読まれたら拒否をやめる。 + +ADR 9 は、権限確認は既存の応答経路で扱い、Steer では承認しないとした。実装はフラグ付きの Steer を一律に失敗として返していた。Paseo の `PluginAgentSession.steerActiveTurn` は失敗を `unavailable` とみなし、同じ `clientMessageId` のまま、実行中のターンを中断して送り直す。プラグインはこの ID を重複として拒否したため、アプリからの Steer はすべて `Duplicate ZCode clientMessageId` で失敗した。 + +3c5b227 の確認では、ZCode は権限確認を待っている間も `requestedDelivery: "guide"` の `sendText` を受け付け、実行中のターンの `pendingInputs` に積む(`prompt-admission.ts`、`steering.ts`)。これを読むのは、そのモデルステップのツール呼び出しがすべて終わった区切り(`turn-tools.ts`)か、テキストだけのステップの終わり(`turn-stop.ts`)である。権限確認で止まったターンはこの区切りに達しないので、Steer は読まれない。公式 Desktop は対話の保留中は入力欄を隠すので、この状況でメッセージを送ること自体が無い(`ConversationComposer.tsx`)。 + +## Decision Drivers + +- Paseo の公開 Provider 仕様に従い、アプリからの Steer を受け付ける。 +- 入力を失ったり二重に送ったりしない。Steer は先に ZCode に受け付けさせる。 +- ZCode の公式の対話の答え方だけを使い、Paseo と ZCode を改変しない。 +- Steer が読まれた後に出た権限確認には、ユーザーが自分で答えられるようにする。 + +## Considered Options + +- フラグ付きの Steer を失敗として返し、Paseo の中断と送り直しに任せる。 +- Steer を受け付けた後、読まれるまで保留中の対話を拒否の操作で答える。 +- 拒否に `freeText` でメッセージを付ける。 + +## Decision Outcome + +Chosen option: 「Steer を受け付けた後、読まれるまで保留中の対話を拒否の操作で答える」。Paseo の仕様をそのまま満たし、ZCode の公式の答え方だけで実現できるため。 + +- フラグ付きの Steer も通常の Steer と同じく `guide` として送り、ZCode の受付確認の後にだけ拒否を始める。 +- その入力の `userInput` 行(`sourceCommandId` が一致)が現れるまで、保留中の対話と、その間に新しく出た対話を拒否する。同じモデルステップの後続のツールが新しい権限確認を出すことがあるため、一度では足りない。行が現れたとき、または実行が停止・終了したときにやめる。 +- 権限確認は、提示した最初の拒否の操作(ZCode の `deny`。`workflowRefine` より前に並ぶ)で答える。質問と Plan 承認は `decline` で答え、`cancel` は使わない。サブエージェントの権限確認も対象にする。親の Agent ツールの呼び出しが終わるまで Steer は読まれないため。 +- `freeText` は付けない。ZCode は拒否のツール結果に「ユーザーの指示を待つ」よう求める定型文を返し、その直後に Steer がユーザーのメッセージとして読まれる。 +- 拒否のコマンドが失敗したときは、ユーザーが答えた権限確認の失敗と同じく、セッションの実行時エラーとして扱う。 +- 受け付けなかったプロンプトの `clientMessageId` は再利用できるようにする。Paseo の中断と送り直しが同じ ID を使うため。 + +フラグの無い Steer では、これまでどおり権限確認に触れない。Steer で権限を承認することは無い。 + +### Consequences + +- Good, because アプリからの Steer が、権限確認で止まったターンでも同じ実行の中で読まれる。 +- Good, because 受付・拒否・読み込みを、ZCode の公式の答え方と V4 の状態だけで確認できる。 +- Bad, because Plan モードで Plan 承認を拒否すると ZCode のターンが終わる(`turn-control.ts` の `stopTurnAfterResult`)。Steer は `guide.noToolBoundary` でキューに回り、次の ZCode のターンとして始まる。Paseo の実行は ADR 9 のとおり一つのまま続く。 +- Bad, because ZCode Desktop には無い使い方なので、ZCode が対話中の guide の扱いを変えると影響を受ける。 + +### Confirmation + +`test/provider.test.ts` で、フラグ付きの Steer の受付、読まれるまでの拒否(後から出た権限確認を含む)、読まれた後の権限確認がユーザーの答えで解決されること、受け付けなかった Steer の ID を再利用できることを確認する。`server/presentation.test.ts` で各対話の拒否の答えを確認する。`npm run test:upstream` は Paseo の実際のアダプターから `clearPendingPermissions: true` 付きの Steer を送る。 + +## Pros and Cons of the Options + +### フラグ付きの Steer を失敗として返す + +- Good, because 権限確認に一切触れない。 +- Bad, because Paseo は実行中のターンを中断して送り直すので、作業が止まる。ZCode に Steer の機能があるのに使えない。 + +### 受け付けた後に拒否の操作で答える + +- Good, because Paseo の仕様に一致し、Codex・Claude の Provider と同じ挙動になる。 +- Bad, because 自動で拒否される権限確認が増え、その理由はユーザーのメッセージにしか表れない。 + +### 拒否に `freeText` でメッセージを付ける + +- Good, because ツール結果にユーザーの意図が残る。 +- Bad, because 同じ本文が Steer と合わせて二度モデルに渡る。Steer は先に受け付けているので、配送の役には立たない。 + +## More Information + +ADR 9 を補う。ZCode 側の根拠は 3c5b227 の `apps/zcode-cli/packages/core/src/runtime/methods/`(`prompt-admission.ts`、`steering.ts`、`turn-tools.ts`、`turn-stop.ts`)、`core/src/tool/executor/turn-control.ts`、`bootstrap/src/zcode-protocol/interaction-broker.ts`、`bootstrap/src/zcode-protocol-v4/product-projection.ts`。Paseo 側は `docs/providers.md` の steering の節と `plugin-provider.ts` の `steerActiveTurn`。ZCode が対話中の guide を公式に扱うようになったとき、または Paseo の仕様が変わったときに見直す。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 97470ec..24b1b7f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,3 +19,4 @@ * [19. 使えない公式プラグインを隠すビルド時パッチを管理下ランタイムに当てる](0019-使えない公式プラグインを隠すビルド時パッチを管理下ランタイムに当てる.md) * [20. タグの無い上流の同期コミットをpackage versionで特定してZCodeのソース基準にする](0020-タグの無い上流の同期コミットをpackage-versionで特定してzcodeのソース基準にする.md) * [21. Desktop専用のvisualizeプラグインもADR 19の抑止対象に加える](0021-desktop専用のvisualizeプラグインもadr-19の抑止対象に加える.md) +* [22. Paseoが求めるときは受け付けたSteerが読まれるまで保留中の権限確認を拒否する](0022-paseoが求めるときは受け付けたsteerが読まれるまで保留中の権限確認を拒否する.md) diff --git a/docs/verification.md b/docs/verification.md index 892bd68..b1eba1b 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -1,3 +1,33 @@ +# アプリからの Steer が `Duplicate ZCode clientMessageId` で失敗する(2026-10-24) + +ZCode の実行中にアプリからメッセージを送ると、`[System Error] Duplicate ZCode clientMessageId` になり、メッセージが届かなかった。 + +## 原因 + +- Paseo は、アプリで入力したメッセージを実行中のターンへ Steer するとき、常に `clearPendingPermissions: true` を付ける(Paseo の `session.ts`)。プラグインはこのフラグ付きの Steer を、`INTERACTION_UNSUPPORTED` の失敗として返していた。 +- Paseo の `PluginAgentSession.steerActiveTurn` は失敗を `unavailable` とみなし、同じ `clientMessageId` のまま、実行中のターンを中断して送り直す(`agent-manager.ts` の `steerOrReplaceActiveTurn`)。プラグインは一度使った ID を重複として拒否していたので、送り直しも `send()` の時点で失敗した。 +- `test:upstream` と `test:steering-runtime` はフラグを付けずに Steer していたので、この経路を通らなかった。 + +## 修正 + +- フラグ付きの Steer を通常の Steer と同じく `guide` で送る。ZCode が受け付けた後、その入力の `userInput` 行が現れるまで、保留中の権限確認・質問・Plan 承認を拒否の操作で答える。判断は [ADR 22](adr/0022-paseoが求めるときは受け付けたsteerが読まれるまで保留中の権限確認を拒否する.md) に記録した。 +- 受け付けなかったプロンプトの `clientMessageId` は再利用できるようにした。 + +## ZCode の挙動(3c5b227) + +- 権限確認を待っている間も `guide` の `sendText` は受け付けられ、実行中のターンに積まれる。読まれるのは、そのモデルステップのツール呼び出しがすべて終わった区切りである。 +- 権限確認と質問の拒否ではターンは続き、Steer は同じターンで読まれる。Plan モードで Plan 承認を拒否するとターンが終わり、Steer は `guide.noToolBoundary` でキューに回る。 +- `resolveInteraction` は、対話がすでに無くても `accepted` を返す。 +- 公式 Desktop は対話の保留中は入力欄を隠すので、この状況でメッセージを送らない。 + +## 検証(Linux x64、Node.js 26.8.1、2026-10-24) + +- 追加したテストが修正前に失敗することを確認した。フラグ付きの Steer は `failed` になり、同じ ID の送り直しは `Duplicate ZCode clientMessageId` で失敗した。拒否の処理を外すと、権限確認は拒否されなかった。 +- `npm run typecheck`、`npm test`(22 ファイル、209 tests)、`npm run build`、`npm run format:check` が成功した。 +- Paseo 0.12.0(`cafe4b01c8522ff39560241c5deb01427dc8dc5b`)に対する `npm run test:upstream` が成功した。Paseo の実際のアダプターから `clearPendingPermissions: true` 付きの Steer を送っている。ローカルでは `NODE_PATH` がグローバルの `zod` を解決させ、Git 準備の検査が失敗するので、`NODE_PATH` を外して実行した。 + +未検証:実モデルと実ランタイムでの、権限確認で止まったターンへの Steer。実 daemon と実 UI での操作。 + # 実モデル E2E の停止試験のタイムアウト(2026-10-21) [Issue #51](https://github.com/supermomonga/paseo-plugin-zcode-provider/issues/51) の対応。`check-steering-runtime.mjs` の停止の段階が、まれに 180 秒の期限切れで失敗していた。 diff --git a/scripts/check-upstream.mjs b/scripts/check-upstream.mjs index 462b203..5c63ab3 100644 --- a/scripts/check-upstream.mjs +++ b/scripts/check-upstream.mjs @@ -215,10 +215,12 @@ try { const run = await resumed.startTurn("Continue", { clientMessageId: "next", }); + // The Web UI always asks to clear pending permissions when steering. assert.deepEqual( await resumed.steerActiveTurn("Guide", { expectedTurnId: run.turnId, clientMessageId: "guide", + clearPendingPermissions: true, }), { status: "accepted" }, ); diff --git a/server/presentation.test.ts b/server/presentation.test.ts index e4cc597..7031269 100644 --- a/server/presentation.test.ts +++ b/server/presentation.test.ts @@ -60,6 +60,55 @@ it.each([false, true])( ).toEqual({ action: interrupt ? "cancel" : "decline" }); }, ); +it("declines each interaction with its first deny action, without interrupting", () => { + const permission = presentInteraction({ + interactionId: "p", + kind: "permission", + anchorRowId: 1, + createdAt: 1, + payload: { + kind: "permission", + toolCallId: "t", + toolName: "CreateWorkflow", + summary: "create", + detail: {}, + options: [ + { optionId: "allowOnce", label: "Once", kind: "allowOnce" }, + { optionId: "deny", label: "Deny", kind: "deny" }, + { + optionId: "workflowRefine", + label: "Refine", + kind: "custom", + response: { decision: "deny" }, + }, + ], + }, + })!; + expect(permission.decline).toEqual({ optionId: "deny" }); + const plan = presentInteraction({ + ...question, + payload: { + kind: "userInput", + prompt: "Review", + freeText: true, + input: { plan: "# Plan" }, + schema: { interaction: "plan_approval", toolName: "ExitPlanMode" }, + questions: [ + { + question: "Approve?", + header: "Plan", + options: [{ value: "approve", label: "Approve" }], + }, + ], + }, + })!; + expect(plan.decline).toEqual( + plan.answer({ behavior: "deny", selectedActionId: "dismiss:question-1" }), + ); + const asked = presentInteraction(question)!; + expect(asked.decline).toEqual(asked.answer({ behavior: "deny" })); + expect(asked.decline).toEqual({ action: "decline" }); +}); it("does not offer full access as allow once", () => { const p = presentInteraction({ interactionId: "p", diff --git a/server/presentation.ts b/server/presentation.ts index 51481b9..90c261e 100644 --- a/server/presentation.ts +++ b/server/presentation.ts @@ -233,6 +233,8 @@ export function usage(state: ConversationSnapshot): ProviderUsage { export interface PresentedInteraction { request: ProviderPermissionRequest; answer(response: ProviderPermissionResponse): Record; + /** Native answer of the first deny action, without interrupting. */ + decline: Record; } export function presentInteraction( interaction: PendingInteraction, @@ -262,7 +264,8 @@ export function presentInteraction( variant: behavior === "allow" ? "primary" : "danger", }; }); - if (!actions.some((a) => a.behavior === "deny")) + const deny = actions.find((a) => a.behavior === "deny"); + if (!deny) throw new AdapterError( "NATIVE_PROTOCOL_ERROR", "ZCode permission has no deny option", @@ -299,6 +302,7 @@ export function presentInteraction( ); return { optionId: action.id }; }, + decline: { optionId: deny.id }, }; } if (p.sensitive) @@ -414,6 +418,7 @@ export function presentInteraction( }, }; }, + decline: { action: "decline" }, }; } return { @@ -445,6 +450,7 @@ export function presentInteraction( content: answerContent(record(response.updatedInput?.answers)), }; }, + decline: { action: "decline" }, }; } diff --git a/server/provider.ts b/server/provider.ts index 10b651e..27ba7c8 100644 --- a/server/provider.ts +++ b/server/provider.ts @@ -220,7 +220,9 @@ export class ZCodeConnection implements ProviderConnection { if (input.type === "session.prompt") { const promptEntry = entry ?? this.sessions.get(input.sessionId); if (promptEntry?.messages.get(input.prompt.clientMessageId)) return; - promptEntry?.messages.set(input.prompt.clientMessageId, true); + // The input was not admitted. Paseo's interrupt-and-replace fallback + // for an unavailable steer resends it with the same ID. + promptEntry?.messages.delete(input.prompt.clientMessageId); this.emit({ type: "session.prompt_result", sessionId: input.sessionId, @@ -374,11 +376,6 @@ export class ZCodeConnection implements ProviderConnection { switch (input.type) { case "session.prompt": { const prompt = input.prompt.input; - if (input.prompt.clearPendingPermissions) - throw new AdapterError( - "INTERACTION_UNSUPPORTED", - "Steering does not resolve pending ZCode permissions", - ); if (input.prompt.delivery === "steer" && !entry.native.hasActiveTurn()) throw new AdapterError( "SESSION_BUSY", @@ -439,6 +436,7 @@ export class ZCodeConnection implements ProviderConnection { { clientMessageId: input.prompt.clientMessageId, delivery: input.prompt.delivery, + clearPendingPermissions: input.prompt.clearPendingPermissions, beforeSend: async () => { if (!entry.submitted) { await this.persistenceStore.save( diff --git a/server/session.ts b/server/session.ts index bd6e771..3b5ee4b 100644 --- a/server/session.ts +++ b/server/session.ts @@ -55,6 +55,8 @@ interface Admission { accepted: boolean; consumed: boolean; productTurnId?: string; + /** Paseo's clearPendingPermissions: decline interactions until consumed. */ + releasesInteractions?: boolean; } interface Run { id: string; @@ -286,6 +288,7 @@ export class ZCodeSession { }); } } + this.releaseInteractions(); if ( this.state.workspaceHookAdmission || this.state.pendingInteractions.some( @@ -323,6 +326,29 @@ export class ZCodeSession { } this.maybeComplete(); } + /** + * ZCode reads a steer only after every tool call of the model step settles, + * so a parked turn never reads it. While an accepted steer that asked to clear + * permissions is unread, decline each pending interaction, including requests + * opened after an earlier decline. + */ + private releaseInteractions(): void { + const run = this.active; + if ( + !run || + run.cancelled || + ![...run.inputs.values()].some( + (i) => i.releasesInteractions && i.accepted && !i.consumed, + ) + ) + return; + for (const [id, interaction] of this.pending) { + if (this.answering.has(id)) continue; + this.resolveInteraction(id, interaction.decline).catch((error) => + this.runtimeFailed(diagnosticError(error, this.bridge.diagnostic)), + ); + } + } private maybeComplete(): void { const run = this.active; if ( @@ -537,6 +563,7 @@ export class ZCodeSession { options?: { clientMessageId: string; delivery?: "auto" | "steer"; + clearPendingPermissions?: boolean; beforeSend?: () => Promise; }, ): Promise<{ turnId: string }> { @@ -634,6 +661,7 @@ export class ZCodeSession { ); this.assertOpen(); input.accepted = true; + input.releasesInteractions = options?.clearPendingPermissions === true; if (options?.clientMessageId) this.emit({ type: "prompt_accepted", @@ -701,7 +729,12 @@ export class ZCodeSession { "SESSION_NOT_FOUND", "ZCode interaction is no longer pending", ); - const answer = pending.answer(response); + await this.resolveInteraction(id, pending.answer(response)); + } + private async resolveInteraction( + id: string, + answer: Record, + ): Promise { this.answering.add(id); try { await this.control("resolveInteraction", { interactionId: id, answer }); diff --git a/test/provider.test.ts b/test/provider.test.ts index 5bf4e1c..25d8267 100644 --- a/test/provider.test.ts +++ b/test/provider.test.ts @@ -419,6 +419,160 @@ it("answers native interactions once and ignores responses after their observed }, }); }); +it("steers a typed message, which always asks to clear pending permissions", async () => { + const f = await fixture(); + await f.open(); + await f.prompt(); + await f.send({ + type: "session.prompt", + sessionId: "public", + prompt: { + clientMessageId: "typed", + input: { type: "message", content: [{ type: "text", text: "steer" }] }, + delivery: "steer", + clearPendingPermissions: true, + }, + }); + expect( + await f.wait( + (e) => + e.type === "session.prompt_result" && e.clientMessageId === "typed", + ), + ).toMatchObject({ result: { type: "steer" } }); + await completeTurn(f.host); + expect( + f.host.calls.some((c) => c.params?.envelope?.type === "resolveInteraction"), + ).toBe(false); +}); +it("declines interactions blocking a typed steer until ZCode reads it", async () => { + const f = await fixture(); + await f.open(); + await f.prompt(); + const permission = (id: string) => ({ + interactionId: id, + kind: "permission" as const, + anchorRowId: 1, + createdAt: 1, + payload: { + kind: "permission" as const, + toolCallId: id, + toolName: "Bash", + summary: "run", + detail: { command: "pwd" }, + options: [ + { optionId: "once", label: "Once", kind: "allowOnce" as const }, + { optionId: "deny", label: "Deny", kind: "deny" as const }, + ], + }, + }); + const request = async (id: string) => { + await f.host.deltas([ + { + op: "state.updated", + patch: { + pendingInteractions: [ + ...f.host.state.pendingInteractions, + permission(id), + ], + }, + }, + ]); + await f.wait((e) => e.type === "session.permission" && e.request.id === id); + }; + const resolutions = () => + f.host.calls + .filter((c) => c.params?.envelope?.type === "resolveInteraction") + .map((c) => c.params.envelope.payload); + await request("blocking"); + f.host.deferConsumption = true; + await f.send({ + type: "session.prompt", + sessionId: "public", + prompt: { + clientMessageId: "typed", + input: { type: "message", content: [{ type: "text", text: "steer" }] }, + delivery: "steer", + clearPendingPermissions: true, + }, + }); + expect( + await f.wait( + (e) => + e.type === "session.prompt_result" && e.clientMessageId === "typed", + ), + ).toMatchObject({ result: { type: "steer" } }); + await f.wait( + (e) => + e.type === "session.permission_resolved" && e.permissionId === "blocking", + ); + // A later tool of the same model step asks again before the steer is read. + await request("later"); + await f.wait( + (e) => + e.type === "session.permission_resolved" && e.permissionId === "later", + ); + const steer = f.host.calls.findLast( + (c) => c.params?.envelope?.type === "sendText", + )!.params.envelope.commandId; + await f.host.consume(steer, "steer", true); + await request("after-read"); + await f.send({ + type: "session.permission", + sessionId: "public", + permissionId: "after-read", + response: { behavior: "allow", selectedActionId: "once" }, + }); + await f.wait( + (e) => + e.type === "session.permission_resolved" && + e.permissionId === "after-read", + ); + expect(resolutions()).toEqual([ + { interactionId: "blocking", answer: { optionId: "deny" } }, + { interactionId: "later", answer: { optionId: "deny" } }, + { interactionId: "after-read", answer: { optionId: "once" } }, + ]); + await completeTurn(f.host); + expect(f.events.some((e) => e.type === "session.runtime_failed")).toBe(false); +}); +it("accepts Paseo's replacement prompt after a steer was not admitted", async () => { + const f = await fixture(); + await f.open(); + await f.prompt(); + const input = { + type: "message" as const, + content: [{ type: "text" as const, text: "/review src" }], + }; + await f.send({ + type: "session.prompt", + sessionId: "public", + prompt: { clientMessageId: "m2", input, delivery: "steer" }, + }); + expect( + await f.wait( + (e) => e.type === "session.prompt_result" && e.clientMessageId === "m2", + ), + ).toMatchObject({ result: { type: "failed" } }); + // Paseo interrupts and resends the unavailable steer with its clientMessageId. + await f.send({ + type: "session.interrupt", + sessionId: "public", + requestId: "stop", + }); + await f.wait((e) => e.type === "session.turn" && e.state === "canceled"); + f.events.length = 0; + await f.send({ + type: "session.prompt", + sessionId: "public", + prompt: { clientMessageId: "m2", input, delivery: "auto" }, + }); + expect( + await f.wait( + (e) => e.type === "session.prompt_result" && e.clientMessageId === "m2", + ), + ).toMatchObject({ result: { type: "turn" } }); + await completeTurn(f.host); +}); it("offers guarded mode only on ZCode 3.15.1 and later", async () => { const f = await fixture(); const modes = () =>