Skip to content

Commit e2277b5

Browse files
saltboCopilot
andcommitted
fix(providers): normalize Copilot CLI tool names to canonical PascalCase
Map lowercase snake_case tool names from Copilot CLI to the PascalCase canonical names the frontend recognizes (bash→Bash, edit→Edit, create→Write, etc.), matching the normalization Codex does for its JSONL history. Internal CLI tools (report_intent, stop_bash, read_bash, write_bash, list_bash) are skipped — no block.start emitted and tool_result silently dropped — keeping the frontend free of implementation-detail noise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 9c82623 commit e2277b5

2 files changed

Lines changed: 127 additions & 19 deletions

File tree

packages/cli/src/providers/copilot.ts

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,57 @@ interface MapState {
4040
pendingToolUses: Map<string, ContentBlock & { type: "tool_use" }>;
4141
}
4242

43+
/**
44+
* Normalize Copilot CLI tool names to the canonical names the frontend recognizes
45+
* (matching Claude/Codex tool_use names). Copilot CLI uses lowercase snake_case;
46+
* known tools are mapped to PascalCase; unknown tools pass through for the fallback UI.
47+
* Returns `{ name: null }` for internal CLI tools that should not be surfaced.
48+
*/
49+
function normalizeCopilotTool(name: string, input: Record<string, unknown>): { name: string | null; input: Record<string, unknown> } {
50+
switch (name) {
51+
case "bash":
52+
return { name: "Bash", input };
53+
case "read":
54+
case "view":
55+
return { name: "Read", input };
56+
case "write":
57+
case "create":
58+
return { name: "Write", input };
59+
case "edit":
60+
return { name: "Edit", input };
61+
case "multi_edit":
62+
return { name: "MultiEdit", input };
63+
case "glob":
64+
return { name: "Glob", input };
65+
case "grep":
66+
return { name: "Grep", input };
67+
case "web_fetch":
68+
return { name: "WebFetch", input };
69+
case "web_search":
70+
return { name: "WebSearch", input };
71+
// Copilot CLI tool name differs from Claude's canonical name
72+
case "ask_user":
73+
return { name: "AskUserQuestion", input };
74+
// Copilot CLI tool name differs from Claude's canonical name
75+
case "task":
76+
return { name: "Agent", input };
77+
case "todo_write":
78+
return { name: "TodoWrite", input };
79+
case "notebook_edit":
80+
return { name: "NotebookEdit", input };
81+
// ExitPlanMode / SlashCommand are Claude-only tools; Copilot CLI never emits them
82+
// Internal CLI tools with no frontend equivalent — skip
83+
case "report_intent":
84+
case "stop_bash":
85+
case "read_bash":
86+
case "write_bash":
87+
case "list_bash":
88+
return { name: null, input };
89+
default:
90+
return { name, input };
91+
}
92+
}
93+
4394
/**
4495
* Map a single Copilot SDK SessionEvent to zero or more AgentEvents.
4596
* Mutates `state` for turn/usage tracking.
@@ -86,11 +137,14 @@ export function* mapCopilotEvent(event: SessionEvent, state: MapState): Generato
86137

87138
if (toolRequests) {
88139
for (const tr of toolRequests) {
140+
const normalized = normalizeCopilotTool(tr.name, (tr.arguments as Record<string, unknown>) ?? {});
141+
// Skip internal CLI tools that have no frontend representation
142+
if (normalized.name === null) continue;
89143
const block: ContentBlock & { type: "tool_use" } = {
90144
type: "tool_use",
91145
id: tr.toolCallId,
92-
name: tr.name,
93-
input: (tr.arguments as Record<string, unknown>) ?? {},
146+
name: normalized.name,
147+
input: normalized.input,
94148
};
95149
// Cache so tool.execution_complete can close the same block
96150
state.pendingToolUses.set(tr.toolCallId, block);
@@ -107,16 +161,20 @@ export function* mapCopilotEvent(event: SessionEvent, state: MapState): Generato
107161
if (toolUseBlock) {
108162
state.pendingToolUses.delete(toolCallId);
109163
yield { type: "block.done", block: toolUseBlock };
164+
// Only emit tool_result for tools that were surfaced to the frontend
165+
const output = result?.content ?? (success ? "" : "Tool execution failed");
166+
const resultBlock: ContentBlock = {
167+
type: "tool_result",
168+
tool_use_id: toolCallId,
169+
output,
170+
error: !success || undefined,
171+
};
172+
yield { type: "block.done", block: resultBlock };
173+
} else {
174+
// No pending entry: either the tool was an internal tool (skipped intentionally)
175+
// or an unexpected toolCallId mismatch — log for diagnosability
176+
logger.debug(`tool.execution_complete: no pending tool_use for toolCallId=${toolCallId} (skipped or mismatch)`);
110177
}
111-
// Emit the tool result as a separate block
112-
const output = result?.content ?? (success ? "" : "Tool execution failed");
113-
const resultBlock: ContentBlock = {
114-
type: "tool_result",
115-
tool_use_id: toolCallId,
116-
output,
117-
error: !success || undefined,
118-
};
119-
yield { type: "block.done", block: resultBlock };
120178
return;
121179
}
122180

tests/providers/copilot.test.ts

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ describe("assistant.message with toolRequests", () => {
170170
data: {
171171
toolRequests: [
172172
{ toolCallId: "tc1", name: "bash", arguments: { command: "ls" } },
173-
{ toolCallId: "tc2", name: "read_file", arguments: { path: "/tmp/x" } },
173+
{ toolCallId: "tc2", name: "glob", arguments: { pattern: "**/*.ts" } },
174174
],
175175
},
176176
} as SessionEvent;
@@ -196,7 +196,7 @@ describe("assistant.message with toolRequests", () => {
196196
}[];
197197
const toolStart = events.find((e) => e.type === "block.start" && e.block?.type === "tool_use");
198198
expect(toolStart?.block?.id).toBe("tc1");
199-
expect(toolStart?.block?.name).toBe("bash");
199+
expect(toolStart?.block?.name).toBe("Bash");
200200
expect(toolStart?.block?.input).toEqual({ command: "ls" });
201201
});
202202

