diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 21039f5..303a9d8 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -55,6 +55,7 @@ function createSendTurnHarness(runtimeMode: "approval-required" | "full-access" collabReceiverTurns: new Map(), collabReceiverParents: new Map(), reviewTurnIds: new Set(), + lastSentTurnParams: undefined, }; const requireSession = vi @@ -924,6 +925,89 @@ describe("sendTurn", () => { }); }); +describe("sendTurn turn-param deduplication", () => { + it("first turn (lastSentTurnParams undefined) sends collaborationMode, runtimeOverrides, model, and effort", async () => { + const { manager, sendRequest } = createSendTurnHarness("full-access"); + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "hello", + interactionMode: "default", + effort: "medium", + }); + const params = sendRequest.mock.calls[0]![2] as Record; + expect(params).toHaveProperty("collaborationMode"); + expect(params).toHaveProperty("approvalPolicy", "never"); + expect(params).toHaveProperty("sandboxPolicy"); + expect(params).toHaveProperty("model"); + expect(params).toHaveProperty("effort", "medium"); + }); + + it("second turn with identical params omits collaborationMode, runtimeOverrides, model, and effort", async () => { + const { manager, context, sendRequest } = createSendTurnHarness("full-access"); + (context as Record).lastSentTurnParams = { + interactionMode: "default", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + effort: "medium", + }; + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "follow-up", + interactionMode: "default", + effort: "medium", + }); + const params = sendRequest.mock.calls[0]![2] as Record; + expect(params).not.toHaveProperty("collaborationMode"); + expect(params).not.toHaveProperty("approvalPolicy"); + expect(params).not.toHaveProperty("sandboxPolicy"); + expect(params).not.toHaveProperty("effort"); + expect(params).not.toHaveProperty("model"); + }); + + it("turn after interactionMode change re-sends collaborationMode", async () => { + const { manager, context, sendRequest } = createSendTurnHarness("full-access"); + (context as Record).lastSentTurnParams = { + interactionMode: "default", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + effort: "medium", + }; + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "plan this", + interactionMode: "plan", + effort: "medium", + }); + const params = sendRequest.mock.calls[0]![2] as Record; + expect(params).toHaveProperty("collaborationMode"); + const collab = params.collaborationMode as { mode: string }; + expect(collab.mode).toBe("plan"); + expect(params).not.toHaveProperty("approvalPolicy"); + expect(params).not.toHaveProperty("sandboxPolicy"); + }); + + it("turn after runtimeMode change re-sends approvalPolicy and sandboxPolicy", async () => { + const { manager, context, sendRequest } = createSendTurnHarness("full-access"); + (context as Record).lastSentTurnParams = { + interactionMode: "default", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + effort: "medium", + }; + (context.session as Record).runtimeMode = "approval-required"; + await manager.sendTurn({ + threadId: asThreadId("thread_1"), + input: "next turn", + interactionMode: "default", + effort: "medium", + }); + const params = sendRequest.mock.calls[0]![2] as Record; + expect(params).toHaveProperty("approvalPolicy", "untrusted"); + expect(params).toHaveProperty("sandboxPolicy", { type: "readOnly" }); + expect(params).not.toHaveProperty("collaborationMode"); + }); +}); + describe("steerTurn", () => { it("steers the active Codex turn when the session is already running", async () => { const { manager, context, sendRequest } = createSendTurnHarness(); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index c21b31b..3ed5b91 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -106,6 +106,14 @@ interface CodexSessionContext { nextRequestId: number; stopping: boolean; discovery?: boolean; + lastSentTurnParams: CodexLastSentTurnParams | undefined; +} + +interface CodexLastSentTurnParams { + interactionMode: "default" | "plan" | undefined; + runtimeMode: RuntimeMode | undefined; + model: string | undefined; + effort: string | undefined; } interface CodexSkillListInput { @@ -728,6 +736,7 @@ export class CodexAppServerManager extends EventEmitter { }); const createInput = harness.getLastCreateQueryInput(); - assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.deepEqual(createInput?.options.settingSources, ["user", "project"]); assert.equal(createInput?.options.permissionMode, "bypassPermissions"); assert.equal(createInput?.options.allowDangerouslySkipPermissions, true); }).pipe( @@ -306,7 +306,7 @@ describe("ClaudeAdapterLive", () => { }); const createInput = harness.getLastCreateQueryInput(); - assert.deepEqual(createInput?.options.settingSources, ["user", "project", "local"]); + assert.deepEqual(createInput?.options.settingSources, ["user", "project"]); assert.equal(createInput?.options.permissionMode, undefined); assert.equal(createInput?.options.allowDangerouslySkipPermissions, undefined); assert.deepEqual(createInput?.options.systemPrompt, { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 56b747c..ffa7679 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -199,6 +199,7 @@ interface ClaudeSessionContext { streamFiber: Fiber.Fiber | undefined; readonly startedAt: string; readonly basePermissionMode: PermissionMode | undefined; + lastSentPermissionMode: PermissionMode | undefined; currentApiModelId: string | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; @@ -730,11 +731,7 @@ const SUPPORTED_CLAUDE_IMAGE_MIME_TYPES = new Set([ "image/png", "image/webp", ]); -const CLAUDE_SETTING_SOURCES = [ - "user", - "project", - "local", -] as const satisfies ReadonlyArray; +const CLAUDE_SETTING_SOURCES = ["user", "project"] as const satisfies ReadonlyArray; const EMBEDDED_CLAUDE_SYSTEM_PROMPT_APPEND = [ "You are running inside Peak Code, a coding app that embeds the Claude Agent SDK.", "Do not present the host app as Claude Code unless the user is explicitly asking about Claude Code.", @@ -3318,6 +3315,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { streamFiber: undefined, startedAt, basePermissionMode: permissionMode, + lastSentPermissionMode: undefined, currentApiModelId: apiModelId, resumeSessionId: sessionId, pendingApprovals, @@ -3433,16 +3431,22 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { // "plan" maps directly to the SDK's "plan" permission mode; // "default" restores the session's original permission mode. // When interactionMode is absent we leave the current mode unchanged. - if (input.interactionMode === "plan") { - yield* Effect.tryPromise({ - try: () => context.query.setPermissionMode("plan"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), - }); - } else if (input.interactionMode === "default") { - yield* Effect.tryPromise({ - try: () => context.query.setPermissionMode(context.basePermissionMode ?? "default"), - catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), - }); + const targetPermissionMode: PermissionMode = + input.interactionMode === "plan" + ? "plan" + : (context.basePermissionMode ?? "default"); + + if ( + input.interactionMode === "plan" || + input.interactionMode === "default" + ) { + if (context.lastSentPermissionMode !== targetPermissionMode) { + yield* Effect.tryPromise({ + try: () => context.query.setPermissionMode(targetPermissionMode), + catch: (cause) => toRequestError(input.threadId, "turn/setPermissionMode", cause), + }); + context.lastSentPermissionMode = targetPermissionMode; + } } const turnId = TurnId.makeUnsafe(yield* Random.nextUUIDv4);