diff --git a/README.md b/README.md index c4bca09..fa20f66 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,65 @@ For full setup and remote usage details, see [docs/telegram.md](docs/telegram.md | `CoClaw.telegram.silentUnauthorized` | `false` | When `true`, silently drop messages from non-linked users instead of replying with `Unauthorized` | | `CoClaw.agents.mode` | `slash` | Multi-agent mode: `off`, `slash` (only on `/agents`), or `always` (route every prompt) | | `CoClaw.agents.maxParallelCoders` | `4` | Maximum number of coder agents that may run in parallel (1–8) | +| `CoClaw.agents.minParallelCoders` | `1` | Minimum number of coder agents to spawn per task. The splitter pads small tasks with generic lanes (implementation, tests, docs, …) up to this floor. Capped to `maxParallelCoders` | +| `CoClaw.agents.summaryMaxChars` | `8000` | Per-task character cap for the multi-agent run summary and the copy persisted to shared memory. `0` = unlimited | +| `CoClaw.agents.alwaysShowFullOutput` | `false` | When `true`, every `/agents` run skips the per-task cap (equivalent to typing `--full` on every prompt). One-shot alternative: prefix or suffix your prompt with `--full` (aliases: `--all`, `--no-truncate`) | | `CoClaw.tools.maxPerRequest` | `120` | Hard cap on tools sent per LM request. Lower this if a model rejects calls with “Cannot have more than N tools per request” (most providers cap at 128) | | `CoClaw.tools.exclude` | `[]` | Substring patterns; matching tool names are dropped before the request reaches the model (e.g. `["mssql", "jupyter"]`) | | `CoClaw.tools.priority` | `[]` | Substring patterns; matching tools are bumped above CoClaw’s own tools so they survive the per-request cap | +## Development + +```bash +# Install dependencies +npm install + +# Build (production) +npm run build + +# Watch mode (development) +npm run watch + +# Type-check +npm run typecheck + +# Lint +npm run lint + +# Run tests +npm test + +# Package as .vsix +npm run package +``` + +To debug, open the project in VS Code and press **F5** — this launches an Extension Development Host with CoClaw loaded. + +## Architecture + +``` +src/ +├── extension.ts # Extension entry point +├── agents/ # Multi-agent orchestration (Planner, Coder, Reviewer, Tester, Memory) +├── commands/ # VS Code command implementations +├── cron/ # Cron job scheduler for /open mode +├── heartbeat/ # Heartbeat checks for /open mode +├── lm/ # Language model integration (model manager, prompt builder, tool filter) +├── memory/ # Two-layer memory system (daily + long-term) +├── participant/ # Copilot Chat participant handler +├── profile/ # Identity (SOUL) and user profile (USER) management +├── telegram/ # Telegram bot bridge +├── tools/ # LM tool definitions and handlers +├── ui/ # Tree views, webviews, status bar +└── util/ # Shared utilities +``` + +## Contributing + +See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for branch protection rules and PR requirements. + +In short: open a PR against `main`. CI must pass (`typecheck`, `test`, `build`, `package`) and one CODEOWNER must approve before merging. + ## Documentation - [Getting Started](docs/getting-started.md) diff --git a/docs/commands.md b/docs/commands.md index ceb3d95..1a77811 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -92,7 +92,7 @@ Runs your task through the multi-agent orchestrator. A Planner produces a JSON D @CoClaw /agents Build a login page with API and tests ``` -Live progress is shown in the **Agents** sidebar (the CoClaw activity bar icon). Tune fan-out width with `CoClaw.agents.maxParallelCoders` and switch modes with `CoClaw.agents.mode` (`off`, `slash`, `always`). +Live progress is shown in the **Agents** sidebar (the CoClaw activity bar icon). Tune fan-out width with `CoClaw.agents.maxParallelCoders` (ceiling) and `CoClaw.agents.minParallelCoders` (floor — pads small tasks with generic lanes), and switch modes with `CoClaw.agents.mode` (`off`, `slash`, `always`). ## Command Palette Commands diff --git a/docs/configuration.md b/docs/configuration.md index de0df93..0a01049 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -132,6 +132,53 @@ All CoClaw settings are under the `CoClaw.*` namespace. Open them quickly with * - **Range:** 1–8 - **Description:** Maximum number of coder agents that may run in parallel during a `/agents` run. +### `CoClaw.agents.minParallelCoders` + +- **Type:** integer +- **Default:** `1` (no forced minimum — current/legacy behavior) +- **Range:** 1–8 +- **Description:** Minimum number of coder agents to spawn for any single coder task. When the heuristic splitter would naturally produce fewer (e.g. an atomic typo fix, or a task that doesn't trigger any work-lane keywords), it is **padded** with generic lanes from this fallback list — in this order: + `implementation` → `tests` → `docs` → `error-handling` → `logging-telemetry` → `config-build` → `auth-security` → `business-logic`. +- **Always capped to `maxParallelCoders`** at runtime, so a misconfigured `min=8 / max=4` pair silently behaves as `min=4`. +- **Trade-off:** raising the floor guarantees more parallel coverage on small tasks, but for genuinely atomic work (a one-line fix) it spends model tokens on padded lanes that may not have anything meaningful to do. Default `1` keeps the original "split only when warranted" behavior. + +### `CoClaw.agents.summaryMaxChars` + +- **Type:** integer +- **Default:** `8000` +- **Range:** 0–200000 (0 = unlimited) +- **Description:** Per-task character cap applied in two places: + 1. The chat-panel summary block that lists `✓ []` results at the end of a `/agents` run. + 2. The copy of each agent's text written to the shared-memory store under `:` for downstream agents (Reviewer/Tester) to read. + + Bump it to `0` for unlimited output if you want the full report inline; raise it to a finite number (e.g. `20000`) if you need more room without going boundless. Lowering it shortens the chat but also reduces the context that dependent agents receive — keep both axes in mind. + +### `CoClaw.agents.alwaysShowFullOutput` + +- **Type:** boolean +- **Default:** `false` +- **Description:** When `true`, every `/agents` run skips the per-task character cap entirely — equivalent to passing `--full` on every prompt. This overrides `CoClaw.agents.summaryMaxChars`. Useful when you regularly need long, untrimmed summaries; otherwise leave it off and pass `--full` ad-hoc. + +#### Precedence + +The runtime cap is resolved in this order (first match wins): + +1. `--full` (or `--all` / `--no-truncate` / `--notrunc`) anywhere in the `/agents` prompt as a leading or trailing token → unlimited for this run only. +2. `CoClaw.agents.alwaysShowFullOutput: true` → unlimited for every run. +3. `CoClaw.agents.summaryMaxChars: 0` → unlimited for every run (legacy "magic 0" form). +4. `CoClaw.agents.summaryMaxChars: ` → cap at N characters per task. +5. Default → 8000 characters. + +#### Per-run override: `--full` flag + +Prefer not to touch settings every time you want a long answer? Prefix or suffix your `/agents` prompt with `--full` (aliases: `--all`, `--no-truncate`, `--notrunc`) and the cap is bypassed for that run only: + +``` +@CoClaw /agents --full investigate the workflow files and summarize every job +``` + +The flag is stripped before the planner sees the prompt, so it never leaks into the agent's reasoning. A mid-prompt occurrence (e.g. *"explain what `--full` does"*) is preserved verbatim so you can still talk *about* the flag without triggering it. + ## Tool-Selection Settings These settings control which tools CoClaw forwards to the language model on every request. They apply uniformly across the chat participant, the Telegram bridge, and the multi-agent orchestrator. diff --git a/package.json b/package.json index 20a53ab..7b674dc 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "CoClaw", "displayName": "CoClaw", "description": "AI coding assistant with persistent memory, powered by GitHub Copilot", - "version": "0.2.2", + "version": "0.2.3", "publisher": "gdhanush270", "license": "Apache-2.0", "icon": "icon.png", @@ -521,6 +521,25 @@ "maximum": 8, "description": "Maximum number of parallel Coder agents the orchestrator may fan out for a single coder task." }, + "CoClaw.agents.minParallelCoders": { + "type": "integer", + "default": 1, + "minimum": 1, + "maximum": 8, + "description": "Minimum number of parallel Coder agents to spawn for a single coder task. When the splitter would produce fewer (e.g. for a small task), it is padded with generic lanes (implementation, tests, docs, ...) up to this floor. Default 1 = current behavior (no forced minimum). Capped to 'maxParallelCoders' if you accidentally set it higher. Use with care: a min above 1 forces parallel work even on atomic tasks like typo fixes." + }, + "CoClaw.agents.summaryMaxChars": { + "type": "integer", + "default": 8000, + "minimum": 0, + "maximum": 200000, + "description": "Per-task character cap for the multi-agent run summary (and the copy persisted to shared memory). 0 = unlimited (use with care: a runaway agent can dump tens of thousands of chars into the chat). Ignored when 'CoClaw.agents.alwaysShowFullOutput' is true or when '--full' is in the prompt." + }, + "CoClaw.agents.alwaysShowFullOutput": { + "type": "boolean", + "default": false, + "description": "When true, every /agents run prints each agent's full output without truncation — equivalent to passing '--full' on every prompt. Overrides 'CoClaw.agents.summaryMaxChars'. Use this if you regularly need long, untrimmed summaries; leave off (default) and pass '--full' ad-hoc when you want occasional long output." + }, "CoClaw.tools.maxPerRequest": { "type": "integer", "default": 120, diff --git a/src/agents/CoderSplitter.ts b/src/agents/CoderSplitter.ts index 8308cff..b03c2a3 100644 --- a/src/agents/CoderSplitter.ts +++ b/src/agents/CoderSplitter.ts @@ -7,13 +7,31 @@ export interface SplitResult { didSplit: boolean; } +/** + * Generic lanes used to *pad* a coder task up to `minParallel` when the + * heuristic would otherwise hand back fewer units. Ordered by usefulness — + * implementation/tests/docs are almost always something a coder can do. + */ +const PAD_LANES: readonly string[] = [ + 'implementation', + 'tests', + 'docs', + 'error-handling', + 'logging-telemetry', + 'config-build', + 'auth-security', + 'business-logic', +]; + /** * Decide how many parallel coder agents to fan out for a single coder task. * Heuristic order: * 1. If the planner provided `units`, use them (one agent per unit, capped). * 2. Otherwise extract file-path-like tokens from the prompt. * 3. Otherwise extract bullet/numbered list items from the prompt. - * 4. Otherwise leave as a single task. + * 4. Otherwise leave as a single task — UNLESS minParallel > 1, in which + * case pad with generic lanes (implementation/tests/docs/...) to reach + * the floor. * * If two units target the same file path, they are chained sequentially * (dependsOn) instead of parallelized to avoid file-write races. @@ -22,17 +40,35 @@ export function splitCoderTask( task: SubTask, userPrompt: string, maxParallel: number, + minParallel = 1, ): SplitResult { if (task.agent !== 'coder') { return { replacements: [task], didSplit: false }; } const cap = Math.max(1, Math.min(maxParallel, 8)); + // Minimum can never exceed the maximum, and never goes below 1. We + // silently clamp instead of erroring so a misconfigured `min > max` + // setting doesn't block /agents from running. + const floor = Math.max(1, Math.min(minParallel, cap)); + let units = (task.units && task.units.length > 0) ? task.units.slice() : extractUnits(task.prompt, userPrompt); // Deduplicate while preserving order units = Array.from(new Set(units.map(u => u.trim()).filter(u => u.length > 0))); + // Pad up to the floor with generic lanes that aren't already represented. + if (units.length < floor) { + const have = new Set(units.map(u => u.toLowerCase())); + for (const lane of PAD_LANES) { + if (units.length >= floor) { break; } + if (!have.has(lane)) { + units.push(lane); + have.add(lane); + } + } + } + if (units.length <= 1) { return { replacements: [task], didSplit: false }; } diff --git a/src/agents/Orchestrator.ts b/src/agents/Orchestrator.ts index b064033..756c837 100644 --- a/src/agents/Orchestrator.ts +++ b/src/agents/Orchestrator.ts @@ -8,6 +8,7 @@ import { splitCoderTask } from './CoderSplitter'; import { AGENT_DEFINITIONS } from './AgentDefinitions'; import { AgentRole, PlanDocument, SubTask } from './types'; import { AgentSpawner } from '../tools/spawnAgentTool'; +import { Logger } from '../util/Logger'; export interface SpawnerHolder { current: AgentSpawner | undefined; @@ -19,7 +20,10 @@ interface PendingSpawn { export class Orchestrator implements AgentSpawner { private maxParallelCoders = 4; + private minParallelCoders = 1; private spawnWaiters = new Map(); + /** Last clamp signature we already warned about — prevents log spam on every /agents run. */ + private lastClampWarning: string | undefined; constructor( private readonly modelManager: ModelManager, @@ -34,11 +38,39 @@ export class Orchestrator implements AgentSpawner { token: vscode.CancellationToken, toolInvocationToken?: vscode.ChatParticipantToolToken, ): Promise { - this.maxParallelCoders = vscode.workspace.getConfiguration('CoClaw.agents') - .get('maxParallelCoders', 4); + const agentsCfg = vscode.workspace.getConfiguration('CoClaw.agents'); + this.maxParallelCoders = agentsCfg.get('maxParallelCoders', 4); + // min must always be ≤ max; clamp rather than failing so a + // misconfigured pair (e.g. min=4, max=2) still lets /agents run. + // Surface a one-shot warning per (rawMin, max) pair so the user knows + // their setting was overridden — silent clamping is confusing. + const rawMin = agentsCfg.get('minParallelCoders', 1); + this.minParallelCoders = Math.max(1, Math.min(rawMin, this.maxParallelCoders)); + if (rawMin > this.maxParallelCoders) { + const sig = `${rawMin}-${this.maxParallelCoders}`; + if (this.lastClampWarning !== sig) { + this.lastClampWarning = sig; + Logger.warn( + 'orchestrator', + `'CoClaw.agents.minParallelCoders' (${rawMin}) is greater than 'maxParallelCoders' (${this.maxParallelCoders}); ` + + `clamping the floor to ${this.maxParallelCoders}. Raise 'maxParallelCoders' if you really want ${rawMin} parallel coders.`, + ); + stream.markdown( + `> ⚠️ \`CoClaw.agents.minParallelCoders\` (${rawMin}) > \`maxParallelCoders\` (${this.maxParallelCoders}); using **${this.maxParallelCoders}** as the floor for this run.\n\n`, + ); + } + } + + // Per-run override: a leading/trailing `--full` (or `--all`, + // `--no-truncate`) flag tells the renderer to skip its char cap for + // this run only. Stripped from the prompt before the planner sees it. + const { prompt, fullOutput: flagFullOutput } = parseRunFlags(userPrompt); + const settingFullOutput = vscode.workspace.getConfiguration('CoClaw.agents') + .get('alwaysShowFullOutput', false); + const fullOutput = flagFullOutput || settingFullOutput; const runId = randomUUID(); - const run = this.registry.createRun(runId, userPrompt); + const run = this.registry.createRun(runId, prompt); this.spawnerHolder.current = this; try { @@ -46,12 +78,17 @@ export class Orchestrator implements AgentSpawner { const agentRunner = new SpecializedAgent(model); stream.markdown(`### Multi-agent run \`${runId.substring(0, 8)}\`\n\n`); + if (flagFullOutput) { + stream.markdown(`_Flag detected: \`--full\` — per-task output truncation disabled for this run._\n\n`); + } else if (settingFullOutput) { + stream.markdown(`_\`CoClaw.agents.alwaysShowFullOutput\` is on — per-task output truncation disabled._\n\n`); + } stream.progress('Planner: decomposing task...'); // --- Step A: Planner --- const plannerResult = await agentRunner.runAgent( 'planner', - `User task:\n${userPrompt}\n\nProduce the JSON DAG now.`, + `User task:\n${prompt}\n\nProduce the JSON DAG now.`, runId, 'planner', token, @@ -63,7 +100,7 @@ export class Orchestrator implements AgentSpawner { stream.markdown('_Planner did not return valid JSON; retrying with strict reminder..._\n\n'); const retry = await agentRunner.runAgent( 'planner', - `Your previous output was not valid JSON. Output ONLY a JSON object matching the required schema. User task:\n${userPrompt}`, + `Your previous output was not valid JSON. Output ONLY a JSON object matching the required schema. User task:\n${prompt}`, runId, 'planner', token, @@ -77,7 +114,7 @@ export class Orchestrator implements AgentSpawner { tasks: [{ id: 'coder-1', agent: 'coder', - prompt: userPrompt, + prompt, dependsOn: [], status: 'pending', }], @@ -85,7 +122,7 @@ export class Orchestrator implements AgentSpawner { } // --- Step A.5: Auto-fanout coder tasks --- - plan.tasks = this.applyAutoFanout(plan.tasks, userPrompt); + plan.tasks = this.applyAutoFanout(plan.tasks, prompt); // Cycle check if (this.hasCycle(plan.tasks)) { @@ -98,13 +135,13 @@ export class Orchestrator implements AgentSpawner { this.renderPlan(plan.tasks, stream); // --- Step B: Dependency loop --- - await this.executeDag(runId, plan.tasks, agentRunner, stream, token, toolInvocationToken); + await this.executeDag(runId, plan.tasks, agentRunner, stream, token, toolInvocationToken, fullOutput); // --- Step C: Summary --- const finalRun = this.registry.getRun(runId)!; const anyFailed = finalRun.tasks.some(t => t.status === 'failed'); this.registry.completeRun(runId, anyFailed ? 'failed' : 'done'); - this.renderSummary(finalRun.tasks, stream); + this.renderSummary(finalRun.tasks, stream, fullOutput); } catch (e) { stream.markdown(`\n\n_Orchestrator error: ${e instanceof Error ? e.message : String(e)}_`); this.registry.completeRun(runId, 'failed'); @@ -160,7 +197,7 @@ export class Orchestrator implements AgentSpawner { for (const t of tasks) { if (t.agent !== 'coder') { out.push(t); continue; } - const split = splitCoderTask(t, userPrompt, this.maxParallelCoders); + const split = splitCoderTask(t, userPrompt, this.maxParallelCoders, this.minParallelCoders); if (split.didSplit) { remap.set(t.id, split.replacements.map(r => r.id)); out.push(...split.replacements); @@ -224,17 +261,26 @@ export class Orchestrator implements AgentSpawner { stream.markdown('\n'); } - private renderSummary(tasks: SubTask[], stream: vscode.ChatResponseStream): void { + private renderSummary(tasks: SubTask[], stream: vscode.ChatResponseStream, fullOutput = false): void { const counts = { done: 0, failed: 0, pending: 0, running: 0 }; for (const t of tasks) { counts[t.status]++; } stream.markdown(`\n### Summary\n`); stream.markdown(`Done: ${counts.done} · Failed: ${counts.failed}${counts.pending ? ` · Skipped: ${counts.pending}` : ''}\n\n`); + + const cap = resolveOutputCap(fullOutput); + for (const t of tasks) { const icon = t.status === 'done' ? '✓' : t.status === 'failed' ? '✗' : '·'; stream.markdown(`**${icon} ${t.id} [${t.agent}]**\n`); if (t.output) { - const snippet = t.output.length > 400 ? t.output.substring(0, 400) + '…' : t.output; - stream.markdown(`\n${snippet}\n\n`); + const out = t.output; + if (out.length > cap) { + const head = out.substring(0, cap); + const dropped = out.length - cap; + stream.markdown(`\n${head}\n\n_[output truncated — ${dropped.toLocaleString()} more chars dropped. Raise \`CoClaw.agents.summaryMaxChars\` to see the full text (set to 0 for unlimited).]_\n\n`); + } else { + stream.markdown(`\n${out}\n\n`); + } } else if (t.error) { stream.markdown(`\n_${t.error}_\n\n`); } @@ -248,6 +294,7 @@ export class Orchestrator implements AgentSpawner { stream: vscode.ChatResponseStream, token: vscode.CancellationToken, toolInvocationToken?: vscode.ChatParticipantToolToken, + fullOutput = false, ): Promise { const byId = () => new Map(this.registry.getRun(runId)!.tasks.map(t => [t.id, t])); const inFlight = new Map>(); @@ -291,9 +338,14 @@ export class Orchestrator implements AgentSpawner { token, toolInvocationToken, ); - // Persist the agent's text output to shared memory automatically + // Persist the agent's text output to shared memory automatically. + // Same cap source as the chat renderer so raising one raises + // the other (otherwise dependent agents would still see a + // truncated view of their predecessors). if (result.text.trim()) { - await this.sharedStore.write(runId, `${task.agent}:${task.id}`, result.text.substring(0, 4000), task.agent); + const cap = resolveOutputCap(fullOutput); + const stored = result.text.length > cap ? result.text.substring(0, cap) : result.text; + await this.sharedStore.write(runId, `${task.agent}:${task.id}`, stored, task.agent); } this.registry.updateTask(runId, task.id, { status: 'done', @@ -379,3 +431,65 @@ export class Orchestrator implements AgentSpawner { } } } + +/** Recognized aliases for the "no truncation" run flag. */ +const FULL_OUTPUT_FLAGS: ReadonlySet = new Set([ + '--full', '--all', '--no-truncate', '--notrunc', +]); + +/** + * Resolve the per-task character cap, honoring (in order): + * 1. The per-run override (`--full` flag from `parseRunFlags`). + * 2. `CoClaw.agents.alwaysShowFullOutput` (global "no truncation" toggle). + * 3. `CoClaw.agents.summaryMaxChars` (numeric cap; 0 = unlimited). + * 4. The default of 8000 characters. + * + * Returns `Infinity` when truncation should be skipped, so callers can use + * `text.length > cap` uniformly without special-casing the unlimited mode. + */ +export function resolveOutputCap(perRunFullOutput = false): number { + let cfg: vscode.WorkspaceConfiguration | undefined; + try { cfg = vscode.workspace.getConfiguration('CoClaw.agents'); } + catch { cfg = undefined; } + + if (perRunFullOutput) { return Number.POSITIVE_INFINITY; } + if (cfg?.get('alwaysShowFullOutput', false)) { return Number.POSITIVE_INFINITY; } + + const rawCap = cfg?.get('summaryMaxChars', 8000) ?? 8000; + if (!Number.isFinite(rawCap) || rawCap <= 0) { return Number.POSITIVE_INFINITY; } + return Math.floor(rawCap); +} + +/** + * Strip a leading or trailing run-control flag (e.g. `--full`) from the + * raw `/agents` prompt. Mid-prompt occurrences are preserved verbatim so + * a user actually writing about a literal `--full` flag isn't munged. + * + * Exported visibility: kept private to the file but written as a free + * function to make it easy to unit-test independently of the orchestrator. + */ +export function parseRunFlags(raw: string): { prompt: string; fullOutput: boolean } { + let prompt = raw; + let fullOutput = false; + let changed = true; + // Strip flags repeatedly so e.g. "--full --all do X" still works. + while (changed) { + changed = false; + const trimmed = prompt.trim(); + const leading = trimmed.match(/^(\S+)\s*/); + if (leading && FULL_OUTPUT_FLAGS.has(leading[1].toLowerCase())) { + prompt = trimmed.slice(leading[0].length); + fullOutput = true; + changed = true; + continue; + } + const trailing = trimmed.match(/\s+(\S+)$/); + if (trailing && FULL_OUTPUT_FLAGS.has(trailing[1].toLowerCase())) { + prompt = trimmed.slice(0, trimmed.length - trailing[0].length); + fullOutput = true; + changed = true; + } + } + return { prompt: prompt.trim(), fullOutput }; +} + diff --git a/src/telegram/TelegramSettingsUi.ts b/src/telegram/TelegramSettingsUi.ts index a090b30..4b7e605 100644 --- a/src/telegram/TelegramSettingsUi.ts +++ b/src/telegram/TelegramSettingsUi.ts @@ -36,6 +36,7 @@ export const SETTINGS: SettingDefinition[] = [ // Agents { key: 'CoClaw.agents.mode', label: 'Mode', description: 'Multi-agent orchestration mode', type: 'enum', enumValues: ['off', 'slash', 'always'], group: 'Agents' }, { key: 'CoClaw.agents.maxParallelCoders', label: 'Max parallel coders', description: 'Max parallel Coder agents per task', type: 'number', min: 1, max: 8, step: 1, group: 'Agents' }, + { key: 'CoClaw.agents.minParallelCoders', label: 'Min parallel coders', description: 'Min Coder agents to spawn per task (pads with generic lanes when needed; capped to max)', type: 'number', min: 1, max: 8, step: 1, group: 'Agents' }, // Memory { key: 'CoClaw.memory.autoExtract', label: 'Auto-extract', description: 'Auto-extract facts from responses', type: 'boolean', group: 'Memory' }, diff --git a/src/test/coderSplitter.test.ts b/src/test/coderSplitter.test.ts new file mode 100644 index 0000000..6db8652 --- /dev/null +++ b/src/test/coderSplitter.test.ts @@ -0,0 +1,91 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { splitCoderTask } from '../agents/CoderSplitter'; +import { SubTask } from '../agents/types'; + +function task(prompt: string, units?: string[]): SubTask { + return { + id: 'c1', + agent: 'coder', + prompt, + units, + dependsOn: [], + status: 'pending', + }; +} + +describe('CoderSplitter splitCoderTask', () => { + it('returns the original task untouched when no units and no min floor (atomic work)', () => { + const t = task('fix the typo in line 42 of README.md'); + const r = splitCoderTask(t, '', 4, 1); + assert.strictEqual(r.didSplit, false); + assert.strictEqual(r.replacements.length, 1); + assert.strictEqual(r.replacements[0].id, 'c1'); + }); + + it('honors planner-provided units up to the cap', () => { + const t = task('implement feature', ['frontend.tsx', 'backend.ts', 'schema.sql', 'tests.ts', 'docs.md']); + const r = splitCoderTask(t, '', 3, 1); + assert.strictEqual(r.didSplit, true); + assert.strictEqual(r.replacements.length, 3, 'cap of 3 should win over 5 units'); + }); + + it('pads up to minParallel with generic lanes when the task is atomic', () => { + const t = task('fix the typo'); + const r = splitCoderTask(t, '', 4, 3); + assert.strictEqual(r.didSplit, true); + assert.strictEqual(r.replacements.length, 3, `expected 3 padded coders; got ${r.replacements.length}`); + // Pad order = implementation, tests, docs (per PAD_LANES order). + const units = r.replacements.map(c => c.units?.[0]); + assert.deepStrictEqual(units, ['implementation', 'tests', 'docs']); + }); + + it('does not duplicate a lane that already came from the prompt when padding', () => { + const t = task('add unit tests'); + const r = splitCoderTask(t, '', 4, 3); + assert.strictEqual(r.didSplit, true); + const units = r.replacements.map(c => c.units?.[0]); + // 'tests' was auto-decomposed from the prompt; padder must NOT add it again. + const testsCount = units.filter(u => u === 'tests').length; + assert.strictEqual(testsCount, 1, `'tests' should appear exactly once; got ${units.join(', ')}`); + }); + + it('clamps minParallel to maxParallel when min > max', () => { + const t = task('fix the typo'); + const r = splitCoderTask(t, '', 2, 8); + assert.strictEqual(r.replacements.length, 2, 'min=8 with max=2 should clamp to 2 coders'); + }); + + it('clamps minParallel below 1 to 1 (treats absurd values as default)', () => { + const t = task('fix the typo'); + const r = splitCoderTask(t, '', 4, 0); + assert.strictEqual(r.didSplit, false); + assert.strictEqual(r.replacements.length, 1); + }); + + it('leaves natural fan-out alone when units already meet the floor', () => { + const t = task('build feature', ['ui.tsx', 'api.ts']); + const r = splitCoderTask(t, '', 4, 2); + assert.strictEqual(r.replacements.length, 2); + assert.strictEqual(r.replacements[0].units?.[0], 'ui.tsx'); + assert.strictEqual(r.replacements[1].units?.[0], 'api.ts'); + }); + + it('chains coders that target the same file path sequentially (no parallel writes)', () => { + const t = task('split work', ['src/app.ts: add header', 'src/app.ts: add footer']); + const r = splitCoderTask(t, '', 4, 1); + assert.strictEqual(r.replacements.length, 2); + // The second coder should depend on the first because both touch src/app.ts. + assert.deepStrictEqual(r.replacements[0].dependsOn, []); + assert.ok(r.replacements[1].dependsOn.includes(r.replacements[0].id), + 'second coder should depend on first to serialize same-file writes'); + }); + + it('skips non-coder agents entirely', () => { + const t: SubTask = { ...task(''), agent: 'reviewer' }; + const r = splitCoderTask(t, '', 4, 4); + assert.strictEqual(r.didSplit, false); + assert.strictEqual(r.replacements.length, 1); + assert.strictEqual(r.replacements[0], t, 'non-coder tasks should be returned unchanged'); + }); +}); diff --git a/src/test/orchestratorPlan.test.ts b/src/test/orchestratorPlan.test.ts index 11f432f..4403678 100644 --- a/src/test/orchestratorPlan.test.ts +++ b/src/test/orchestratorPlan.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert'; import * as vscode from 'vscode'; -import { Orchestrator } from '../agents/Orchestrator'; +import { Orchestrator, parseRunFlags, resolveOutputCap } from '../agents/Orchestrator'; import { RunRegistry } from '../agents/RunRegistry'; import { SharedMemoryStore } from '../agents/SharedMemoryStore'; @@ -114,3 +114,98 @@ describe('Orchestrator.hasCycle', () => { assert.strictEqual(orch.hasCycle(tasks), false); }); }); + +describe('Orchestrator parseRunFlags', () => { + it('returns the prompt unchanged when no flags are present', () => { + const r = parseRunFlags('investigate the workflow files'); + assert.strictEqual(r.fullOutput, false); + assert.strictEqual(r.prompt, 'investigate the workflow files'); + }); + + it('strips a leading --full and sets fullOutput', () => { + const r = parseRunFlags('--full investigate the workflow files'); + assert.strictEqual(r.fullOutput, true); + assert.strictEqual(r.prompt, 'investigate the workflow files'); + }); + + it('strips a trailing --full and sets fullOutput', () => { + const r = parseRunFlags('investigate the workflow files --full'); + assert.strictEqual(r.fullOutput, true); + assert.strictEqual(r.prompt, 'investigate the workflow files'); + }); + + it('accepts the --all and --no-truncate aliases', () => { + for (const flag of ['--all', '--no-truncate', '--notrunc']) { + const r = parseRunFlags(`${flag} ship it`); + assert.strictEqual(r.fullOutput, true, `expected ${flag} to set fullOutput`); + assert.strictEqual(r.prompt, 'ship it'); + } + }); + + it('strips multiple stacked flags from either end', () => { + const r = parseRunFlags('--full --all do the thing --no-truncate'); + assert.strictEqual(r.fullOutput, true); + assert.strictEqual(r.prompt, 'do the thing'); + }); + + it('preserves a mid-prompt --full so prompts about the flag itself are not munged', () => { + const r = parseRunFlags('explain what --full does in /agents'); + assert.strictEqual(r.fullOutput, false); + assert.strictEqual(r.prompt, 'explain what --full does in /agents'); + }); + + it('handles flag-only prompts gracefully (empty stripped result)', () => { + const r = parseRunFlags('--full'); + assert.strictEqual(r.fullOutput, true); + assert.strictEqual(r.prompt, ''); + }); +}); + +describe('Orchestrator resolveOutputCap', () => { + const originalGetConfig = vscode.workspace.getConfiguration; + + function stubConfig(values: { summaryMaxChars?: number; alwaysShowFullOutput?: boolean }): void { + (vscode.workspace as any).getConfiguration = (section?: string) => ({ + get: (key: string, defaultValue: T): T => { + if (section === 'CoClaw.agents' && key in values) { + return (values as any)[key] as T; + } + return defaultValue; + }, + }); + } + + afterEach(() => { + (vscode.workspace as any).getConfiguration = originalGetConfig; + }); + + it('returns the configured cap when nothing forces full output', () => { + stubConfig({ summaryMaxChars: 1234 }); + assert.strictEqual(resolveOutputCap(false), 1234); + }); + + it('returns Infinity when the per-run flag is true (highest precedence)', () => { + stubConfig({ summaryMaxChars: 1234, alwaysShowFullOutput: false }); + assert.strictEqual(resolveOutputCap(true), Number.POSITIVE_INFINITY); + }); + + it('returns Infinity when alwaysShowFullOutput is on, even with a finite cap', () => { + stubConfig({ summaryMaxChars: 500, alwaysShowFullOutput: true }); + assert.strictEqual(resolveOutputCap(false), Number.POSITIVE_INFINITY); + }); + + it('treats summaryMaxChars=0 as unlimited', () => { + stubConfig({ summaryMaxChars: 0 }); + assert.strictEqual(resolveOutputCap(false), Number.POSITIVE_INFINITY); + }); + + it('falls back to the 8000 default when no settings are present', () => { + stubConfig({}); + assert.strictEqual(resolveOutputCap(false), 8000); + }); + + it('floors fractional caps to a safe integer', () => { + stubConfig({ summaryMaxChars: 1234.9 }); + assert.strictEqual(resolveOutputCap(false), 1234); + }); +});