@@ -324,7 +324,7 @@ describe("tool.execution_complete failure", () => {
324324
// ---------------------------------------------------------------------------
325325

326326
describe("tool.execution_complete with no matching pending tool_use", () => {
327-
it("emits only block.done with type tool_result when no pending tool_use exists", () => {
327+
it("emits no events when toolCallId has no matching pending tool_use", () => {
328328
const state = makeState();
329329
const event = {
330330
type: "tool.execution_complete",
@@ -334,9 +334,7 @@ describe("tool.execution_complete with no matching pending tool_use", () => {
334334
type: string;
335335
block?: { type: string; tool_use_id: string; output: string; error?: boolean };
336336
}[];
337-
expect(events).toHaveLength(1);
338-
expect(events[0].type).toBe("block.done");
339-
expect(events[0].block?.type).toBe("tool_result");
337+
expect(events).toHaveLength(0);
340338
});
341339

342340
it("does not emit block.done for tool_use when toolCallId does not match any pending entry", () => {
@@ -350,8 +348,7 @@ describe("tool.execution_complete with no matching pending tool_use", () => {
350348
type: string;
351349
block?: { type: string };
352350
}[];
353-
expect(events).toHaveLength(1);
354-
expect(events[0].block?.type).toBe("tool_result");
351+
expect(events).toHaveLength(0);
355352
});
356353
});
357354

@@ -559,6 +556,59 @@ describe("session.error", () => {
559556
});
560557
});
561558

559+
// ---------------------------------------------------------------------------
560+
// internal tool skipping
561+
// ---------------------------------------------------------------------------
562+
563+
describe("internal tool skipping", () => {
564+
it("emits no block.start events when all tool requests are internal tools", () => {
565+
const state = makeState({ turnOpen: true });
566+
const event = {
567+
type: "assistant.message",
568+
data: {
569+
toolRequests: [{ toolCallId: "tc-skip", name: "report_intent", arguments: {} }],
570+
},
571+
} as SessionEvent;
572+
const events = collect(mapCopilotEvent(event, state)) as {
573+
type: string;
574+
block?: { type: string };
575+
}[];
576+
const blockStarts = events.filter((e) => e.type === "block.start");
577+
expect(blockStarts).toHaveLength(0);
578+
});
579+
580+
it("emits no events for tool.execution_complete when toolCallId has no matching pending entry", () => {
581+
const state = makeState();
582+
// No pending tool_use registered — simulates a skipped tool whose tc-skip was never stored
583+
const event = {
584+
type: "tool.execution_complete",
585+
data: { toolCallId: "tc-skip", result: { content: "ok" }, success: true },
586+
} as SessionEvent;
587+
const events = collect(mapCopilotEvent(event, state));
588+
expect(events).toHaveLength(0);
589+
});
590+
591+
it("emits exactly one block.start for bash and skips report_intent in a mixed toolRequests list", () => {
592+
const state = makeState({ turnOpen: true });
593+
const event = {
594+
type: "assistant.message",
595+
data: {
596+
toolRequests: [
597+
{ toolCallId: "tc-bash", name: "bash", arguments: { command: "ls" } },
598+
{ toolCallId: "tc-skip", name: "report_intent", arguments: {} },
599+
],
600+
},
601+
} as SessionEvent;
602+
const events = collect(mapCopilotEvent(event, state)) as {
603+
type: string;
604+
block?: { type: string; name: string };
605+
}[];
606+
const blockStarts = events.filter((e) => e.type === "block.start" && e.block?.type === "tool_use");
607+
expect(blockStarts).toHaveLength(1);
608+
expect(blockStarts[0].block?.name).toBe("Bash");
609+
});
610+
});
611+
562612
// ---------------------------------------------------------------------------
563613
// Unknown event type
564614
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)