Skip to content

Commit e1aedc5

Browse files
committed
fix: keep heredoc exec bodies out of command summaries
1 parent 8604dbd commit e1aedc5

3 files changed

Lines changed: 168 additions & 2 deletions

File tree

src/agents/tool-display-exec-shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export function unwrapShellWrapper(command: string): string {
237237
return inner ? (stripOuterQuotes(inner) ?? command) : command;
238238
}
239239

240-
function scanTopLevelChars(
240+
export function scanTopLevelChars(
241241
command: string,
242242
visit: (char: string, index: number) => boolean | void,
243243
): void {

src/agents/tool-display-exec.ts

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
firstPositional,
1212
optionValue,
1313
positionalArgs,
14+
scanTopLevelChars,
1415
splitShellWords,
1516
splitTopLevelPipes,
1617
splitTopLevelStages,
@@ -298,6 +299,115 @@ function summarizePipeline(stage: string): string {
298299
return summarizeKnownExec(trimLeadingEnv(splitShellWords(stage)));
299300
}
300301

302+
type HeredocTerminator = {
303+
value: string;
304+
stripLeadingTabs: boolean;
305+
};
306+
307+
function collectHeredocTerminators(commandLine: string): HeredocTerminator[] {
308+
const terminators: HeredocTerminator[] = [];
309+
scanTopLevelChars(commandLine, (char, index) => {
310+
if (char !== "<" || commandLine[index + 1] !== "<" || commandLine[index + 2] === "<") {
311+
return true;
312+
}
313+
314+
const stripLeadingTabs = commandLine[index + 2] === "-";
315+
const parsed = parseHeredocTerminator(commandLine, index + (stripLeadingTabs ? 3 : 2));
316+
if (parsed) {
317+
terminators.push({ value: parsed, stripLeadingTabs });
318+
}
319+
return true;
320+
});
321+
return terminators;
322+
}
323+
324+
function parseHeredocTerminator(commandLine: string, rawStart: number): string | undefined {
325+
let start = rawStart;
326+
while (/\s/u.test(commandLine[start] ?? "")) {
327+
start += 1;
328+
}
329+
330+
let value = "";
331+
let quote: '"' | "'" | undefined;
332+
333+
for (let index = start; index < commandLine.length; index += 1) {
334+
const char = commandLine[index] ?? "";
335+
336+
if (quote) {
337+
if (char === quote) {
338+
quote = undefined;
339+
continue;
340+
}
341+
if (quote === '"' && char === "\\" && index + 1 < commandLine.length) {
342+
index += 1;
343+
value += commandLine[index] ?? "";
344+
continue;
345+
}
346+
value += char;
347+
continue;
348+
}
349+
350+
if (/[\s;&|<>]/u.test(char)) {
351+
break;
352+
}
353+
if (char === "'" || char === '"') {
354+
quote = char;
355+
continue;
356+
}
357+
if (char === "\\" && index + 1 < commandLine.length) {
358+
index += 1;
359+
value += commandLine[index] ?? "";
360+
continue;
361+
}
362+
value += char;
363+
}
364+
365+
return value || undefined;
366+
}
367+
368+
function commandWithoutHeredocBodies(command: string): string | undefined {
369+
if (!command.includes("\n")) {
370+
return undefined;
371+
}
372+
373+
const lines = command.split(/\r?\n/u);
374+
const summaryLines: string[] = [];
375+
let foundHeredoc = false;
376+
377+
for (let index = 0; index < lines.length; index += 1) {
378+
const line = lines[index] ?? "";
379+
summaryLines.push(line);
380+
381+
const terminators = collectHeredocTerminators(line);
382+
if (terminators.length === 0) {
383+
continue;
384+
}
385+
foundHeredoc = true;
386+
387+
for (const terminator of terminators) {
388+
index += 1;
389+
while (index < lines.length) {
390+
const candidate = terminator.stripLeadingTabs
391+
? (lines[index] ?? "").replace(/^\t+/u, "")
392+
: (lines[index] ?? "");
393+
if (candidate === terminator.value) {
394+
break;
395+
}
396+
index += 1;
397+
}
398+
}
399+
}
400+
401+
if (!foundHeredoc) {
402+
return undefined;
403+
}
404+
405+
return summaryLines
406+
.map((line) => line.trim())
407+
.filter(Boolean)
408+
.join("; ");
409+
}
410+
301411
type ExecSummary = {
302412
text: string;
303413
chdirPath?: string;
@@ -361,7 +471,8 @@ function summarizeExecCommand(command: string): ExecSummary | undefined {
361471
return chdirPath ? { text: "", chdirPath } : undefined;
362472
}
363473

364-
const stages = splitTopLevelStages(cleaned);
474+
const summaryCommand = commandWithoutHeredocBodies(cleaned) ?? cleaned;
475+
const stages = splitTopLevelStages(summaryCommand);
365476
if (stages.length === 0) {
366477
return undefined;
367478
}

src/agents/tool-display.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,61 @@ describe("tool display details", () => {
464464
expect(nodeShortCheckDetail).toContain("check js syntax for /tmp/test.js");
465465
});
466466

467+
it("does not split heredoc body content into exec stages", () => {
468+
const detail = formatToolDetail(
469+
resolveToolDisplay({
470+
name: "exec",
471+
args: {
472+
command: [
473+
"python3 <<'PY'",
474+
"const slugify = () => 'court-mix';",
475+
"if (true) console.log('a') && console.log('b');",
476+
"cat <<YAML",
477+
"- uses: subosito/flutter-action@v2",
478+
"YAML",
479+
"PY",
480+
].join("\n"),
481+
workdir: "/Users/example/.openclaw/workspace",
482+
},
483+
detailMode: "explain",
484+
}),
485+
);
486+
487+
expect(detail).toBe("run python3 inline script (heredoc) (agent)");
488+
});
489+
490+
it("keeps command stages after a heredoc terminator", () => {
491+
const detail = formatToolDetail(
492+
resolveToolDisplay({
493+
name: "exec",
494+
args: {
495+
command: ["python3 <<'PY'", "print('body && not a command')", "PY", "npm test"].join(
496+
"\n",
497+
),
498+
},
499+
detailMode: "explain",
500+
}),
501+
);
502+
503+
expect(detail).toBe("run python3 inline script (heredoc) → run tests");
504+
});
505+
506+
it("matches shell-quoted heredoc terminators before keeping later stages", () => {
507+
const detail = formatToolDetail(
508+
resolveToolDisplay({
509+
name: "exec",
510+
args: {
511+
command: ["python3 <<\\PY", "print('body && not a command')", "PY", "npm test"].join(
512+
"\n",
513+
),
514+
},
515+
detailMode: "explain",
516+
}),
517+
);
518+
519+
expect(detail).toBe("run python3 inline script (heredoc) → run tests");
520+
});
521+
467522
it("appends node name to exec detail when node is set", () => {
468523
const detail = formatToolDetail(
469524
resolveToolDisplay({

0 commit comments

Comments
 (0)