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
68 assert.isNotNull(subagent.providerThreadId);68 assert.isNotNull(subagent.providerThreadId);
69 assert.isNotNull(subagent.nativeTaskRef);69 assert.isNotNull(subagent.nativeTaskRef);
70 assert.isNotNull(subagent.completedAt);70 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);
71 if (subagent.childThreadId === null) {79 if (subagent.childThreadId === null) {
72 throw new Error(`Subagent ${subagent.id} is missing its child thread`);80 throw new Error(`Subagent ${subagent.id} is missing its child thread`);
73 }81 }
apps/server/src/orchestration-v2/testkit/fixtures/subagent_v2/codex_output.ts+8−0
@@ -47,6 +47,14 @@ export function assertSubagentV2Output( · 46 unmodified lines
47 assert.isNotNull(subagent.providerThreadId);47 assert.isNotNull(subagent.providerThreadId);
48 assert.isNotNull(subagent.nativeTaskRef);48 assert.isNotNull(subagent.nativeTaskRef);
49 assert.isNotNull(subagent.completedAt);49 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);
50 if (subagent.childThreadId === null) {58 if (subagent.childThreadId === null) {
51 throw new Error(`Subagent ${subagent.id} is missing its child thread`);59 throw new Error(`Subagent ${subagent.id} is missing its child thread`);
52 }60 }
apps/web/src/components/chat/MessagesTimeline.test.tsx+264−0
@@ -921,9 +921,273 @@ describe("MessagesTimeline", () => { · 920 unmodified lines
921 expect(markup).toContain('aria-label="Open Package audit"');921 expect(markup).toContain('aria-label="Open Package audit"');
922 expect(markup).toContain("Reading src/index.ts");922 expect(markup).toContain("Reading src/index.ts");
923 expect(markup).not.toContain("Inspect the package");923 expect(markup).not.toContain("Inspect the package");
924 expect(markup).not.toContain('data-v2-subagent-result-disclosure="true"');
924 expect(markup).not.toContain("Work Log");925 expect(markup).not.toContain("Work Log");
925 });926 });
926 927
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
927 it("renders V2 provider failures as standalone error rows", async () => {1191 it("renders V2 provider failures as standalone error rows", async () => {
928 const { MessagesTimeline } = await import("./MessagesTimeline");1192 const { MessagesTimeline } = await import("./MessagesTimeline");
929 const markup = renderToStaticMarkup(1193 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
2import type { TimestampFormat } from "@t3tools/contracts/settings";2import type { TimestampFormat } from "@t3tools/contracts/settings";
3import {3import {
4 BotIcon,4 BotIcon,
5 ChevronDownIcon,
5 ExternalLinkIcon,6 ExternalLinkIcon,
6 GitForkIcon,7 GitForkIcon,
7 MessageSquareIcon,8 MessageSquareIcon,
@@ -28,6 +29,22 @@ export function isV2LifecycleItem(item: OrchestrationV2TurnItem): boolean { · 20 unmodified lines
28 return LIFECYCLE_TYPES.has(item.type);29 return LIFECYCLE_TYPES.has(item.type);
29}30}
30 31
32// Aborted subagents (cancelled/interrupted) keep whatever result text had
33// streamed before the abort, so only completed/failed results are final.
34const 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.
41const TERMINAL_SUBAGENT_STATUSES = new Set<OrchestrationV2TurnItem["status"]>([
42 "completed",
43 "failed",
44 "cancelled",
45 "interrupted",
46]);
47
31export function V2LifecycleRow(props: {48export function V2LifecycleRow(props: {
32 readonly item: OrchestrationV2TurnItem;49 readonly item: OrchestrationV2TurnItem;
33 readonly createdAt: string;50 readonly createdAt: string;
@@ -115,14 +132,20 @@ export function V2LifecycleRow(props: { · 81 unmodified lines
115 );132 );
116 }133 }
117 if (item.type === "subagent") {134 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);
118 return (140 return (
119 <RelatedThreadCard141 <RelatedThreadCard
120 itemType={item.type}142 itemType={item.type}
121 icon={BotIcon}143 icon={BotIcon}
122 title={subagentDisplayTitle(item.title ?? "Subagent")}144 title={subagentDisplayTitle(item.title ?? "Subagent")}
123 detail={item.result ?? item.progress ?? item.prompt}145 detail={detail}
124 badge={item.status}146 badge={item.status}
125 threadId={item.childThreadId}147 threadId={item.childThreadId}
148 expandedDetail={finalResult}
126 onOpenThread={props.onOpenThread}149 onOpenThread={props.onOpenThread}
127 />150 />
128 );151 );
@@ -135,12 +158,14 @@ function RelatedThreadCard(props: { · 6 unmodified lines
135 readonly icon: LucideIcon;158 readonly icon: LucideIcon;
136 readonly title: string;159 readonly title: string;
137 readonly detail: string;160 readonly detail: string;
161 readonly expandedDetail?: string | null;
138 readonly badge: string;162 readonly badge: string;
139 readonly threadId: ThreadId | null;163 readonly threadId: ThreadId | null;
140 readonly onOpenThread: (threadId: ThreadId) => void;164 readonly onOpenThread: (threadId: ThreadId) => void;
141}) {165}) {
142 const Icon = props.icon;166 const Icon = props.icon;
143 const threadId = props.threadId;167 const threadId = props.threadId;
168 const expandedDetail = props.expandedDetail ?? null;
144 const content = (169 const content = (
145 <>170 <>
146 <Icon className="size-3.5 shrink-0 text-muted-foreground" />171 <Icon className="size-3.5 shrink-0 text-muted-foreground" />
@@ -149,12 +174,46 @@ function RelatedThreadCard(props: { · 2 unmodified lines
149 <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">174 <span className="rounded-full border border-border/70 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
150 {props.badge}175 {props.badge}
151 </span>176 </span>
152 {threadId === null ? null : (
153 <ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
154 )}
155 </>177 </>
156 );178 );
157 179
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
158 return threadId === null ? (217 return threadId === null ? (
159 <div218 <div
160 data-v2-item-type={props.itemType}219 data-v2-item-type={props.itemType}
@@ -171,6 +230,7 @@ function RelatedThreadCard(props: { · 10 unmodified lines
171 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"230 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"
172 >231 >
173 {content}232 {content}
233 <ExternalLinkIcon className="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
174 </button>234 </button>
175 );235 );
176}236}

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