@mystilleef/pi-subagent 0.10.2 → 0.12.0

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.
@@ -1,12 +1,24 @@
1
1
  export const SUBAGENT_RESULT_CONTRACT = `
2
- - Always present results unchanged to the main agent.
3
- - End your final response with exactly one line:
4
- - Outcome: <short, single, compact lower-case sentence>.
5
- - Outcome summarizes the result of your task in a single sentence.
6
- - The outcome line is for internal use by the agent.
7
- - Don't present outcome line in the main agent's response.
8
- `;
2
+ ## Subagent Result Contract
3
+
4
+ **MANDATORY**: Emit the task result upon completion.
5
+
6
+ ### Directives
9
7
 
10
- export function appendSubagentResultContract(prompt: string): string {
11
- return `${prompt}\n\n${SUBAGENT_RESULT_CONTRACT}`;
12
- }
8
+ - Write the complete task result as assistant text — mandatory, non-empty, and trimmed,
9
+ before calling \`complete\`.
10
+ - Call \`complete\` as the final action after the result text; \`outcome\` must be a short,
11
+ one-sentence progress summary only, not the full result.
12
+ - This contract applies after any amount of tool use, reading, editing, or multi-step work.
13
+
14
+ ### Constraints
15
+
16
+ - **NEVER** omit the result text response.
17
+ - **NEVER** emit whitespace-only, empty-string, or blank result text before \`complete\`.
18
+ - **NEVER** wrap the entire result in a code block or code fence.
19
+ - **NEVER** end the assistant response with text alone after tool calls; a terminal
20
+ \`complete\` call is required.
21
+ - **NEVER** write assistant text after \`complete\`.
22
+ - Call \`complete\` exactly once; multiple \`complete\` calls are not allowed.
23
+ - \`outcome\` must be concise and contain only a progress summary.
24
+ `;
@@ -0,0 +1,36 @@
1
+ import * as fs from "node:fs";
2
+ import type { AgentConfig } from "../agent/agents.js";
3
+ import { writePromptToTempFile } from "../shared/utils.js";
4
+
5
+ export type TempPrompt = { dir: string; filePath: string };
6
+
7
+ export type PromptSetupResult =
8
+ | { tmpPrompt: TempPrompt | null }
9
+ | { error: unknown };
10
+
11
+ export async function cleanupTempPrompt(tmpPrompt: TempPrompt): Promise<void> {
12
+ try {
13
+ await fs.promises.unlink(tmpPrompt.filePath);
14
+ await fs.promises.rmdir(tmpPrompt.dir);
15
+ } catch {
16
+ /* temp file cleanup failures are non-fatal; OS will clean up eventually */
17
+ }
18
+ }
19
+
20
+ export function beginPromptSetup(
21
+ agent: AgentConfig,
22
+ ): Promise<PromptSetupResult> {
23
+ if (!agent.systemPrompt.trim()) return Promise.resolve({ tmpPrompt: null });
24
+ return writePromptToTempFile(agent.name, agent.systemPrompt).then(
25
+ (tmpPrompt) => ({ tmpPrompt }),
26
+ (error: unknown) => ({ error }),
27
+ );
28
+ }
29
+
30
+ export async function cleanupPromptSetupResult(
31
+ setup: PromptSetupResult,
32
+ ): Promise<void> {
33
+ if ("tmpPrompt" in setup && setup.tmpPrompt) {
34
+ await cleanupTempPrompt(setup.tmpPrompt);
35
+ }
36
+ }
@@ -0,0 +1,142 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import type { AgentConfig } from "../agent/agents.js";
3
+ import {
4
+ type SingleResult,
5
+ TOOL_RESULT_FAILED_MESSAGE,
6
+ } from "../shared/types.js";
7
+ import {
8
+ extractFinalOutputFromMessages,
9
+ truncateOutput,
10
+ } from "../shared/utils.js";
11
+ import { resolveContextWindowTokens } from "./process-utils.js";
12
+
13
+ export type RuntimeResult = SingleResult & { messages: Message[] };
14
+
15
+ const EMPTY_USAGE = {
16
+ input: 0,
17
+ output: 0,
18
+ cacheRead: 0,
19
+ cacheWrite: 0,
20
+ cost: 0,
21
+ contextTokens: 0,
22
+ turns: 0,
23
+ };
24
+
25
+ export function initRuntimeResult(
26
+ agentName: string,
27
+ source: "user" | "project" | "unknown",
28
+ task: string,
29
+ modelDisplay: string | undefined,
30
+ ): RuntimeResult {
31
+ return {
32
+ agent: agentName,
33
+ agentSource: source,
34
+ task,
35
+ exitCode: 0,
36
+ finalOutput: "",
37
+ messages: [],
38
+ stderr: "",
39
+ usage: { ...EMPTY_USAGE },
40
+ model: modelDisplay,
41
+ };
42
+ }
43
+
44
+ function accumulateUsage(result: RuntimeResult, msg: Message): void {
45
+ if (msg.role !== "assistant") return;
46
+ result.usage.turns++;
47
+ const { usage } = msg;
48
+ if (!usage) return;
49
+ result.usage.input += usage.input || 0;
50
+ result.usage.output += usage.output || 0;
51
+ result.usage.cacheRead += usage.cacheRead || 0;
52
+ result.usage.cacheWrite += usage.cacheWrite || 0;
53
+ result.usage.cost += usage.cost?.total || 0;
54
+ result.usage.contextTokens = usage.totalTokens || 0;
55
+ const ctxWindowTokens = resolveContextWindowTokens(msg);
56
+ if (ctxWindowTokens !== undefined)
57
+ result.usage.contextWindowTokens = ctxWindowTokens;
58
+ }
59
+
60
+ export function addMessageToResult(result: RuntimeResult, msg: Message): void {
61
+ result.messages.push(msg);
62
+ result.finalOutput = truncateOutput(
63
+ extractFinalOutputFromMessages(result.messages),
64
+ );
65
+ if (msg.role === "toolResult" && msg.isError) {
66
+ result.errorMessage ||= TOOL_RESULT_FAILED_MESSAGE;
67
+ } else if (result.errorMessage === TOOL_RESULT_FAILED_MESSAGE) {
68
+ delete result.errorMessage;
69
+ }
70
+ if (msg.role === "assistant") {
71
+ accumulateUsage(result, msg);
72
+ if (!result.model && msg.model) result.model = msg.model;
73
+ if (msg.stopReason) result.stopReason = msg.stopReason;
74
+ if (msg.errorMessage) result.errorMessage = msg.errorMessage;
75
+ }
76
+ }
77
+
78
+ export function rebuildResultFromMessages(
79
+ result: RuntimeResult,
80
+ messages: Message[],
81
+ ): void {
82
+ const { model } = result;
83
+ result.messages = [];
84
+ result.finalOutput = "";
85
+ result.usage = { ...EMPTY_USAGE };
86
+ result.model = model;
87
+ delete result.errorMessage;
88
+ delete result.stopReason;
89
+ for (const msg of messages) {
90
+ addMessageToResult(result, msg);
91
+ }
92
+ }
93
+
94
+ export function createErrorResult(
95
+ agent: string,
96
+ source: "user" | "project" | "unknown",
97
+ task: string,
98
+ error: string,
99
+ model?: string,
100
+ ): SingleResult {
101
+ return {
102
+ agent,
103
+ agentSource: source,
104
+ task,
105
+ exitCode: 1,
106
+ finalOutput: "",
107
+ stderr: error,
108
+ usage: { ...EMPTY_USAGE },
109
+ model,
110
+ };
111
+ }
112
+
113
+ export function errorForUnknownAgent(
114
+ agentName: string,
115
+ agents: AgentConfig[],
116
+ task: string,
117
+ ): SingleResult {
118
+ const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
119
+ return createErrorResult(
120
+ agentName,
121
+ "unknown",
122
+ task,
123
+ `Unknown agent: "${agentName}". Available agents: ${available}.`,
124
+ );
125
+ }
126
+
127
+ export function errorForDepthLimit(
128
+ agentName: string,
129
+ source: "user" | "project" | "unknown",
130
+ task: string,
131
+ depth: number,
132
+ maxDepth: number,
133
+ model?: string,
134
+ ): SingleResult {
135
+ return createErrorResult(
136
+ agentName,
137
+ source,
138
+ task,
139
+ `Subagent nesting limit reached (depth ${depth}/${maxDepth}).`,
140
+ model,
141
+ );
142
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ parseSamplingParams,
4
+ type SamplingParams,
5
+ } from "../shared/sampling.js";
6
+
7
+ function applySamplingParams(
8
+ target: Record<string, unknown>,
9
+ params: SamplingParams,
10
+ topPKey: "topP" | "top_p",
11
+ ): Record<string, unknown> {
12
+ const result: Record<string, unknown> = { ...target };
13
+ if (params.temperature !== undefined)
14
+ result["temperature"] = params.temperature;
15
+ if (params.topP !== undefined) result[topPKey] = params.topP;
16
+ return result;
17
+ }
18
+
19
+ export function patchPayload(
20
+ payload: Record<string, unknown>,
21
+ params: SamplingParams,
22
+ ): Record<string, unknown> {
23
+ if ("generationConfig" in payload) {
24
+ const origConfig =
25
+ payload["generationConfig"] &&
26
+ typeof payload["generationConfig"] === "object"
27
+ ? (payload["generationConfig"] as Record<string, unknown>)
28
+ : {};
29
+ return {
30
+ ...payload,
31
+ generationConfig: applySamplingParams(origConfig, params, "topP"),
32
+ };
33
+ }
34
+ return applySamplingParams(payload, params, "top_p");
35
+ }
36
+
37
+ export default function (pi: ExtensionAPI) {
38
+ const params = parseSamplingParams(process.env["PI_SAMPLING_PARAMS"]);
39
+ if (!params) return;
40
+ pi.on("before_provider_request", (event) => {
41
+ if (
42
+ !event.payload ||
43
+ typeof event.payload !== "object" ||
44
+ Array.isArray(event.payload)
45
+ )
46
+ return;
47
+ return patchPayload(event.payload as Record<string, unknown>, params);
48
+ });
49
+ }
@@ -0,0 +1,138 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import { makeToolPreview, renderToolActivity } from "../progress/progress.js";
3
+ import { SENSITIVE_PATTERN } from "../progress/progress-format.js";
4
+ import { isToolCallPart } from "../progress/progress-state.js";
5
+ import type {
6
+ OnUpdateCallback,
7
+ StreamingProgress,
8
+ SubagentDetails,
9
+ ToolActivity,
10
+ } from "../shared/types.js";
11
+ import { findLastAssistantTextMessage } from "../shared/utils.js";
12
+ import type { RuntimeResult } from "./result-builder.js";
13
+
14
+ function findRecentMessagesAnchor(messages: Message[]): number {
15
+ return findLastAssistantTextMessage(messages);
16
+ }
17
+
18
+ function sanitizeProgressPreview(preview: string, toolName: string): string {
19
+ return SENSITIVE_PATTERN.test(preview) ? toolName : preview;
20
+ }
21
+
22
+ function deriveStreamingProgress(messages: Message[]): StreamingProgress {
23
+ const toolCalls: { id: string; preview: string }[] = [];
24
+ let lastToolPreview: string | undefined;
25
+ let activeToolActivity: ToolActivity | undefined;
26
+ for (const msg of messages) {
27
+ if (msg.role !== "assistant" || !Array.isArray(msg.content)) continue;
28
+ for (const part of msg.content) {
29
+ if (!isToolCallPart(part)) continue;
30
+ const preview = sanitizeProgressPreview(
31
+ makeToolPreview(part.name, part.arguments),
32
+ part.name,
33
+ );
34
+ toolCalls.push({ id: part.id, preview });
35
+ lastToolPreview = preview;
36
+ activeToolActivity = { toolName: part.name, inputSummary: preview };
37
+ }
38
+ }
39
+ const activityText = renderToolActivity(activeToolActivity);
40
+ return {
41
+ activeToolActivity,
42
+ activityText,
43
+ toolCalls,
44
+ lastToolPreview,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Merge incoming tool activity with existing progress activity.
50
+ * When tool names match, prefer richer inputSummary from incoming.
51
+ * Otherwise, replace entirely with incoming activity.
52
+ */
53
+ function mergeToolActivity(
54
+ existing: ToolActivity | undefined,
55
+ incoming: ToolActivity,
56
+ ): ToolActivity {
57
+ if (existing && existing.toolName === incoming.toolName) {
58
+ const incomingSummary = incoming.inputSummary;
59
+ const preferIncoming =
60
+ incomingSummary && incomingSummary !== incoming.toolName;
61
+ return {
62
+ ...existing,
63
+ inputSummary: preferIncoming ? incomingSummary : existing.inputSummary,
64
+ instanceName: incoming.instanceName ?? existing.instanceName,
65
+ child: incoming.child ?? existing.child,
66
+ };
67
+ }
68
+ return incoming;
69
+ }
70
+
71
+ /**
72
+ * Apply tool activity and result completion updates to progress state.
73
+ * Handles merging of child events with parent activity tree.
74
+ */
75
+ function applyActivityUpdates(
76
+ progress: StreamingProgress,
77
+ options: { toolActivity?: ToolActivity; toolResultCompleted?: boolean },
78
+ previousActivity?: ToolActivity,
79
+ ): void {
80
+ if (options.toolResultCompleted && previousActivity) {
81
+ progress.activeToolActivity = previousActivity;
82
+ const renderedText = renderToolActivity(previousActivity);
83
+ if (renderedText !== undefined) progress.activityText = renderedText;
84
+ else delete progress.activityText;
85
+ }
86
+ if (options.toolActivity) {
87
+ progress.activeToolActivity = mergeToolActivity(
88
+ progress.activeToolActivity,
89
+ options.toolActivity,
90
+ );
91
+ const renderedActivity = renderToolActivity(progress.activeToolActivity);
92
+ if (renderedActivity !== undefined)
93
+ progress.activityText = renderedActivity;
94
+ else delete progress.activityText;
95
+ }
96
+ if (options.toolResultCompleted) {
97
+ progress.toolResultCompleted = true;
98
+ }
99
+ }
100
+
101
+ export type EmitUpdateFn = (options?: {
102
+ toolActivity?: ToolActivity;
103
+ toolResultCompleted?: boolean;
104
+ }) => void;
105
+
106
+ export function makeEmitUpdate(
107
+ result: RuntimeResult,
108
+ onUpdate: OnUpdateCallback | undefined,
109
+ makeDetails: (
110
+ results: RuntimeResult[],
111
+ options?: { includeMessages?: boolean; recentMessages?: Message[] },
112
+ ) => SubagentDetails,
113
+ ): EmitUpdateFn {
114
+ return (options) => {
115
+ const msgs = result.messages;
116
+ const anchorIdx = findRecentMessagesAnchor(msgs);
117
+ const recentMessages =
118
+ anchorIdx >= 0 ? msgs.slice(anchorIdx) : msgs.slice(-5);
119
+ const progress = deriveStreamingProgress(msgs);
120
+ if (options) {
121
+ applyActivityUpdates(
122
+ progress,
123
+ options,
124
+ result.progress?.activeToolActivity,
125
+ );
126
+ }
127
+ result.progress = progress;
128
+ onUpdate?.({
129
+ content: [
130
+ { type: "text", text: progress.activityText ?? "(running...)" },
131
+ ],
132
+ details: makeDetails([result], {
133
+ includeMessages: true,
134
+ recentMessages,
135
+ }),
136
+ });
137
+ };
138
+ }
@@ -11,14 +11,17 @@ import type {
11
11
  AgentScope,
12
12
  ThinkingLevel,
13
13
  } from "../agent/agents.js";
14
- import { runSingleAgent, SubagentAbortError } from "../child/process.js";
14
+ import { runSingleAgent } from "../child/process.js";
15
15
  import { deliverNotification } from "../notification/delivery.js";
16
16
  import {
17
17
  buildNotificationRequest,
18
18
  isDesktopNotificationsEnabled,
19
19
  isPerJobNotificationEnabled,
20
20
  } from "../notification/desktop-notification.js";
21
- import { formatSubagentResultForParent } from "../output/summary.js";
21
+ import {
22
+ formatSubagentFailureForParent,
23
+ formatSubagentResultForParent,
24
+ } from "../output/summary.js";
22
25
  import {
23
26
  cancelProgressState,
24
27
  createProgressState,
@@ -31,7 +34,6 @@ import {
31
34
  type DetailsOptions,
32
35
  getFeedbackSummaryText,
33
36
  getLatestResult,
34
- getResultDisplayText,
35
37
  patchProgressFromDetails,
36
38
  sanitizeDetailsForDisplay,
37
39
  sanitizeResultDetails,
@@ -198,9 +200,7 @@ function finishLifecycleFailure(
198
200
  failProgressState(lc.requestId, errorMessage);
199
201
  lc.ctx.ui?.notify(errorMessage, "error");
200
202
  const latestResult = getLatestResult(details);
201
- const content = latestResult
202
- ? formatSubagentResultForParent(latestResult) || "(failed)"
203
- : errorMessage;
203
+ const content = formatSubagentFailureForParent(errorMessage, latestResult);
204
204
  sendSubagentResultMessage(lc.pi, content, displayDetails);
205
205
  return createCompletedToolResult(content, displayDetails);
206
206
  }
@@ -220,12 +220,12 @@ function finishLifecycleResult(
220
220
  const displayDetails = sanitizeDetailsForDisplay(details, lc.debug);
221
221
  const content = formatSubagentResultForParent(result) || "(no output)";
222
222
  const toolResult = createCompletedToolResult(content, displayDetails);
223
- finalizeProgressState(lc.requestId, getFeedbackSummaryText(toolResult));
224
- sendSubagentResultMessage(
225
- lc.pi,
226
- getResultDisplayText(toolResult),
227
- displayDetails,
223
+ finalizeProgressState(
224
+ lc.requestId,
225
+ getFeedbackSummaryText(toolResult),
226
+ result.outcome,
228
227
  );
228
+ sendSubagentResultMessage(lc.pi, content, displayDetails);
229
229
  return toolResult;
230
230
  }
231
231
 
@@ -277,7 +277,7 @@ async function runSubagentLifecycle(
277
277
  };
278
278
  const timerTick = setInterval(requestProgressRender, 500);
279
279
  try {
280
- const result = await runSingleAgent(
280
+ const outcome = await runSingleAgent(
281
281
  lc.ctx.cwd,
282
282
  lc.agents,
283
283
  lc.agentName,
@@ -289,14 +289,18 @@ async function runSubagentLifecycle(
289
289
  lc.parentThinking,
290
290
  lc.debug,
291
291
  );
292
- return finishLifecycleResult(lc, result);
292
+ if (outcome.kind === "aborted") {
293
+ const details = lc.makeDetails([outcome.result]);
294
+ const cancelReason =
295
+ outcome.result.termination?.cancelReason ?? "Aborted";
296
+ return finishLifecycleFailure(lc, cancelReason, details);
297
+ }
298
+ return finishLifecycleResult(lc, outcome.result);
293
299
  } catch (error) {
294
- const abortResult =
295
- error instanceof SubagentAbortError ? error.result : undefined;
296
300
  return finishLifecycleFailure(
297
301
  lc,
298
302
  error instanceof Error ? error.message : String(error),
299
- abortResult ? lc.makeDetails([abortResult]) : lc.makeDetails([]),
303
+ lc.makeDetails([]),
300
304
  );
301
305
  } finally {
302
306
  clearInterval(timerTick);
@@ -58,7 +58,7 @@ export function normalizeTerminalSentence(
58
58
  "",
59
59
  );
60
60
  const withoutLabel = withoutStatusPrefix.replace(
61
- /^\s*(?:status|summary|result|output|message|error|check|outcome|project summary):\s+/i,
61
+ /^\s*(?:status|summary|result|output|message|error|check|project summary):\s+/i,
62
62
  "",
63
63
  );
64
64
  const normalizedOnce = normalizeSummaryValue(withoutLabel);
@@ -17,7 +17,7 @@ const FEEDBACK_UI_GENERIC_CANDIDATES = new Set([
17
17
  ]);
18
18
 
19
19
  const FEEDBACK_UI_LABEL_PATTERN =
20
- /^\s*(outcome|project summary|result|summary|status|output|message|error|check):\s*/i;
20
+ /^\s*(project summary|result|summary|status|output|message|error|check):\s*/i;
21
21
 
22
22
  export function formatSubagentResultForParent(result: SingleResult): string {
23
23
  return result.thinkingWarning
@@ -25,15 +25,31 @@ export function formatSubagentResultForParent(result: SingleResult): string {
25
25
  : result.finalOutput;
26
26
  }
27
27
 
28
- export function summarizeFeedbackUiFinalOutput(finalOutput: string): string {
28
+ export function formatSubagentFailureForParent(
29
+ errorMessage: string,
30
+ result?: SingleResult,
31
+ ): string {
32
+ if (!result) return errorMessage;
33
+ const formatted = formatSubagentResultForParent(result);
34
+ if (!formatted.trim()) return `(failed) ${errorMessage}`;
35
+ return `(failed) ${errorMessage}\n\n${formatted}`;
36
+ }
37
+
38
+ export function summarizeFeedbackUiFinalOutput(
39
+ finalOutput: string,
40
+ outcome?: string,
41
+ ): string {
42
+ if (outcome?.trim()) {
43
+ return normalizeTerminalSentence(
44
+ outcome,
45
+ FEEDBACK_UI_SUMMARY_MAX_CHARS,
46
+ ).toLowerCase();
47
+ }
29
48
  const candidates = finalOutput
30
49
  .split(/\r?\n|(?<=[.!?])\s+/)
31
50
  .map((candidate) => normalizeFeedbackUiSummaryCandidate(candidate))
32
51
  .filter(({ text }) => hasSummaryValue(text));
33
- const selected =
34
- candidates.find(({ label }) => label === "outcome") ??
35
- candidates.find(({ label }) => label) ??
36
- candidates[0];
52
+ const selected = candidates.find(({ label }) => label) ?? candidates[0];
37
53
  return (selected?.text ?? "completed task").toLowerCase();
38
54
  }
39
55
 
package/src/output/ui.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Message } from "@earendil-works/pi-ai";
2
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
2
  import {
4
3
  Box,
@@ -130,19 +129,7 @@ export function formatToolCall(
130
129
  return themeFg("accent", toolName) + themeFg("dim", ` ${target}`);
131
130
  }
132
131
 
133
- export function getFinalOutput(messages: Message[]): string {
134
- const lastAsst = messages.findLast((m) => m.role === "assistant");
135
- const lastText = lastAsst?.content.findLast((p) => p.type === "text");
136
- return lastText?.type === "text" ? lastText.text : "";
137
- }
138
-
139
- /**
140
- * Removes the "Outcome:" line from subagent output to avoid redundancy in result cards.
141
- */
142
- function stripOutcomeLineForResultUi(output: string): string {
143
- const stripped = output.replace(/^\s*Outcome:[^\r\n]*(?:\r?\n|$)/gim, "");
144
- return stripped.trim() ? stripped : output;
145
- }
132
+ export { extractFinalOutputFromMessages as getFinalOutput } from "../shared/utils.js";
146
133
 
147
134
  function makeMarkdownTheme(theme: SubagentTheme): MarkdownTheme {
148
135
  const fg = (c: ThemeColor) => (text: string) => theme.fg(c, text);
@@ -191,7 +178,7 @@ export function renderSubagentToolResult(
191
178
  return renderSubagentResult(result, theme, display);
192
179
  }
193
180
 
194
- // Invariants: Red background = failure, green = success. Trims redundant "Outcome:" lines. Shows usage stats + duration in footer.
181
+ // Invariants: Red background = failure, green = success. Shows usage stats + duration in footer.
195
182
  export function renderSubagentResult(
196
183
  result: { content: { type: string; text?: string }[]; details?: unknown },
197
184
  theme: SubagentTheme,
@@ -215,7 +202,7 @@ export function renderSubagentResult(
215
202
  : failed
216
203
  ? "error"
217
204
  : "success";
218
- const finalOutput = r.finalOutput ?? getFinalOutput(r.messages ?? []);
205
+ const finalOutput = r.finalOutput ?? "";
219
206
  const title = formatSubagentTitle(r.agent, r.instanceName, theme);
220
207
  let effectiveBody = bodyOverride ?? finalOutput;
221
208
  if (display?.isPartial && !finalOutput?.trim() && !bodyOverride) {
@@ -225,14 +212,17 @@ export function renderSubagentResult(
225
212
  r.progress?.lastToolPreview ||
226
213
  "(running...)";
227
214
  }
228
- const bodyText = stripOutcomeLineForResultUi(effectiveBody);
215
+ const bodyText = effectiveBody.trim();
229
216
  const toolCount = r.progress?.toolCalls?.length ?? 0;
230
- const toolLabel = `${toolCount} ${toolCount === 1 ? "tool" : "tools"}`;
231
217
  const ctxPercent = formatContextPercent({
232
218
  contextTokens: r.usage.contextTokens,
233
219
  contextWindowTokens: r.usage.contextWindowTokens,
234
220
  });
235
- const metadata = `${toolLabel} · ${ctxPercent} ctx · ${formatElapsed(r.durationMs ?? 0)}`;
221
+ const metadata = formatProgressMetadata(
222
+ toolCount,
223
+ ctxPercent,
224
+ formatElapsed(r.durationMs ?? 0),
225
+ );
236
226
  const usageStr = formatResultFooter(r.usage, r.model);
237
227
  return renderStatusCard(
238
228
  {
@@ -310,6 +300,15 @@ function selectRunsBoardBody(state: SubagentProgressState): string {
310
300
  );
311
301
  }
312
302
 
303
+ function formatProgressMetadata(
304
+ toolCount: number,
305
+ ctxPercent: string,
306
+ elapsed: string,
307
+ ): string {
308
+ const toolLabel = toolCount === 1 ? "tool" : "tools";
309
+ return `${toolCount} ${toolLabel} · ${ctxPercent} ctx · ${elapsed}`;
310
+ }
311
+
313
312
  function renderJobCard(
314
313
  state: SubagentProgressState,
315
314
  theme: SubagentTheme,
@@ -319,8 +318,7 @@ function renderJobCard(
319
318
  state.durationMs ?? Date.now() - state.startTime,
320
319
  );
321
320
  const ctxPercent = formatContextPercent(state);
322
- const toolLabel = state.toolCount === 1 ? "tool" : "tools";
323
- const metadata = `${state.toolCount} ${toolLabel} · ${ctxPercent} ctx · ${elapsed}`;
321
+ const metadata = formatProgressMetadata(state.toolCount, ctxPercent, elapsed);
324
322
  const bodyText = selectRunsBoardBody(state);
325
323
  const preview =
326
324
  bodyText.length > BODY_PREVIEW_MAX
@@ -7,6 +7,7 @@ import {
7
7
  normalizeSummaryValue,
8
8
  normalizeTerminalSentence,
9
9
  } from "../output/normalize.js";
10
+ import { summarizeFeedbackUiFinalOutput } from "../output/summary.js";
10
11
  import type {
11
12
  SingleResult,
12
13
  SubagentDetails,
@@ -133,10 +134,11 @@ function storeTerminalProgressState(
133
134
  export function finalizeProgressState(
134
135
  requestId: string,
135
136
  finalOutput: string,
137
+ outcome?: string,
136
138
  ): void {
137
139
  storeTerminalProgressState(requestId, {
138
140
  status: "success",
139
- finalOutput: makeProgressFinalOutput(finalOutput),
141
+ finalOutput: makeProgressFinalOutput(finalOutput, outcome),
140
142
  });
141
143
  }
142
144
 
@@ -167,17 +169,13 @@ export function makeTaskPreview(task: string): string {
167
169
  return flat || "(agent default)";
168
170
  }
169
171
 
170
- function makeProgressFinalOutput(finalOutput: string): string {
171
- const lines = finalOutput
172
- .split(/\r?\n/)
173
- .map((l) => l.trim())
174
- .filter(Boolean);
175
- const outcomeLine = lines.find((line) => /^Outcome:\s*/i.test(line));
176
- const selected = outcomeLine ?? lines[0] ?? "";
177
- const normalized = normalizeTerminalSentence(selected);
178
- if (!normalized) return "";
179
- if (isStatusOnlySuccess(normalized)) return "completed task";
180
- return normalized;
172
+ function makeProgressFinalOutput(
173
+ finalOutput: string,
174
+ outcome?: string,
175
+ ): string {
176
+ const summary = summarizeFeedbackUiFinalOutput(finalOutput, outcome);
177
+ if (isStatusOnlySuccess(summary)) return "completed task";
178
+ return summary;
181
179
  }
182
180
 
183
181
  function deriveFailureTerminalSentence(errorText: string): string {