History

maybe fix codex subturns

Julius Marminge committed 8 months ago

Commit ef843d41 parent 654ca0d4 files · +334 −16Browse files

Showing the changes to apps/server/src/codexAppServerManager.test.ts (1 of 4 files). Show all

Unified · Split

apps/server/src/codexAppServerManager.test.ts+187−0
@@ -37,6 +37,7 @@ function createSendTurnHarness() { · 36 unmodified lines
3737 planType: null,
3838 sparkEnabled: true,
3939 },
40+ collabReceiverTurns: new Map(),
4041 };
4142
4243 const requireSession = vi
@@ -75,6 +76,7 @@ function createThreadControlHarness() { · 32 unmodified lines
7576 createdAt: "2026-02-10T00:00:00.000Z",
7677 updatedAt: "2026-02-10T00:00:00.000Z",
7778 },
79+ collabReceiverTurns: new Map(),
7880 };
7981
8082 const requireSession = vi
@@ -117,6 +119,7 @@ function createPendingUserInputHarness() { · 36 unmodified lines
117119 },
118120 ],
119121 ]),
122+ collabReceiverTurns: new Map(),
120123 };
121124
122125 const requireSession = vi
@@ -135,6 +138,43 @@ function createPendingUserInputHarness() { · 12 unmodified lines
135138 return { manager, context, requireSession, writeMessage, emitEvent };
136139 }
137140
141+function createCollabNotificationHarness() {
142+ const manager = new CodexAppServerManager();
143+ const context = {
144+ session: {
145+ provider: "codex",
146+ status: "running",
147+ threadId: asThreadId("thread_1"),
148+ runtimeMode: "full-access",
149+ model: "gpt-5.3-codex",
150+ activeTurnId: "turn_parent",
151+ resumeCursor: { threadId: "provider_parent" },
152+ createdAt: "2026-02-10T00:00:00.000Z",
153+ updatedAt: "2026-02-10T00:00:00.000Z",
154+ },
155+ account: {
156+ type: "unknown",
157+ planType: null,
158+ sparkEnabled: true,
159+ },
160+ pending: new Map(),
161+ pendingApprovals: new Map(),
162+ pendingUserInputs: new Map(),
163+ collabReceiverTurns: new Map<string, string>(),
164+ nextRequestId: 1,
165+ stopping: false,
166+ };
167+
168+ const emitEvent = vi
169+ .spyOn(manager as unknown as { emitEvent: (...args: unknown[]) => void }, "emitEvent")
170+ .mockImplementation(() => {});
171+ const updateSession = vi
172+ .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession")
173+ .mockImplementation(() => {});
174+
175+ return { manager, context, emitEvent, updateSession };
176+}
177+
138178 describe("classifyCodexStderrLine", () => {
139179 it("ignores empty lines", () => {
140180 expect(classifyCodexStderrLine(" ")).toBeNull();
@@ -721,6 +761,7 @@ describe("respondToUserInput", () => { · 580 unmodified lines
721761 },
722762 pendingApprovals: new Map(),
723763 pendingUserInputs: new Map(),
764+ collabReceiverTurns: new Map(),
724765 };
725766 type ApprovalRequestContext = {
726767 session: typeof context.session;
@@ -748,6 +789,152 @@ describe("respondToUserInput", () => { · 21 unmodified lines
748789 });
749790 });
750791
792+describe("collab child conversation routing", () => {
793+ it("rewrites child notification turn ids onto the parent turn", () => {
794+ const { manager, context, emitEvent } = createCollabNotificationHarness();
795+
796+ (
797+ manager as unknown as {
798+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
799+ }
800+ ).handleServerNotification(context, {
801+ method: "item/completed",
802+ params: {
803+ item: {
804+ type: "collabAgentToolCall",
805+ id: "call_collab_1",
806+ receiverThreadIds: ["child_provider_1"],
807+ },
808+ threadId: "provider_parent",
809+ turnId: "turn_parent",
810+ },
811+ });
812+
813+ (
814+ manager as unknown as {
815+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
816+ }
817+ ).handleServerNotification(context, {
818+ method: "item/agentMessage/delta",
819+ params: {
820+ threadId: "child_provider_1",
821+ turnId: "turn_child_1",
822+ itemId: "msg_child_1",
823+ delta: "working",
824+ },
825+ });
826+
827+ expect(emitEvent).toHaveBeenLastCalledWith(
828+ expect.objectContaining({
829+ method: "item/agentMessage/delta",
830+ turnId: "turn_parent",
831+ itemId: "msg_child_1",
832+ }),
833+ );
834+ });
835+
836+ it("suppresses child lifecycle notifications so they cannot replace the parent turn", () => {
837+ const { manager, context, emitEvent, updateSession } = createCollabNotificationHarness();
838+
839+ (
840+ manager as unknown as {
841+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
842+ }
843+ ).handleServerNotification(context, {
844+ method: "item/completed",
845+ params: {
846+ item: {
847+ type: "collabAgentToolCall",
848+ id: "call_collab_1",
849+ receiverThreadIds: ["child_provider_1"],
850+ },
851+ threadId: "provider_parent",
852+ turnId: "turn_parent",
853+ },
854+ });
855+ emitEvent.mockClear();
856+ updateSession.mockClear();
857+
858+ (
859+ manager as unknown as {
860+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
861+ }
862+ ).handleServerNotification(context, {
863+ method: "turn/started",
864+ params: {
865+ threadId: "child_provider_1",
866+ turn: { id: "turn_child_1" },
867+ },
868+ });
869+
870+ (
871+ manager as unknown as {
872+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
873+ }
874+ ).handleServerNotification(context, {
875+ method: "turn/completed",
876+ params: {
877+ threadId: "child_provider_1",
878+ turn: { id: "turn_child_1", status: "completed" },
879+ },
880+ });
881+
882+ expect(emitEvent).not.toHaveBeenCalled();
883+ expect(updateSession).not.toHaveBeenCalled();
884+ });
885+
886+ it("rewrites child approval requests onto the parent turn", () => {
887+ const { manager, context, emitEvent } = createCollabNotificationHarness();
888+
889+ (
890+ manager as unknown as {
891+ handleServerNotification: (context: unknown, notification: Record<string, unknown>) => void;
892+ }
893+ ).handleServerNotification(context, {
894+ method: "item/completed",
895+ params: {
896+ item: {
897+ type: "collabAgentToolCall",
898+ id: "call_collab_1",
899+ receiverThreadIds: ["child_provider_1"],
900+ },
901+ threadId: "provider_parent",
902+ turnId: "turn_parent",
903+ },
904+ });
905+ emitEvent.mockClear();
906+
907+ (
908+ manager as unknown as {
909+ handleServerRequest: (context: unknown, request: Record<string, unknown>) => void;
910+ }
911+ ).handleServerRequest(context, {
912+ id: 42,
913+ method: "item/commandExecution/requestApproval",
914+ params: {
915+ threadId: "child_provider_1",
916+ turnId: "turn_child_1",
917+ itemId: "call_child_1",
918+ command: "bun install",
919+ },
920+ });
921+
922+ expect(Array.from(context.pendingApprovals.values())[0]).toEqual(
923+ expect.objectContaining({
924+ turnId: "turn_parent",
925+ itemId: "call_child_1",
926+ }),
927+ );
928+ expect(emitEvent).toHaveBeenCalledWith(
929+ expect.objectContaining({
930+ method: "item/commandExecution/requestApproval",
931+ turnId: "turn_parent",
932+ itemId: "call_child_1",
933+ }),
934+ );
935+ });
936+});
937+
751938 describe.skipIf(!process.env.CODEX_BINARY_PATH)("startSession live Codex resume", () => {
752939 it("keeps prior thread history when resuming with a changed runtime mode", async () => {
753940 const workspaceDir = mkdtempSync(path.join(os.tmpdir(), "codex-live-resume-"));