@selesai/code 0.13.31 → 0.13.33

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 (49) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/core/remote-catalog-provider.js +5 -1
  3. package/dist/core/remote-catalog-provider.test.d.ts +1 -0
  4. package/dist/core/remote-catalog-provider.test.js +29 -0
  5. package/dist/extensions/capability-gateway/index.ts +9 -4
  6. package/dist/extensions/capability-gateway/integration.test.ts +69 -3
  7. package/dist/extensions/pi-subagents/agents/worker.md +3 -2
  8. package/dist/extensions/pi-subagents/docs/agents.md +2 -0
  9. package/dist/extensions/pi-subagents/docs/extension-api.md +3 -1
  10. package/dist/extensions/pi-subagents/docs/observability.md +2 -0
  11. package/dist/extensions/pi-subagents/docs/tool-reference.md +2 -2
  12. package/dist/extensions/pi-subagents/docs/workflows.md +1 -1
  13. package/dist/extensions/pi-subagents/src/extension/rpc.ts +10 -1
  14. package/dist/extensions/pi-subagents/src/runs/background/active-async-capacity.ts +3 -21
  15. package/dist/extensions/pi-subagents/src/runs/background/async-execution.ts +2 -1
  16. package/dist/extensions/pi-subagents/src/runs/background/run-child-session.ts +1 -0
  17. package/dist/extensions/pi-subagents/src/runs/background/run-status.ts +5 -1
  18. package/dist/extensions/pi-subagents/src/runs/background/subagent-runner.ts +10 -3
  19. package/dist/extensions/pi-subagents/src/runs/background/workflow-terminal-proof.ts +67 -0
  20. package/dist/extensions/pi-subagents/src/runs/foreground/execution.ts +6 -2
  21. package/dist/extensions/pi-subagents/src/runs/shared/child-tool-plan.ts +67 -5
  22. package/dist/extensions/pi-subagents/src/runs/shared/completion-guard.ts +6 -0
  23. package/dist/extensions/pi-subagents/src/runs/shared/external-cli-runner.ts +2 -1
  24. package/dist/extensions/pi-subagents/src/runs/shared/git-environment.ts +29 -0
  25. package/dist/extensions/pi-subagents/src/runs/shared/structured-output.ts +69 -0
  26. package/dist/extensions/pi-subagents/src/shared/types.ts +20 -0
  27. package/dist/extensions/pi-subagents/src/slash/slash-commands.ts +4 -238
  28. package/dist/extensions/pi-subagents/src/slash/subagent-cost.ts +280 -0
  29. package/dist/extensions/pi-subagents/test/integration/async-execution.part-3.test.ts +67 -0
  30. package/dist/extensions/pi-subagents/test/integration/in-process-child.test.ts +29 -1
  31. package/dist/extensions/pi-subagents/test/integration/intercom-result-delivery.test.ts +6 -3
  32. package/dist/extensions/pi-subagents/test/integration/single-execution.part-2.test.ts +25 -0
  33. package/dist/extensions/pi-subagents/test/unit/agent-frontmatter.test.ts +5 -5
  34. package/dist/extensions/pi-subagents/test/unit/async-spawn-preload.test.ts +21 -0
  35. package/dist/extensions/pi-subagents/test/unit/child-tool-plan-permission-system.test.ts +98 -0
  36. package/dist/extensions/pi-subagents/test/unit/child-tool-plan.test.ts +11 -0
  37. package/dist/extensions/pi-subagents/test/unit/completion-guard.test.ts +12 -0
  38. package/dist/extensions/pi-subagents/test/unit/external-cli-runner.test.ts +25 -0
  39. package/dist/extensions/pi-subagents/test/unit/git-environment.test.ts +38 -0
  40. package/dist/extensions/pi-subagents/test/unit/preflight.test.ts +3 -1
  41. package/dist/extensions/pi-subagents/test/unit/rpc.test.ts +105 -1
  42. package/dist/extensions/pi-subagents/test/unit/run-status.test.ts +36 -0
  43. package/dist/extensions/pi-subagents/test/unit/structured-output-rejection.test.ts +67 -0
  44. package/dist/extensions/pi-subagents/test/unit/workflow-terminal-proof.test.ts +98 -0
  45. package/dist/extensions/pi-web-agent/src/extension.ts +2 -1
  46. package/dist/skills/pi-subagents/references/constraints-and-recipes.md +8 -8
  47. package/dist/skills/pi-subagents/references/execution-controls.md +1 -1
  48. package/dist/skills/pi-subagents/references/prompting-and-roles.md +1 -1
  49. package/package.json +3 -3
