Skip to content

Commit 4ec9fe3

Browse files
committed
fix(parser): render codex function calls as informative tool blocks
1 parent 93fc60e commit 4ec9fe3

6 files changed

Lines changed: 406 additions & 11 deletions

File tree

frontend/src/lib/utils/content-parser.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,15 @@ describe("parseContent", () => {
108108
});
109109
});
110110

111+
it("parses codex tool markers and normalizes label", () => {
112+
const segments = parseContent("[exec_command]\n$ rg --files");
113+
expect(segments[0]).toEqual({
114+
type: "tool",
115+
content: "$ rg --files",
116+
label: "Bash",
117+
});
118+
});
119+
111120
it("drops overlapping matches", () => {
112121
const text = "[Thinking]\nI think\n[Bash]\necho ok";
113122
const segments = parseContent(text);
@@ -173,4 +182,12 @@ describe("isToolOnly", () => {
173182
});
174183
expect(isToolOnly(msg)).toBe(true);
175184
});
185+
186+
it("treats codex markers as tool-only content", () => {
187+
const msg = makeMsg({
188+
has_tool_use: true,
189+
content: "[exec_command]\n$ pwd",
190+
});
191+
expect(isToolOnly(msg)).toBe(true);
192+
});
176193
});

frontend/src/lib/utils/content-parser.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,17 @@ const THINKING_RE =
2020
const TOOL_NAMES =
2121
"Tool|Read|Write|Edit|Bash|Glob|Grep|Task|" +
2222
"Question|Todo List|Entering Plan Mode|" +
23-
"Exiting Plan Mode";
23+
"Exiting Plan Mode|exec_command|shell_command|" +
24+
"write_stdin|apply_patch|shell|parallel|view_image|" +
25+
"request_user_input|update_plan";
26+
27+
const TOOL_ALIASES: Record<string, string> = {
28+
exec_command: "Bash",
29+
shell_command: "Bash",
30+
write_stdin: "Bash",
31+
shell: "Bash",
32+
apply_patch: "Edit",
33+
};
2434

