@selesai/code 0.5.29 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +1 -1
  3. package/dist/core/system-prompt.d.ts.map +1 -1
  4. package/dist/core/system-prompt.js +18 -0
  5. package/dist/core/system-prompt.js.map +1 -1
  6. package/dist/core/system-prompt.test.d.ts +2 -0
  7. package/dist/core/system-prompt.test.d.ts.map +1 -0
  8. package/dist/core/system-prompt.test.js +89 -0
  9. package/dist/core/system-prompt.test.js.map +1 -0
  10. package/dist/defaults/models.json +13 -45
  11. package/dist/defaults/settings.json +1 -2
  12. package/dist/extensions/copy-turn.test.ts +131 -0
  13. package/dist/extensions/copy-turn.ts +6 -1
  14. package/dist/extensions/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  15. package/dist/extensions/package.json +0 -1
  16. package/dist/extensions/pi-subagents/CHANGELOG.md +3 -0
  17. package/dist/extensions/pi-subagents/README.md +27 -32
  18. package/dist/extensions/pi-subagents/agents/architect.md +4 -4
  19. package/dist/extensions/pi-subagents/agents/builder.md +5 -4
  20. package/dist/extensions/pi-subagents/agents/commentator.md +3 -2
  21. package/dist/extensions/pi-subagents/agents/explorer.md +3 -2
  22. package/dist/extensions/pi-subagents/agents/recapper.md +3 -2
  23. package/dist/extensions/pi-subagents/agents/researcher.md +4 -3
  24. package/dist/extensions/pi-subagents/skills/pi-subagents/SKILL.md +2 -0
  25. package/dist/extensions/pi-subagents/skills/pi-subagents/references/constraints-and-recipes.md +10 -9
  26. package/dist/extensions/pi-subagents/skills/pi-subagents/references/execution-controls.md +12 -11
  27. package/dist/extensions/pi-subagents/skills/pi-subagents/references/prompting-and-roles.md +10 -11
  28. package/dist/extensions/pi-subagents/src/agents/agent-management.ts +56 -9
  29. package/dist/extensions/pi-subagents/src/agents/task-aware-routing.ts +125 -0
  30. package/dist/extensions/pi-subagents/src/api/preflight.ts +1 -1
  31. package/dist/extensions/pi-subagents/src/extension/index.ts +5 -1
  32. package/dist/extensions/pi-subagents/src/extension/schemas.ts +2 -2
  33. package/dist/extensions/pi-subagents/src/extension/tool-description.ts +24 -7
  34. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +23 -5
  35. package/dist/extensions/pi-subagents/src/runs/background/notify.ts +27 -1
  36. package/dist/extensions/pi-subagents/src/runs/background/result-watcher.ts +64 -6
  37. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +16 -1
  38. package/dist/extensions/pi-subagents/src/runs/foreground/chain-execution.ts +72 -18
  39. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +19 -5
  40. package/dist/extensions/pi-subagents/src/runs/foreground/subagent-executor.ts +127 -31
  41. package/dist/extensions/pi-subagents/src/runs/shared/acceptance.ts +4 -6
  42. package/dist/extensions/pi-subagents/src/runs/shared/single-output.ts +63 -9
  43. package/dist/extensions/pi-subagents/src/runs/shared/task-intent.ts +21 -0
  44. package/dist/extensions/pi-subagents/src/shared/types.ts +41 -2
  45. package/dist/extensions/pi-subagents/src/shared/utils.ts +29 -1
  46. package/dist/extensions/pi-subagents/src/slash/delegation-adapters.ts +5 -1
  47. package/dist/extensions/pi-subagents/src/tui/render.ts +28 -6
  48. package/dist/extensions/pi-subagents/test/e2e/real-session-subagent.test.ts +111 -6
  49. package/dist/extensions/pi-subagents/test/integration/async-execution.test.ts +74 -43
  50. package/dist/extensions/pi-subagents/test/integration/chain-execution.test.ts +36 -21
  51. package/dist/extensions/pi-subagents/test/integration/fork-context-execution.test.ts +5 -3
  52. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +20 -8
  53. package/dist/extensions/pi-subagents/test/integration/parallel-execution.test.ts +14 -7
  54. package/dist/extensions/pi-subagents/test/integration/result-watcher.test.ts +81 -5
  55. package/dist/extensions/pi-subagents/test/integration/single-execution.test.ts +49 -10
  56. package/dist/extensions/pi-subagents/test/support/real-session-runner.ts +18 -2
  57. package/dist/extensions/pi-subagents/test/unit/agent-disabled.test.ts +1 -1
  58. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +70 -6
  59. package/dist/extensions/pi-subagents/test/unit/agent-management.test.ts +161 -1
  60. package/dist/extensions/pi-subagents/test/unit/builtin-agent-documentation.test.ts +63 -0
  61. package/dist/extensions/pi-subagents/test/unit/capability-ceiling-agent-allowlist.test.ts +34 -0
  62. package/dist/extensions/pi-subagents/test/unit/delegation-api.test.ts +24 -0
  63. package/dist/extensions/pi-subagents/test/unit/index-child-registration.test.ts +6 -1
  64. package/dist/extensions/pi-subagents/test/unit/notify.test.ts +29 -0
  65. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +2 -0
  66. package/dist/extensions/pi-subagents/test/unit/schemas.test.ts +12 -0
  67. package/dist/extensions/pi-subagents/test/unit/single-output.test.ts +91 -1
  68. package/dist/extensions/pi-subagents/test/unit/task-aware-routing.test.ts +213 -0
  69. package/dist/extensions/pi-subagents/test/unit/task-intent.test.ts +23 -1
  70. package/dist/extensions/pi-subagents/test/unit/tool-description.test.ts +60 -9
  71. package/dist/skills/ponytail/SKILL.md +1 -3
  72. package/docs/plans/subagent-delegation/phase-0-correctness.md +265 -0
  73. package/docs/plans/subagent-delegation/phase-1-behavioral-contract.md +486 -0
  74. package/docs/plans/subagent-delegation/phase-2-context-controls.md +282 -0
  75. package/docs/plans/subagent-delegation/phase-3-advisory-routing.md +362 -0
  76. package/docs/plans/subagent-delegation/phase-4-optional-enforcement.md +381 -0
  77. package/package.json +2 -2
  78. package/dist/extensions/caveman/caveman-instructions.cjs +0 -11
  79. package/dist/extensions/caveman/index.js +0 -118
  80. package/dist/extensions/caveman/package.json +0 -8
  81. package/dist/extensions/caveman/test/extension.test.js +0 -203
  82. package/dist/extensions/caveman/test/helpers.test.js +0 -58
  83. package/dist/skills/caveman/SKILL.md +0 -50
