authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-08-28 01:42:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 01:42:28-07:00
log49f6241ddeee2a5d20ec181b5b94ec0a9ac72f7b
tree9b285be1291e01359ec8e3ad68adaab0bdc9896e
parent7068e86f76e908c77ff04d1a684840257eab3a8a
signature Signed by PGP key B5690EEEBB952194

fix(codex): show sub-agent models (#8502)


10 files changed, 762 insertions(+), 37 deletions(-)

apps/server/src/orchestration/ThreadBackgroundLiveness.test.ts+31-1
......@@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test";
22import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts";
33
44describe("ThreadBackgroundLiveness", () => {
5 it("does not let status-free progress restart an idle task", () => {
5 it("does not let status-free progress or metadata restart an idle task", () => {
66 const liveness = ThreadBackgroundLiveness.make();
77 liveness.recordTaskLiveness({
88 threadId: "thread",
......@@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => {
2525 status: undefined,
2626 kind: "progress",
2727 });
28 liveness.recordTaskLiveness({
29 threadId: "thread",
30 taskId: "task",
31 taskType: undefined,
32 status: undefined,
33 kind: "updated",
34 });
35 expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
36
37 liveness.recordTaskLiveness({
38 threadId: "thread",
39 taskId: "completed-task",
40 taskType: undefined,
41 status: undefined,
42 kind: "started",
43 });
44 liveness.recordTaskLiveness({
45 threadId: "thread",
46 taskId: "completed-task",
47 taskType: undefined,
48 status: "completed",
49 kind: "completed",
50 });
51 liveness.recordTaskLiveness({
52 threadId: "thread",
53 taskId: "completed-task",
54 taskType: undefined,
55 status: undefined,
56 kind: "updated",
57 });
2858 expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull();
2959 });
3060
apps/server/src/orchestration/ThreadBackgroundLiveness.ts+3-4
......@@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] {
130130 return;
131131 }
132132
133 // Status-free progress is a description tick, not a restart. A delayed
134 // progress event after idle must not put the task back in the live set
135 // (#7128).
136 if (input.kind === "progress" && input.status === undefined) {
133 // Status-free progress and metadata updates are not restarts. A delayed
134 // row after idle must not put the task back in the live set (#7128).
135 if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) {
137136 const existing = stateByThreadId.get(input.threadId);
138137 const stillLive =
139138 existing !== undefined &&
apps/server/src/provider/Layers/CodexAdapter.test.ts+83
......@@ -557,6 +557,89 @@ function startLifecycleRuntime() {
557557}
558558
559559lifecycleLayer("CodexAdapterLive lifecycle", (it) => {
560 it.effect("carries child model metadata through every task event", () =>
561 Effect.gen(function* () {
562 const { adapter, runtime } = yield* startLifecycleRuntime();
563 const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 10)).pipe(
564 Effect.forkChild,
565 );
566
567 const cases = [
568 ["collabAgent/started", {}],
569 ["collabAgent/activity", { activityKind: "started" }],
570 ["collabAgent/turnStarted", {}],
571 ["collabAgent/turnCompleted", { turn: { status: "completed" } }],
572 ["collabAgent/statusChanged", { status: { type: "active", activeFlags: [] } }],
573 ["collabAgent/tokenUsage", { tokenUsage: { total: { totalTokens: 42 } } }],
574 ["collabAgent/item", { item: { type: "commandExecution", command: "pwd" } }],
575 ["collabAgent/closed", {}],
576 ["collabAgent/metadataUpdated", {}],
577 ] as const;
578
579 for (const [index, [method, extra]] of cases.entries()) {
580 yield* runtime.emit({
581 id: asEventId(`evt-child-model-${index}`),
582 kind: "notification",
583 provider: ProviderDriverKind.make("codex"),
584 createdAt: "2026-01-01T00:00:00.000Z",
585 method,
586 threadId: asThreadId("thread-1"),
587 turnId: asTurnId("turn-1"),
588 payload: {
589 agentThreadId: "child-model",
590 agentPath: "/root/model-check",
591 model: " gpt-5.6-sol ",
592 effort: " high ",
593 ...extra,
594 },
595 });
596 }
597 yield* runtime.emit({
598 id: asEventId("evt-child-model-blank"),
599 kind: "notification",
600 provider: ProviderDriverKind.make("codex"),
601 createdAt: "2026-01-01T00:00:00.000Z",
602 method: "collabAgent/metadataUpdated",
603 threadId: asThreadId("thread-1"),
604 turnId: asTurnId("turn-1"),
605 payload: {
606 agentThreadId: "child-model",
607 model: " ",
608 effort: "",
609 },
610 });
611
612 const events = Array.from(yield* Fiber.join(eventsFiber));
613 NodeAssert.deepStrictEqual(
614 events.map((event) => event.type),
615 [
616 "task.started",
617 "task.started",
618 "task.updated",
619 "task.updated",
620 "task.updated",
621 "task.progress",
622 "task.progress",
623 "task.updated",
624 "task.updated",
625 "task.updated",
626 ],
627 );
628 for (const event of events.slice(0, -1)) {
629 const payload = event.payload as Record<string, unknown>;
630 NodeAssert.equal(payload.model, "gpt-5.6-sol");
631 NodeAssert.equal(payload.effort, "high");
632 }
633
634 const metadataPayload = events[8]?.payload as Record<string, unknown>;
635 NodeAssert.equal("status" in metadataPayload, false);
636 const blankMetadataPayload = events[9]?.payload as Record<string, unknown>;
637 NodeAssert.equal("status" in blankMetadataPayload, false);
638 NodeAssert.equal("model" in blankMetadataPayload, false);
639 NodeAssert.equal("effort" in blankMetadataPayload, false);
640 }),
641 );
642
560643 it.effect("does not reactivate an idle child after a parent interaction", () =>
561644 Effect.gen(function* () {
562645 const { adapter, runtime } = yield* startLifecycleRuntime();
apps/server/src/provider/Layers/CodexAdapter.ts+24-18
......@@ -535,12 +535,16 @@ function mapCollabAgentEvent(
535535 // finding: progress rows renamed math_one to its UUID).
536536 const knownName = nickname ?? pathLeaf;
537537 const title = knownName ?? agentThreadId;
538 const model = typeof payload.model === "string" ? payload.model.trim() : "";
539 const effort = typeof payload.effort === "string" ? payload.effort.trim() : "";
538540 // Identity repeated on every status patch so rows are self-describing when
539541 // the start row ages out of activity retention (review finding: a
540542 // reconstructed agent had a UUID name and no role/path).
541 const statusLinkage = {
543 const linkage = {
542544 role,
543545 ...(knownName ? { title: knownName } : {}),
546 ...(model ? { model } : {}),
547 ...(effort ? { effort } : {}),
544548 ...(agentPath ? { agentPath } : {}),
545549 timelineBypass: true,
546550 } as const;
......@@ -555,15 +559,21 @@ function mapCollabAgentEvent(
555559 taskId,
556560 description: title,
557561 title,
558 role,
559 ...(agentPath ? { agentPath } : {}),
562 ...linkage,
560563 ...(typeof payload.parentThreadId === "string"
561564 ? { parentAgentId: payload.parentThreadId }
562565 : {}),
563 timelineBypass: true,
564566 },
565567 },
566568 ];
569 case "collabAgent/metadataUpdated":
570 return [
571 {
572 ...base,
573 type: "task.updated",
574 payload: { taskId, ...linkage },
575 },
576 ];
567577 case "collabAgent/activity": {
568578 const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : "";
569579 if (activityKind === "interrupted") {
......@@ -571,7 +581,7 @@ function mapCollabAgentEvent(
571581 {
572582 ...base,
573583 type: "task.updated",
574 payload: { taskId, status: "interrupted", ...statusLinkage },
584 payload: { taskId, status: "interrupted", ...linkage },
575585 },
576586 ];
577587 }
......@@ -588,9 +598,7 @@ function mapCollabAgentEvent(
588598 taskId,
589599 description: title,
590600 title,
591 role,
592 ...(agentPath ? { agentPath } : {}),
593 timelineBypass: true,
601 ...linkage,
594602 },
595603 },
596604 ];
......@@ -604,7 +612,7 @@ function mapCollabAgentEvent(
604612 {
605613 ...base,
606614 type: "task.updated",
607 payload: { taskId, status: "running", ...statusLinkage },
615 payload: { taskId, status: "running", ...linkage },
608616 },
609617 ];
610618 case "collabAgent/turnCompleted": {
......@@ -624,7 +632,7 @@ function mapCollabAgentEvent(
624632 {
625633 ...base,
626634 type: "task.updated",
627 payload: { taskId, status, ...statusLinkage },
635 payload: { taskId, status, ...linkage },
628636 },
629637 ];
630638 }
......@@ -640,7 +648,7 @@ function mapCollabAgentEvent(
640648 {
641649 ...base,
642650 type: "task.updated",
643 payload: { taskId, status: "failed", ...statusLinkage },
651 payload: { taskId, status: "failed", ...linkage },
644652 },
645653 ];
646654 }
......@@ -653,7 +661,7 @@ function mapCollabAgentEvent(
653661 {
654662 ...base,
655663 type: "task.updated",
656 payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage },
664 payload: { taskId, status: waiting ? "waiting" : "running", ...linkage },
657665 },
658666 ];
659667 }
......@@ -662,7 +670,7 @@ function mapCollabAgentEvent(
662670 {
663671 ...base,
664672 type: "task.updated",
665 payload: { taskId, status: "idle", ...statusLinkage },
673 payload: { taskId, status: "idle", ...linkage },
666674 },
667675 ];
668676 }
......@@ -709,9 +717,8 @@ function mapCollabAgentEvent(
709717 payload: {
710718 taskId,
711719 description: title,
712 ...(knownName ? { title: knownName } : {}),
720 ...linkage,
713721 typedUsage,
714 timelineBypass: true,
715722 },
716723 },
717724 ];
......@@ -741,9 +748,8 @@ function mapCollabAgentEvent(
741748 payload: {
742749 taskId,
743750 description: title,
744 ...(knownName ? { title: knownName } : {}),
751 ...linkage,
745752 summary,
746 timelineBypass: true,
747753 },
748754 },
749755 ];
......@@ -753,7 +759,7 @@ function mapCollabAgentEvent(
753759 {
754760 ...base,
755761 type: "task.updated",
756 payload: { taskId, status: "interrupted", ...statusLinkage },
762 payload: { taskId, status: "interrupted", ...linkage },
757763 },
758764 ];
759765 default:
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+309
......@@ -81,10 +81,319 @@ function buildScript() {
8181 };
8282}
8383
84function capturedStartedActivity(childId = CHILD_A) {
85 const captured = wireFixture.notifications.find((entry) => {
86 const item = (entry.params as { item?: { type?: string; kind?: string } }).item;
87 return item?.type === "subAgentActivity" && item.kind === "started";
88 });
89 assert.isDefined(captured);
90 return {
91 ...captured,
92 params: {
93 ...captured.params,
94 item: {
95 ...captured.params.item,
96 agentThreadId: childId,
97 agentPath: "/root/model-check",
98 },
99 },
100 };
101}
102
103function capturedSpawnedThread(childId = CHILD_A) {
104 const captured = wireFixture.notifications.find((entry) => entry.method === "thread/started");
105 assert.isDefined(captured);
106 return {
107 ...captured,
108 params: {
109 thread: {
110 ...captured.params.thread,
111 id: childId,
112 sessionId: childId,
113 parentThreadId: ROOT,
114 agentNickname: "model-check",
115 agentRole: "verifier",
116 source: {
117 subAgent: {
118 thread_spawn: {
119 agent_nickname: "model-check",
120 agent_path: "/root/model-check",
121 agent_role: "verifier",
122 depth: 1,
123 parent_thread_id: ROOT,
124 },
125 },
126 },
127 },
128 },
129 };
130}
131
132function childSettings(threadId: string, model: string, effort: string) {
133 return {
134 method: "thread/settings/updated",
135 params: {
136 threadId,
137 threadSettings: {
138 approvalPolicy: "on-request",
139 approvalsReviewer: "auto_review",
140 collaborationMode: { mode: "default", settings: { model } },
141 cwd: "/workspace/repo",
142 effort,
143 model,
144 modelProvider: "openai",
145 sandboxPolicy: { type: "dangerFullAccess" },
146 },
147 },
148 };
149}
150
151function readRecordedRequests() {
152 return NodeFS.readFileSync(`${scriptPath}.requests`, "utf8")
153 .trim()
154 .split("\n")
155 .filter((line) => line.length > 0)
156 .map((line) => JSON.parse(line) as { method: string; params: Record<string, unknown> });
157}
158
84159const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json");
85160const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh");
86161
87162describe("CodexSessionRuntime collab integration", () => {
163 it.effect("looks up child model metadata once after activity registration", () =>
164 Effect.gen(function* () {
165 const script = {
166 rootThreadId: ROOT,
167 recordRequests: true,
168 notifications: [
169 capturedStartedActivity(),
170 capturedStartedActivity(),
171 {
172 ...capturedStartedActivity(CHILD_B),
173 params: {
174 ...capturedStartedActivity(CHILD_B).params,
175 item: { ...capturedStartedActivity(CHILD_B).params.item, kind: "interacted" },
176 },
177 },
178 { method: "thread/closed", params: { threadId: CHILD_B } },
179 capturedSpawnedThread(ROOT),
180 ],
181 childResumeSnapshots: {
182 [CHILD_A]: { model: "gpt-5.6-luna", reasoningEffort: "low" },
183 },
184 };
185 // @effect-diagnostics-next-line preferSchemaOverJson:off
186 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
187 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
188 yield* Effect.addFinalizer(() =>
189 Effect.sync(() => {
190 NodeFS.rmSync(scriptPath, { force: true });
191 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
192 }),
193 );
194
195 const runtime = yield* makeCodexSessionRuntime({
196 threadId: ThreadId.make("thread-collab-model-activity"),
197 binaryPath: peerPath,
198 cwd: "/tmp",
199 runtimeMode: "full-access",
200 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
201 });
202 const metadataFiber = yield* runtime.events.pipe(
203 Stream.filter(
204 (event) =>
205 event.method === "collabAgent/metadataUpdated" &&
206 (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A,
207 ),
208 Stream.take(1),
209 Stream.runCollect,
210 Effect.forkScoped,
211 );
212
213 const session = yield* runtime.start();
214 assert.equal(session.model, "gpt-5.6-sol");
215 yield* runtime.sendTurn({ input: "start one child" });
216 const metadataEvents = Array.from(yield* Fiber.join(metadataFiber));
217 assert.deepInclude(metadataEvents[0]?.payload, {
218 agentThreadId: CHILD_A,
219 model: "gpt-5.6-luna",
220 effort: "low",
221 });
222 assert.deepEqual(readRecordedRequests(), [
223 {
224 method: "thread/resume",
225 params: { threadId: CHILD_A, excludeTurns: true },
226 },
227 ]);
228
229 yield* runtime.close;
230 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
231 );
232
233 it.effect("keeps child settings and reroutes newer than the resume snapshot", () =>
234 Effect.gen(function* () {
235 const statusChanged = wireFixture.notifications.find(
236 (entry) =>
237 entry.method === "thread/status/changed" &&
238 (entry.params as { threadId?: string }).threadId === CHILD_A,
239 );
240 assert.isDefined(statusChanged);
241 const script = {
242 rootThreadId: ROOT,
243 recordRequests: true,
244 notifications: [
245 childSettings(CHILD_A, "child-before", "medium"),
246 capturedSpawnedThread(),
247 childSettings(CHILD_A, "child-after", "high"),
248 {
249 method: "model/rerouted",
250 params: {
251 threadId: CHILD_A,
252 turnId: `${CHILD_A}-turn`,
253 fromModel: "child-after",
254 toModel: "child-rerouted",
255 reason: "highRiskCyberActivity",
256 },
257 },
258 {
259 method: "model/rerouted",
260 params: {
261 threadId: ROOT,
262 turnId: `${ROOT}-turn`,
263 fromModel: "gpt-5.6-sol",
264 toModel: "root-rerouted",
265 reason: "highRiskCyberActivity",
266 },
267 },
268 ],
269 childResumeSnapshots: {
270 [CHILD_A]: {
271 model: "stale-snapshot",
272 reasoningEffort: "low",
273 notifications: [statusChanged],
274 },
275 },
276 };
277 // @effect-diagnostics-next-line preferSchemaOverJson:off
278 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
279 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
280 yield* Effect.addFinalizer(() =>
281 Effect.sync(() => {
282 NodeFS.rmSync(scriptPath, { force: true });
283 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
284 }),
285 );
286
287 const runtime = yield* makeCodexSessionRuntime({
288 threadId: ThreadId.make("thread-collab-model-spawn"),
289 binaryPath: peerPath,
290 cwd: "/tmp",
291 runtimeMode: "full-access",
292 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
293 });
294 const eventsFiber = yield* runtime.events.pipe(
295 Stream.takeUntil(
296 (event) =>
297 event.method === "collabAgent/statusChanged" &&
298 (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A,
299 ),
300 Stream.runCollect,
301 Effect.forkScoped,
302 );
303
304 yield* runtime.start();
305 yield* runtime.sendTurn({ input: "start one spawned child" });
306 const events = Array.from(yield* Fiber.join(eventsFiber));
307 const started = events.find((event) => event.method === "collabAgent/started");
308 assert.deepInclude(started?.payload, {
309 agentThreadId: CHILD_A,
310 model: "child-before",
311 effort: "medium",
312 });
313 const childStatus = events.find((event) => event.method === "collabAgent/statusChanged");
314 assert.deepInclude(childStatus?.payload, {
315 agentThreadId: CHILD_A,
316 model: "child-rerouted",
317 effort: "high",
318 });
319 assert.isTrue(
320 events.some(
321 (event) =>
322 event.method === "model/rerouted" &&
323 (event.payload as { threadId?: string }).threadId === ROOT,
324 ),
325 "the root reroute must stay on the parent path",
326 );
327 assert.isFalse(
328 events.some(
329 (event) =>
330 (event.method === "thread/settings/updated" || event.method === "model/rerouted") &&
331 (event.payload as { threadId?: string }).threadId === CHILD_A,
332 ),
333 "child metadata notifications must not leak to the parent path",
334 );
335 assert.equal(readRecordedRequests().length, 1);
336
337 yield* runtime.close;
338 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
339 );
340
341 it.effect("does not delay the parent turn when the child lookup fails", () =>
342 Effect.gen(function* () {
343 yield* Effect.addFinalizer(() =>
344 Effect.sync(() => {
345 NodeFS.rmSync(scriptPath, { force: true });
346 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
347 }),
348 );
349 for (const [name, childSnapshot] of [
350 ["hang", { hang: true }],
351 ["error", { error: "child unavailable" }],
352 ] as const) {
353 yield* Effect.gen(function* () {
354 const marker = `lookup-${name}`;
355 const script = {
356 rootThreadId: ROOT,
357 recordRequests: true,
358 resumeRequestMarker: marker,
359 notifications: [capturedStartedActivity()],
360 childResumeSnapshots: { [CHILD_A]: childSnapshot },
361 };
362 // @effect-diagnostics-next-line preferSchemaOverJson:off
363 NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8");
364 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
365
366 const runtime = yield* makeCodexSessionRuntime({
367 threadId: ThreadId.make(`thread-collab-model-${name}`),
368 binaryPath: peerPath,
369 cwd: "/tmp",
370 runtimeMode: "full-access",
371 environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath },
372 });
373 const eventsFiber = yield* runtime.events.pipe(
374 Stream.takeUntil(
375 (event) =>
376 event.method === "serverRequest/resolved" &&
377 (event.payload as { requestId?: string }).requestId === marker,
378 ),
379 Stream.runCollect,
380 Effect.forkScoped,
381 );
382
383 yield* runtime.start();
384 yield* runtime.sendTurn({ input: "finish without child metadata" });
385 const events = Array.from(yield* Fiber.join(eventsFiber));
386 assert.isTrue(events.some((event) => event.method === "turn/completed"));
387 assert.equal(readRecordedRequests().length, 1);
388
389 yield* runtime.close;
390 NodeFS.rmSync(scriptPath, { force: true });
391 NodeFS.rmSync(`${scriptPath}.requests`, { force: true });
392 }).pipe(Effect.scoped);
393 }
394 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
395 );
396
88397 it.effect("replays the captured fan-out into synthetic agent events without child leaks", () =>
89398 Effect.gen(function* () {
90399 // @effect-diagnostics-next-line preferSchemaOverJson:off
apps/server/src/provider/Layers/CodexCollabWire.test.ts+4
......@@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => {
119119 "turn/completed",
120120 "thread/status/changed",
121121 "thread/tokenUsage/updated",
122 "thread/settings/updated",
123 "model/rerouted",
122124 "item/started",
123125 "item/completed",
124126 "thread/closed",
......@@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => {
159161 "turn/completed",
160162 "turn/plan/updated",
161163 "item/plan/delta",
164 "thread/settings/updated",
165 "model/rerouted",
162166 ]) {
163167 assert.notEqual(
164168 routeCodexChildNotification(method),
apps/server/src/provider/Layers/CodexSessionRuntime.ts+216-13
......@@ -137,6 +137,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP
137137const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect(
138138 CodexTurnStartParamsWithCollaborationMode,
139139);
140const CodexChildResumeMetadata = Schema.Struct({
141 thread: Schema.Struct({ id: Schema.String }),
142 model: Schema.String,
143 reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)),
144});
145const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata);
140146
141147export type CodexTurnStartParamsWithCollaborationMode =
142148 typeof CodexTurnStartParamsWithCollaborationMode.Type;
......@@ -731,7 +737,9 @@ function readNotificationThreadId(notification: CodexServerNotification): string
731737 case "thread/unarchived":
732738 case "thread/closed":
733739 case "thread/name/updated":
740 case "thread/settings/updated":
734741 case "thread/tokenUsage/updated":
742 case "model/rerouted":
735743 case "turn/started":
736744 case "hook/started":
737745 case "turn/completed":
......@@ -904,6 +912,35 @@ interface CollabChildAgentState {
904912 readonly spawnTurnId: TurnId | undefined;
905913}
906914
915interface CollabChildMetadataState {
916 readonly model: string | undefined;
917 readonly effort: string | undefined;
918 readonly lookupStarted: boolean;
919 readonly closed: boolean;
920}
921
922function collabChildIdentity(
923 child: CollabChildAgentState,
924 metadata: CollabChildMetadataState | undefined,
925) {
926 return {
927 agentThreadId: child.agentThreadId,
928 ...(child.nickname ? { nickname: child.nickname } : {}),
929 ...(child.role ? { role: child.role } : {}),
930 ...(child.agentPath ? { agentPath: child.agentPath } : {}),
931 ...(metadata?.model ? { model: metadata.model } : {}),
932 ...(metadata?.effort ? { effort: metadata.effort } : {}),
933 };
934}
935
936function nonEmptyMetadataValue(value: unknown): string | undefined {
937 if (typeof value !== "string") {
938 return undefined;
939 }
940 const trimmed = value.trim();
941 return trimmed.length > 0 ? trimmed : undefined;
942}
943
907944function readThreadSpawnSource(thread: { readonly source: unknown }):
908945 | {
909946 nickname: string | undefined;
......@@ -969,7 +1006,9 @@ function shouldSuppressChildConversationNotification(
9691006 method === "thread/closed" ||
9701007 method === "thread/compacted" ||
9711008 method === "thread/name/updated" ||
1009 method === "thread/settings/updated" ||
9721010 method === "thread/tokenUsage/updated" ||
1011 method === "model/rerouted" ||
9731012 method === "turn/started" ||
9741013 method === "turn/completed" ||
9751014 method === "turn/plan/updated" ||
......@@ -1000,6 +1039,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet<string> = new Set([
10001039 "turn/completed",
10011040 "thread/status/changed",
10021041 "thread/tokenUsage/updated",
1042 "thread/settings/updated",
1043 "model/rerouted",
10031044 "item/started",
10041045 "item/completed",
10051046 "thread/closed",
......@@ -1018,7 +1059,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet<string> = new Set([
10181059 "turn/plan/updated",
10191060 "turn/diff/updated",
10201061 "thread/name/updated",
1021 "thread/settings/updated",
10221062 "rawResponseItem/completed",
10231063 // Child-owned thread lifecycle: the parent adapter maps these onto the
10241064 // PARENT thread (archived/compacted state), so a child compacting would
......@@ -1126,6 +1166,7 @@ export const makeCodexSessionRuntime = (
11261166 const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>());
11271167 const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>());
11281168 const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>());
1169 const collabChildMetadataRef = yield* Ref.make(new Map<string, CollabChildMetadataState>());
11291170 /** Child provider-thread id → its currently running provider turn id. */
11301171 const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>());
11311172 const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter();
......@@ -1221,6 +1262,133 @@ export const makeCodexSessionRuntime = (
12211262 message,
12221263 });
12231264
1265 const updateCollabChildMetadata = (
1266 agentThreadId: string,
1267 update: { readonly model?: string; readonly effort?: string },
1268 overwriteKnown: boolean,
1269 ) =>
1270 Ref.modify(collabChildMetadataRef, (current) => {
1271 const previous = current.get(agentThreadId) ?? {
1272 model: undefined,
1273 effort: undefined,
1274 lookupStarted: false,
1275 closed: false,
1276 };
1277 const model =
1278 update.model && (overwriteKnown || !previous.model) ? update.model : previous.model;
1279 const effort =
1280 update.effort && (overwriteKnown || !previous.effort) ? update.effort : previous.effort;
1281 const changed = model !== previous.model || effort !== previous.effort;
1282 if (!changed) {
1283 return [false, current] as const;
1284 }
1285 const next = new Map(current);
1286 next.set(agentThreadId, { ...previous, model, effort });
1287 return [true, next] as const;
1288 });
1289
1290 const markCollabChildClosed = (agentThreadId: string) =>
1291 Ref.update(collabChildMetadataRef, (current) => {
1292 const previous = current.get(agentThreadId) ?? {
1293 model: undefined,
1294 effort: undefined,
1295 lookupStarted: false,
1296 closed: false,
1297 };
1298 if (previous.closed) {
1299 return current;
1300 }
1301 const next = new Map(current);
1302 next.set(agentThreadId, { ...previous, closed: true });
1303 return next;
1304 });
1305
1306 const markCollabChildOpen = (agentThreadId: string) =>
1307 Ref.update(collabChildMetadataRef, (current) => {
1308 const previous = current.get(agentThreadId);
1309 if (!previous?.closed) {
1310 return current;
1311 }
1312 const next = new Map(current);
1313 next.set(agentThreadId, { ...previous, closed: false });
1314 return next;
1315 });
1316
1317 const emitCollabChildMetadataUpdated = Effect.fn(
1318 "CodexSessionRuntime.emitCollabChildMetadataUpdated",
1319 )(function* (agentThreadId: string) {
1320 const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId);
1321 const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId);
1322 if (!child || metadata?.closed) {
1323 return;
1324 }
1325 yield* emitEvent({
1326 kind: "notification",
1327 threadId: options.threadId,
1328 ...(child.spawnTurnId ? { turnId: child.spawnTurnId } : {}),
1329 method: "collabAgent/metadataUpdated",
1330 payload: collabChildIdentity(child, metadata),
1331 });
1332 });
1333
1334 const startCollabChildMetadataLookup = Effect.fn(
1335 "CodexSessionRuntime.startCollabChildMetadataLookup",
1336 )(function* (agentThreadId: string) {
1337 const shouldStart = yield* Ref.modify(collabChildMetadataRef, (current) => {
1338 const previous = current.get(agentThreadId) ?? {
1339 model: undefined,
1340 effort: undefined,
1341 lookupStarted: false,
1342 closed: false,
1343 };
1344 if (previous.lookupStarted || previous.closed) {
1345 return [false, current] as const;
1346 }
1347 const next = new Map(current);
1348 next.set(agentThreadId, { ...previous, lookupStarted: true });
1349 return [true, next] as const;
1350 });
1351 if (!shouldStart) {
1352 return;
1353 }
1354
1355 // The child is already loaded. This rejoins it without starting a turn,
1356 // and excludeTurns avoids loading or replaying its history.
1357 yield* client.raw
1358 .request("thread/resume", { threadId: agentThreadId, excludeTurns: true })
1359 .pipe(
1360 Effect.flatMap(decodeCodexChildResumeMetadata),
1361 Effect.timeout("5 seconds"),
1362 Effect.flatMap((response) =>
1363 Effect.gen(function* () {
1364 if (response.thread.id !== agentThreadId) {
1365 return;
1366 }
1367 const child = (yield* Ref.get(collabChildAgentsRef)).get(agentThreadId);
1368 const metadata = (yield* Ref.get(collabChildMetadataRef)).get(agentThreadId);
1369 if (!child || metadata?.closed) {
1370 return;
1371 }
1372 const model = nonEmptyMetadataValue(response.model);
1373 const effort = nonEmptyMetadataValue(response.reasoningEffort);
1374 const changed = yield* updateCollabChildMetadata(
1375 agentThreadId,
1376 {
1377 ...(model ? { model } : {}),
1378 ...(effort ? { effort } : {}),
1379 },
1380 false,
1381 );
1382 if (changed) {
1383 yield* emitCollabChildMetadataUpdated(agentThreadId);
1384 }
1385 }),
1386 ),
1387 Effect.catch(() => Effect.void),
1388 Effect.forkIn(runtimeScope),
1389 );
1390 });
1391
12241392 const settlePendingApprovals = (decision: ProviderApprovalDecision) =>
12251393 Ref.get(pendingApprovalsRef).pipe(
12261394 Effect.flatMap((pendingApprovals) =>
......@@ -1261,6 +1429,10 @@ export const makeCodexSessionRuntime = (
12611429 if (!spawn) {
12621430 return false;
12631431 }
1432 const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef));
1433 if (thread.id === rootProviderThreadId) {
1434 return false;
1435 }
12641436 // Merge with any subAgentActivity registration that got here
12651437 // first. spawnTurnId is REGISTRATION-time-only on both paths: for
12661438 // an already-known child we keep its value (set or unset) — a
......@@ -1287,20 +1459,19 @@ export const makeCodexSessionRuntime = (
12871459 next.set(thread.id, state);
12881460 return next;
12891461 });
1462 const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id);
12901463 yield* emitEvent({
12911464 kind: "notification",
12921465 threadId: options.threadId,
12931466 method: "collabAgent/started",
12941467 ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}),
12951468 payload: {
1296 agentThreadId: state.agentThreadId,
1297 ...(state.nickname ? { nickname: state.nickname } : {}),
1298 ...(state.role ? { role: state.role } : {}),
1299 ...(state.agentPath ? { agentPath: state.agentPath } : {}),
1469 ...collabChildIdentity(state, metadata),
13001470 ...(state.depth !== undefined ? { depth: state.depth } : {}),
13011471 ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}),
13021472 },
13031473 });
1474 yield* startCollabChildMetadataLookup(thread.id);
13041475 return true;
13051476 }
13061477
......@@ -1350,17 +1521,22 @@ export const makeCodexSessionRuntime = (
13501521 return next;
13511522 });
13521523 const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId);
1524 const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId);
13531525 yield* emitEvent({
13541526 kind: "notification",
13551527 threadId: options.threadId,
13561528 method: "collabAgent/activity",
13571529 ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}),
13581530 payload: {
1359 agentThreadId: item.agentThreadId,
1360 agentPath: item.agentPath,
1531 ...(registeredChild
1532 ? collabChildIdentity(registeredChild, metadata)
1533 : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }),
13611534 activityKind: item.kind,
13621535 },
13631536 });
1537 if (item.kind === "started") {
1538 yield* startCollabChildMetadataLookup(item.agentThreadId);
1539 }
13641540 return true;
13651541 }
13661542
......@@ -1376,19 +1552,45 @@ export const makeCodexSessionRuntime = (
13761552 if (providerConversationId === interceptRootId) {
13771553 return false;
13781554 }
1555
1556 if (
1557 interceptRootId !== undefined &&
1558 (notification.method === "thread/settings/updated" ||
1559 notification.method === "model/rerouted")
1560 ) {
1561 const model = nonEmptyMetadataValue(
1562 notification.method === "thread/settings/updated"
1563 ? notification.params.threadSettings.model
1564 : notification.params.toModel,
1565 );
1566 const effort =
1567 notification.method === "thread/settings/updated"
1568 ? nonEmptyMetadataValue(notification.params.threadSettings.effort)
1569 : undefined;
1570 const changed = yield* updateCollabChildMetadata(
1571 providerConversationId,
1572 {
1573 ...(model ? { model } : {}),
1574 ...(effort ? { effort } : {}),
1575 },
1576 true,
1577 );
1578 if (changed && (yield* Ref.get(collabChildAgentsRef)).has(providerConversationId)) {
1579 yield* emitCollabChildMetadataUpdated(providerConversationId);
1580 }
1581 return true;
1582 }
1583
13791584 const children = yield* Ref.get(collabChildAgentsRef);
13801585 const child = children.get(providerConversationId);
13811586 if (!child) {
13821587 return false;
13831588 }
1384 const childIdentity = {
1385 agentThreadId: child.agentThreadId,
1386 ...(child.nickname ? { nickname: child.nickname } : {}),
1387 ...(child.role ? { role: child.role } : {}),
1388 ...(child.agentPath ? { agentPath: child.agentPath } : {}),
1389 };
1589 const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId);
1590 const childIdentity = collabChildIdentity(child, metadata);
13901591 switch (notification.method) {
13911592 case "turn/started": {
1593 yield* markCollabChildOpen(child.agentThreadId);
13921594 const childTurnId =
13931595 typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string"
13941596 ? ((notification.params as { turn: { id: string } }).turn.id as string)
......@@ -1472,6 +1674,7 @@ export const makeCodexSessionRuntime = (
14721674 next.delete(child.agentThreadId);
14731675 return next;
14741676 });
1677 yield* markCollabChildClosed(child.agentThreadId);
14751678 yield* emitEvent({
14761679 kind: "notification",
14771680 threadId: options.threadId,
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+49-1
......@@ -57,7 +57,55 @@ rl.on("line", (line) => {
5757 });
5858 return;
5959 }
60 if (method === "thread/start" || method === "thread/resume") {
60 if (method === "thread/start") {
61 write({ id, result: fixture.responses.threadStart });
62 return;
63 }
64 if (method === "thread/resume") {
65 if (script.recordRequests) {
66 NodeFS.appendFileSync(
67 `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`,
68 `${JSON.stringify({ method, params: message.params })}\n`,
69 );
70 }
71 const threadId = message.params?.threadId;
72 const childSnapshot = script.childResumeSnapshots?.[threadId];
73 if (script.resumeRequestMarker) {
74 write({
75 jsonrpc: "2.0",
76 method: "serverRequest/resolved",
77 params: {
78 threadId: script.rootThreadId,
79 requestId: script.resumeRequestMarker,
80 },
81 });
82 }
83 if (childSnapshot?.hang) {
84 return;
85 }
86 if (childSnapshot?.error) {
87 write({ id, error: { code: -32000, message: childSnapshot.error } });
88 return;
89 }
90 if (childSnapshot) {
91 write({
92 id,
93 result: {
94 ...fixture.responses.threadStart,
95 model: childSnapshot.model,
96 reasoningEffort: childSnapshot.reasoningEffort,
97 thread: {
98 ...fixture.responses.threadStart.thread,
99 id: threadId,
100 sessionId: threadId,
101 },
102 },
103 });
104 for (const notification of childSnapshot.notifications ?? []) {
105 write({ jsonrpc: "2.0", method: notification.method, params: notification.params });
106 }
107 return;
108 }
61109 write({ id, result: fixture.responses.threadStart });
62110 return;
63111 }
docs/user/providers-codex.md+6
......@@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr
3434issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy
3535and share with OpenAI employees.
3636
37## Sub-agent models
38
39The web and desktop Agents panel shows each sub-agent's model and reasoning effort when Codex
40reports them. If Codex does not report either value, T3 Code leaves it out instead of using the
41parent agent's settings.
42
3743## Approve access to other apps
3844
3945When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for
packages/client-runtime/src/state/subagentRuntime.test.ts+37
......@@ -603,6 +603,43 @@ describe("model and effort attribution", () => {
603603 expect(agents[0]!.effort).toBe("high");
604604 });
605605
606 it("applies metadata-only updates without changing the current status", () => {
607 const waitingRows = [
608 activity("task.updated", {
609 taskId: "task-metadata",
610 title: "Check metadata",
611 status: "waiting",
612 }),
613 activity("task.updated", {
614 taskId: "task-metadata",
615 model: "gpt-5.6-sol",
616 effort: "high",
617 }),
618 ];
619 const waitingAgent = fold(waitingRows)[0]!;
620 expect(waitingAgent.status).toBe("waiting");
621 expect(formatSubagentModelLabel(waitingAgent.model, waitingAgent.effort)).toBe(
622 "gpt-5.6-sol · high",
623 );
624
625 const idleRows = [
626 ...waitingRows,
627 activity("task.updated", { taskId: "task-metadata", status: "idle" }),
628 activity("task.updated", { taskId: "task-metadata", model: "gpt-5.6-sol" }),
629 ];
630 expect(fold(idleRows)[0]!.status).toBe("idle");
631
632 const completedAgent = fold([
633 ...idleRows,
634 activity("task.progress", { taskId: "task-metadata", typedUsage: { totalTokens: 42 } }),
635 activity("task.completed", { taskId: "task-metadata", status: "completed" }),
636 activity("task.updated", { taskId: "task-metadata", effort: "high" }),
637 ])[0]!;
638 expect(completedAgent.status).toBe("completed");
639 expect(completedAgent.model).toBe("gpt-5.6-sol");
640 expect(completedAgent.effort).toBe("high");
641 });
642
606643 it("formatSubagentModelLabel compacts ids and appends effort", () => {
607644 expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high");
608645 expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4");