diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 5ade0ad88..b016409fe 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/tools/patch/applicator.ts b/packages/coding-agent/src/core/tools/patch/applicator.ts index c6cefc8fb..eb4dd5843 100644 --- a/packages/coding-agent/src/core/tools/patch/applicator.ts +++ b/packages/coding-agent/src/core/tools/patch/applicator.ts @@ -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) { diff --git a/packages/coding-agent/src/core/tools/patch/normalize.ts b/packages/coding-agent/src/core/tools/patch/normalize.ts index b16f2f179..550666d76 100644 --- a/packages/coding-agent/src/core/tools/patch/normalize.ts +++ b/packages/coding-agent/src/core/tools/patch/normalize.ts @@ -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); diff --git a/packages/coding-agent/src/core/tools/python.ts b/packages/coding-agent/src/core/tools/python.ts index 2441352a0..2a2397bce 100644 --- a/packages/coding-agent/src/core/tools/python.ts +++ b/packages/coding-agent/src/core/tools/python.ts @@ -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; +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 { 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 { 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 { } if (output.type === "status") { statusEvents.push(output.event); + cellStatusEvents.push(output.event); } } @@ -250,23 +310,41 @@ export class PythonTool implements AgentTool { } 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 { : `${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, }; diff --git a/packages/coding-agent/src/core/tools/task/index.ts b/packages/coding-agent/src/core/tools/task/index.ts index 4c787bd06..224e6bfd5 100644 --- a/packages/coding-agent/src/core/tools/task/index.ts +++ b/packages/coding-agent/src/core/tools/task/index.ts @@ -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 { // 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 ({ - 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 { - 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 { - 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 { + 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); + 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, 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); + 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 | 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 = { diff --git a/packages/coding-agent/src/core/tools/task/template.ts b/packages/coding-agent/src/core/tools/task/template.ts new file mode 100644 index 000000000..5803bb913 --- /dev/null +++ b/packages/coding-agent/src/core/tools/task/template.ts @@ -0,0 +1,37 @@ +export function renderTemplate(template: string, vars: Record): 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 | 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; +} diff --git a/packages/coding-agent/src/core/tools/task/types.ts b/packages/coding-agent/src/core/tools/task/types.ts index e7ab2e13a..8c08fc325 100644 --- a/packages/coding-agent/src/core/tools/task/types.ts +++ b/packages/coding-agent/src/core/tools/task/types.ts @@ -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; @@ -55,7 +57,7 @@ export type TaskItem = Static; /** 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; description?: string; currentTool?: string; currentToolArgs?: string; @@ -141,6 +144,7 @@ export interface SingleResult { agent: string; agentSource: AgentSource; task: string; + vars?: Record; description?: string; exitCode: number; output: string; diff --git a/packages/coding-agent/src/modes/interactive/components/python-execution.ts b/packages/coding-agent/src/modes/interactive/components/python-execution.ts index f191fcfd1..1a557b847 100644 --- a/packages/coding-agent/src/modes/interactive/components/python-execution.ts +++ b/packages/coding-agent/src/modes/interactive/components/python-execution.ts @@ -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) { diff --git a/packages/coding-agent/src/prompts/system/system-prompt.md b/packages/coding-agent/src/prompts/system/system-prompt.md index f5b7a4136..2dbc376f2 100644 --- a/packages/coding-agent/src/prompts/system/system-prompt.md +++ b/packages/coding-agent/src/prompts/system/system-prompt.md @@ -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}} @@ -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. diff --git a/packages/coding-agent/src/prompts/tools/task.md b/packages/coding-agent/src/prompts/tools/task.md index c886327d8..f73094e83 100644 --- a/packages/coding-agent/src/prompts/tools/task.md +++ b/packages/coding-agent/src/prompts/tools/task.md @@ -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" } } ] } - -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 diff --git a/packages/coding-agent/test/tools/python-renderer.test.ts b/packages/coding-agent/test/tools/python-renderer.test.ts index 50a211690..6630c2c7d 100644 --- a/packages/coding-agent/test/tools/python-renderer.test.ts +++ b/packages/coding-agent/test/tools/python-renderer.test.ts @@ -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"); }); }); diff --git a/packages/coding-agent/test/tools/task-template.test.ts b/packages/coding-agent/test/tools/task-template.test.ts new file mode 100644 index 000000000..12080e3dc --- /dev/null +++ b/packages/coding-agent/test/tools/task-template.test.ts @@ -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(); + }); +});