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
2 changes: 2 additions & 0 deletions docs/0-requirements.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion mcp-server/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
24 changes: 14 additions & 10 deletions mcp-server/test/get-pending-status-decision-shape.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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: [
{
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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 } }),
},
],
};
Expand Down
Loading