2535
const TOOL_RE = new RegExp(
2636
`\\[(${TOOL_NAMES})([^\\]]*)\\]([\\s\\S]*?)(?=\\n\\[|\\n\\n|$)`,
@@ -76,9 +86,10 @@ function extractMatches(text: string): Match[] {
7686
for (const m of text.matchAll(TOOL_RE)) {
7787
const toolName = m[1] ?? "";
7888
const toolArgs = (m[2] ?? "").trim();
89+
const displayName = TOOL_ALIASES[toolName] ?? toolName;
7990
const label = toolArgs
80-
? `${toolName} ${toolArgs}`
81-
: toolName;
91+
? `${displayName} ${toolArgs}`
92+
: displayName;
8293
matches.push({
8394
start: m.index!,
8495
end: m.index! + m[0].length,

internal/parser/codex.go

Lines changed: 301 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77
"path/filepath"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -156,11 +157,307 @@ func (b *codexSessionBuilder) handleFunctionCall(
156157
func formatCodexFunctionCall(
157158
name string, payload gjson.Result,
158159
) string {
159-
summary := payload.Get("summary").Str
160-
if summary != "" {
161-
return fmt.Sprintf("[%s: %s]", name, summary)
160+
summary := sanitizeToolLabel(payload.Get("summary").Str)
161+
args, rawArgs := parseCodexFunctionArgs(payload)
162+
163+
switch name {
164+
case "exec_command", "shell_command", "shell":
165+
return formatCodexBashCall(summary, args, rawArgs)
166+
case "write_stdin":
167+
return formatCodexWriteStdinCall(summary, args, rawArgs)
168+
case "apply_patch":
169+
return formatCodexApplyPatchCall(summary, args, rawArgs)
162170
}
163-
return fmt.Sprintf("[%s]", name)
171+
172+
category := NormalizeToolCategory(name)
173+
if category == "Other" {
174+
header := formatToolHeader("Tool", name)
175+
if summary != "" {
176+
return header + "\n" + summary
177+
}
178+
if preview := codexArgPreview(args, rawArgs); preview != "" {
179+
return header + "\n" + preview
180+
}
181+
return header
182+
}
183+
184+
detail := firstNonEmpty(summary,
185+
codexCategoryDetail(category, args))
186+
header := formatToolHeader(category, detail)
187+
if preview := codexArgPreview(args, rawArgs); preview != "" {
188+
return header + "\n" + preview
189+
}
190+
return header
191+
}
192+
193+
func parseCodexFunctionArgs(
194+
payload gjson.Result,
195+
) (gjson.Result, string) {
196+
for _, key := range []string{"arguments", "input"} {
197+
arg := payload.Get(key)
198+
if !arg.Exists() {
199+
continue
200+
}
201+
202+
switch arg.Type {
203+
case gjson.String:
204+
s := strings.TrimSpace(arg.Str)
205+
if s == "" {
206+
continue
207+
}
208+
if gjson.Valid(s) {
209+
return gjson.Parse(s), ""
210+
}
211+
return gjson.Result{}, s
212+
default:
213+
if arg.IsObject() || arg.IsArray() {
214+
return arg, ""
215+
}
216+
raw := strings.TrimSpace(arg.Raw)
217+
if raw == "" {
218+
continue
219+
}
220+
if gjson.Valid(raw) {
221+
return gjson.Parse(raw), ""
222+
}
223+
return gjson.Result{}, raw
224+
}
225+
}
226+
return gjson.Result{}, ""
227+
}
228+
229+
func formatCodexBashCall(
230+
summary string, args gjson.Result, rawArgs string,
231+
) string {
232+
cmd := codexArgValue(args, "cmd", "command")
233+
if cmd == "" && rawArgs != "" && !gjson.Valid(rawArgs) {
234+
cmd = rawArgs
235+
}
236+
if cmd == "" && args.Type == gjson.String {
237+
cmd = strings.TrimSpace(args.Str)
238+
}
239+
240+
header := formatToolHeader("Bash", summary)
241+
if cmd != "" {
242+
return header + "\n$ " + cmd
243+
}
244+
if preview := codexArgPreview(args, rawArgs); preview != "" {
245+
return header + "\n" + preview
246+
}
247+
return header
248+
}
249+
250+
func formatCodexWriteStdinCall(
251+
summary string, args gjson.Result, rawArgs string,
252+
) string {
253+
if summary == "" {
254+
if sid := codexArgValue(args, "session_id"); sid != "" {
255+
summary = "stdin -> " + sid
256+
} else {
257+
summary = "stdin"
258+
}
259+
}
260+
261+
header := formatToolHeader("Bash", summary)
262+
chars := codexArgString(args, "chars")
263+
if chars != "" {
264+
quoted := strings.Trim(
265+
strconv.QuoteToASCII(chars), "\"",
266+
)
267+
return header + "\n" + truncate(quoted, 220)
268+
}
269+
270+
if preview := codexArgPreview(args, rawArgs); preview != "" {
271+
return header + "\n" + preview
272+
}
273+
return header
274+
}
275+
276+
func formatCodexApplyPatchCall(
277+
summary string, args gjson.Result, rawArgs string,
278+
) string {
279+
patch := codexArgString(args, "patch")
280+
if patch == "" && strings.Contains(rawArgs, "*** Begin Patch") {
281+
patch = rawArgs
282+
}
283+
284+
files := extractPatchedFiles(patch)
285+
if summary == "" {
286+
summary = summarizePatchedFiles(files)
287+
}
288+
289+
header := formatToolHeader("Edit", summary)
290+
if len(files) > 1 {
291+
limit := min(len(files), 6)
292+
body := strings.Join(files[:limit], "\n")
293+
if len(files) > limit {
294+
body += fmt.Sprintf("\n+%d more files", len(files)-limit)
295+
}
296+
return header + "\n" + body
297+
}
298+
if preview := codexArgPreview(args, rawArgs); preview != "" &&
299+
len(files) == 0 {
300+
return header + "\n" + preview
301+
}
302+
return header
303+
}
304+
305+
func extractPatchedFiles(patch string) []string {
306+
if patch == "" {
307+
return nil
308+
}
309+
310+
var files []string
311+
seen := make(map[string]struct{})
312+
lines := strings.Split(patch, "\n")
313+
for _, line := range lines {
314+
for _, prefix := range []string{
315+
"*** Add File: ",
316+
"*** Update File: ",
317+
"*** Delete File: ",
318+
"*** Move to: ",
319+
} {
320+
if !strings.HasPrefix(line, prefix) {
321+
continue
322+
}
323+
file := strings.TrimSpace(
324+
strings.TrimPrefix(line, prefix),
325+
)
326+
if file == "" {
327+
continue
328+
}
329+
if _, ok := seen[file]; ok {
330+
continue
331+
}
332+
seen[file] = struct{}{}
333+
files = append(files, file)
334+
break
335+
}
336+
}
337+
return files
338+
}
339+
340+
func summarizePatchedFiles(files []string) string {
341+
switch len(files) {
342+
case 0:
343+
return ""
344+
case 1:
345+
return files[0]
346+
default:
347+
return fmt.Sprintf(
348+
"%s (+%d more)",
349+
files[0], len(files)-1,
350+
)
351+
}
352+
}
353+
354+
func codexCategoryDetail(
355+
category string, args gjson.Result,
356+
) string {
357+
switch category {
358+
case "Read", "Write", "Edit":
359+
return codexArgValue(args, "file_path", "path")
360+
case "Grep":
361+
return codexArgValue(args, "pattern")
362+
case "Glob":
363+
pattern := codexArgValue(args, "pattern")
364+
path := codexArgValue(args, "path")
365+
if pattern != "" && path != "" {
366+
return fmt.Sprintf("%s in %s", pattern, path)
367+
}
368+
return firstNonEmpty(pattern, path)
369+
case "Task":
370+
desc := codexArgValue(args, "description")
371+
agent := codexArgValue(args, "subagent_type")
372+
if desc != "" && agent != "" {
373+
return fmt.Sprintf("%s (%s)", desc, agent)
374+
}
375+
return firstNonEmpty(desc, agent)
376+
default:
377+
return ""
378+
}
379+
}
380+
381+
func codexArgString(
382+
args gjson.Result, path string,
383+
) string {
384+
v := args.Get(path)
385+
if !v.Exists() {
386+
return ""
387+
}
388+
if v.Type == gjson.String {
389+
return v.Str
390+
}
391+
raw := strings.TrimSpace(v.Raw)
392+
if raw == "" || raw == "null" {
393+
return ""
394+
}
395+
return raw
396+
}
397+
398+
func codexArgValue(
399+
args gjson.Result, paths ...string,
400+
) string {
401+
for _, path := range paths {
402+
v := strings.TrimSpace(codexArgString(args, path))
403+
if v != "" {
404+
return v
405+
}
406+
}
407+
return ""
408+
}
409+
410+
func codexArgPreview(
411+
args gjson.Result, rawArgs string,
412+
) string {
413+
if rawArgs != "" {
414+
flat := strings.Join(
415+
strings.Fields(rawArgs), " ",
416+
)
417+
return truncate(flat, 220)
418+
}
419+
if args.Exists() {
420+
flat := strings.Join(
421+
strings.Fields(args.Raw), " ",
422+
)
423+
if flat != "" {
424+
return truncate(flat, 220)
425+
}
426+
}
427+
return ""
428+
}
429+
430+
func formatToolHeader(
431+
label, detail string,
432+
) string {
433+
label = sanitizeToolLabel(label)
434+
if label == "" {
435+
label = "Tool"
436+
}
437+
detail = sanitizeToolLabel(detail)
438+
if detail != "" {
439+
return fmt.Sprintf("[%s: %s]", label, detail)
440+
}
441+
return fmt.Sprintf("[%s]", label)
442+
}
443+
444+
func sanitizeToolLabel(s string) string {
445+
s = strings.TrimSpace(s)
446+
if s == "" {
447+
return ""
448+
}
449+
s = strings.ReplaceAll(s, "]", ")")
450+
return strings.Join(strings.Fields(s), " ")
451+
}
452+
453+
func firstNonEmpty(vals ...string) string {
454+
for _, v := range vals {
455+
v = strings.TrimSpace(v)
456+
if v != "" {
457+
return v
458+
}
459+
}
460+
return ""
164461
}
165462

166463
// extractCodexContent joins all text blocks from a Codex

0 commit comments

Comments
 (0)