From 5f382782506402de1f8fd4150481dafa8aaf012c Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 25 May 2026 10:01:55 +0900 Subject: [PATCH] fix(bridge): empty silent get_pending_status wrap when pending_count is 0 (#221) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wrapGetPendingStatusAsDecisionJson で payload.pending_count === 0 の時に ラップせずリモート戻り値をそのまま返すよう変更。Claude Code は decision schema 未一致 JSON を silent discard するため、hook 経由で additionalContext に空 reminder ("No pending GitHub webhook events.") が 注入されなくなり、毎ターンの空 noise を排除できる。手動 tool 呼び出し ではリモートの raw payload が返るため AI は直接判定できる。 リモート (Worker + DO) 側の戻り値構造は変更しない。 - mcp-server/server/index.js: 早期 return ロジック追加、docstring 更新 - mcp-server/test/get-pending-status-decision-shape.test.mjs: 既存の zero-pending wrap テストを "returns zero-pending payload untouched" に書き換え、"preserves sibling result fields" は pending_count=3 で wrap-path 維持 - docs/0-requirements.md / .ja.md: F3.1 に empty silent サブ仕様追加 Closes #221 --- docs/0-requirements.ja.md | 2 ++ docs/0-requirements.md | 2 ++ mcp-server/server/index.js | 12 +++++++++- ...get-pending-status-decision-shape.test.mjs | 24 +++++++++++-------- 4 files changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index e33296c..892fd3f 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -104,6 +104,8 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル **F3.1 ローカルブリッジ整形:** ローカルブリッジは `get_pending_status` の戻り値を Claude Code UserPromptSubmit hook の decision JSON shape (`hookSpecificOutput.hookEventName="UserPromptSubmit"` + `additionalContext` に pending_count / types / latest_received_at の自然文要約) にラップして返す。これは `type: "mcp_tool"` UserPromptSubmit hook 経由の呼び出しで戻り値が AI 文脈に注入されるための要件であり、手動 tool 呼び出し時も同 shape で返る。リモート (Worker + DO) 側の戻り値構造は変更しない。 +**F3.1 空状態の silent (empty silent, #221):** `pending_count == 0` の場合はラップせずリモート戻り値をそのまま返す。Claude Code 側で decision schema に一致しない JSON は silent discard されるため、hook 経由の呼び出しで `additionalContext` に何も注入されず、毎ターン空 reminder のノイズが消える。手動 tool 呼び出しではリモートの raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) が返り、AI は内容を直接判定できる。 + **イベントサマリー構造:** ```json diff --git a/docs/0-requirements.md b/docs/0-requirements.md index c09cd16..1d92538 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -104,6 +104,8 @@ WebhookMcpAgent DO が以下のツールセットを提供する。ローカル **F3.1 Local bridge shaping:** The local bridge wraps `get_pending_status` results into the Claude Code UserPromptSubmit hook decision JSON shape (`hookSpecificOutput.hookEventName="UserPromptSubmit"` plus a natural-language summary of pending_count / types / latest_received_at in `additionalContext`). This is required so values returned via `type: "mcp_tool"` UserPromptSubmit hooks reach the AI prompt context; manual tool calls receive the same shape. The remote (Worker + DO) return contract is unchanged. +**F3.1 Empty silent (#221):** When `pending_count == 0`, the local bridge returns the remote payload untouched (no wrap). Claude Code silently discards JSON that does not match a decision schema, so hook callers receive nothing in `additionalContext` — eliminating the per-turn empty-reminder noise. Manual tool callers see the raw payload (`{pending_count: 0, types: {}, latest_received_at: null}`) and can interpret it directly. + **イベントサマリー構造:** ```json diff --git a/mcp-server/server/index.js b/mcp-server/server/index.js index 01e0601..188705f 100644 --- a/mcp-server/server/index.js +++ b/mcp-server/server/index.js @@ -833,6 +833,12 @@ function formatPendingStatusSummary(payload) { * Manual `tool_use` callers still get a JSON object back; AI can read the * decision shape directly. No `decision: "block"` field is set, so the * user's prompt is never blocked. + * + * Empty silent (#221): when the parsed payload reports `pending_count === 0`, + * return the result untouched (no wrap). The raw remote JSON does not match + * any Claude Code decision schema, so hook callers receive nothing in + * `additionalContext` — eliminating the per-turn empty reminder noise. Manual + * callers see the raw payload, which is self-describing. */ function wrapGetPendingStatusAsDecisionJson(result) { if (!result || !Array.isArray(result.content) || result.content.length === 0) { @@ -844,7 +850,11 @@ function wrapGetPendingStatusAsDecisionJson(result) { } let summary; try { - summary = formatPendingStatusSummary(JSON.parse(first.text)); + const payload = JSON.parse(first.text); + if (payload && typeof payload === "object" && payload.pending_count === 0) { + return result; + } + summary = formatPendingStatusSummary(payload); } catch { summary = first.text.slice(0, 200); } diff --git a/mcp-server/test/get-pending-status-decision-shape.test.mjs b/mcp-server/test/get-pending-status-decision-shape.test.mjs index 4c609b9..6ad57af 100644 --- a/mcp-server/test/get-pending-status-decision-shape.test.mjs +++ b/mcp-server/test/get-pending-status-decision-shape.test.mjs @@ -65,7 +65,11 @@ function wrapGetPendingStatusAsDecisionJson(result) { } let summary; try { - summary = formatPendingStatusSummary(JSON.parse(first.text)); + const payload = JSON.parse(first.text); + if (payload && typeof payload === "object" && payload.pending_count === 0) { + return result; + } + summary = formatPendingStatusSummary(payload); } catch { summary = first.text.slice(0, 200); } @@ -112,7 +116,7 @@ test("wraps non-empty pending payload as UserPromptSubmit decision JSON", () => assert.equal("decision" in decision, false); }); -test("wraps zero-pending payload with explicit no-events sentence", () => { +test("returns zero-pending payload untouched (empty silent, #221)", () => { const remote = { content: [ { @@ -126,13 +130,13 @@ test("wraps zero-pending payload with explicit no-events sentence", () => { ], }; - const decision = JSON.parse( - wrapGetPendingStatusAsDecisionJson(remote).content[0].text, - ); - assert.equal( - decision.hookSpecificOutput.additionalContext, - "No pending GitHub webhook events.", - ); + const wrapped = wrapGetPendingStatusAsDecisionJson(remote); + assert.deepEqual(wrapped, remote); + // No decision schema is produced; hook callers receive nothing in + // additionalContext, eliminating the per-turn empty reminder noise. + const parsed = JSON.parse(wrapped.content[0].text); + assert.equal(parsed.pending_count, 0); + assert.equal("hookSpecificOutput" in parsed, false); }); test("falls back to truncated raw text when remote payload is not JSON", () => { @@ -169,7 +173,7 @@ test("preserves sibling result fields like isError when wrapping", () => { content: [ { type: "text", - text: JSON.stringify({ pending_count: 0, types: {} }), + text: JSON.stringify({ pending_count: 3, types: { issues: 3 } }), }, ], };