| author | |
| committer | |
| log | 49f6241ddeee2a5d20ec181b5b94ec0a9ac72f7b |
| tree | 9b285be1291e01359ec8e3ad68adaab0bdc9896e |
| parent | 7068e86f76e908c77ff04d1a684840257eab3a8a |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; |
| 2 | import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; | 2 | import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; |
| 3 | 3 | ||
| 4 | describe("ThreadBackgroundLiveness", () => { | 4 | describe("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", () => { |
| 6 | const liveness = ThreadBackgroundLiveness.make(); | 6 | const liveness = ThreadBackgroundLiveness.make(); |
| 7 | liveness.recordTaskLiveness({ | 7 | liveness.recordTaskLiveness({ |
| 8 | threadId: "thread", | 8 | threadId: "thread", |
| ... | @@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => { | ... | @@ -25,6 +25,36 @@ describe("ThreadBackgroundLiveness", () => { |
| 25 | status: undefined, | 25 | status: undefined, |
| 26 | kind: "progress", | 26 | kind: "progress", |
| 27 | }); | 27 | }); |
| 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 | }); | ||
| 28 | expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); | 58 | expect(liveness.getThreadBackgroundLiveness("thread")).toBeNull(); |
| 29 | }); | 59 | }); |
| 30 | 60 |
apps/server/src/orchestration/ThreadBackgroundLiveness.ts+3-4| ... | @@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] { | ... | @@ -130,10 +130,9 @@ export function make(): ThreadBackgroundLivenessService["Service"] { |
| 130 | return; | 130 | return; |
| 131 | } | 131 | } |
| 132 | 132 | ||
| 133 | // Status-free progress is a description tick, not a restart. A delayed | 133 | // Status-free progress and metadata updates are not restarts. A delayed |
| 134 | // progress event after idle must not put the task back in the live set | 134 | // row after idle must not put the task back in the live set (#7128). |
| 135 | // (#7128). | 135 | if ((input.kind === "progress" || input.kind === "updated") && input.status === undefined) { |
| 136 | if (input.kind === "progress" && input.status === undefined) { | ||
| 137 | const existing = stateByThreadId.get(input.threadId); | 136 | const existing = stateByThreadId.get(input.threadId); |
| 138 | const stillLive = | 137 | const stillLive = |
| 139 | existing !== undefined && | 138 | existing !== undefined && |
apps/server/src/provider/Layers/CodexAdapter.test.ts+83| ... | @@ -557,6 +557,89 @@ function startLifecycleRuntime() { | ... | @@ -557,6 +557,89 @@ function startLifecycleRuntime() { |
| 557 | } | 557 | } |
| 558 | 558 | ||
| 559 | lifecycleLayer("CodexAdapterLive lifecycle", (it) => { | 559 | lifecycleLayer("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 | |||
| 560 | it.effect("does not reactivate an idle child after a parent interaction", () => | 643 | it.effect("does not reactivate an idle child after a parent interaction", () => |
| 561 | Effect.gen(function* () { | 644 | Effect.gen(function* () { |
| 562 | const { adapter, runtime } = yield* startLifecycleRuntime(); | 645 | const { adapter, runtime } = yield* startLifecycleRuntime(); |
apps/server/src/provider/Layers/CodexAdapter.ts+24-18| ... | @@ -535,12 +535,16 @@ function mapCollabAgentEvent( | ... | @@ -535,12 +535,16 @@ function mapCollabAgentEvent( |
| 535 | // finding: progress rows renamed math_one to its UUID). | 535 | // finding: progress rows renamed math_one to its UUID). |
| 536 | const knownName = nickname ?? pathLeaf; | 536 | const knownName = nickname ?? pathLeaf; |
| 537 | const title = knownName ?? agentThreadId; | 537 | const title = knownName ?? agentThreadId; |
| 538 | const model = typeof payload.model === "string" ? payload.model.trim() : ""; | ||
| 539 | const effort = typeof payload.effort === "string" ? payload.effort.trim() : ""; | ||
| 538 | // Identity repeated on every status patch so rows are self-describing when | 540 | // Identity repeated on every status patch so rows are self-describing when |
| 539 | // the start row ages out of activity retention (review finding: a | 541 | // the start row ages out of activity retention (review finding: a |
| 540 | // reconstructed agent had a UUID name and no role/path). | 542 | // reconstructed agent had a UUID name and no role/path). |
| 541 | const statusLinkage = { | 543 | const linkage = { |
| 542 | role, | 544 | role, |
| 543 | ...(knownName ? { title: knownName } : {}), | 545 | ...(knownName ? { title: knownName } : {}), |
| 546 | ...(model ? { model } : {}), | ||
| 547 | ...(effort ? { effort } : {}), | ||
| 544 | ...(agentPath ? { agentPath } : {}), | 548 | ...(agentPath ? { agentPath } : {}), |
| 545 | timelineBypass: true, | 549 | timelineBypass: true, |
| 546 | } as const; | 550 | } as const; |
| ... | @@ -555,15 +559,21 @@ function mapCollabAgentEvent( | ... | @@ -555,15 +559,21 @@ function mapCollabAgentEvent( |
| 555 | taskId, | 559 | taskId, |
| 556 | description: title, | 560 | description: title, |
| 557 | title, | 561 | title, |
| 558 | role, | 562 | ...linkage, |
| 559 | ...(agentPath ? { agentPath } : {}), | ||
| 560 | ...(typeof payload.parentThreadId === "string" | 563 | ...(typeof payload.parentThreadId === "string" |
| 561 | ? { parentAgentId: payload.parentThreadId } | 564 | ? { parentAgentId: payload.parentThreadId } |
| 562 | : {}), | 565 | : {}), |
| 563 | timelineBypass: true, | ||
| 564 | }, | 566 | }, |
| 565 | }, | 567 | }, |
| 566 | ]; | 568 | ]; |
| 569 | case "collabAgent/metadataUpdated": | ||
| 570 | return [ | ||
| 571 | { | ||
| 572 | ...base, | ||
| 573 | type: "task.updated", | ||
| 574 | payload: { taskId, ...linkage }, | ||
| 575 | }, | ||
| 576 | ]; | ||
| 567 | case "collabAgent/activity": { | 577 | case "collabAgent/activity": { |
| 568 | const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; | 578 | const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; |
| 569 | if (activityKind === "interrupted") { | 579 | if (activityKind === "interrupted") { |
| ... | @@ -571,7 +581,7 @@ function mapCollabAgentEvent( | ... | @@ -571,7 +581,7 @@ function mapCollabAgentEvent( |
| 571 | { | 581 | { |
| 572 | ...base, | 582 | ...base, |
| 573 | type: "task.updated", | 583 | type: "task.updated", |
| 574 | payload: { taskId, status: "interrupted", ...statusLinkage }, | 584 | payload: { taskId, status: "interrupted", ...linkage }, |
| 575 | }, | 585 | }, |
| 576 | ]; | 586 | ]; |
| 577 | } | 587 | } |
| ... | @@ -588,9 +598,7 @@ function mapCollabAgentEvent( | ... | @@ -588,9 +598,7 @@ function mapCollabAgentEvent( |
| 588 | taskId, | 598 | taskId, |
| 589 | description: title, | 599 | description: title, |
| 590 | title, | 600 | title, |
| 591 | role, | 601 | ...linkage, |
| 592 | ...(agentPath ? { agentPath } : {}), | ||
| 593 | timelineBypass: true, | ||
| 594 | }, | 602 | }, |
| 595 | }, | 603 | }, |
| 596 | ]; | 604 | ]; |
| ... | @@ -604,7 +612,7 @@ function mapCollabAgentEvent( | ... | @@ -604,7 +612,7 @@ function mapCollabAgentEvent( |
| 604 | { | 612 | { |
| 605 | ...base, | 613 | ...base, |
| 606 | type: "task.updated", | 614 | type: "task.updated", |
| 607 | payload: { taskId, status: "running", ...statusLinkage }, | 615 | payload: { taskId, status: "running", ...linkage }, |
| 608 | }, | 616 | }, |
| 609 | ]; | 617 | ]; |
| 610 | case "collabAgent/turnCompleted": { | 618 | case "collabAgent/turnCompleted": { |
| ... | @@ -624,7 +632,7 @@ function mapCollabAgentEvent( | ... | @@ -624,7 +632,7 @@ function mapCollabAgentEvent( |
| 624 | { | 632 | { |
| 625 | ...base, | 633 | ...base, |
| 626 | type: "task.updated", | 634 | type: "task.updated", |
| 627 | payload: { taskId, status, ...statusLinkage }, | 635 | payload: { taskId, status, ...linkage }, |
| 628 | }, | 636 | }, |
| 629 | ]; | 637 | ]; |
| 630 | } | 638 | } |
| ... | @@ -640,7 +648,7 @@ function mapCollabAgentEvent( | ... | @@ -640,7 +648,7 @@ function mapCollabAgentEvent( |
| 640 | { | 648 | { |
| 641 | ...base, | 649 | ...base, |
| 642 | type: "task.updated", | 650 | type: "task.updated", |
| 643 | payload: { taskId, status: "failed", ...statusLinkage }, | 651 | payload: { taskId, status: "failed", ...linkage }, |
| 644 | }, | 652 | }, |
| 645 | ]; | 653 | ]; |
| 646 | } | 654 | } |
| ... | @@ -653,7 +661,7 @@ function mapCollabAgentEvent( | ... | @@ -653,7 +661,7 @@ function mapCollabAgentEvent( |
| 653 | { | 661 | { |
| 654 | ...base, | 662 | ...base, |
| 655 | type: "task.updated", | 663 | type: "task.updated", |
| 656 | payload: { taskId, status: waiting ? "waiting" : "running", ...statusLinkage }, | 664 | payload: { taskId, status: waiting ? "waiting" : "running", ...linkage }, |
| 657 | }, | 665 | }, |
| 658 | ]; | 666 | ]; |
| 659 | } | 667 | } |
| ... | @@ -662,7 +670,7 @@ function mapCollabAgentEvent( | ... | @@ -662,7 +670,7 @@ function mapCollabAgentEvent( |
| 662 | { | 670 | { |
| 663 | ...base, | 671 | ...base, |
| 664 | type: "task.updated", | 672 | type: "task.updated", |
| 665 | payload: { taskId, status: "idle", ...statusLinkage }, | 673 | payload: { taskId, status: "idle", ...linkage }, |
| 666 | }, | 674 | }, |
| 667 | ]; | 675 | ]; |
| 668 | } | 676 | } |
| ... | @@ -709,9 +717,8 @@ function mapCollabAgentEvent( | ... | @@ -709,9 +717,8 @@ function mapCollabAgentEvent( |
| 709 | payload: { | 717 | payload: { |
| 710 | taskId, | 718 | taskId, |
| 711 | description: title, | 719 | description: title, |
| 712 | ...(knownName ? { title: knownName } : {}), | 720 | ...linkage, |
| 713 | typedUsage, | 721 | typedUsage, |
| 714 | timelineBypass: true, | ||
| 715 | }, | 722 | }, |
| 716 | }, | 723 | }, |
| 717 | ]; | 724 | ]; |
| ... | @@ -741,9 +748,8 @@ function mapCollabAgentEvent( | ... | @@ -741,9 +748,8 @@ function mapCollabAgentEvent( |
| 741 | payload: { | 748 | payload: { |
| 742 | taskId, | 749 | taskId, |
| 743 | description: title, | 750 | description: title, |
| 744 | ...(knownName ? { title: knownName } : {}), | 751 | ...linkage, |
| 745 | summary, | 752 | summary, |
| 746 | timelineBypass: true, | ||
| 747 | }, | 753 | }, |
| 748 | }, | 754 | }, |
| 749 | ]; | 755 | ]; |
| ... | @@ -753,7 +759,7 @@ function mapCollabAgentEvent( | ... | @@ -753,7 +759,7 @@ function mapCollabAgentEvent( |
| 753 | { | 759 | { |
| 754 | ...base, | 760 | ...base, |
| 755 | type: "task.updated", | 761 | type: "task.updated", |
| 756 | payload: { taskId, status: "interrupted", ...statusLinkage }, | 762 | payload: { taskId, status: "interrupted", ...linkage }, |
| 757 | }, | 763 | }, |
| 758 | ]; | 764 | ]; |
| 759 | default: | 765 | default: |
apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts+309| ... | @@ -81,10 +81,319 @@ function buildScript() { | ... | @@ -81,10 +81,319 @@ function buildScript() { |
| 81 | }; | 81 | }; |
| 82 | } | 82 | } |
| 83 | 83 | ||
| 84 | function 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 | |||
| 103 | function 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 | |||
| 132 | function 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 | |||
| 151 | function 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 | |||
| 84 | const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); | 159 | const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); |
| 85 | const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); | 160 | const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); |
| 86 | 161 | ||
| 87 | describe("CodexSessionRuntime collab integration", () => { | 162 | describe("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 | |||
| 88 | it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => | 397 | it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => |
| 89 | Effect.gen(function* () { | 398 | Effect.gen(function* () { |
| 90 | // @effect-diagnostics-next-line preferSchemaOverJson:off | 399 | // @effect-diagnostics-next-line preferSchemaOverJson:off |
apps/server/src/provider/Layers/CodexCollabWire.test.ts+4| ... | @@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => { | ... | @@ -119,6 +119,8 @@ describe("routeCodexChildNotification", () => { |
| 119 | "turn/completed", | 119 | "turn/completed", |
| 120 | "thread/status/changed", | 120 | "thread/status/changed", |
| 121 | "thread/tokenUsage/updated", | 121 | "thread/tokenUsage/updated", |
| 122 | "thread/settings/updated", | ||
| 123 | "model/rerouted", | ||
| 122 | "item/started", | 124 | "item/started", |
| 123 | "item/completed", | 125 | "item/completed", |
| 124 | "thread/closed", | 126 | "thread/closed", |
| ... | @@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => { | ... | @@ -159,6 +161,8 @@ describe("routeCodexChildNotification", () => { |
| 159 | "turn/completed", | 161 | "turn/completed", |
| 160 | "turn/plan/updated", | 162 | "turn/plan/updated", |
| 161 | "item/plan/delta", | 163 | "item/plan/delta", |
| 164 | "thread/settings/updated", | ||
| 165 | "model/rerouted", | ||
| 162 | ]) { | 166 | ]) { |
| 163 | assert.notEqual( | 167 | assert.notEqual( |
| 164 | routeCodexChildNotification(method), | 168 | routeCodexChildNotification(method), |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+216-13| ... | @@ -137,6 +137,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP | ... | @@ -137,6 +137,12 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP |
| 137 | const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( | 137 | const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( |
| 138 | CodexTurnStartParamsWithCollaborationMode, | 138 | CodexTurnStartParamsWithCollaborationMode, |
| 139 | ); | 139 | ); |
| 140 | const CodexChildResumeMetadata = Schema.Struct({ | ||
| 141 | thread: Schema.Struct({ id: Schema.String }), | ||
| 142 | model: Schema.String, | ||
| 143 | reasoningEffort: Schema.optionalKey(Schema.NullOr(Schema.String)), | ||
| 144 | }); | ||
| 145 | const decodeCodexChildResumeMetadata = Schema.decodeUnknownEffect(CodexChildResumeMetadata); | ||
| 140 | 146 | ||
| 141 | export type CodexTurnStartParamsWithCollaborationMode = | 147 | export type CodexTurnStartParamsWithCollaborationMode = |
| 142 | typeof CodexTurnStartParamsWithCollaborationMode.Type; | 148 | typeof CodexTurnStartParamsWithCollaborationMode.Type; |
| ... | @@ -731,7 +737,9 @@ function readNotificationThreadId(notification: CodexServerNotification): string | ... | @@ -731,7 +737,9 @@ function readNotificationThreadId(notification: CodexServerNotification): string |
| 731 | case "thread/unarchived": | 737 | case "thread/unarchived": |
| 732 | case "thread/closed": | 738 | case "thread/closed": |
| 733 | case "thread/name/updated": | 739 | case "thread/name/updated": |
| 740 | case "thread/settings/updated": | ||
| 734 | case "thread/tokenUsage/updated": | 741 | case "thread/tokenUsage/updated": |
| 742 | case "model/rerouted": | ||
| 735 | case "turn/started": | 743 | case "turn/started": |
| 736 | case "hook/started": | 744 | case "hook/started": |
| 737 | case "turn/completed": | 745 | case "turn/completed": |
| ... | @@ -904,6 +912,35 @@ interface CollabChildAgentState { | ... | @@ -904,6 +912,35 @@ interface CollabChildAgentState { |
| 904 | readonly spawnTurnId: TurnId | undefined; | 912 | readonly spawnTurnId: TurnId | undefined; |
| 905 | } | 913 | } |
| 906 | 914 | ||
| 915 | interface CollabChildMetadataState { | ||
| 916 | readonly model: string | undefined; | ||
| 917 | readonly effort: string | undefined; | ||
| 918 | readonly lookupStarted: boolean; | ||
| 919 | readonly closed: boolean; | ||
| 920 | } | ||
| 921 | |||
| 922 | function 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 | |||
| 936 | function 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 | |||
| 907 | function readThreadSpawnSource(thread: { readonly source: unknown }): | 944 | function readThreadSpawnSource(thread: { readonly source: unknown }): |
| 908 | | { | 945 | | { |
| 909 | nickname: string | undefined; | 946 | nickname: string | undefined; |
| ... | @@ -969,7 +1006,9 @@ function shouldSuppressChildConversationNotification( | ... | @@ -969,7 +1006,9 @@ function shouldSuppressChildConversationNotification( |
| 969 | method === "thread/closed" || | 1006 | method === "thread/closed" || |
| 970 | method === "thread/compacted" || | 1007 | method === "thread/compacted" || |
| 971 | method === "thread/name/updated" || | 1008 | method === "thread/name/updated" || |
| 1009 | method === "thread/settings/updated" || | ||
| 972 | method === "thread/tokenUsage/updated" || | 1010 | method === "thread/tokenUsage/updated" || |
| 1011 | method === "model/rerouted" || | ||
| 973 | method === "turn/started" || | 1012 | method === "turn/started" || |
| 974 | method === "turn/completed" || | 1013 | method === "turn/completed" || |
| 975 | method === "turn/plan/updated" || | 1014 | method === "turn/plan/updated" || |
| ... | @@ -1000,6 +1039,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet<string> = new Set([ | ... | @@ -1000,6 +1039,8 @@ const CHILD_AGENT_EVENT_METHODS: ReadonlySet<string> = new Set([ |
| 1000 | "turn/completed", | 1039 | "turn/completed", |
| 1001 | "thread/status/changed", | 1040 | "thread/status/changed", |
| 1002 | "thread/tokenUsage/updated", | 1041 | "thread/tokenUsage/updated", |
| 1042 | "thread/settings/updated", | ||
| 1043 | "model/rerouted", | ||
| 1003 | "item/started", | 1044 | "item/started", |
| 1004 | "item/completed", | 1045 | "item/completed", |
| 1005 | "thread/closed", | 1046 | "thread/closed", |
| ... | @@ -1018,7 +1059,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet<string> = new Set([ | ... | @@ -1018,7 +1059,6 @@ const CHILD_CHATTER_METHODS: ReadonlySet<string> = new Set([ |
| 1018 | "turn/plan/updated", | 1059 | "turn/plan/updated", |
| 1019 | "turn/diff/updated", | 1060 | "turn/diff/updated", |
| 1020 | "thread/name/updated", | 1061 | "thread/name/updated", |
| 1021 | "thread/settings/updated", | ||
| 1022 | "rawResponseItem/completed", | 1062 | "rawResponseItem/completed", |
| 1023 | // Child-owned thread lifecycle: the parent adapter maps these onto the | 1063 | // Child-owned thread lifecycle: the parent adapter maps these onto the |
| 1024 | // PARENT thread (archived/compacted state), so a child compacting would | 1064 | // PARENT thread (archived/compacted state), so a child compacting would |
| ... | @@ -1126,6 +1166,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1126,6 +1166,7 @@ export const makeCodexSessionRuntime = ( |
| 1126 | const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>()); | 1166 | const pendingUserInputsRef = yield* Ref.make(new Map<ApprovalRequestId, PendingUserInput>()); |
| 1127 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); | 1167 | const collabReceiverTurnsRef = yield* Ref.make(new Map<string, TurnId>()); |
| 1128 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); | 1168 | const collabChildAgentsRef = yield* Ref.make(new Map<string, CollabChildAgentState>()); |
| 1169 | const collabChildMetadataRef = yield* Ref.make(new Map<string, CollabChildMetadataState>()); | ||
| 1129 | /** Child provider-thread id → its currently running provider turn id. */ | 1170 | /** Child provider-thread id → its currently running provider turn id. */ |
| 1130 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); | 1171 | const collabChildLiveTurnsRef = yield* Ref.make(new Map<string, string>()); |
| 1131 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); | 1172 | const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); |
| ... | @@ -1221,6 +1262,133 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1221,6 +1262,133 @@ export const makeCodexSessionRuntime = ( |
| 1221 | message, | 1262 | message, |
| 1222 | }); | 1263 | }); |
| 1223 | 1264 | ||
| 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 | |||
| 1224 | const settlePendingApprovals = (decision: ProviderApprovalDecision) => | 1392 | const settlePendingApprovals = (decision: ProviderApprovalDecision) => |
| 1225 | Ref.get(pendingApprovalsRef).pipe( | 1393 | Ref.get(pendingApprovalsRef).pipe( |
| 1226 | Effect.flatMap((pendingApprovals) => | 1394 | Effect.flatMap((pendingApprovals) => |
| ... | @@ -1261,6 +1429,10 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1261,6 +1429,10 @@ export const makeCodexSessionRuntime = ( |
| 1261 | if (!spawn) { | 1429 | if (!spawn) { |
| 1262 | return false; | 1430 | return false; |
| 1263 | } | 1431 | } |
| 1432 | const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); | ||
| 1433 | if (thread.id === rootProviderThreadId) { | ||
| 1434 | return false; | ||
| 1435 | } | ||
| 1264 | // Merge with any subAgentActivity registration that got here | 1436 | // Merge with any subAgentActivity registration that got here |
| 1265 | // first. spawnTurnId is REGISTRATION-time-only on both paths: for | 1437 | // first. spawnTurnId is REGISTRATION-time-only on both paths: for |
| 1266 | // an already-known child we keep its value (set or unset) — a | 1438 | // an already-known child we keep its value (set or unset) — a |
| ... | @@ -1287,20 +1459,19 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1287,20 +1459,19 @@ export const makeCodexSessionRuntime = ( |
| 1287 | next.set(thread.id, state); | 1459 | next.set(thread.id, state); |
| 1288 | return next; | 1460 | return next; |
| 1289 | }); | 1461 | }); |
| 1462 | const metadata = (yield* Ref.get(collabChildMetadataRef)).get(thread.id); | ||
| 1290 | yield* emitEvent({ | 1463 | yield* emitEvent({ |
| 1291 | kind: "notification", | 1464 | kind: "notification", |
| 1292 | threadId: options.threadId, | 1465 | threadId: options.threadId, |
| 1293 | method: "collabAgent/started", | 1466 | method: "collabAgent/started", |
| 1294 | ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), | 1467 | ...(state.spawnTurnId ? { turnId: state.spawnTurnId } : {}), |
| 1295 | payload: { | 1468 | payload: { |
| 1296 | agentThreadId: state.agentThreadId, | 1469 | ...collabChildIdentity(state, metadata), |
| 1297 | ...(state.nickname ? { nickname: state.nickname } : {}), | ||
| 1298 | ...(state.role ? { role: state.role } : {}), | ||
| 1299 | ...(state.agentPath ? { agentPath: state.agentPath } : {}), | ||
| 1300 | ...(state.depth !== undefined ? { depth: state.depth } : {}), | 1470 | ...(state.depth !== undefined ? { depth: state.depth } : {}), |
| 1301 | ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), | 1471 | ...(state.parentThreadId ? { parentThreadId: state.parentThreadId } : {}), |
| 1302 | }, | 1472 | }, |
| 1303 | }); | 1473 | }); |
| 1474 | yield* startCollabChildMetadataLookup(thread.id); | ||
| 1304 | return true; | 1475 | return true; |
| 1305 | } | 1476 | } |
| 1306 | 1477 | ||
| ... | @@ -1350,17 +1521,22 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1350,17 +1521,22 @@ export const makeCodexSessionRuntime = ( |
| 1350 | return next; | 1521 | return next; |
| 1351 | }); | 1522 | }); |
| 1352 | const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); | 1523 | const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); |
| 1524 | const metadata = (yield* Ref.get(collabChildMetadataRef)).get(item.agentThreadId); | ||
| 1353 | yield* emitEvent({ | 1525 | yield* emitEvent({ |
| 1354 | kind: "notification", | 1526 | kind: "notification", |
| 1355 | threadId: options.threadId, | 1527 | threadId: options.threadId, |
| 1356 | method: "collabAgent/activity", | 1528 | method: "collabAgent/activity", |
| 1357 | ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), | 1529 | ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), |
| 1358 | payload: { | 1530 | payload: { |
| 1359 | agentThreadId: item.agentThreadId, | 1531 | ...(registeredChild |
| 1360 | agentPath: item.agentPath, | 1532 | ? collabChildIdentity(registeredChild, metadata) |
| 1533 | : { agentThreadId: item.agentThreadId, agentPath: item.agentPath }), | ||
| 1361 | activityKind: item.kind, | 1534 | activityKind: item.kind, |
| 1362 | }, | 1535 | }, |
| 1363 | }); | 1536 | }); |
| 1537 | if (item.kind === "started") { | ||
| 1538 | yield* startCollabChildMetadataLookup(item.agentThreadId); | ||
| 1539 | } | ||
| 1364 | return true; | 1540 | return true; |
| 1365 | } | 1541 | } |
| 1366 | 1542 | ||
| ... | @@ -1376,19 +1552,45 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1376,19 +1552,45 @@ export const makeCodexSessionRuntime = ( |
| 1376 | if (providerConversationId === interceptRootId) { | 1552 | if (providerConversationId === interceptRootId) { |
| 1377 | return false; | 1553 | return false; |
| 1378 | } | 1554 | } |
| 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 | |||
| 1379 | const children = yield* Ref.get(collabChildAgentsRef); | 1584 | const children = yield* Ref.get(collabChildAgentsRef); |
| 1380 | const child = children.get(providerConversationId); | 1585 | const child = children.get(providerConversationId); |
| 1381 | if (!child) { | 1586 | if (!child) { |
| 1382 | return false; | 1587 | return false; |
| 1383 | } | 1588 | } |
| 1384 | const childIdentity = { | 1589 | const metadata = (yield* Ref.get(collabChildMetadataRef)).get(child.agentThreadId); |
| 1385 | agentThreadId: child.agentThreadId, | 1590 | const childIdentity = collabChildIdentity(child, metadata); |
| 1386 | ...(child.nickname ? { nickname: child.nickname } : {}), | ||
| 1387 | ...(child.role ? { role: child.role } : {}), | ||
| 1388 | ...(child.agentPath ? { agentPath: child.agentPath } : {}), | ||
| 1389 | }; | ||
| 1390 | switch (notification.method) { | 1591 | switch (notification.method) { |
| 1391 | case "turn/started": { | 1592 | case "turn/started": { |
| 1593 | yield* markCollabChildOpen(child.agentThreadId); | ||
| 1392 | const childTurnId = | 1594 | const childTurnId = |
| 1393 | typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" | 1595 | typeof (notification.params as { turn?: { id?: unknown } }).turn?.id === "string" |
| 1394 | ? ((notification.params as { turn: { id: string } }).turn.id as string) | 1596 | ? ((notification.params as { turn: { id: string } }).turn.id as string) |
| ... | @@ -1472,6 +1674,7 @@ export const makeCodexSessionRuntime = ( | ... | @@ -1472,6 +1674,7 @@ export const makeCodexSessionRuntime = ( |
| 1472 | next.delete(child.agentThreadId); | 1674 | next.delete(child.agentThreadId); |
| 1473 | return next; | 1675 | return next; |
| 1474 | }); | 1676 | }); |
| 1677 | yield* markCollabChildClosed(child.agentThreadId); | ||
| 1475 | yield* emitEvent({ | 1678 | yield* emitEvent({ |
| 1476 | kind: "notification", | 1679 | kind: "notification", |
| 1477 | threadId: options.threadId, | 1680 | threadId: options.threadId, |
apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs+49-1| ... | @@ -57,7 +57,55 @@ rl.on("line", (line) => { | ... | @@ -57,7 +57,55 @@ rl.on("line", (line) => { |
| 57 | }); | 57 | }); |
| 58 | return; | 58 | return; |
| 59 | } | 59 | } |
| 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 | } | ||
| 61 | write({ id, result: fixture.responses.threadStart }); | 109 | write({ id, result: fixture.responses.threadStart }); |
| 62 | return; | 110 | return; |
| 63 | } | 111 | } |
docs/user/providers-codex.md+6| ... | @@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr | ... | @@ -34,6 +34,12 @@ In an existing Codex thread, send `/feedback` or `/feedback` followed by a descr |
| 34 | issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy | 34 | issue. T3 Code uploads the thread and Codex logs to OpenAI and shows a thread ID that you can copy |
| 35 | and share with OpenAI employees. | 35 | and share with OpenAI employees. |
| 36 | 36 | ||
| 37 | ## Sub-agent models | ||
| 38 | |||
| 39 | The web and desktop Agents panel shows each sub-agent's model and reasoning effort when Codex | ||
| 40 | reports them. If Codex does not report either value, T3 Code leaves it out instead of using the | ||
| 41 | parent agent's settings. | ||
| 42 | |||
| 37 | ## Approve access to other apps | 43 | ## Approve access to other apps |
| 38 | 44 | ||
| 39 | When a Codex tool needs access to an app such as Safari, T3 Code shows the app name and asks for | 45 | When 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", () => { | ... | @@ -603,6 +603,43 @@ describe("model and effort attribution", () => { |
| 603 | expect(agents[0]!.effort).toBe("high"); | 603 | expect(agents[0]!.effort).toBe("high"); |
| 604 | }); | 604 | }); |
| 605 | 605 | ||
| 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 | |||
| 606 | it("formatSubagentModelLabel compacts ids and appends effort", () => { | 643 | it("formatSubagentModelLabel compacts ids and appends effort", () => { |
| 607 | expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high"); | 644 | expect(formatSubagentModelLabel("claude-sonnet-5[1m]", "high")).toBe("sonnet-5[1m] · high"); |
| 608 | expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4"); | 645 | expect(formatSubagentModelLabel("claude-opus-4-20250514", null)).toBe("opus-4"); |