Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.”
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ links:
kind: amendedby
- target: 13
kind: amendedby
- target: 22
kind: amendedby
---

# V4入力受付とネイティブ待機キューを一つのPaseo実行へ対応付ける
Expand Down
Original file line number Diff line number Diff line change
@@ -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 の仕様が変わったときに見直す。
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
30 changes: 30 additions & 0 deletions docs/verification.md
Original file line number Diff line number Diff line change
@@ -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 秒の期限切れで失敗していた。
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-upstream.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
);
Expand Down
49 changes: 49 additions & 0 deletions server/presentation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion server/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ export function usage(state: ConversationSnapshot): ProviderUsage {
export interface PresentedInteraction {
request: ProviderPermissionRequest;
answer(response: ProviderPermissionResponse): Record<string, unknown>;
/** Native answer of the first deny action, without interrupting. */
decline: Record<string, unknown>;
}
export function presentInteraction(
interaction: PendingInteraction,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -299,6 +302,7 @@ export function presentInteraction(
);
return { optionId: action.id };
},
decline: { optionId: deny.id },
};
}
if (p.sensitive)
Expand Down Expand Up @@ -414,6 +418,7 @@ export function presentInteraction(
},
};
},
decline: { action: "decline" },
};
}
return {
Expand Down Expand Up @@ -445,6 +450,7 @@ export function presentInteraction(
content: answerContent(record(response.updatedInput?.answers)),
};
},
decline: { action: "decline" },
};
}

Expand Down
10 changes: 4 additions & 6 deletions server/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading