1import type {
2 OrchestrationV2ConversationMessage,
3 OrchestrationV2Notification,
4 OrchestrationV2NotificationSource,
5 OrchestrationV2Subagent,
6 OrchestrationV2TurnItem,
7 ThreadId,
8} from "@t3tools/contracts";
9
10type NotificationOutcome = OrchestrationV2Notification["outcome"];
11
12/** A piece of background work an adapter can name, by kind. */
13export type BackgroundWork = {
14 /** Subagent title, command, or monitor description. */
15 readonly label?: string | undefined;
16} & (
17 | { readonly kind: "subagent"; readonly childThreadId?: ThreadId | undefined }
18 | { readonly kind: "command"; readonly exitCode?: number | undefined }
19 | { readonly kind: "monitor" }
20 | { readonly kind: "background_task" }
21);
22
23/** Background work an adapter saw finish or report, as the user should read it. */
24export type BackgroundWorkReport = BackgroundWork & { readonly outcome: NotificationOutcome };
25
26const LABEL_MAX_LENGTH = 80;
27
28export function reportLabel(label: string | undefined): string | undefined {
29 const firstLine = label?.trim().split("\n")[0]?.trim();
30 if (firstLine === undefined || firstLine.length === 0) return undefined;
31 return firstLine.length > LABEL_MAX_LENGTH
32 ? `${firstLine.slice(0, LABEL_MAX_LENGTH - 1)}…`
33 : firstLine;
34}
35
36const KIND_NOUN: Record<BackgroundWork["kind"], readonly [string, string]> = {
37 subagent: ["Subagent", "subagents"],
38 command: ["Command", "commands"],
39 monitor: ["Monitor", "monitors"],
40 background_task: ["Background task", "background tasks"],
41};
42
43function outcomeVerb(kind: BackgroundWork["kind"], outcome: NotificationOutcome) {
44 switch (outcome) {
45 case "failed":
46 return "failed";
47 case "cancelled":
48 return "was stopped";
49 case "updated":
50 return kind === "monitor" ? "reported new output" : "updated";
51 case "completed":
52 case "unknown":
53 return "finished";
54 }
55}
56
57function combinedOutcome(outcomes: ReadonlyArray<NotificationOutcome>): NotificationOutcome {
58 if (outcomes.includes("failed")) return "failed";
59 if (outcomes.includes("cancelled")) return "cancelled";
60 if (outcomes.length > 0 && outcomes.every((outcome) => outcome === "completed")) {
61 return "completed";
62 }
63 return outcomes.includes("updated") ? "updated" : "unknown";
64}
65
66function exitSuffix(report: BackgroundWorkReport): string {
67 return report.kind === "command" && report.exitCode !== undefined
68 ? ` (exit ${report.exitCode})`
69 : "";
70}
71
72function namedReport(report: BackgroundWorkReport, capitalize: boolean): string {
73 const noun = KIND_NOUN[report.kind][0];
74 const label = reportLabel(report.label);
75 const named = `${capitalize ? noun : noun.toLowerCase()}${label === undefined ? "" : ` "${label}"`}`;
76 return `${named}${exitSuffix(report)}`;
77}
78
79function joinNames(names: ReadonlyArray<string>): string {
80 if (names.length <= 1) return names.join("");
81 return `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
82}
83
84function reportsSummary(
85 reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>],
86 outcome: NotificationOutcome,
87): string {
88 const [first] = reports;
89 if (reports.length === 1) {
90 const label = reportLabel(first.label);
91 const noun = KIND_NOUN[first.kind][0];
92 return `${noun}${label === undefined ? "" : ` "${label}"`} ${outcomeVerb(first.kind, first.outcome)}${exitSuffix(first)}`;
93 }
94 const verb =
95 outcome === "failed"
96 ? "failed"
97 : outcome === "cancelled"
98 ? "were stopped"
99 : outcome === "updated"
100 ? "updated"
101 : "finished";
102 if (reports.length > 3 || reports.every((report) => reportLabel(report.label) === undefined)) {
103 const kinds = new Set(reports.map((report) => report.kind));
104 const noun = kinds.size === 1 ? KIND_NOUN[first.kind][1] : "background tasks";
105 return `${reports.length} ${noun} ${verb}`;
106 }
107 return `${joinNames(reports.map((report, index) => namedReport(report, index === 0)))} ${verb}`;
108}
109
110/** Reports of one kind share it; mixed kinds are generic background work. */
111function reportsSource(
112 reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>],
113): OrchestrationV2NotificationSource {
114 const [first, ...rest] = reports;
115 if (rest.some((report) => report.kind !== first.kind)) return { kind: "background_task" };
116 switch (first.kind) {
117 case "subagent":
118 // One subagent is the thing the row opens; several open nothing.
119 return rest.length === 0 && first.childThreadId !== undefined
120 ? { kind: "subagent", childThreadId: first.childThreadId }
121 : { kind: "subagent" };
122 case "command":
123 case "monitor":
124 case "background_task":
125 return { kind: first.kind };
126 }
127}
128
129/**
130 * A notification that says which background work a provider reported. Null
131 * when nothing is known, so the caller keeps its generic notification.
132 */
133export function backgroundWorkNotification(
134 reports: readonly [BackgroundWorkReport, ...ReadonlyArray<BackgroundWorkReport>],
135): OrchestrationV2Notification;
136export function backgroundWorkNotification(
137 reports: ReadonlyArray<BackgroundWorkReport>,
138): OrchestrationV2Notification | null;
139export function backgroundWorkNotification(
140 reports: ReadonlyArray<BackgroundWorkReport>,
141): OrchestrationV2Notification | null {
142 const [first, ...rest] = reports;
143 if (first === undefined) return null;
144 const outcome = combinedOutcome(reports.map((report) => report.outcome));
145 return {
146 source: reportsSource([first, ...rest]),
147 outcome,
148 summary: reportsSummary([first, ...rest], outcome),
149 };
150}
151
152function delegatedTaskReport(
153 task: OrchestrationV2Subagent | undefined,
154): Extract<BackgroundWorkReport, { readonly kind: "subagent" }> {
155 const outcome: NotificationOutcome =
156 task?.status === "failed"
157 ? "failed"
158 : task?.status === "cancelled" || task?.status === "interrupted"
159 ? "cancelled"
160 : task?.status === "completed"
161 ? "completed"
162 : "unknown";
163 return {
164 kind: "subagent",
165 // Delegated tasks are titled by their prompt unless the caller named them.
166 label: task?.title?.trim() || task?.prompt,
167 outcome,
168 ...(task?.childThreadId == null ? {} : { childThreadId: task.childThreadId }),
169 };
170}
171
172/** Summarizes the delegated tasks one completion delivery reports, out of all its parent run delegated. */
173function delegatedCompletionNotification(
174 completion: NonNullable<OrchestrationV2ConversationMessage["delegatedCompletion"]>,
175 tasks: ReadonlyArray<OrchestrationV2Subagent>,
176): OrchestrationV2Notification {
177 const taskIds = completion.taskIds;
178 const reports = taskIds.map((taskId) =>
179 delegatedTaskReport(tasks.find((task) => task.id === taskId)),
180 );
181 const delegatedByRun = tasks.filter(
182 (task) => task.origin === "app_owned" && task.runId === completion.parentRunId,
183 ).length;
184 const outcome = combinedOutcome(reports.map((report) => report.outcome));
185 const verb = outcome === "failed" ? "failed" : outcome === "cancelled" ? "stopped" : "finished";
186 const [only] = reports;
187 if (reports.length === 1 && only !== undefined) {
188 const label = reportLabel(only.label);
189 return {
190 source: {
191 kind: "delegated_task",
192 taskIds,
193 ...(only.childThreadId === undefined ? {} : { childThreadId: only.childThreadId }),
194 },
195 outcome,
196 summary: `Delegated task${label === undefined ? "" : ` "${label}"`} ${verb}`,
197 };
198 }
199 const labels = reports.flatMap((report) => reportLabel(report.label) ?? []);
200 const count =
201 delegatedByRun > taskIds.length
202 ? `${taskIds.length} of ${delegatedByRun}`
203 : `${taskIds.length}`;
204 return {
205 source: { kind: "delegated_task", taskIds },
206 outcome,
207 summary: `${count} delegated tasks ${verb}${labels.length === 0 ? "" : `: ${labels.join(", ")}`}`,
208 };
209}
210
211/** Keep the delivery message intact while projecting its trigger as an activity. */
212export function notificationTurnItem(
213 item: OrchestrationV2TurnItem,
214 message: Pick<OrchestrationV2ConversationMessage, "notification" | "delegatedCompletion">,
215 tasks: ReadonlyArray<OrchestrationV2Subagent>,
216): OrchestrationV2TurnItem {
217 if (item.type !== "user_message") return item;
218 const notification =
219 message.delegatedCompletion === undefined
220 ? message.notification
221 : delegatedCompletionNotification(message.delegatedCompletion, tasks);
222 if (notification === undefined) return item;
223 const {
224 type: _type,
225 messageId: _messageId,
226 inputIntent: _inputIntent,
227 text: _text,
228 attachments: _attachments,
229 createdBy: _createdBy,
230 creationSource: _creationSource,
231 scheduledTaskId: _scheduledTaskId,
232 ...base
233 } = item;
234 return { ...base, type: "notification", ...notification };
235}