Skip to content

Commit 24119df

Browse files
saltboCopilot
andcommitted
fix(providers): remap Copilot CLI tool fields and surface user messages
Write/Edit/Read use different field names than Claude's canonical shapes: - write/create: path→file_path, file_text→content - edit: path→file_path, old_str→old_string, new_str→new_string - multi_edit: same as edit + edits array items - read/view: path→file_path Also handle user.message SDK events → message.user AgentEvent so the initial prompt and follow-up messages appear in the chat history. TODO: move tool arg types to packages/shared for type-safe mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 17edc51 commit 24119df

2 files changed

Lines changed: 118 additions & 4 deletions

File tree

packages/cli/src/providers/copilot.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,45 @@ interface MapState {
4545
* (matching Claude/Codex tool_use names). Copilot CLI uses lowercase snake_case;
4646
* known tools are mapped to PascalCase; unknown tools pass through for the fallback UI.
4747
* Returns `{ name: null }` for internal CLI tools that should not be surfaced.
48+
*
49+
* TODO: tool arg field shapes (file_path, content, etc.) are defined only in the
50+
* frontend's tool-uis.tsx. Move them to packages/shared so this mapping can be typed.
4851
*/
4952
function normalizeCopilotTool(name: string, input: Record<string, unknown>): { name: string | null; input: Record<string, unknown> } {
5053
switch (name) {
5154
case "bash":
5255
return { name: "Bash", input };
5356
case "read":
5457
case "view":
55-
return { name: "Read", input };
58+
// Copilot: { path } → Claude: { file_path }
59+
return { name: "Read", input: { ...input, file_path: input.path ?? input.file_path } };
5660
case "write":
5761
case "create":
58-
return { name: "Write", input };
62+
// Copilot: { path, file_text } → Claude: { file_path, content }
63+
return { name: "Write", input: { ...input, file_path: input.path ?? input.file_path, content: input.file_text ?? input.content } };
5964
case "edit":
60-
return { name: "Edit", input };
65+
// Copilot: { path, old_str, new_str } → Claude: { file_path, old_string, new_string }
66+
return {
67+
name: "Edit",
68+
input: {
69+
...input,
70+
file_path: input.path ?? input.file_path,
71+
old_string: input.old_str ?? input.old_string,
72+
new_string: input.new_str ?? input.new_string,
73+
},
74+
};
6175
case "multi_edit":
62-
return { name: "MultiEdit", input };
76+
// Copilot: { path, edits: [{old_str, new_str}] } → Claude: { file_path, edits: [{old_string, new_string}] }
77+
return {
78+
name: "MultiEdit",
79+
input: {
80+
...input,
81+
file_path: input.path ?? input.file_path,
82+
edits: Array.isArray(input.edits)
83+
? input.edits.map((e: any) => ({ ...e, old_string: e.old_str ?? e.old_string, new_string: e.new_str ?? e.new_string }))
84+
: input.edits,
85+
},
86+
};
6387
case "glob":
6488
return { name: "Glob", input };
6589
case "grep":
@@ -97,6 +121,14 @@ function normalizeCopilotTool(name: string, input: Record<string, unknown>): { n
97121
*/
98122
export function* mapCopilotEvent(event: SessionEvent, state: MapState): Generator<AgentEvent> {
99123
switch (event.type) {
124+
case "user.message": {
125+
// The user's prompt sent to the session — surface as message.user
126+
if (event.data.content) {
127+
yield { type: "message.user", text: event.data.content };
128+
}
129+
return;
130+
}
131+
100132
case "assistant.turn_start": {
101133
if (!state.turnOpen) {
102134
state.turnOpen = true;

tests/providers/copilot.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,85 @@ describe("unknown event type", () => {
621621
expect(events).toHaveLength(0);
622622
});
623623
});
624+
625+
// ---------------------------------------------------------------------------
626+
// user.message
627+
// ---------------------------------------------------------------------------
628+
629+
describe("user.message", () => {
630+
it("emits message.user event with content when data.content is present", () => {
631+
const state = makeState();
632+
const event = { type: "user.message", data: { content: "Hello from user" } } as SessionEvent;
633+
const events = collect(mapCopilotEvent(event, state)) as { type: string; text?: string }[];
634+
expect(events).toHaveLength(1);
635+
expect(events[0]).toEqual({ type: "message.user", text: "Hello from user" });
636+
});
637+
638+
it("emits no events when data.content is empty string", () => {
639+
const state = makeState();
640+
const event = { type: "user.message", data: { content: "" } } as SessionEvent;
641+
const events = collect(mapCopilotEvent(event, state));
642+
expect(events).toHaveLength(0);
643+
});
644+
645+
it("emits no events when data.content is undefined (falsy)", () => {
646+
const state = makeState();
647+
const event = { type: "user.message", data: {} } as SessionEvent;
648+
const events = collect(mapCopilotEvent(event, state));
649+
expect(events).toHaveLength(0);
650+
});
651+
});
652+
653+
// ---------------------------------------------------------------------------
654+
// normalizeCopilotTool field remapping
655+
// ---------------------------------------------------------------------------
656+
657+
describe("normalizeCopilotTool field remapping", () => {
658+
it("write: remaps path → file_path and file_text → content in block input", () => {
659+
const state = makeState({ turnOpen: true });
660+
const event = {
661+
type: "assistant.message",
662+
data: {
663+
toolRequests: [{ toolCallId: "tw1", name: "write", arguments: { path: "/tmp/f.ts", file_text: "hello" } }],
664+
},
665+
} as SessionEvent;
666+
const events = collect(mapCopilotEvent(event, state)) as {
667+
type: string;
668+
block?: { type: string; name: string; input: Record<string, unknown> };
669+
}[];
670+
const toolStart = events.find((e) => e.type === "block.start" && e.block?.type === "tool_use");
671+
expect(toolStart?.block?.input).toMatchObject({ file_path: "/tmp/f.ts", content: "hello" });
672+
});
673+
674+
it("edit: remaps path → file_path, old_str → old_string, new_str → new_string in block input", () => {
675+
const state = makeState({ turnOpen: true });
676+
const event = {
677+
type: "assistant.message",
678+
data: {
679+
toolRequests: [{ toolCallId: "te1", name: "edit", arguments: { path: "/tmp/f.ts", old_str: "a", new_str: "b" } }],
680+
},
681+
} as SessionEvent;
682+
const events = collect(mapCopilotEvent(event, state)) as {
683+
type: string;
684+
block?: { type: string; name: string; input: Record<string, unknown> };
685+
}[];
686+
const toolStart = events.find((e) => e.type === "block.start" && e.block?.type === "tool_use");
687+
expect(toolStart?.block?.input).toMatchObject({ file_path: "/tmp/f.ts", old_string: "a", new_string: "b" });
688+
});
689+
690+
it("read: remaps path → file_path in block input", () => {
691+
const state = makeState({ turnOpen: true });
692+
const event = {
693+
type: "assistant.message",
694+
data: {
695+
toolRequests: [{ toolCallId: "tr1", name: "read", arguments: { path: "/tmp/f.ts" } }],
696+
},
697+
} as SessionEvent;
698+
const events = collect(mapCopilotEvent(event, state)) as {
699+
type: string;
700+
block?: { type: string; name: string; input: Record<string, unknown> };
701+
}[];
702+
const toolStart = events.find((e) => e.type === "block.start" && e.block?.type === "tool_use");
703+
expect(toolStart?.block?.input).toMatchObject({ file_path: "/tmp/f.ts" });
704+
});
705+
});

0 commit comments

Comments
 (0)