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 } }), }, ], };