@@ -0,0 +1,280 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { ExtensionContext } from "@selesai/code";
4
+ import { getArtifactPaths, getArtifactsDir } from "../shared/artifacts.ts";
5
+ import { formatTokens } from "../shared/formatters.ts";
6
+ import { DIRS, SLASH_RESULT_TYPE, type Details, type SingleResult, type SubagentState, type Usage } from "../shared/types.ts";
7
+ import { readStatus } from "../shared/utils.ts";
8
+ import { readWorkflowReceipt } from "../workflows/workflow-receipt.ts";
9
+ import { resolveSlashMessageDetails } from "./slash-live-state.ts";
10
+
11
+ /** Structured parent-plus-child accounting for one session; the source of `/subagent-cost` and the RPC `cost` method. */
12
+ export interface SubagentCostReport {
13
+ version: 1;
14
+ parent: Usage;
15
+ children: SubagentCostChild[];
16
+ childTotal: Usage;
17
+ total: Usage;
18
+ /** Async children whose usage metadata could not be resolved; the child total is a lower bound when this is non-zero. */
19
+ unresolvedAsyncChildren: number;
20
+ }
21
+
22
+ export interface SubagentCostChild {
23
+ label: string;
24
+ agent?: string;
25
+ runId?: string;
26
+ usage: Usage;
27
+ sessionFile?: string;
28
+ }
29
+
30
+ export const SUBAGENT_COST_REPORT_VERSION = 1 as const;
31
+
32
+ function emptyUsage(): Usage {
33
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
34
+ }
35
+
36
+ function addUsage(target: Usage, source: Usage): void {
37
+ target.input += source.input;
38
+ target.output += source.output;
39
+ target.cacheRead += source.cacheRead;
40
+ target.cacheWrite += source.cacheWrite;
41
+ target.cost += source.cost;
42
+ target.turns += source.turns;
43
+ }
44
+
45
+ function usageHasValue(usage: Usage): boolean {
46
+ return usage.input !== 0 || usage.output !== 0 || usage.cacheRead !== 0 || usage.cacheWrite !== 0 || usage.cost !== 0 || usage.turns !== 0;
47
+ }
48
+
49
+ function nonNegativeNumber(value: unknown): number | undefined {
50
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
51
+ }
52
+
53
+ function usageFromValue(value: unknown, turnsOverride?: number): Usage | undefined {
54
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
55
+ const record = value as Record<string, unknown>;
56
+ const costValue = typeof record.cost === "object" && record.cost !== null && !Array.isArray(record.cost)
57
+ ? (record.cost as Record<string, unknown>).total
58
+ : record.cost;
59
+ const turnsValue = turnsOverride ?? record.turns;
60
+ const usage = {
61
+ input: nonNegativeNumber(record.input) ?? 0,
62
+ output: nonNegativeNumber(record.output) ?? 0,
63
+ cacheRead: nonNegativeNumber(record.cacheRead) ?? 0,
64
+ cacheWrite: nonNegativeNumber(record.cacheWrite) ?? 0,
65
+ cost: nonNegativeNumber(costValue) ?? 0,
66
+ turns: nonNegativeNumber(turnsValue) ?? 0,
67
+ };
68
+ return Number.isSafeInteger(usage.turns) ? usage : undefined;
69
+ }
70
+
71
+ function assistantUsageFromMessage(message: unknown): Usage | undefined {
72
+ if (!message || typeof message !== "object") return undefined;
73
+ const msg = message as { role?: unknown; usage?: unknown };
74
+ return msg.role === "assistant" ? usageFromValue(msg.usage, 1) : undefined;
75
+ }
76
+
77
+ function compactionUsageFromEntry(entry: unknown): Usage | undefined {
78
+ if (!entry || typeof entry !== "object") return undefined;
79
+ const record = entry as { type?: unknown; usage?: unknown };
80
+ return record.type === "compaction" ? usageFromValue(record.usage, 0) : undefined;
81
+ }
82
+
83
+ const MAX_USAGE_METADATA_BYTES = 2 * 1024 * 1024;
84
+
85
+ function readUsageMetadata(metadataPath: string): Record<string, unknown> | undefined {
86
+ let fd: number | undefined;
87
+ try {
88
+ fd = fs.openSync(metadataPath, "r");
89
+ const stat = fs.fstatSync(fd);
90
+ if (!stat.isFile() || stat.size > MAX_USAGE_METADATA_BYTES) return undefined;
91
+ const buffer = Buffer.allocUnsafe(stat.size);
92
+ let offset = 0;
93
+ while (offset < buffer.length) {
94
+ const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
95
+ if (bytesRead <= 0) return undefined;
96
+ offset += bytesRead;
97
+ }
98
+ const value: unknown = JSON.parse(buffer.toString("utf-8"));
99
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
100
+ } finally {
101
+ if (fd !== undefined) fs.closeSync(fd);
102
+ }
103
+ }
104
+
105
+ function isSubagentDetails(value: unknown): value is Details {
106
+ if (!value || typeof value !== "object") return false;
107
+ const details = value as { mode?: unknown; results?: unknown };
108
+ return typeof details.mode === "string" && Array.isArray(details.results);
109
+ }
110
+
111
+ function detailsFromSessionEntry(entry: unknown): Details | undefined {
112
+ if (!entry || typeof entry !== "object") return undefined;
113
+ const record = entry as { type?: unknown; customType?: unknown; details?: unknown; message?: unknown };
114
+ if (record.type === "custom_message" && record.customType === SLASH_RESULT_TYPE) {
115
+ const details = resolveSlashMessageDetails(record.details)?.result.details;
116
+ return isSubagentDetails(details) ? details : undefined;
117
+ }
118
+ if (record.type !== "message" || !record.message || typeof record.message !== "object") return undefined;
119
+ const message = record.message as { role?: unknown; toolName?: unknown; details?: unknown };
120
+ if (message.role !== "toolResult" || (message.toolName !== "subagent" && message.toolName !== "bg_wait")) return undefined;
121
+ return isSubagentDetails(message.details) ? message.details : undefined;
122
+ }
123
+
124
+ function metadataUsage(
125
+ artifactsDirs: string[],
126
+ input: { runId: string; agent: string },
127
+ ): Usage | undefined {
128
+ if (!/^[A-Za-z0-9._-]+$/.test(input.runId)) return undefined;
129
+ for (const artifactsDir of artifactsDirs) {
130
+ for (const index of [0, undefined] as const) {
131
+ const metadataPath = getArtifactPaths(artifactsDir, input.runId, input.agent, index).metadataPath;
132
+ try {
133
+ const metadata = readUsageMetadata(metadataPath);
134
+ if (!metadata) continue;
135
+ if (metadata.runId !== input.runId || metadata.agent !== input.agent) continue;
136
+ const usage = usageFromValue(metadata.usage);
137
+ if (usage && usageHasValue(usage)) return usage;
138
+ } catch (error) {
139
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to read subagent usage metadata '${metadataPath}':`, error);
140
+ }
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ /**
147
+ * Collect parent and child usage for the current session branch. Foreground
148
+ * children come from persisted `subagent`/`bg_wait` tool-result details; async
149
+ * workflow children are resolved through receipts and artifact metadata.
150
+ */
151
+ export function collectSubagentCost(
152
+ ctx: ExtensionContext,
153
+ state: Pick<SubagentState, "baseCwd" | "artifactDirPreference">,
154
+ ): SubagentCostReport {
155
+ const parent = emptyUsage();
156
+ const childTotal = emptyUsage();
157
+ const total = emptyUsage();
158
+ const children: SubagentCostChild[] = [];
159
+ const seenChildren = new Set<string>();
160
+ const workflowRunIds = new Set<string>();
161
+ let unresolvedAsyncChildren = 0;
162
+
163
+ const addChild = (input: { agent?: string; runId?: string; usage?: Usage; sessionFile?: string }): boolean => {
164
+ if (!input.usage || !usageHasValue(input.usage)) return false;
165
+ const identity = input.runId ? `run:${input.runId}` : input.sessionFile ? `session:${input.sessionFile}` : undefined;
166
+ if (identity && seenChildren.has(identity)) return true;
167
+ if (identity) seenChildren.add(identity);
168
+ const usage = { ...input.usage };
169
+ children.push({
170
+ label: `Child ${children.length + 1} (${input.agent ?? "unknown"})`,
171
+ ...(input.agent ? { agent: input.agent } : {}),
172
+ ...(input.runId ? { runId: input.runId } : {}),
173
+ usage,
174
+ ...(input.sessionFile ? { sessionFile: input.sessionFile } : {}),
175
+ });
176
+ addUsage(childTotal, usage);
177
+ return true;
178
+ };
179
+
180
+ for (const entry of ctx.sessionManager.getBranch()) {
181
+ const message = entry.type === "message" ? (entry as { message?: unknown }).message : undefined;
182
+ const parentUsage = assistantUsageFromMessage(message) ?? compactionUsageFromEntry(entry);
183
+ if (parentUsage) addUsage(parent, parentUsage);
184
+ const details = detailsFromSessionEntry(entry);
185
+ if (!details) continue;
186
+ if (details.mode === "workflow" && details.runId) workflowRunIds.add(details.runId);
187
+ for (const result of details.results) {
188
+ const resultRunId = (result as SingleResult & { runId?: unknown }).runId;
189
+ addChild({ agent: result.agent, runId: typeof resultRunId === "string" ? resultRunId : undefined, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
190
+ }
191
+ for (const completion of details.completions ?? []) {
192
+ if (completion.mode === "workflow") workflowRunIds.add(completion.runId);
193
+ for (const result of completion.results ?? []) {
194
+ addChild({ agent: result.agent, runId: result.runId, usage: usageFromValue(result.usage), sessionFile: result.sessionFile });
195
+ }
196
+ }
197
+ }
198
+
199
+ let sessionFile: string | null = null;
200
+ try {
201
+ sessionFile = ctx.sessionManager.getSessionFile() ?? null;
202
+ } catch { /* an unpersisted session can still report direct child usage */ }
203
+ const artifactsDirs = new Set<string>();
204
+ const addArtifactsDir = (cwd: string | undefined): void => {
205
+ try {
206
+ artifactsDirs.add(getArtifactsDir(sessionFile, cwd, state.artifactDirPreference));
207
+ } catch { /* invalid or unavailable project roots are ignored */ }
208
+ };
209
+ addArtifactsDir(ctx.cwd);
210
+ addArtifactsDir(state.baseCwd);
211
+
212
+ for (const workflowRunId of workflowRunIds) {
213
+ try {
214
+ const status = readStatus(path.join(DIRS.async, workflowRunId));
215
+ addArtifactsDir(status?.cwd);
216
+ const receipt = readWorkflowReceipt(DIRS.async, workflowRunId);
217
+ const summaryChildren = new Map((receipt.workflowChildren?.children ?? []).map((child) => [child.childId, child]));
218
+ const refsByRunId = new Map<string, { runId: string; agent?: string }>();
219
+ const addRef = (runId: string | undefined, agent: string | undefined): void => {
220
+ if (!runId) return;
221
+ const existing = refsByRunId.get(runId);
222
+ if (!existing || (!existing.agent && agent)) refsByRunId.set(runId, { runId, ...(agent ? { agent } : {}) });
223
+ };
224
+ for (const [key, entry] of Object.entries(receipt.entries)) {
225
+ const summaryChild = summaryChildren.get(key);
226
+ const runIds = entry.continuation?.runIds?.length
227
+ ? entry.continuation.runIds
228
+ : entry.latestRunId ? [entry.latestRunId] : summaryChild?.runId ? [summaryChild.runId] : [];
229
+ for (const runId of runIds) addRef(runId, entry.agent ?? summaryChild?.agent);
230
+ }
231
+ for (const child of receipt.workflowChildren?.children ?? []) {
232
+ if (!Object.hasOwn(receipt.entries, child.childId)) addRef(child.runId, child.agent);
233
+ }
234
+ for (const ref of refsByRunId.values()) {
235
+ if (seenChildren.has(`run:${ref.runId}`)) continue;
236
+ if (!ref.agent) {
237
+ unresolvedAsyncChildren += 1;
238
+ continue;
239
+ }
240
+ const usage = metadataUsage([...artifactsDirs], { runId: ref.runId, agent: ref.agent });
241
+ if (!usage || !addChild({ ...ref, usage })) unresolvedAsyncChildren += 1;
242
+ }
243
+ } catch (error) {
244
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") console.error(`Failed to resolve async subagent usage for '${workflowRunId}':`, error);
245
+ }
246
+ }
247
+
248
+ addUsage(total, parent);
249
+ addUsage(total, childTotal);
250
+ return { version: SUBAGENT_COST_REPORT_VERSION, parent, children, childTotal, total, unresolvedAsyncChildren };
251
+ }
252
+
253
+ function formatCostUsage(label: string, usage: Usage): string {
254
+ const extras = [
255
+ usage.cacheRead ? `cache read ${formatTokens(usage.cacheRead)}` : "",
256
+ usage.cacheWrite ? `cache write ${formatTokens(usage.cacheWrite)}` : "",
257
+ usage.turns ? `${usage.turns} turn${usage.turns === 1 ? "" : "s"}` : "",
258
+ ].filter(Boolean);
259
+ return `${label}: ↑${formatTokens(usage.input)} ↓${formatTokens(usage.output)} $${usage.cost.toFixed(4)}${extras.length ? ` (${extras.join(", ")})` : ""}`;
260
+ }
261
+
262
+ /** The `/subagent-cost` text rendering of a collected report. */
263
+ export function formatSubagentCostReport(report: SubagentCostReport): string {
264
+ const lines = [
265
+ "Subagent cost",
266
+ "",
267
+ formatCostUsage("Parent", report.parent),
268
+ ];
269
+ if (report.children.length === 0) {
270
+ lines.push("No subagent child usage found in this session.");
271
+ } else {
272
+ for (const child of report.children) {
273
+ lines.push(formatCostUsage(child.label, child.usage));
274
+ if (child.sessionFile) lines.push(` Session: ${child.sessionFile}`);
275
+ }
276
+ }
277
+ if (report.unresolvedAsyncChildren > 0) lines.push(`Async child usage unavailable: ${report.unresolvedAsyncChildren}.`);
278
+ lines.push("────────────────────────────", formatCostUsage("Children", report.childTotal), formatCostUsage("Total", report.total));
279
+ return lines.join("\n");
280
+ }
@@ -17,6 +17,7 @@ import { createEventBus, createTempDir, events, makeAgent, makeMinimalCtx, remov
17
17
  import { discoverAgents } from "../../src/agents/agents.ts";
18
18
  import { ACTIVE_ASYNC_CAPACITY_DIR, acquireActiveAsyncCapacity, activeAsyncCapacitySessionKey } from "../../src/runs/background/active-async-capacity.ts";
19
19
  import { deriveForkPromptCacheKey } from "../../src/runs/shared/child-tool-plan.ts";
20
+ import { INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR, validateStructuredOutputValue } from "../../src/runs/shared/structured-output.ts";
20
21
  import type { AsyncExecutionResult, AsyncResultPayload, AsyncStatusPayload } from "../support/async-execution-fixture.ts";
21
22
  import {
22
23
  installAsyncExecutionHooks, waitForMockPiRuntime, available, isAsyncAvailable,
@@ -1102,6 +1103,72 @@ export default function() {
1102
1103
  assert.deepEqual(payload.results[0]?.structuredOutput, { ok: true, note: "async" });
1103
1104
  });
1104
1105
 
1106
+ it("background settlement preserves rejected structured_output evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1107
+ const diagnostic = "Structured output validation failed: ok: is required";
1108
+ mockPi.onCall({
1109
+ jsonl: [
1110
+ { type: "message_end", message: { role: "assistant", content: [{ type: "toolCall", id: "structured-rejected", name: "structured_output", arguments: { value: {} } }], model: "mock/test-model", stopReason: "toolUse", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: { total: 0 } } } },
1111
+ { type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: {} } },
1112
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: diagnostic }] } },
1113
+ { type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output" },
1114
+ ],
1115
+ });
1116
+ const id = `async-single-schema-rejected-${Date.now().toString(36)}`;
1117
+
1118
+ executeAsyncSingle(id, {
1119
+ agent: "worker",
1120
+ task: "Return structured data",
1121
+ agentConfig: makeAgent("worker"),
1122
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1123
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1124
+ shareEnabled: false,
1125
+ sessionRoot: path.join(tempDir, "sessions"),
1126
+ maxSubagentDepth: 2,
1127
+ acceptance: false,
1128
+ structuredOutputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } },
1129
+ });
1130
+
1131
+ const payload = JSON.parse(fs.readFileSync(await waitForAsyncResultFile(id, 10_000), "utf-8")) as AsyncResultPayload;
1132
+ const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
1133
+ assert.equal(payload.success, false);
1134
+ assert.match(payload.results[0]?.error ?? "", /Structured output validation failed: ok: is required/);
1135
+ assert.doesNotMatch(payload.results[0]?.error ?? "", /Missing structured_output call/);
1136
+ assert.equal(payload.results[0]?.structuredOutputFailed, true);
1137
+ assert.match(status.steps?.[0]?.error ?? "", /Structured output validation failed: ok: is required/);
1138
+ });
1139
+
1140
+ it("does not persist malformed outputSchema compiler text in background evidence", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1141
+ const sentinel = "BACKGROUND_PRIVATE_SCHEMA_SENTINEL";
1142
+ const structuredOutputSchema = { type: "string", pattern: `${sentinel}_[invalid` };
1143
+ const validation = await validateStructuredOutputValue(structuredOutputSchema, "value");
1144
+ assert.equal(validation.status, "invalid");
1145
+ if (validation.status !== "invalid") return;
1146
+ assert.match(validation.message, new RegExp(sentinel));
1147
+ mockPi.onCall({
1148
+ jsonl: [
1149
+ { type: "tool_execution_start", toolCallId: "structured-malformed-schema", toolName: "structured_output", args: { value: "value" } },
1150
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-malformed-schema", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: ${validation.message}` }] } },
1151
+ { type: "tool_execution_end", toolCallId: "structured-malformed-schema", toolName: "structured_output" },
1152
+ ],
1153
+ });
1154
+ const id = `async-single-malformed-schema-${Date.now().toString(36)}`;
1155
+ executeAsyncSingle(id, {
1156
+ agent: "worker", task: "Return structured data", agentConfig: makeAgent("worker"),
1157
+ ctx: { pi: { events: { emit() {} } }, cwd: tempDir, currentSessionId: "session-1" },
1158
+ artifactConfig: { enabled: false, includeInput: false, includeOutput: false, includeJsonl: false, includeMetadata: false, cleanupDays: 7 },
1159
+ shareEnabled: false, sessionRoot: path.join(tempDir, "sessions"), maxSubagentDepth: 2,
1160
+ acceptance: false, structuredOutputSchema,
1161
+ });
1162
+
1163
+ const resultPath = await waitForAsyncResultFile(id, 10_000);
1164
+ const payloadText = fs.readFileSync(resultPath, "utf-8");
1165
+ const payload = JSON.parse(payloadText) as AsyncResultPayload;
1166
+ const status = await waitForAsyncState(id, (candidate) => candidate.state === "failed");
1167
+ assert.match(payload.results[0]?.error ?? "", new RegExp(`^${escapeRegExp(INVALID_STRUCTURED_OUTPUT_SCHEMA_ERROR)}`));
1168
+ assert.equal(payloadText.includes(sentinel), false);
1169
+ assert.equal((status.steps?.[0]?.error ?? "").includes(sentinel), false);
1170
+ });
1171
+
1105
1172
  it("background outputSchema runs fail closed when required acceptanceReport is missing", { skip: !isAsyncAvailable() ? "jiti not available" : undefined }, async () => {
1106
1173
  mockPi.onCall({ output: "structured", structuredOutput: { ok: true } });
1107
1174
  const id = `async-schema-missing-acceptance-${Date.now().toString(36)}`;
@@ -9,6 +9,7 @@ import { describe, it, before, after, beforeEach } from "node:test";
9
9
  import assert from "node:assert/strict";
10
10
  import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
+ import { fileURLToPath } from "node:url";
12
13
  import type { MockPi } from "../support/helpers.ts";
13
14
  import { createMockPi, createTempDir, makeAgent, makeAgentConfigs, removeTempDir } from "../support/helpers.ts";
14
15
  import { runSync } from "../../src/runs/foreground/execution.ts";
@@ -17,6 +18,9 @@ import { createNestedRoute } from "../../src/runs/shared/nested-events.ts";
17
18
  import { createStructuredOutputRuntime } from "../../src/runs/shared/structured-output.ts";
18
19
  import type { ForegroundChildSessionControls, SingleResult } from "../../src/shared/types.ts";
19
20
 
21
+ /** Explicit child tool surfaces also receive the scoped capability gateway controls. */
22
+ const CHILD_CAPABILITY_GATEWAY_TOOLS = ["capability_catalog", "capability_discover", "capability_skill_show"];
23
+
20
24
  async function waitFor(read: () => boolean, timeoutMs = 5_000): Promise<void> {
21
25
  const deadline = Date.now() + timeoutMs;
22
26
  while (!read()) {
@@ -71,6 +75,30 @@ describe("in-process foreground child", () => {
71
75
  }
72
76
  });
73
77
 
78
+ it("passes wrapped core tools and the Graft provider to a launched foreground child", async () => {
79
+ const wrapperPath = path.join(tempDir, "wrapped-core-tools.ts");
80
+ const graftExtensionPath = fileURLToPath(new URL("../../../pi-graft/index.ts", import.meta.url));
81
+ const tools = ["read", "grep", "graft_find_code"];
82
+ fs.writeFileSync(wrapperPath, [
83
+ "export default function register(pi) {",
84
+ " const available = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));",
85
+ " for (const name of ['read', 'grep']) {",
86
+ " const tool = available.get(name);",
87
+ " if (tool) pi.registerTool({ ...tool, description: 'wrapped ' + name + ': ' + tool.description });",
88
+ " }",
89
+ "}",
90
+ ].join("\n"));
91
+ mockPi.onCall({ output: "read, grep, and Graft tools were available" });
92
+ const result = await runSync(tempDir, [makeAgent("worker", { tools, subagentOnlyExtensions: [wrapperPath, graftExtensionPath] })], "worker", "Inspect with read, grep, and Graft", { runId: "wrapped-graft-child" });
93
+ assert.equal(result.exitCode, 0, result.error);
94
+ const launch = mockPi.sessions[0]?.launch;
95
+ const expectedTools = [...tools, ...CHILD_CAPABILITY_GATEWAY_TOOLS];
96
+ assert.deepEqual(launch?.tools, expectedTools);
97
+ assert.deepEqual(launch?.runtime.requiredTools, expectedTools);
98
+ assert.ok(launch?.extensionPaths.includes(wrapperPath));
99
+ assert.ok(launch?.extensionPaths.includes(graftExtensionPath));
100
+ });
101
+
74
102
  it("adds the fanout hook and nested route only for fanout-authorized children", async () => {
75
103
  const route = createNestedRoute("hooks-fanout");
76
104
  try {
@@ -84,7 +112,7 @@ describe("in-process foreground child", () => {
84
112
  assert.equal(session?.launch.runtime.fanoutChild, true);
85
113
  assert.deepEqual(session?.launch.runtime.nestedRoute, route);
86
114
  assert.deepEqual(session?.launch.runtime.nestedParent, { parentRunId: "hooks-fanout", parentChildIndex: 0, depth: 1, path: [{ runId: "hooks-fanout", stepIndex: 0, agent: "delegator" }] });
87
- assert.deepEqual(session?.launch.tools, ["read", "subagent"]);
115
+ assert.deepEqual(session?.launch.tools, ["read", "subagent", ...CHILD_CAPABILITY_GATEWAY_TOOLS]);
88
116
  } finally {
89
117
  fs.rmSync(path.dirname(route.eventSink), { recursive: true, force: true });
90
118
  }
@@ -23,6 +23,9 @@ import {
23
23
  tryImport,
24
24
  } from "../support/helpers.ts";
25
25
 
26
+ /** Children with an explicit tool surface also receive the scoped capability gateway controls. */
27
+ const CHILD_CAPABILITY_GATEWAY_TOOLS = "capability_catalog,capability_discover,capability_skill_show";
28
+
26
29
  interface ExecutorResult {
27
30
  content: Array<{ text?: string }>;
28
31
  isError?: boolean;
@@ -308,8 +311,8 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
308
311
 
309
312
  const isolatedArgs = await readMockCallArgs(0);
310
313
  const normalArgs = await readMockCallArgs(1);
311
- assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], "read");
312
- assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], "read,contact_supervisor");
314
+ assert.equal(isolatedArgs[isolatedArgs.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
315
+ assert.equal(normalArgs[normalArgs.indexOf("--tools") + 1], `read,contact_supervisor,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
313
316
  assert.equal(events.emitted.filter((entry) => entry.channel === "subagent:result-intercom").length, 1);
314
317
  assert.deepEqual(result.details?.workflow?.value, { isolated: "Isolated child output", normal: "Normal child output" });
315
318
  });
@@ -1106,7 +1109,7 @@ describe("intercom result delivery cutover", { skip: !available ? "executor not
1106
1109
  const args = await readMockCallArgs(0);
1107
1110
  assert.equal(args[args.indexOf("--session") + 1], sessionFile);
1108
1111
  assert.equal(args[args.indexOf("--model") + 1], "anthropic/claude-sonnet-4:high");
1109
- assert.equal(args[args.indexOf("--tools") + 1], "read");
1112
+ assert.equal(args[args.indexOf("--tools") + 1], `read,${CHILD_CAPABILITY_GATEWAY_TOOLS}`);
1110
1113
  assert.equal(args.includes("--system-prompt"), true);
1111
1114
  assert.equal(args.includes("--append-system-prompt"), false);
1112
1115
  await waitForFile(path.join(RESULTS_DIR, `${revivedId}.json`));
@@ -1623,6 +1623,31 @@ if (!fs.existsSync(${JSON.stringify(holdPath)})) { console.log('{}'); } else {
1623
1623
  if (child?.artifactPaths?.outputPath) assert.match(fs.readFileSync(child.artifactPaths.outputPath, "utf-8"), /"note": "captured"/);
1624
1624
  });
1625
1625
 
1626
+ it("reports bounded structured_output rejection evidence without leaking the submitted value", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1627
+ const secret = "PRIVATE_STRUCTURED_OUTPUT_VALUE";
1628
+ mockPi.onCall({
1629
+ stdoutRaw: [
1630
+ { type: "tool_execution_start", toolCallId: "structured-rejected", toolName: "structured_output", args: { value: { secret } } },
1631
+ { type: "tool_result_end", message: { role: "toolResult", toolCallId: "structured-rejected", toolName: "structured_output", isError: true, content: [{ type: "text", text: `Structured output validation failed: value.secret: expected boolean; received value: ${secret}` }] } },
1632
+ { type: "tool_execution_end", toolCallId: "structured-rejected", toolName: "structured_output", isError: true },
1633
+ ].map((entry) => JSON.stringify(entry)).join("\n") + "\n",
1634
+ });
1635
+ const executor = makeExecutor([makeAgent("echo")]);
1636
+ const result = await executor.execute(
1637
+ "single-schema-rejected",
1638
+ { agent: "echo", task: "Return structured data", outputSchema: { type: "object", required: ["ok"], properties: { ok: { type: "boolean" } } }, acceptance: false },
1639
+ new AbortController().signal,
1640
+ undefined,
1641
+ makeMinimalCtx(tempDir),
1642
+ );
1643
+
1644
+ const child = result.details?.results?.[0];
1645
+ assert.equal(result.isError, true);
1646
+ assert.equal(child?.structuredOutputFailed, true);
1647
+ assert.match(child?.error ?? "", /Structured output validation failed: value.secret/);
1648
+ assert.doesNotMatch(child?.error ?? "", new RegExp(secret));
1649
+ });
1650
+
1626
1651
  it("routes retained workflow follow-ups to distinct outputs without overwriting the writer report", { skip: !createSubagentExecutor ? "executor not importable" : undefined }, async () => {
1627
1652
  for (const relative of [false, true]) {
1628
1653
  const writerPath = path.join(tempDir, `writer-${relative}.md`);
@@ -638,16 +638,16 @@ Do work
638
638
  assert.equal(worker?.defaultContext, "fork");
639
639
  });
640
640
 
641
- it("loads packaged worker and oracle with fork defaultContext and advisor alias", () => {
641
+ it("loads packaged worker with fresh writer defaults and keeps oracle fork context", () => {
642
642
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagents-builtin-default-context-"));
643
643
  tempDirs.push(dir);
644
644
  const agents = discoverAgentsAll(dir).builtin;
645
645
 
646
- for (const name of ["worker", "oracle"]) {
647
- const agent = agents.find((candidate) => candidate.name === name);
648
- assert.equal(agent?.defaultContext, "fork", `${name} should default to fork context`);
649
- }
646
+ const worker = agents.find((candidate) => candidate.name === "worker");
647
+ assert.equal(worker?.defaultContext, "fresh");
648
+ assert.equal(worker?.acceptanceRole, "writer");
650
649
  const oracle = agents.find((candidate) => candidate.name === "oracle");
650
+ assert.equal(oracle?.defaultContext, "fork");
651
651
  assert.deepEqual(oracle?.aliases, ["advisor"]);
652
652
  assert.doesNotMatch(oracle?.tools?.join(",") ?? "", /contact_supervisor/);
653
653
  for (const name of ["scout", "researcher", "oracle", "reviewer"]) {
@@ -37,7 +37,19 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
37
37
  fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: pkg, version: "0.85.0", exports }));
38
38
  }
39
39
  const originalArgv1 = process.argv[1];
40
+ const inheritedGitEnv = {
41
+ GIT_DIR: "/outer/repo/.git",
42
+ GIT_WORK_TREE: "/outer/repo",
43
+ GIT_INDEX_FILE: "/outer/repo/.git/index",
44
+ GIT_CONFIG_COUNT: "1",
45
+ GIT_CONFIG_KEY_0: "core.hooksPath",
46
+ GIT_CONFIG_VALUE_0: "/outer/hooks",
47
+ GIT_AUTHOR_NAME: "Kept Author",
48
+ GIT_ENV_SENTINEL_KEEP: "kept",
49
+ };
50
+ const previousGitEnv = Object.fromEntries(Object.keys(inheritedGitEnv).map((key) => [key, process.env[key]]));
40
51
  try {
52
+ Object.assign(process.env, inheritedGitEnv);
41
53
  for (const pkg of Object.keys(hostExports)) {
42
54
  if (pkg !== server) writeHostPackage(pkg);
43
55
  }
@@ -79,6 +91,11 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
79
91
  assert.equal(spawn.mock.callCount(), scenario === "supplemental" ? 1 : scenario === "complete" ? 2 : 3);
80
92
  const [command, args, options] = spawn.mock.calls.at(-1)!.arguments;
81
93
  assert.ok(path.isAbsolute(command));
94
+ for (const key of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"]) {
95
+ assert.equal(options.env[key], undefined, key);
96
+ }
97
+ assert.equal(options.env.GIT_AUTHOR_NAME, "Kept Author");
98
+ assert.equal(options.env.GIT_ENV_SENTINEL_KEEP, "kept");
82
99
  assert.equal(options.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV], host);
83
100
  const actualAliases = JSON.parse(options.env.JITI_ALIAS) as Record<string, string>;
84
101
  assert.deepEqual(Object.fromEntries(Object.entries(actualAliases).map(([key, target]) => [key, fs.realpathSync(target)])), expectedAliases);
@@ -96,6 +113,10 @@ test("executeAsyncSingle preloads supplemental server aliases before jiti, but n
96
113
  } finally {
97
114
  t.mock.restoreAll();
98
115
  syncBuiltinESMExports();
116
+ for (const [key, value] of Object.entries(previousGitEnv)) {
117
+ if (value === undefined) delete process.env[key];
118
+ else process.env[key] = value;
119
+ }
99
120
  if (originalArgv1 === undefined) delete process.argv[1];
100
121
  else process.argv[1] = originalArgv1;
101
122
  fs.rmSync(root, { recursive: true, force: true });