History

feat(subagents): disclose projected results consistently (#3866)

Normalize subagent result disclosure across timeline rendering and collapsed/expanded states.

Alex committed 3 months ago

Commit d9f84eb1 parent 1bc99ec4 files · +344 −4Browse files

Unified · Split

4 files
  1. apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts +8 −0
  2. apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts +8 −0
  3. apps/web/src/components/chat/MessagesTimeline.test.tsx +264 −0
  4. apps/web/src/components/chat/V2LifecycleRow.tsx +64 −4
apps/server/src/orchestration-v2/testkit/fixtures/subagent/codex_output.ts+8−0
@@ -68,6 +68,14 @@ export function assertSubagentOutput( · 67 unmodified lines
6868 assert.isNotNull(subagent.providerThreadId);
6969 assert.isNotNull(subagent.nativeTaskRef);
7070 assert.isNotNull(subagent.completedAt);
71+ const parentItem = projection.turnItems.find(
72+ (item) => item.type === "subagent" && item.subagentId === subagent.id,
73+ );
74+ assert.isDefined(parentItem);
75+ if (parentItem?.type !== "subagent") {
76+ throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`);
77+ }
78+ assert.equal(parentItem.result, subagent.result);
7179 if (subagent.childThreadId === null) {
7280 throw new Error(`Subagent ${subagent.id} is missing its child thread`);
7381 }
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts+8−0
@@ -47,6 +47,14 @@ export function assertSubagentV2Output( · 46 unmodified lines
4747 assert.isNotNull(subagent.providerThreadId);
4848 assert.isNotNull(subagent.nativeTaskRef);
4949 assert.isNotNull(subagent.completedAt);
50+ const parentItem = projection.turnItems.find(
51+ (item) => item.type === "subagent" && item.subagentId === subagent.id,
52+ );
53+ assert.isDefined(parentItem);
54+ if (parentItem?.type !== "subagent") {
55+ throw new Error(`Missing parent lifecycle item for subagent ${subagent.id}`);
56+ }
57+ assert.equal(parentItem.result, subagent.result);
5058 if (subagent.childThreadId === null) {
5159 throw new Error(`Subagent ${subagent.id} is missing its child thread`);
5260 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+264−0
@@ -921,9 +921,273 @@ describe("MessagesTimeline", () => { · 920 unmodified lines
921921 expect(markup).toContain('aria-label="Open Package audit"');
922922 expect(markup).toContain("Reading src/index.ts");
923923 expect(markup).not.toContain("Inspect the package");
924+ expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
924925 expect(markup).not.toContain("Work Log");
925926 });
926927
928+ it("discloses the full Codex subagent result without projecting child events", async () => {
929+ const { MessagesTimeline } = await import("./MessagesTimeline");
930+ const markup = renderToStaticMarkup(
931+ <MessagesTimeline
932+ {...buildProps()}
933+ timelineEntries={[
934+ {
935+ id: "codex-subagent-result",
936+ kind: "event",
937+ createdAt: MESSAGE_CREATED_AT,
938+ projectedItem: {
939+ position: 0,
940+ visibility: "local",
941+ sourceThreadId: "thread-1",
942+ sourceItemId: "codex-subagent-result",
943+ item: {
944+ id: "codex-subagent-result",
945+ threadId: "thread-1",
946+ runId: "run-1",
947+ nodeId: "node-subagent-1",
948+ providerThreadId: "provider-thread-1",
949+ providerTurnId: "provider-turn-1",
950+ nativeItemRef: null,
951+ parentItemId: null,
952+ ordinal: 1,
953+ status: "completed",
954+ title: "Isolation report",
955+ startedAt: null,
956+ completedAt: null,
957+ updatedAt: {},
958+ type: "subagent",
959+ subagentId: "node-subagent-1",
960+ origin: "provider_native",
961+ driver: "codex",
962+ providerInstanceId: "codex",
963+ childThreadId: "thread-subagent-1",
964+ prompt: "Explain test isolation",
965+ result: "Tests should be isolated.\n\nResult: no shared state.",
966+ },
967+ } as never,
968+ },
969+ ]}
970+ />,
971+ );
972+
973+ expect(markup).toContain('data-v2-item-type="subagent"');
974+ expect(markup).toContain('data-v2-subagent-result-disclosure="true"');
975+ expect(markup).toContain('data-v2-subagent-result="true"');
976+ expect(markup).toContain('aria-label="Show full result for Isolation report"');
977+ expect(markup).toContain('aria-label="Open Isolation report"');
978+ expect(markup).toContain("Tests should be isolated.");
979+ expect(markup).toContain("Result: no shared state.");
980+ expect(markup).not.toContain("Explain test isolation");
981+ });
982+
983+ it("keeps live progress when a running subagent streams a partial result", async () => {
984+ const { MessagesTimeline } = await import("./MessagesTimeline");
985+ const markup = renderToStaticMarkup(
986+ <MessagesTimeline
987+ {...buildProps()}
988+ timelineEntries={[
989+ {
990+ id: "subagent-partial-result",
991+ kind: "event",
992+ createdAt: MESSAGE_CREATED_AT,
993+ projectedItem: {
994+ position: 0,
995+ visibility: "local",
996+ sourceThreadId: "thread-1",
997+ sourceItemId: "subagent-partial-result",
998+ item: {
999+ id: "subagent-partial-result",
1000+ threadId: "thread-1",
1001+ runId: "run-1",
1002+ nodeId: "node-subagent-1",
1003+ providerThreadId: "provider-thread-1",
1004+ providerTurnId: "provider-turn-1",
1005+ nativeItemRef: null,
1006+ parentItemId: null,
1007+ ordinal: 1,
1008+ status: "running",
1009+ title: "Package audit",
1010+ startedAt: null,
1011+ completedAt: null,
1012+ updatedAt: {},
1013+ type: "subagent",
1014+ subagentId: "node-subagent-1",
1015+ origin: "provider_native",
1016+ driver: "codex",
1017+ providerInstanceId: "codex",
1018+ childThreadId: "thread-subagent-1",
1019+ prompt: "Inspect the package",
1020+ progress: "Reading src/index.ts",
1021+ result: "Partial streamed answer so far",
1022+ },
1023+ } as never,
1024+ },
1025+ ]}
1026+ />,
1027+ );
1028+
1029+ expect(markup).toContain('data-v2-item-type="subagent"');
1030+ expect(markup).toContain('aria-label="Open Package audit"');
1031+ expect(markup).toContain("Reading src/index.ts");
1032+ expect(markup).not.toContain("Partial streamed answer so far");
1033+ expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
1034+ });
1035+
1036+ it("shows the streamed result while a subagent runs without progress", async () => {
1037+ const { MessagesTimeline } = await import("./MessagesTimeline");
1038+ const markup = renderToStaticMarkup(
1039+ <MessagesTimeline
1040+ {...buildProps()}
1041+ timelineEntries={[
1042+ {
1043+ id: "subagent-streamed-result",
1044+ kind: "event",
1045+ createdAt: MESSAGE_CREATED_AT,
1046+ projectedItem: {
1047+ position: 0,
1048+ visibility: "local",
1049+ sourceThreadId: "thread-1",
1050+ sourceItemId: "subagent-streamed-result",
1051+ item: {
1052+ id: "subagent-streamed-result",
1053+ threadId: "thread-1",
1054+ runId: "run-1",
1055+ nodeId: "node-subagent-1",
1056+ providerThreadId: "provider-thread-1",
1057+ providerTurnId: "provider-turn-1",
1058+ nativeItemRef: null,
1059+ parentItemId: null,
1060+ ordinal: 1,
1061+ status: "running",
1062+ title: "Package audit",
1063+ startedAt: null,
1064+ completedAt: null,
1065+ updatedAt: {},
1066+ type: "subagent",
1067+ subagentId: "node-subagent-1",
1068+ origin: "provider_native",
1069+ driver: "codex",
1070+ providerInstanceId: "codex",
1071+ childThreadId: "thread-subagent-1",
1072+ prompt: "Inspect the package",
1073+ progress: null,
1074+ result: "Streaming answer so far",
1075+ },
1076+ } as never,
1077+ },
1078+ ]}
1079+ />,
1080+ );
1081+
1082+ expect(markup).toContain('data-v2-item-type="subagent"');
1083+ expect(markup).toContain("Streaming answer so far");
1084+ expect(markup).not.toContain("Inspect the package");
1085+ expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
1086+ });
1087+
1088+ it("treats a cancelled subagent result as partial output", async () => {
1089+ const { MessagesTimeline } = await import("./MessagesTimeline");
1090+ const markup = renderToStaticMarkup(
1091+ <MessagesTimeline
1092+ {...buildProps()}
1093+ timelineEntries={[
1094+ {
1095+ id: "subagent-cancelled-result",
1096+ kind: "event",
1097+ createdAt: MESSAGE_CREATED_AT,
1098+ projectedItem: {
1099+ position: 0,
1100+ visibility: "local",
1101+ sourceThreadId: "thread-1",
1102+ sourceItemId: "subagent-cancelled-result",
1103+ item: {
1104+ id: "subagent-cancelled-result",
1105+ threadId: "thread-1",
1106+ runId: "run-1",
1107+ nodeId: "node-subagent-1",
1108+ providerThreadId: "provider-thread-1",
1109+ providerTurnId: "provider-turn-1",
1110+ nativeItemRef: null,
1111+ parentItemId: null,
1112+ ordinal: 1,
1113+ status: "cancelled",
1114+ title: "Package audit",
1115+ startedAt: null,
1116+ completedAt: null,
1117+ updatedAt: {},
1118+ type: "subagent",
1119+ subagentId: "node-subagent-1",
1120+ origin: "provider_native",
1121+ driver: "codex",
1122+ providerInstanceId: "codex",
1123+ childThreadId: "thread-subagent-1",
1124+ prompt: "Inspect the package",
1125+ progress: "Reading src/index.ts",
1126+ result: "Partial output before cancel",
1127+ },
1128+ } as never,
1129+ },
1130+ ]}
1131+ />,
1132+ );
1133+
1134+ expect(markup).toContain('data-v2-item-type="subagent"');
1135+ expect(markup).toContain("Partial output before cancel");
1136+ expect(markup).not.toContain("Reading src/index.ts");
1137+ expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
1138+ });
1139+
1140+ it("falls back to progress when a completed subagent result is whitespace-only", async () => {
1141+ const { MessagesTimeline } = await import("./MessagesTimeline");
1142+ const markup = renderToStaticMarkup(
1143+ <MessagesTimeline
1144+ {...buildProps()}
1145+ timelineEntries={[
1146+ {
1147+ id: "subagent-blank-result",
1148+ kind: "event",
1149+ createdAt: MESSAGE_CREATED_AT,
1150+ projectedItem: {
1151+ position: 0,
1152+ visibility: "local",
1153+ sourceThreadId: "thread-1",
1154+ sourceItemId: "subagent-blank-result",
1155+ item: {
1156+ id: "subagent-blank-result",
1157+ threadId: "thread-1",
1158+ runId: "run-1",
1159+ nodeId: "node-subagent-1",
1160+ providerThreadId: "provider-thread-1",
1161+ providerTurnId: "provider-turn-1",
1162+ nativeItemRef: null,
1163+ parentItemId: null,
1164+ ordinal: 1,
1165+ status: "completed",
1166+ title: "Package audit",
1167+ startedAt: null,
1168+ completedAt: null,
1169+ updatedAt: {},
1170+ type: "subagent",
1171+ subagentId: "node-subagent-1",
1172+ origin: "provider_native",
1173+ driver: "codex",
1174+ providerInstanceId: "codex",
1175+ childThreadId: "thread-subagent-1",
1176+ prompt: "Inspect the package",
1177+ progress: "Audited 12 packages",
1178+ result: " \n\t ",
1179+ },
1180+ } as never,
1181+ },
1182+ ]}
1183+ />,
1184+ );
1185+
1186+ expect(markup).toContain('data-v2-item-type="subagent"');
1187+ expect(markup).toContain("Audited 12 packages");
1188+ expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
1189+ });
1190+
9271191 it("renders V2 provider failures as standalone error rows", async () => {
9281192 const { MessagesTimeline } = await import("./MessagesTimeline");
9291193 const markup = renderToStaticMarkup(
apps/web/src/components/chat/V2LifecycleRow.tsx+64−4
@@ -2,6 +2,7 @@ import type { OrchestrationV2TurnItem, ThreadId } from "@t3tools/contracts"; · 1 unmodified line
22 import type { TimestampFormat } from "@t3tools/contracts/settings";
33 import {
44 BotIcon,
5+ ChevronDownIcon,
56 ExternalLinkIcon,
67 GitForkIcon,
78 MessageSquareIcon,
@@ -28,6 +29,22 @@ export function isV2LifecycleItem(item: OrchestrationV2TurnItem): boolean { · 20 unmodified lines
2829 return LIFECYCLE_TYPES.has(item.type);
2930 }
3031
32+// Aborted subagents (cancelled/interrupted) keep whatever result text had
33+// streamed before the abort, so only completed/failed results are final.
34+const FINAL_RESULT_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([
35+ "completed",
36+ "failed",
37+]);
38+
39+// Once a subagent stops, its last streamed result says more than the stale
40+// progress line; while it runs, live progress comes first.
41+const TERMINAL_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([
42+ "completed",
43+ "failed",
44+ "cancelled",
45+ "interrupted",
46+]);
47+
3148 export function V2LifecycleRow(props: {
3249 readonly item: OrchestrationV2TurnItem;
3350 readonly createdAt: string;
@@ -115,14 +132,20 @@ export function V2LifecycleRow(props: { · 81 unmodified lines
115132 );
116133 }
117134 if (item.type === "subagent") {
135+ const streamedResult = item.result?.trim() ? item.result : null;
136+ const finalResult = FINAL_RESULT_SUBAGENT_STATUSES.has(item.status) ? streamedResult : null;
137+ const detail = TERMINAL_SUBAGENT_STATUSES.has(item.status)
138+ ? (streamedResult ?? item.progress ?? item.prompt)
139+ : (item.progress ?? streamedResult ?? item.prompt);
118140 return (
119141 <RelatedThreadCard
120142 itemType={item.type}
121143 icon={BotIcon}
122144 title={subagentDisplayTitle(item.title ?? "Subagent")}
123− detail={item.result ?? item.progress ?? item.prompt}
145+ detail={detail}
124146 badge={item.status}
125147 threadId={item.childThreadId}
148+ expandedDetail={finalResult}
126149 onOpenThread={props.onOpenThread}
127150 />
128151 );
@@ -135,12 +158,14 @@ function RelatedThreadCard(props: { · 6 unmodified lines
135158 readonly icon: LucideIcon;
136159 readonly title: string;
137160 readonly detail: string;
161+ readonly expandedDetail?: string | null;
138162 readonly badge: string;
139163 readonly threadId: ThreadId | null;
140164 readonly onOpenThread: (threadId: ThreadId) => void;
141165 }) {
142166 const Icon = props.icon;
143167 const threadId = props.threadId;
168+ const expandedDetail = props.expandedDetail ?? null;
144169 const content = (
145170 <>
146171 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
@@ -149,12 +174,46 @@ function RelatedThreadCard(props: { · 2 unmodified lines
149174 <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
150175 {props.badge}
151176 </span>
152− {threadId === null ? null : (
153− <ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
154− )}
155177 </>
156178 );
157179
180+ if (expandedDetail !== null) {
181+ return (
182+ <div
183+ data-v2-item-type={props.itemType}
184+ className="relative min-w-0 overflow-hidden rounded-lg border border-border/60 bg-card/30"
185+ >
186+ <details className="group" data-v2-subagent-result-disclosure="true">
187+ <summary
188+ aria-label={`Show full result for ${props.title}`}
189+ className="flex min-w-0 cursor-pointer list-none items-center gap-2 px-3 py-2 pr-11 text-left transition-colors hover:bg-muted/50 [&::-webkit-details-marker]:hidden"
190+ >
191+ {content}
192+ <ChevronDownIcon
193+ className="size-3 shrink-0 text-muted-foreground transition-transform group-open:rotate-180"
194+ aria-hidden="true"
195+ />
196+ </summary>
197+ <div className="border-border/60 border-t px-3 py-2" data-v2-subagent-result="true">
198+ <p className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-foreground">
199+ {expandedDetail}
200+ </p>
201+ </div>
202+ </details>
203+ {threadId === null ? null : (
204+ <button
205+ type="button"
206+ aria-label={`Open ${props.title}`}
207+ onClick={() => props.onOpenThread(threadId)}
208+ className="absolute top-1.5 right-1.5 flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
209+ >
210+ <ExternalLinkIcon className="size-3" aria-hidden="true" />
211+ </button>
212+ )}
213+ </div>
214+ );
215+ }
216+
158217 return threadId === null ? (
159218 <div
160219 data-v2-item-type={props.itemType}
@@ -171,6 +230,7 @@ function RelatedThreadCard(props: { · 10 unmodified lines
171230 className="flex w-full min-w-0 items-center gap-2 rounded-lg border border-border/60 bg-card/30 px-3 py-2 text-left transition-colors hover:bg-muted/50"
172231 >
173232 {content}
233+ <ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
174234 </button>
175235 );
176236 }

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t