feat(coding-agent): added cell-by-cell Python execution tracking with duration and exit codes

- Added cell-by-cell Python execution tracking with duration and exit codes.
- Added template system with {{placeholders}} for task tool context rendering.
- Enhanced patch applicator to preserve exact indentation when intended.
- Changed Python tool to use cells array instead of single code parameter.
- Updated task tool schema to require vars instead of task field.
This commit is contained in:
can1357
2026-01-21 11:54:07 +01:00
parent 4555c8ba9d
commit 96822471c2
13 changed files with 769 additions and 142 deletions
+14
View File
@@ -1,8 +1,15 @@
# Changelog
## [Unreleased]
### Added
- Added cell-by-cell status tracking with duration and exit code for Python execution
- Added syntax highlighting for Python code in execution display
- Added template system with {{placeholders}} for task tool context
- Added task variables support for filling context placeholders
- Added enhanced task progress display with variable values
- Added concurrent work handling guidance in system prompt
- Added extension system support for user Python execution events
- Added Python mode border color theming across all themes
- Added Python execution indicator to welcome screen help text
@@ -16,6 +23,11 @@
### Changed
- Updated Python execution to display cells in bordered blocks with status indicators
- Changed task tool to use template-based context instead of simple concatenation
- Enhanced Python execution component with proper syntax highlighting
- Improved patch applicator to preserve exact indentation when intended
- Updated task tool schema to require vars instead of task field
- Updated Python execution component to use pythonMode theming instead of bashMode
- Enhanced UI helpers to handle pending Python components properly
- Changed Python tool to use `cells` array instead of single `code` parameter
@@ -24,6 +36,8 @@
### Fixed
- Fixed indentation preservation for exact matches and indentation-only patches
- Fixed Python execution status updates to show real-time cell progress
- Fixed indentation adjustment logic to handle edge cases with mixed indentation levels
- Fixed patch indentation normalization for fuzzy matches, tab/space diffs, and ambiguous context alignment
@@ -83,6 +83,34 @@ function adjustLinesIndentation(patternLines: string[], actualLines: string[], n
return newLines;
}
// If pattern already matches actual exactly (including indentation), preserve agent's intended changes
if (patternLines.length === actualLines.length) {
let exactMatch = true;
for (let i = 0; i < patternLines.length; i++) {
if (patternLines[i] !== actualLines[i]) {
exactMatch = false;
break;
}
}
if (exactMatch) {
return newLines;
}
}
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
if (patternLines.length === newLines.length) {
let indentationOnly = true;
for (let i = 0; i < patternLines.length; i++) {
if (patternLines[i].trim() !== newLines[i].trim()) {
indentationOnly = false;
break;
}
}
if (indentationOnly) {
return newLines;
}
}
// Detect indent character from actual content
let indentChar = " ";
for (const line of actualLines) {
@@ -299,6 +299,27 @@ export function normalizeForFuzzy(line: string): string {
* to each line in newText.
*/
export function adjustIndentation(oldText: string, actualText: string, newText: string): string {
// If old text already matches actual text exactly, preserve agent's intended indentation
if (oldText === actualText) {
return newText;
}
// If the patch is purely an indentation change (same trimmed content), apply exactly as specified
const oldLines = oldText.split("\n");
const newLines = newText.split("\n");
if (oldLines.length === newLines.length) {
let indentationOnly = true;
for (let i = 0; i < oldLines.length; i++) {
if (oldLines[i].trim() !== newLines[i].trim()) {
indentationOnly = false;
break;
}
}
if (indentationOnly) {
return newText;
}
}
const oldProfile = buildIndentProfile(oldText);
const actualProfile = buildIndentProfile(actualText);
const newProfile = buildIndentProfile(newText);
+369 -73
View File
@@ -2,10 +2,10 @@ import { relative, resolve, sep } from "node:path";
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { ImageContent } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
import { Text, truncateToWidth } from "@oh-my-pi/pi-tui";
import { Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
import { type Static, Type } from "@sinclair/typebox";
import { truncateToVisualLines } from "../../modes/interactive/components/visual-truncate";
import type { Theme } from "../../modes/interactive/theme/theme";
import { highlightCode, type Theme } from "../../modes/interactive/theme/theme";
import pythonDescription from "../../prompts/tools/python.md" with { type: "text" };
import type { RenderResultOptions } from "../custom-tools/types";
import { renderPromptTemplate } from "../prompt-templates";
@@ -67,7 +67,19 @@ export type PythonToolResult = {
export type PythonProxyExecutor = (params: PythonToolParams, signal?: AbortSignal) => Promise<PythonToolResult>;
export interface PythonCellResult {
index: number;
title?: string;
code: string;
output: string;
status: "pending" | "running" | "complete" | "error";
durationMs?: number;
exitCode?: number;
statusEvents?: PythonStatusEvent[];
}
export interface PythonToolDetails {
cells?: PythonCellResult[];
truncation?: TruncationResult;
fullOutputPath?: string;
fullOutput?: string;
@@ -188,6 +200,65 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
let tailBytes = 0;
const jsonOutputs: unknown[] = [];
const images: ImageContent[] = [];
const statusEvents: PythonStatusEvent[] = [];
const cellResults: PythonCellResult[] = cells.map((cell, index) => ({
index,
title: cell.title,
code: cell.code,
output: "",
status: "pending",
}));
const cellOutputs: string[] = [];
let lastFullOutputPath: string | undefined;
const appendTail = (text: string) => {
if (!text) return;
const chunkBytes = Buffer.byteLength(text, "utf-8");
tailChunks.push({ text, bytes: chunkBytes });
tailBytes += chunkBytes;
while (tailBytes > maxTailBytes && tailChunks.length > 1) {
const removed = tailChunks.shift();
if (removed) {
tailBytes -= removed.bytes;
}
}
};
const buildUpdateDetails = (truncation?: TruncationResult): PythonToolDetails => {
const details: PythonToolDetails = {
cells: cellResults.map((cell) => ({
...cell,
statusEvents: cell.statusEvents ? [...cell.statusEvents] : undefined,
})),
};
if (truncation) {
details.truncation = truncation;
}
if (lastFullOutputPath) {
details.fullOutputPath = lastFullOutputPath;
}
if (jsonOutputs.length > 0) {
details.jsonOutputs = jsonOutputs;
}
if (images.length > 0) {
details.images = images;
}
if (statusEvents.length > 0) {
details.statusEvents = statusEvents;
}
return details;
};
const pushUpdate = () => {
if (!onUpdate) return;
const tailText = tailChunks.map((entry) => entry.text).join("");
const truncation = truncateTail(tailText);
onUpdate({
content: [{ type: "text", text: truncation.content || "" }],
details: buildUpdateDetails(truncation.truncated ? truncation : undefined),
});
};
const sessionFile = this.session.getSessionFile?.() ?? undefined;
const sessionId = sessionFile ? `session:${sessionFile}:cwd:${commandCwd}` : `cwd:${commandCwd}`;
@@ -198,41 +269,29 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
sessionId,
kernelMode: this.session.settings?.getPythonKernelMode?.() ?? "session",
useSharedGateway: this.session.settings?.getPythonSharedGateway?.() ?? true,
onChunk: (chunk) => {
const chunkBytes = Buffer.byteLength(chunk, "utf-8");
tailChunks.push({ text: chunk, bytes: chunkBytes });
tailBytes += chunkBytes;
while (tailBytes > maxTailBytes && tailChunks.length > 1) {
const removed = tailChunks.shift();
if (removed) {
tailBytes -= removed.bytes;
}
}
if (onUpdate) {
const tailText = tailChunks.map((entry) => entry.text).join("");
const truncation = truncateTail(tailText);
onUpdate({
content: [{ type: "text", text: truncation.content || "" }],
details: truncation.truncated ? { truncation } : undefined,
});
}
},
};
const statusEvents: PythonStatusEvent[] = [];
const cellOutputs: string[] = [];
let lastFullOutputPath: string | undefined;
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const isFirstCell = i === 0;
const cellResult = cellResults[i];
cellResult.status = "running";
cellResult.output = "";
cellResult.statusEvents = undefined;
cellResult.exitCode = undefined;
cellResult.durationMs = undefined;
pushUpdate();
const executorOptions: PythonExecutorOptions = {
...baseExecutorOptions,
reset: isFirstCell ? reset : false,
};
const startTime = Date.now();
const result = await executePython(cell.code, executorOptions);
const durationMs = Date.now() - startTime;
const cellStatusEvents: PythonStatusEvent[] = [];
for (const output of result.displayOutputs) {
if (output.type === "json") {
jsonOutputs.push(output.data);
@@ -242,6 +301,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
}
if (output.type === "status") {
statusEvents.push(output.event);
cellStatusEvents.push(output.event);
}
}
@@ -250,23 +310,41 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
}
const cellOutput = result.output.trim();
cellResult.output = cellOutput;
cellResult.exitCode = result.exitCode;
cellResult.durationMs = durationMs;
cellResult.statusEvents = cellStatusEvents.length > 0 ? cellStatusEvents : undefined;
let combinedCellOutput = "";
if (cells.length > 1) {
const cellHeader = `[${i + 1}/${cells.length}]`;
const cellTitle = cell.title ? ` ${cell.title}` : "";
if (cellOutput) {
cellOutputs.push(`${cellHeader}${cellTitle}\n${cellOutput}`);
combinedCellOutput = `${cellHeader}${cellTitle}\n${cellOutput}`;
} else {
cellOutputs.push(`${cellHeader}${cellTitle} (ok)`);
combinedCellOutput = `${cellHeader}${cellTitle} (ok)`;
}
cellOutputs.push(combinedCellOutput);
} else if (cellOutput) {
cellOutputs.push(cellOutput);
combinedCellOutput = cellOutput;
cellOutputs.push(combinedCellOutput);
}
if (combinedCellOutput) {
const prefix = cellOutputs.length > 1 ? "\n\n" : "";
appendTail(`${prefix}${combinedCellOutput}`);
}
if (result.cancelled) {
cellResult.status = "error";
pushUpdate();
const errorMsg = result.output || "Command aborted";
throw new Error(cells.length > 1 ? `Cell ${i + 1} aborted: ${errorMsg}` : errorMsg);
}
if (result.exitCode !== 0 && result.exitCode !== undefined) {
cellResult.status = "error";
pushUpdate();
const combinedOutput = cellOutputs.join("\n\n");
throw new Error(
cells.length > 1
@@ -274,36 +352,32 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
: `${combinedOutput}\n\nCommand exited with code ${result.exitCode}`,
);
}
cellResult.status = "complete";
pushUpdate();
}
const combinedOutput = cellOutputs.join("\n\n");
const truncation = truncateTail(combinedOutput);
let outputText =
truncation.content || (jsonOutputs.length > 0 || images.length > 0 ? "(no text output)" : "(no output)");
let details: PythonToolDetails | undefined;
const details: PythonToolDetails = {
cells: cellResults,
fullOutputPath: lastFullOutputPath,
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
if (truncation.truncated) {
details = {
truncation,
fullOutputPath: lastFullOutputPath,
jsonOutputs: jsonOutputs,
images,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
details.truncation = truncation;
outputText += formatTailTruncationNotice(truncation, {
fullOutputPath: lastFullOutputPath,
originalContent: combinedOutput,
});
}
if (!details && (jsonOutputs.length > 0 || images.length > 0 || statusEvents.length > 0)) {
details = {
jsonOutputs: jsonOutputs.length > 0 ? jsonOutputs : undefined,
images: images.length > 0 ? images : undefined,
statusEvents: statusEvents.length > 0 ? statusEvents : undefined,
};
}
return { content: [{ type: "text", text: outputText }], details };
} finally {
signal?.removeEventListener("abort", onAbort);
@@ -644,11 +718,173 @@ function renderStatusEvents(events: PythonStatusEvent[], theme: Theme, expanded:
return lines;
}
function applyCellBackground(line: string, width: number, bgFn?: (text: string) => string): string {
if (!bgFn) return line;
if (width <= 0) return bgFn(line);
const paddingNeeded = Math.max(0, width - visibleWidth(line));
const padded = line + " ".repeat(paddingNeeded);
return bgFn(padded);
}
function highlightPythonCode(code?: string): string[] {
return highlightCode(code ?? "", "python");
}
function formatCellStatus(cell: PythonCellResult, ui: ToolUIKit, spinnerFrame?: number): string | undefined {
switch (cell.status) {
case "pending":
return `${ui.statusIcon("pending")} ${ui.theme.fg("muted", "pending")}`;
case "running":
return `${ui.statusIcon("running", spinnerFrame)} ${ui.theme.fg("muted", "running")}`;
case "complete":
return ui.statusIcon("success");
case "error":
return ui.statusIcon("error");
}
}
function formatCellHeader(
cell: PythonCellResult,
index: number,
total: number,
ui: ToolUIKit,
spinnerFrame?: number,
workdirLabel?: string,
): string {
const indexLabel = ui.theme.fg("accent", `[${index + 1}/${total}]`);
const title = cell.title ? ` ${cell.title}` : "";
const metaParts: string[] = [];
if (workdirLabel) {
metaParts.push(ui.theme.fg("dim", workdirLabel));
}
if (cell.durationMs !== undefined) {
metaParts.push(ui.theme.fg("dim", `(${ui.formatDuration(cell.durationMs)})`));
}
const statusLabel = formatCellStatus(cell, ui, spinnerFrame);
if (statusLabel) {
metaParts.push(statusLabel);
}
const meta = metaParts.length > 0 ? ` ${metaParts.join(ui.theme.fg("dim", ui.theme.sep.dot))}` : "";
return `${indexLabel}${title}${meta}`;
}
function formatCellOutputLines(
cell: PythonCellResult,
expanded: boolean,
previewLines: number,
theme: Theme,
): { lines: string[]; hiddenCount: number } {
const rawLines = cell.output ? cell.output.split("\n") : [];
const displayLines = expanded ? rawLines : rawLines.slice(-previewLines);
const hiddenCount = rawLines.length - displayLines.length;
const outputLines = displayLines.map((line) => theme.fg("toolOutput", line));
if (outputLines.length === 0) {
return { lines: [], hiddenCount: 0 };
}
return { lines: outputLines, hiddenCount };
}
function renderCellBlock(
cell: PythonCellResult,
index: number,
total: number,
ui: ToolUIKit,
options: {
expanded: boolean;
previewLines: number;
spinnerFrame?: number;
showOutput: boolean;
workdirLabel?: string;
width: number;
bgFn?: (text: string) => string;
},
): string[] {
const { expanded, previewLines, spinnerFrame, showOutput, workdirLabel, width, bgFn } = options;
const h = ui.theme.boxSharp.horizontal;
const v = ui.theme.boxSharp.vertical;
const cap = h.repeat(3);
const border = (text: string) => ui.theme.fg("dim", text);
const lineWidth = Math.max(0, width);
const buildBarLine = (leftChar: string, label?: string): string => {
const left = border(`${leftChar}${cap}`);
if (lineWidth <= 0) return left;
const rawLabel = label ? ` ${label} ` : " ";
const maxLabelWidth = Math.max(0, lineWidth - visibleWidth(left));
const trimmedLabel = truncateToWidth(rawLabel, maxLabelWidth, ui.theme.format.ellipsis);
const fillCount = Math.max(0, lineWidth - visibleWidth(left + trimmedLabel));
return `${left}${trimmedLabel}${border(h.repeat(fillCount))}`;
};
const lines: string[] = [];
lines.push(
applyCellBackground(
buildBarLine(ui.theme.boxSharp.topLeft, formatCellHeader(cell, index, total, ui, spinnerFrame, workdirLabel)),
lineWidth,
bgFn,
),
);
const codePrefix = border(`${v} `);
const codeWidth = Math.max(0, lineWidth - visibleWidth(codePrefix));
const codeLines = highlightPythonCode(cell.code);
for (const line of codeLines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
const statusLines = renderStatusEvents(cell.statusEvents ?? [], ui.theme, expanded);
const outputContent = formatCellOutputLines(cell, expanded, previewLines, ui.theme);
const hasOutput = outputContent.lines.length > 0;
const hasStatus = statusLines.length > 0;
const showOutputSection = showOutput && (hasOutput || hasStatus);
if (showOutputSection) {
lines.push(
applyCellBackground(
buildBarLine(ui.theme.boxSharp.teeRight, ui.theme.fg("toolTitle", "Output")),
lineWidth,
bgFn,
),
);
for (const line of outputContent.lines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
if (!expanded && outputContent.hiddenCount > 0) {
const hint = ui.theme.fg(
"dim",
`${ui.theme.format.ellipsis} ${outputContent.hiddenCount} more lines (ctrl+o to expand)`,
);
lines.push(
applyCellBackground(
`${codePrefix}${truncateToWidth(hint, codeWidth, ui.theme.format.ellipsis)}`,
lineWidth,
bgFn,
),
);
}
for (const line of statusLines) {
const text = truncateToWidth(line, codeWidth, ui.theme.format.ellipsis);
lines.push(applyCellBackground(`${codePrefix}${text}`, lineWidth, bgFn));
}
}
const bottomLeft = border(`${ui.theme.boxSharp.bottomLeft}${cap}`);
const bottomFillCount = Math.max(0, lineWidth - visibleWidth(bottomLeft));
const bottomLine = `${bottomLeft}${border(h.repeat(bottomFillCount))}`;
lines.push(applyCellBackground(bottomLine, lineWidth, bgFn));
return lines;
}
export const pythonToolRenderer = {
renderCall(args: PythonRenderArgs, uiTheme: Theme): Component {
const ui = new ToolUIKit(uiTheme);
const cells = args.cells ?? [];
const prompt = uiTheme.fg("accent", ">>>");
const cwd = process.cwd();
let displayWorkdir = args.cwd;
@@ -666,29 +902,44 @@ export const pythonToolRenderer = {
}
}
const workdirPrefix = displayWorkdir ? uiTheme.fg("dim", `cd ${displayWorkdir} && `) : "";
const workdirLabel = displayWorkdir ? `cd ${displayWorkdir}` : undefined;
if (cells.length === 0) {
const text = ui.title(`${prompt} ${workdirPrefix}${uiTheme.format.ellipsis}`);
const prompt = uiTheme.fg("accent", ">>>");
const prefix = workdirLabel ? `${uiTheme.fg("dim", `${workdirLabel} && `)}` : "";
const text = ui.title(`${prompt} ${prefix}${uiTheme.format.ellipsis}`);
return new Text(text, 0, 0);
}
if (cells.length === 1) {
const cell = cells[0];
const label = cell.title ? `${cell.title}: ` : "";
const text = ui.title(`${prompt} ${workdirPrefix}${label}${cell.code}`);
return new Text(text, 0, 0);
}
// Multiple cells: show each with index
const lines: string[] = [];
for (let i = 0; i < cells.length; i++) {
const cellPrompt = uiTheme.fg("accent", `[${i + 1}]`);
const prefix = i === 0 ? workdirPrefix : "";
const label = cells[i].title ? `${cells[i].title}: ` : "";
lines.push(ui.title(`${cellPrompt} ${prefix}${label}${cells[i].code}`));
}
return new Text(lines.join("\n"), 0, 0);
return {
render: (width: number): string[] => {
const lines: string[] = [];
for (let i = 0; i < cells.length; i++) {
const cell = cells[i];
const cellResult: PythonCellResult = {
index: i,
title: cell.title,
code: cell.code,
output: "",
status: "pending",
};
lines.push(
...renderCellBlock(cellResult, i, cells.length, ui, {
expanded: true,
previewLines: PYTHON_DEFAULT_PREVIEW_LINES,
showOutput: false,
workdirLabel: i === 0 ? workdirLabel : undefined,
width,
bgFn: (text: string) => uiTheme.bg("toolPendingBg", text),
}),
);
if (i < cells.length - 1) {
lines.push("");
}
}
return lines;
},
invalidate: () => {},
};
},
renderResult(
@@ -704,7 +955,6 @@ export const pythonToolRenderer = {
const previewLines = renderContext?.previewLines ?? PYTHON_DEFAULT_PREVIEW_LINES;
const output = renderContext?.output ?? (result.content?.find((c) => c.type === "text")?.text ?? "").trim();
const fullOutput = details?.fullOutput;
const displayOutput = expanded ? (fullOutput ?? output) : output;
const showingFullOutput = expanded && fullOutput !== undefined;
const jsonOutputs = details?.jsonOutputs ?? [];
@@ -714,12 +964,6 @@ export const pythonToolRenderer = {
return [header, ...treeLines];
});
// Render status events
const statusEvents = details?.statusEvents ?? [];
const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
const truncation = details?.truncation;
const fullOutputPath = details?.fullOutputPath;
const timeoutSeconds = renderContext?.timeout;
@@ -747,12 +991,63 @@ export const pythonToolRenderer = {
}
}
const cellResults = details?.cells;
if (cellResults && cellResults.length > 0) {
return {
render: (width: number): string[] => {
const lines: string[] = [];
for (let i = 0; i < cellResults.length; i++) {
const cell = cellResults[i];
const showOutput = cell.status !== "pending";
const bgColor =
cell.status === "error"
? "toolErrorBg"
: cell.status === "complete"
? "toolSuccessBg"
: "toolPendingBg";
lines.push(
...renderCellBlock(cell, i, cellResults.length, ui, {
expanded,
previewLines,
spinnerFrame: options.spinnerFrame,
showOutput,
width,
bgFn: (text: string) => uiTheme.bg(bgColor, text),
}),
);
if (i < cellResults.length - 1) {
lines.push("");
}
}
if (jsonLines.length > 0) {
if (lines.length > 0) {
lines.push("");
}
lines.push(...jsonLines);
}
if (timeoutLine) {
lines.push(timeoutLine);
}
if (warningLine) {
lines.push(warningLine);
}
return lines;
},
invalidate: () => {},
};
}
const displayOutput = expanded ? (fullOutput ?? output) : output;
const combinedOutput = [displayOutput, ...jsonLines].filter(Boolean).join("\n");
const statusEvents = details?.statusEvents ?? [];
const statusLines = renderStatusEvents(statusEvents, uiTheme, expanded);
if (!combinedOutput && statusLines.length === 0) {
const lines = [timeoutLine, warningLine].filter(Boolean) as string[];
return new Text(lines.join("\n"), 0, 0);
}
// If only status events (no text output), show them directly
if (!combinedOutput && statusLines.length > 0) {
const lines = [...statusLines, timeoutLine, warningLine].filter(Boolean) as string[];
return new Text(lines.join("\n"), 0, 0);
@@ -795,7 +1090,6 @@ export const pythonToolRenderer = {
outputLines.push(truncateToWidth(skippedLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
}
outputLines.push(...cachedLines);
// Add status events below the output
for (const statusLine of statusLines) {
outputLines.push(truncateToWidth(statusLine, width, uiTheme.fg("dim", uiTheme.format.ellipsis)));
}
@@ -814,4 +1108,6 @@ export const pythonToolRenderer = {
},
};
},
mergeCallAndResult: true,
inline: true,
};
@@ -28,12 +28,14 @@ import { discoverAgents, getAgent } from "./discovery";
import { runSubprocess } from "./executor";
import { mapWithConcurrencyLimit } from "./parallel";
import { renderCall, renderResult } from "./render";
import { renderTemplate, validateTaskTemplate } from "./template";
import {
type AgentProgress,
MAX_AGENTS_IN_DESCRIPTION,
MAX_CONCURRENCY,
MAX_PARALLEL_TASKS,
type SingleResult,
type TaskParams,
type TaskToolDetails,
taskSchema,
} from "./types";
@@ -112,14 +114,6 @@ async function buildDescription(cwd: string): Promise<string> {
// Tool Class
// ═══════════════════════════════════════════════════════════════════════════
type TaskParams = {
agent: string;
context?: string;
model?: string;
output?: unknown;
tasks: Array<{ id: string; task: string; description: string }>;
};
/**
* Task tool - Delegate tasks to specialized agents.
*
@@ -201,7 +195,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
content: [
{
type: "text",
text: `No tasks provided. Use: { agent, context, tasks: [{id, task, description}, ...] }`,
text: `No tasks provided. Use: { agent, context, tasks: [{id, description, vars}, ...] }`,
},
],
details: {
@@ -277,6 +271,18 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
};
}
const templateError = validateTaskTemplate(context, tasks);
if (templateError) {
return {
content: [{ type: "text", text: templateError }],
details: {
projectAgentsDir,
results: [],
totalDurationMs: 0,
},
};
}
// Derive artifacts directory
const sessionFile = this.session.getSessionFile();
const artifactsDir = sessionFile ? sessionFile.slice(0, -6) : null;
@@ -341,10 +347,12 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
}
// Build full prompts with context prepended
const contextTemplate = context ?? "";
const tasksWithContext = tasks.map((t) => ({
task: context ? `${context}\n\n${t.task}` : t.task,
task: renderTemplate(contextTemplate, t.vars),
description: t.description,
taskId: t.id,
vars: t.vars,
}));
// Initialize progress for all tasks
@@ -357,6 +365,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
agentSource: agent.source,
status: "pending",
task: t.task,
vars: t.vars,
recentTools: [],
recentOutput: [],
toolCount: 0,
@@ -391,7 +400,10 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
signal,
eventBus: undefined,
onProgress: (progress) => {
progressMap.set(index, structuredClone(progress));
progressMap.set(index, {
...structuredClone(progress),
vars: tasksWithContext[index]?.vars,
});
emitProgress();
},
authStorage: this.session.authStorage,
@@ -405,7 +417,12 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
// Fill in skipped tasks (undefined entries from abort) with placeholder results
const results: SingleResult[] = partialResults.map((result, index) => {
if (result !== undefined) return result;
if (result !== undefined) {
return {
...result,
vars: tasksWithContext[index]?.vars,
};
}
const task = tasksWithContext[index];
return {
index,
@@ -413,6 +430,7 @@ export class TaskTool implements AgentTool<typeof taskSchema, TaskToolDetails, T
agent: agentName,
agentSource: agent.source,
task: task.task,
vars: task.vars,
description: task.description,
exitCode: 1,
output: "",
@@ -67,9 +67,12 @@ function formatFindingSummary(findings: ReportFindingDetails[], theme: Theme): s
return `${theme.fg("dim", "Findings:")} ${parts.join(theme.sep.dot)}`;
}
function formatJsonScalar(value: unknown): string {
function formatJsonScalar(value: unknown, theme: Theme): string {
if (value === null) return "null";
if (typeof value === "string") return `"${value}"`;
if (typeof value === "string") {
const trimmed = truncate(value, 70, theme.format.ellipsis);
return `"${trimmed}"`;
}
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
@@ -108,7 +111,7 @@ function renderJsonTreeLines(
const connector = isLast ? theme.tree.last : theme.tree.branch;
const prefix = `${buildTreePrefix(ancestors, theme)}${theme.fg("dim", connector)} `;
const scalar = formatJsonScalar(val);
const scalar = formatJsonScalar(val, theme);
if (scalar) {
const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
@@ -186,7 +189,33 @@ function renderJsonTreeLines(
pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", String(val))}`);
};
renderNode(value, undefined, [], true, 0);
const renderRoot = (val: unknown) => {
if (Array.isArray(val)) {
for (let i = 0; i < val.length; i++) {
renderNode(val[i], `[${i}]`, [], i === val.length - 1, 1);
if (lines.length >= maxLines) {
truncated = true;
return;
}
}
return;
}
if (val && typeof val === "object") {
const entries = Object.entries(val as Record<string, unknown>);
for (let i = 0; i < entries.length; i++) {
const [childKey, child] = entries[i];
renderNode(child, childKey, [], i === entries.length - 1, 1);
if (lines.length >= maxLines) {
truncated = true;
return;
}
}
return;
}
renderNode(val, undefined, [], true, 0);
};
renderRoot(value);
return { lines, truncated };
}
@@ -203,11 +232,18 @@ function renderOutputSection(
const trimmedOutput = output.trim();
if (!trimmedOutput) return lines;
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
if (trimmedOutput.startsWith("{") || trimmedOutput.startsWith("[")) {
try {
const parsed = JSON.parse(trimmedOutput);
// Collapsed: inline format like Vars
if (!expanded) {
lines.push(`${continuePrefix}${theme.fg("dim", formatOutputInline(parsed, theme))}`);
return lines;
}
// Expanded: tree format
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
const tree = renderJsonTreeLines(parsed, theme, expanded ? 6 : 2, expanded ? 24 : 6);
if (tree.lines.length > 0) {
for (const line of tree.lines) {
@@ -223,6 +259,8 @@ function renderOutputSection(
}
}
lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
const outputLines = output.split("\n").filter((line) => line.trim());
const previewCount = expanded ? maxExpanded : maxCollapsed;
for (const line of outputLines.slice(0, previewCount)) {
@@ -238,6 +276,92 @@ function renderOutputSection(
return lines;
}
function formatVarsInline(vars: Record<string, string>, theme: Theme): string {
const entries = Object.entries(vars);
if (entries.length === 0) return "Vars: none";
const pairs = entries.map(([key, value]) => `${key}=${truncate(value, 24, theme.format.ellipsis)}`);
return `Vars: ${pairs.join(", ")}`;
}
function formatScalarInline(value: unknown, maxLen: number, theme: Theme): string {
if (value === null) return "null";
if (value === undefined) return "undefined";
if (typeof value === "boolean") return String(value);
if (typeof value === "number") return String(value);
if (typeof value === "string") return `"${truncate(value, maxLen, theme.format.ellipsis)}"`;
if (Array.isArray(value)) return `[${value.length} items]`;
if (typeof value === "object") {
const keys = Object.keys(value);
return `{${keys.length} keys}`;
}
return String(value);
}
function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string {
if (data === null || data === undefined) return "Output: none";
// For scalars, show directly
if (typeof data !== "object") {
return `Output: ${formatScalarInline(data, 60, theme)}`;
}
// For arrays, show count and first element preview
if (Array.isArray(data)) {
if (data.length === 0) return "Output: []";
const preview = formatScalarInline(data[0], 40, theme);
return `Output: [${data.length} items] ${preview}${data.length > 1 ? theme.format.ellipsis : ""}`;
}
// For objects, show key=value pairs inline
const entries = Object.entries(data as Record<string, unknown>);
if (entries.length === 0) return "Output: {}";
const pairs: string[] = [];
let totalLen = "Output: ".length;
for (const [key, value] of entries) {
const valueStr = formatScalarInline(value, 24, theme);
const pairStr = `${key}=${valueStr}`;
const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", "
if (totalLen + addLen > maxWidth && pairs.length > 0) {
pairs.push(theme.format.ellipsis);
break;
}
pairs.push(pairStr);
totalLen += addLen;
}
return `Output: ${pairs.join(", ")}`;
}
function renderVarsSection(
vars: Record<string, string> | undefined,
continuePrefix: string,
expanded: boolean,
theme: Theme,
): string[] {
if (!vars || Object.keys(vars).length === 0) return [];
const lines: string[] = [];
if (!expanded) {
lines.push(`${continuePrefix}${theme.fg("dim", formatVarsInline(vars, theme))}`);
return lines;
}
lines.push(`${continuePrefix}${theme.fg("dim", "Vars")}`);
const tree = renderJsonTreeLines(vars, theme, 4, 16);
for (const line of tree.lines) {
lines.push(`${continuePrefix} ${line}`);
}
if (tree.truncated) {
lines.push(`${continuePrefix} ${theme.fg("dim", theme.format.ellipsis)}`);
}
return lines;
}
/**
* Render the tool call arguments.
*/
@@ -247,24 +371,29 @@ export function renderCall(args: TaskParams, theme: Theme): Component {
theme.fg("dim", `${theme.format.bracketLeft}${args.agent}${theme.format.bracketRight}`),
);
if (args.tasks.length === 1) {
// Single task - show description preview
const task = args.tasks[0];
const summary = task.description.trim() || task.task;
const taskPreview = truncate(summary, 50, theme.format.ellipsis);
return new Text(`${label} ${agentTag} ${theme.fg("muted", taskPreview)}`, 0, 0);
const lines: string[] = [];
lines.push(`${label} ${agentTag}`);
const contextTemplate = args.context ?? "";
const context = contextTemplate.trim();
const hasContext = context.length > 0;
const branch = theme.fg("dim", theme.tree.branch);
const last = theme.fg("dim", theme.tree.last);
const vertical = theme.fg("dim", theme.tree.vertical);
if (hasContext) {
lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
for (const line of context.split("\n")) {
const content = line ? theme.fg("muted", line) : "";
lines.push(` ${vertical} ${content}`);
}
lines.push(` ${last} ${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
return new Text(lines.join("\n"), 0, 0);
}
// Multiple tasks - show count and descriptions
const descriptions = args.tasks.map((t) => t.description.trim()).join(", ");
return new Text(
`${label} ${agentTag} ${args.tasks.length} agents: ${theme.fg(
"muted",
truncate(descriptions, 50, theme.format.ellipsis),
)}`,
0,
0,
);
lines.push(`${theme.fg("dim", "Tasks")}: ${theme.fg("muted", `${args.tasks.length} agents`)}`);
return new Text(lines.join("\n"), 0, 0);
}
/**
@@ -278,8 +407,8 @@ function renderAgentProgress(
spinnerFrame?: number,
): string[] {
const lines: string[] = [];
const prefix = isLast ? theme.tree.last : theme.tree.branch;
const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
const icon = getStatusIcon(progress.status, theme, spinnerFrame);
const iconColor =
@@ -316,6 +445,8 @@ function renderAgentProgress(
lines.push(statusLine);
lines.push(...renderVarsSection(progress.vars, continuePrefix, expanded, theme));
// Current tool (if running) or most recent completed tool
if (progress.status === "running") {
if (progress.currentTool) {
@@ -498,8 +629,8 @@ function renderFindings(
*/
function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
const lines: string[] = [];
const prefix = isLast ? theme.tree.last : theme.tree.branch;
const continuePrefix = isLast ? " " : `${theme.tree.vertical} `;
const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
const aborted = result.aborted ?? false;
const success = !aborted && result.exitCode === 0;
@@ -525,6 +656,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
}
lines.push(statusLine);
lines.push(...renderVarsSection(result.vars, continuePrefix, expanded, theme));
// Check for review result (complete with review schema + report_finding)
const completeData = result.extractedToolData?.complete as Array<{ data: unknown }> | undefined;
@@ -634,7 +766,7 @@ export function renderResult(
const abortedCount = details.results.filter((r) => r.aborted).length;
const successCount = details.results.filter((r) => !r.aborted && r.exitCode === 0).length;
const failCount = details.results.length - successCount - abortedCount;
let summary = `\n${theme.fg("dim", "Total:")} `;
let summary = `${theme.fg("dim", "Total:")} `;
if (abortedCount > 0) {
summary += theme.fg("error", `${abortedCount} aborted`);
if (successCount > 0 || failCount > 0) summary += theme.sep.dot;
@@ -656,7 +788,8 @@ export function renderResult(
return new Text(theme.fg("dim", "No results"), 0, 0);
}
return new Text(lines.join("\n"), 0, 0);
const indented = lines.map((line) => (line.trim() ? ` ${line}` : ""));
return new Text(indented.join("\n"), 0, 0);
}
export const taskToolRenderer = {
@@ -0,0 +1,37 @@
export function renderTemplate(template: string, vars: Record<string, string>): string {
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => vars[key] ?? `{{${key}}}`);
}
export function extractPlaceholders(template: string): string[] {
return [...template.matchAll(/\{\{(\w+)\}\}/g)].map((match) => match[1]);
}
export function validateTaskTemplate(
context: string | undefined,
tasks: Array<{ id: string; vars: Record<string, string> }>,
): string | null {
const template = context ?? "";
const placeholders = extractPlaceholders(template);
if (tasks.length > 1 && placeholders.length === 0) {
return "Multi-task invocations require {{placeholders}} in context";
}
if (placeholders.length > 0) {
for (const task of tasks) {
const missing = placeholders.filter((placeholder) => !(placeholder in task.vars));
if (missing.length > 0) {
return `Task "${task.id}" missing vars: ${missing.join(", ")}`;
}
}
}
if (tasks.length > 1 && placeholders.length > 0) {
const withoutPlaceholders = template.replace(/\{\{\w+\}\}/g, "").trim();
if (withoutPlaceholders.length < 50) {
return "Context must contain instructions (50+ chars) around {{placeholders}}";
}
}
return null;
}
@@ -46,8 +46,10 @@ export const taskItemSchema = Type.Object({
description: "Short task identifier for display (max 32 chars, CamelCase, e.g. 'SessionStore', 'WebFetchFix')",
maxLength: 32,
}),
task: Type.String({ description: "Task description for the agent" }),
description: Type.String({ description: "Short description for UI display" }),
vars: Type.Record(Type.String(), Type.String(), {
description: "Template variables to fill {{placeholders}} in context",
}),
});
export type TaskItem = Static<typeof taskItemSchema>;
@@ -55,7 +57,7 @@ export type TaskItem = Static<typeof taskItemSchema>;
/** Task tool parameters */
export const taskSchema = Type.Object({
agent: Type.String({ description: "Agent type to use for all tasks" }),
context: Type.String({ description: "Shared context prepended to all task prompts" }),
context: Type.String({ description: "Template with {{placeholders}} filled by task vars" }),
model: Type.Optional(
Type.String({
description: "Model override for all tasks (fuzzy matching, e.g. 'sonnet', 'opus')",
@@ -120,6 +122,7 @@ export interface AgentProgress {
agentSource: AgentSource;
status: "pending" | "running" | "completed" | "failed" | "aborted";
task: string;
vars?: Record<string, string>;
description?: string;
currentTool?: string;
currentToolArgs?: string;
@@ -141,6 +144,7 @@ export interface SingleResult {
agent: string;
agentSource: AgentSource;
task: string;
vars?: Record<string, string>;
description?: string;
exitCode: number;
output: string;
@@ -11,7 +11,7 @@ import {
type TruncationResult,
truncateTail,
} from "../../../core/tools/truncate";
import { getSymbolTheme, theme } from "../theme/theme";
import { getSymbolTheme, highlightCode, theme } from "../theme/theme";
import { DynamicBorder } from "./dynamic-border";
import { truncateToVisualLines } from "./visual-truncate";
@@ -29,6 +29,16 @@ export class PythonExecutionComponent extends Container {
private contentContainer: Container;
private excludeFromContext: boolean;
private formatHeader(colorKey: "dim" | "pythonMode"): Text {
const prompt = theme.fg(colorKey, theme.bold(">>>"));
const continuation = theme.fg(colorKey, " ");
const codeLines = highlightCode(this.code, "python");
const headerLines = codeLines.map((line, index) =>
index === 0 ? `${prompt} ${line}` : `${continuation}${line}`,
);
return new Text(headerLines.join("\n"), 1, 0);
}
constructor(code: string, ui: TUI, excludeFromContext = false) {
super();
this.code = code;
@@ -42,9 +52,7 @@ export class PythonExecutionComponent extends Container {
this.contentContainer = new Container();
this.addChild(this.contentContainer);
const header = new Text(theme.fg(colorKey, theme.bold(`>>> ${code}`)), 1, 0);
this.contentContainer.addChild(header);
this.contentContainer.addChild(this.formatHeader(colorKey));
this.loader = new Loader(
ui,
@@ -115,8 +123,7 @@ export class PythonExecutionComponent extends Container {
this.contentContainer.clear();
const colorKey = this.excludeFromContext ? "dim" : "pythonMode";
const header = new Text(theme.fg(colorKey, theme.bold(`>>> ${this.code}`)), 1, 0);
this.contentContainer.addChild(header);
this.contentContainer.addChild(this.formatHeader(colorKey));
if (availableLines.length > 0) {
if (this.expanded) {
@@ -200,6 +200,13 @@ Do not open a file hoping to find something. Know where to look first.
- When summarizing: plain text, file paths. Do not echo content back.
{{/ifAny}}
- Be brief. Show file paths clearly.
{{#has tools "ask"}}
### Concurrent work
Other agents or the user may be editing files concurrently.
When file contents differ from expectations or edits fail: re-read and adapt.
**Ask before** `git checkout/restore/reset`, bulk overwrites, or deleting code you didn't write.
{{/has}}
</practice>
<method>
@@ -324,6 +331,7 @@ Keep going until finished.
- Do not write code before stating assumptions.
- Do not claim correctness you haven't verified.
- Do not handle only the happy path.
{{#has tools "ask"}}- If files differ from expectations, ask before discarding uncommitted work.{{/has}}
Let edge cases surface before you handle them. Let the failure modes exist in your mind before you prevent them. Let the code be smaller than your first instinct.
@@ -49,12 +49,12 @@ Agents with "Output: structured" have a fixed schema enforced via frontmatter; y
## Parameters
- `agent`: Agent type to use for all tasks
- `context`: **Required context from conversation** - include ALL relevant info: requirements, schemas, decisions, constraints. Subagents cannot see chat history.
- `model`: (optional) Model override (fuzzy matching, e.g., "sonnet", "opus")
- `tasks`: Array of `{id, task, description}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent)
- `context`: Template with `{{placeholders}}` for multi-task. Each placeholder is filled from task vars.
- `model`: (optional) Model override for all tasks (fuzzy matching, e.g., "sonnet", "opus")
- `tasks`: Array of `{id, description, vars}` - tasks to run in parallel (max {{MAX_PARALLEL_TASKS}}, {{MAX_CONCURRENCY}} concurrent)
- `id`: Short CamelCase identifier for display (max 20 chars, e.g., "SessionStore", "LspRefactor")
- `task`: The task prompt for the agent
- `description`: Short human-readable description of what the task does
- `vars`: Object with keys matching `{{placeholders}}` in context
- `output`: (optional) JTD schema for structured subagent output (used by the complete tool)
## Example
@@ -65,7 +65,7 @@ assistant: I'll execute the refactoring plan.
assistant: Uses the Task tool:
{
"agent": "task",
"context": "Refactoring the auth module into separate concerns.\n\nPlan:\n1. AuthProvider - Extract React context and provider from src/auth/index.tsx\n2. AuthApi - Extract API calls to src/auth/api.ts, use existing fetchJson helper\n3. AuthTypes - Move types to src/auth/types.ts, re-export from index\n\nConstraints:\n- Preserve all existing exports from src/auth/index.tsx\n- Use project's fetchJson (src/utils/http.ts), don't use raw fetch\n- No new dependencies",
"context": "Refactoring the auth module into separate concerns.\n\nPlan:\n1. AuthProvider - Extract React context and provider from src/auth/index.tsx\n2. AuthApi - Extract API calls to src/auth/api.ts, use existing fetchJson helper\n3. AuthTypes - Move types to types.ts, re-export from index\n\nConstraints:\n- Preserve all existing exports from src/auth/index.tsx\n- Use project's fetchJson (src/utils/http.ts), don't use raw fetch\n- No new dependencies\n\nTask: {{step}}\n\nFiles: {{files}}",
"output": {
"properties": {
"summary": { "type": "string" },
@@ -74,14 +74,9 @@ assistant: Uses the Task tool:
}
},
"tasks": [
{ "id": "AuthProvider", "task": "Execute step 1: Extract AuthProvider and AuthContext", "description": "Extract React context" },
{ "id": "AuthApi", "task": "Execute step 2: Extract API calls to api.ts", "description": "Extract API layer" },
{ "id": "AuthTypes", "task": "Execute step 3: Move types to types.ts", "description": "Extract types" }
{ "id": "AuthProvider", "description": "Extract React context", "vars": { "step": "Execute step 1: Extract AuthProvider and AuthContext", "files": "src/auth/index.tsx" } },
{ "id": "AuthApi", "description": "Extract API layer", "vars": { "step": "Execute step 2: Extract API calls to api.ts", "files": "src/auth/api.ts" } },
{ "id": "AuthTypes", "description": "Extract types", "vars": { "step": "Execute step 3: Move types to types.ts", "files": "src/auth/types.ts" } }
]
}
</example>
Key points:
- **Plan in context**: The full plan is written once; each task references its step without repeating shared constraints
- **Parallel execution**: 3 agents run concurrently, each owning one step - no duplicated work
- **Structured output**: JTD schema ensures consistent reporting across all agents
@@ -1,7 +1,6 @@
import { describe, expect, it } from "bun:test";
import stripAnsi from "strip-ansi";
import { pythonToolRenderer } from "../../src/core/tools/python";
import { truncateTail } from "../../src/core/tools/truncate";
import { getThemeByName } from "../../src/modes/interactive/theme/theme";
describe("pythonToolRenderer", () => {
@@ -11,26 +10,37 @@ describe("pythonToolRenderer", () => {
const uiTheme = theme!;
const fullOutput = ["line 1", "line 2", "line 3", "line 4"].join("\n");
const truncation = truncateTail(fullOutput, { maxLines: 2, maxBytes: 128 });
const result = {
content: [{ type: "text", text: truncation.content }],
content: [{ type: "text", text: fullOutput }],
details: {
truncation,
fullOutput,
cells: [
{
index: 0,
title: "run",
code: "print('hello')",
output: fullOutput,
status: "complete" as const,
durationMs: 12,
},
],
},
};
const collapsed = pythonToolRenderer.renderResult(result, { expanded: false, isPartial: false }, uiTheme);
const collapsed = pythonToolRenderer.renderResult(
result,
{ expanded: false, isPartial: false, renderContext: { previewLines: 2 } },
uiTheme,
);
const collapsedLines = stripAnsi(collapsed.render(80).join("\n"));
expect(collapsedLines).toContain("line 4");
expect(collapsedLines).not.toContain("line 1");
expect(collapsedLines).toContain("Truncated:");
expect(collapsedLines).toContain("more lines");
const expanded = pythonToolRenderer.renderResult(result, { expanded: true, isPartial: false }, uiTheme);
const expandedLines = stripAnsi(expanded.render(80).join("\n"));
expect(expandedLines).toContain("line 1");
expect(expandedLines).toContain("line 4");
expect(expandedLines).not.toContain("Truncated:");
expect(expandedLines).not.toContain("more lines");
});
});
@@ -0,0 +1,56 @@
import { describe, expect, test } from "bun:test";
import { extractPlaceholders, renderTemplate, validateTaskTemplate } from "../../src/core/tools/task/template";
// ============================================================================
// renderTemplate
// ============================================================================
describe("task template rendering", () => {
test("extracts placeholders", () => {
expect(extractPlaceholders("Do {{thing}} then {{thing}} with {{target}}")).toEqual(["thing", "thing", "target"]);
});
test("renders single placeholder", () => {
expect(renderTemplate("Hello {{name}}", { name: "Ada" })).toBe("Hello Ada");
});
test("renders multiple placeholders", () => {
expect(renderTemplate("{{greet}} {{name}}", { greet: "Hi", name: "Ada" })).toBe("Hi Ada");
});
test("leaves unknown placeholders intact", () => {
expect(renderTemplate("Hello {{name}} {{missing}}", { name: "Ada" })).toBe("Hello Ada {{missing}}");
});
});
// ============================================================================
// validateTaskTemplate
// ============================================================================
describe("task template validation", () => {
test("requires placeholders for multi-task", () => {
const error = validateTaskTemplate("Just instructions", [
{ id: "One", vars: {} },
{ id: "Two", vars: {} },
]);
expect(error).toBe("Multi-task invocations require {{placeholders}} in context");
});
test("errors on missing vars", () => {
const error = validateTaskTemplate("Do {{thing}}", [{ id: "Only", vars: {} }]);
expect(error).toBe('Task "Only" missing vars: thing');
});
test("errors on empty context around placeholders", () => {
const error = validateTaskTemplate("{{task}}", [
{ id: "A", vars: { task: "One" } },
{ id: "B", vars: { task: "Two" } },
]);
expect(error).toBe("Context must contain instructions (50+ chars) around {{placeholders}}");
});
test("allows single-task without placeholders", () => {
const error = validateTaskTemplate("Just do the thing", [{ id: "Only", vars: {} }]);
expect(error).toBeNull();
});
});