@@ -5,7 +5,7 @@
5
5
  import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import type { Message } from "@earendil-works/pi-ai";
8
- import type { AgentConfig } from "../agents/agents.ts";
8
+ import type { AgentConfig, AgentSource } from "../agents/agents.ts";
9
9
  import type { FSWatcher } from "node:fs";
10
10
  import type { ExtensionContext } from "@selesai/code";
11
11
  import type { ModelScopeConfig } from "../runs/shared/model-scope.ts";
@@ -912,12 +912,51 @@ export interface SpawnBudgetSnapshot {
912
912
  grantHistory: SpawnBudgetGrant[];
913
913
  }
914
914
 
915
+ // ============================================================================
916
+ // Runtime catalog (action:list machine metadata)
917
+ // ============================================================================
918
+
919
+ /** Machine-readable catalog entry for one visible runtime agent. */
920
+ export interface CatalogAgentMetadata {
921
+ name: string;
922
+ source: AgentSource;
923
+ description: string;
924
+ /** false for capability-ceiling-restricted agents (still visible, not launchable). */
925
+ executable: boolean;
926
+ /** Present only when the agent is capability-ceiling-restricted. */
927
+ restrictionSources?: string[];
928
+ aliases?: string[];
929
+ /** Normalized to explicit "fresh" when the agent has no defaultContext. */
930
+ defaultContext: "fresh" | "fork";
931
+ acceptanceRole?: AcceptanceRole;
932
+ /** Effective declared tools: normal tools plus mcp:-prefixed direct MCP tools. */
933
+ tools?: string[];
934
+ }
935
+
936
+ /** Machine-readable catalog entry for one visible chain. */
937
+ export interface CatalogChainMetadata {
938
+ name: string;
939
+ source: AgentSource;
940
+ description: string;
941
+ }
942
+
943
+ /** Versioned action:list catalog mirroring the human list output. */
944
+ export interface CatalogMetadataV1 {
945
+ version: 1;
946
+ agents: CatalogAgentMetadata[];
947
+ chains: CatalogChainMetadata[];
948
+ /** Present only when a capability ceiling restricted visible agents. */
949
+ capabilityCeilingSources?: string[];
950
+ }
951
+
915
952
  export interface Details {
916
953
  mode: SubagentRunMode | "management";
917
954
  runId?: string;
918
955
  /** Run-level context summary. "mixed" when children resolved to different modes. */
919
956
  context?: "fresh" | "fork" | "mixed";
920
957
  results: SingleResult[];
958
+ /** Runtime-resolved human+machine catalog for { action: "list" } results. */
959
+ catalog?: CatalogMetadataV1;
921
960
  controlEvents?: ControlEvent[];
922
961
  steering?: SteerActionResult;
923
962
  asyncId?: string;
@@ -1645,7 +1684,7 @@ export const DEFAULT_MAX_OUTPUT: Required<MaxOutputConfig> = {
1645
1684
  };
1646
1685
 
1647
1686
  export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = {
1648
- enabled: true,
1687
+ enabled: false,
1649
1688
  dir: "project",
1650
1689
  includeInput: true,
1651
1690
  includeOutput: true,
@@ -7,7 +7,7 @@ import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { Message } from "@earendil-works/pi-ai";
9
9
  import { formatToolCall } from "./formatters.ts";
10
- import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts";
10
+ import type { AgentProgress, AsyncStatus, ChainOutputMap, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts";
11
11
 
12
12
  // ============================================================================
13
13
  // File System Utilities
@@ -414,12 +414,40 @@ export function compactForegroundResult(result: SingleResult): SingleResult {
414
414
  messages: undefined,
415
415
  progress: undefined,
416
416
  toolCalls: toolCalls.length ? toolCalls : undefined,
417
+ // Reference-first terminal details: once an authoritative saved output path
418
+ // exists, drop the raw final output and truncation marker from the model-
419
+ // visible projection; consumers recover the file from `savedOutputPath`.
420
+ // Explicit `outputMode: "inline"` is the sole legacy full-text opt-out and
421
+ // keeps its final output in the terminal projection (e.g. delegation v1
422
+ // `response.output` stays populated).
423
+ finalOutput: result.savedOutputPath && result.outputMode !== "inline" ? undefined : result.finalOutput,
424
+ truncation: result.savedOutputPath && result.outputMode !== "inline" ? undefined : result.truncation,
417
425
  };
418
426
  }
419
427
 
428
+ /**
429
+ * Strip chain `details.outputs` text/structured payloads from the terminal
430
+ * projection while retaining the output names and step metadata. Chain output
431
+ * bindings themselves remain reference-first in the completion content and
432
+ * `{outputs.name}` interpolation (see outputEntryFromResult).
433
+ */
434
+ function compactChainOutputs(outputs: ChainOutputMap | undefined): ChainOutputMap | undefined {
435
+ if (!outputs) return undefined;
436
+ const compact: ChainOutputMap = {};
437
+ for (const [name, entry] of Object.entries(outputs)) {
438
+ compact[name] = {
439
+ agent: entry.agent,
440
+ stepIndex: entry.stepIndex,
441
+ text: "",
442
+ };
443
+ }
444
+ return compact;
445
+ }
446
+
420
447
  export function compactForegroundDetails(details: Details): Details {
421
448
  return {
422
449
  ...details,
450
+ outputs: compactChainOutputs(details.outputs),
423
451
  results: details.results.map(compactForegroundResult),
424
452
  progress: details.progress
425
453
  ? details.progress.map(compactCompletedProgress)
@@ -375,7 +375,11 @@ export function toSubagentDelegationExecutionParams(request: SubagentDelegationR
375
375
  toolBudget: request.toolBudget,
376
376
  skill: request.skill,
377
377
  output: request.output,
378
- outputMode: request.outputMode,
378
+ // v1 has no default for outputMode: preserve the legacy full-text contract by
379
+ // resolving an omitted outputMode to explicit inline (response.output stays
380
+ // populated). The model-facing tool's mode-dependent reference-first default
381
+ // (omitted outputMode -> file-only) is intentionally NOT applied here.
382
+ outputMode: request.outputMode ?? "inline",
379
383
  outputSchema: request.outputSchema,
380
384
  agentContract: request.agentContract,
381
385
  acceptance: request.acceptance,
@@ -2,6 +2,7 @@
2
2
  * Rendering functions for subagent results
3
3
  */
4
4
 
5
+ import * as fs from "node:fs";
5
6
  import * as path from "node:path";
6
7
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
7
8
  import { getMarkdownTheme, keyText, type ExtensionContext } from "@selesai/code";
@@ -27,6 +28,27 @@ import { contextModeBadge, contextModePrefix } from "../runs/shared/context-mode
27
28
 
28
29
  type Theme = ExtensionContext["ui"]["theme"];
29
30
 
31
+ /**
32
+ * UI-side output projection: completed terminal results strip `finalOutput`/
33
+ * `truncation` when an authoritative saved output path exists (see
34
+ * compactForegroundResult). The UI recovers the full text from that path so
35
+ * widgets keep showing output without reintroducing it into model-facing
36
+ * details.
37
+ */
38
+ function resultOutputForUi(r: Details["results"][number]): string {
39
+ const output = r.truncation?.text || getSingleResultOutput(r);
40
+ if (output) return output;
41
+ if (r.savedOutputPath) {
42
+ try {
43
+ const content = fs.readFileSync(r.savedOutputPath, "utf-8").trim();
44
+ return content || output;
45
+ } catch {
46
+ return output;
47
+ }
48
+ }
49
+ return output;
50
+ }
51
+
30
52
  function liveDetailKeyText(): string {
31
53
  return keyText("app.tools.expand");
32
54
  }
@@ -1308,7 +1330,7 @@ export function renderWidget(ctx: ExtensionContext, jobs: AsyncJobState[]): void
1308
1330
  }
1309
1331
 
1310
1332
  function renderSingleCompact(d: Details, r: Details["results"][number], theme: Theme, frame?: number): Component {
1311
- const output = r.truncation?.text || getSingleResultOutput(r);
1333
+ const output = resultOutputForUi(r);
1312
1334
  const progress = r.progress || r.progressSummary;
1313
1335
  const isRunning = r.progress?.status === "running";
1314
1336
  const contextBadge = contextModeBadge(theme, r.context ?? d.context);
@@ -1418,7 +1440,7 @@ function renderMultiCompact(d: Details, theme: Theme, frame?: number): Component
1418
1440
  c.addChild(new Text(truncLine(theme.fg("dim", ` ◦ ${pendingLabel}: ${agentName} · pending`), width), 0, 0));
1419
1441
  continue;
1420
1442
  }
1421
- const output = getSingleResultOutput(r);
1443
+ const output = resultOutputForUi(r);
1422
1444
  const progressFromArray = d.progress?.find((p) => p.index === i) || d.progress?.find((p) => p.agent === r.agent && p.status === "running");
1423
1445
  const rProg = r.progress || progressFromArray || r.progressSummary;
1424
1446
  const rRunning = rProg && "status" in rProg && rProg.status === "running";
@@ -1500,7 +1522,7 @@ export function renderSubagentResult(
1500
1522
  ? theme.fg("success", "ok")
1501
1523
  : theme.fg("error", "failed");
1502
1524
  const contextBadge = contextModeBadge(theme, r.context ?? d.context);
1503
- const output = r.truncation?.text || getSingleResultOutput(r);
1525
+ const output = resultOutputForUi(r);
1504
1526
 
1505
1527
  const progressInfo = isRunning && r.progress
1506
1528
  ? ` | ${r.progress.toolCount} tools, ${formatTokens(r.progress.tokens)} tok, ${formatDuration(r.progress.durationMs)}`
@@ -1600,7 +1622,7 @@ export function renderSubagentResult(
1600
1622
  const hasEmptyWithoutTarget = d.results.some((r) =>
1601
1623
  r.exitCode === 0
1602
1624
  && r.progress?.status !== "running"
1603
- && hasEmptyTextOutputWithoutOutputTarget(r.task, getSingleResultOutput(r)),
1625
+ && hasEmptyTextOutputWithoutOutputTarget(r.task, resultOutputForUi(r)),
1604
1626
  );
1605
1627
  const hasWorkflowFailure = workflowGraphHasStatus(d, ["failed"]);
1606
1628
  const hasWorkflowStop = d.results.some((r) => r.stopped && r.progress?.status !== "running") || workflowGraphHasStatus(d, ["stopped"]);
@@ -1658,7 +1680,7 @@ export function renderSubagentResult(
1658
1680
  const isComplete = result && result.exitCode === 0 && result.progress?.status !== "running";
1659
1681
  const isEmptyWithoutTarget = Boolean(result)
1660
1682
  && Boolean(isComplete)
1661
- && hasEmptyTextOutputWithoutOutputTarget(result.task, getSingleResultOutput(result));
1683
+ && hasEmptyTextOutputWithoutOutputTarget(result.task, resultOutputForUi(result));
1662
1684
  const isCurrent = i === (d.currentStepIndex ?? d.results.length);
1663
1685
  const stepIcon = isFailed
1664
1686
  ? theme.fg("error", "failed")
@@ -1729,7 +1751,7 @@ export function renderSubagentResult(
1729
1751
  const rRunning = rProg?.status === "running";
1730
1752
  const stepNumber = typeof rProg?.index === "number" ? rProg.index + 1 : i + 1;
1731
1753
 
1732
- const resultOutput = getSingleResultOutput(r);
1754
+ const resultOutput = resultOutputForUi(r);
1733
1755
  const statusIcon = rRunning
1734
1756
  ? theme.fg("warning", "running")
1735
1757
  : r.exitCode !== 0
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { afterEach, describe, it } from "node:test";
15
15
  import assert from "node:assert/strict";
16
+ import * as fs from "node:fs";
16
17
  import * as os from "node:os";
17
18
  import * as path from "node:path";
18
19
  import { tryImport } from "../support/helpers.ts";
@@ -23,6 +24,17 @@ const piAi = await tryImport<unknown>("@earendil-works/pi-ai");
23
24
  const available = Boolean(piCodingAgent && piAi);
24
25
 
25
26
  const CHILD_MARKER = "CHILD_REAL_SESSION_OK";
27
+
28
+ /**
29
+ * Reference-first tool results: completion content carries "Output saved to:
30
+ * <path> (…)". Read the durable file to inspect the full child output.
31
+ */
32
+ function readSavedOutput(text: string): string {
33
+ const rest = text.split("Output saved to: ")[1];
34
+ assert.ok(rest, `expected a saved-output reference in: ${text.slice(0, 160)}`);
35
+ const outputPath = rest.split(" (")[0]!;
36
+ return fs.readFileSync(outputPath, "utf-8");
37
+ }
26
38
  // Env vars the runner must clear so a parent that was itself spawned as a
27
39
  // subagent child can still launch fresh children. The values are deliberately
28
40
  // bogus sentinels (nonexistent paths) so a leaked value would break spawning.
@@ -122,10 +134,14 @@ Use the available tools.`;
122
134
  const chainDetails = JSON.stringify((toolMessages[1] as { details?: unknown } | undefined)?.details);
123
135
  const structuredDetails = JSON.stringify((toolMessages[2] as { details?: unknown } | undefined)?.details);
124
136
  assert.equal(results.length, 4);
125
- assert.match(results[0] ?? "", /ACTIVE_TOOLS:[^\n]*fixture_search/);
126
- assert.match(results[0] ?? "", /ACTIVE_TOOLS:[^\n]*read/);
127
- assert.match(chainDetails, /ACTIVE_TOOLS:[^\n]*fixture_search/);
128
- assert.match(chainDetails, /ACTIVE_TOOLS:[^\n]*read/);
137
+ const directOutput = readSavedOutput(results[0] ?? "");
138
+ assert.match(directOutput, /ACTIVE_TOOLS:[^\n]*fixture_search/);
139
+ assert.match(directOutput, /ACTIVE_TOOLS:[^\n]*read/);
140
+ const chainFirstChild = (toolMessages[1] as { details?: { results?: Array<{ savedOutputPath?: string }> } } | undefined)?.details?.results?.[0];
141
+ assert.ok(chainFirstChild?.savedOutputPath, "chain details should carry the saved output path");
142
+ const chainOutput = fs.readFileSync(chainFirstChild.savedOutputPath, "utf-8");
143
+ assert.match(chainOutput, /ACTIVE_TOOLS:[^\n]*fixture_search/);
144
+ assert.match(chainOutput, /ACTIVE_TOOLS:[^\n]*read/);
129
145
  assert.match(structuredDetails, /STRUCTURED_OUTPUT_OK/);
130
146
  assert.match(results[3] ?? "", /requested unavailable child tools: missing_search/);
131
147
  assert.match(results[3] ?? "", /subagentOnlyExtensions/);
@@ -172,7 +188,8 @@ Report active tools.`;
172
188
 
173
189
  const results = subagentToolResults(run.parentSession);
174
190
  assert.equal(results.length, 1);
175
- assert.match(results[0] ?? "", /ACTIVE_TOOLS:[^\n]*fixture_async_search/);
191
+ const asyncOutput = readSavedOutput(results[0] ?? "");
192
+ assert.match(asyncOutput, /ACTIVE_TOOLS:[^\n]*fixture_async_search/);
176
193
  assert.doesNotMatch(results[0] ?? "", /requested unavailable child tools/);
177
194
  });
178
195
 
@@ -206,7 +223,7 @@ Report active tools.`;
206
223
 
207
224
  const toolResults = subagentToolResults(run.parentSession);
208
225
  assert.equal(toolResults.length, 1);
209
- assert.match(toolResults[0]!, new RegExp(CHILD_MARKER));
226
+ assert.match(readSavedOutput(toolResults[0]!), new RegExp(CHILD_MARKER));
210
227
  assert.match(run.responseText, new RegExp(CHILD_MARKER));
211
228
  assert.doesNotMatch(run.responseText, /CHILD_MISSING/);
212
229
  assert.ok(run.modelCalls >= 2, `expected parent tool-call and final turns, got ${run.modelCalls}`);
@@ -219,4 +236,92 @@ Report active tools.`;
219
236
  }
220
237
  }
221
238
  });
239
+
240
+ function latestSubagentToolResultText(messages: Array<{ role?: string; toolName?: string; content?: unknown }>): string | undefined {
241
+ for (let i = messages.length - 1; i >= 0; i--) {
242
+ const message = messages[i]!;
243
+ if (message.role === "toolResult" && message.toolName === "subagent") {
244
+ return Array.isArray(message.content)
245
+ ? message.content
246
+ .map((part) => part && typeof part === "object" && (part as { type?: unknown }).type === "text"
247
+ ? String((part as { text?: unknown }).text ?? "")
248
+ : "")
249
+ .join("")
250
+ : "";
251
+ }
252
+ }
253
+ return undefined;
254
+ }
255
+
256
+ it("lists then delegates to a non-bundled discovered writer in a broad-mutation request", async () => {
257
+ const { runRealSubagentSession, subagentCall, subagentToolResults } = await import("../support/real-session-runner.ts");
258
+ const writerAgent = `---
259
+ name: fixture-writer
260
+ description: Scoped mutation-capable fixture writer
261
+ aliases: fw
262
+ tools: read, grep, find, ls, bash, edit, write
263
+ acceptanceRole: writer
264
+ defaultContext: fork
265
+ completionGuard: false
266
+ ---
267
+ Implement the scoped fixture change and return the marker.`;
268
+
269
+ run = await runRealSubagentSession({
270
+ prompt: "Implement the fixture change across the codebase.",
271
+ childText: CHILD_MARKER,
272
+ projectFiles: {
273
+ ".selesai/agents/fixture-writer.md": writerAgent,
274
+ },
275
+ respond(context) {
276
+ const messages = context.messages as Array<{ role?: string; toolName?: string; content?: unknown; details?: unknown }>;
277
+ const subagentResults = messages.filter((message) => message.role === "toolResult" && message.toolName === "subagent");
278
+ if (subagentResults.length === 0) {
279
+ return subagentCall({ action: "list", agentScope: "project" }, "call-list-writer");
280
+ }
281
+ if (subagentResults.length === 1) {
282
+ const listText = latestSubagentToolResultText(messages) ?? "";
283
+ assert.match(
284
+ listText,
285
+ /- fixture-writer \(project, context: fork, role: writer, aliases: fw, tools: read, grep, find, ls, bash, edit, write\)/,
286
+ "catalog must expose the custom writer with its runtime metadata",
287
+ );
288
+ const listedDetails = JSON.stringify(subagentResults.at(-1)?.details ?? {});
289
+ assert.match(listedDetails, /"catalog"/);
290
+ assert.match(listedDetails, /"fixture-writer"/);
291
+ assert.match(listedDetails, /"acceptanceRole":"writer"/);
292
+ return subagentCall(
293
+ { agent: "fixture-writer", task: "Implement the change and return the marker.", context: "fresh", agentScope: "project" },
294
+ "call-fixture-writer",
295
+ );
296
+ }
297
+ return "Broad mutation work complete.";
298
+ },
299
+ timeoutMs: 60_000,
300
+ });
301
+
302
+ const results = subagentToolResults(run.parentSession);
303
+ assert.equal(results.length, 2);
304
+ assert.match(results[0] ?? "", /fixture-writer \(project, context: fork, role: writer/);
305
+ assert.match(readSavedOutput(results[1] ?? ""), new RegExp(CHILD_MARKER));
306
+ });
307
+
308
+ it("keeps tiny targeted reads local without a subagent call", async () => {
309
+ const { runRealSubagentSession, subagentToolResults } = await import("../support/real-session-runner.ts");
310
+ run = await runRealSubagentSession({
311
+ prompt: "What does the README say about subagents?",
312
+ childText: CHILD_MARKER,
313
+ projectFiles: {
314
+ "README.md": "Subagents are delegated workers.",
315
+ },
316
+ respond() {
317
+ return "The README says: Subagents are delegated workers.";
318
+ },
319
+ timeoutMs: 60_000,
320
+ });
321
+
322
+ const results = subagentToolResults(run.parentSession);
323
+ assert.equal(results.length, 0);
324
+ assert.match(run.responseText, /Subagents are delegated workers/);
325
+ assert.ok(run.modelCalls >= 1, `expected at least one parent turn, got ${run.modelCalls}`);
326
+ });
222
327
  });