From cda040b4e5f7f5111ba021314edffed8911d6fa5 Mon Sep 17 00:00:00 2001 From: Chunyue Wang <80630709+openperf@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:11:16 +0800 Subject: [PATCH] fix(agents): clamp subagent spawn thinking overrides Fixes #92412. Subagent spawns that request an unsupported explicit thinking level now clamp through the existing provider/model thinking fallback instead of hard-failing after the orchestrator has already received an accepted ack. The exception is limited to trusted subagent spawn runs by requiring both the subagent lane and a subagent-shaped session key, so interactive and non-subagent explicit `--thinking` validation still fails loudly. Verification: - `node scripts/run-vitest.mjs src/agents/agent-command.live-model-switch.test.ts --maxWorkers=1` - `.agents/skills/autoreview/scripts/autoreview --mode branch --base origin/main` - Testbox-through-Crabbox `tbx_01kv2wt0nqavsmnvzzzy2antrc`: `OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 corepack pnpm check:changed` - GitHub PR checks clean on `c71186863337d9dfb9a18e5349ebef634a7d5ccd` --- .../agent-command.live-model-switch.test.ts | 70 ++++++++++++++++++- src/agents/agent-command.ts | 8 ++- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index f2bb4a15de5e..40caa771afdd 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -328,7 +328,6 @@ vi.mock("../routing/session-key.js", async () => { ); return { ...actual, - isSubagentSessionKey: () => false, normalizeAgentId: (id: string) => id, normalizeMainKey: (key?: string | null) => key?.trim() || "main", }; @@ -1340,6 +1339,75 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }); }); + it("clamps unsupported explicit thinking for subagent spawns instead of throwing", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); + state.resolvedSessionKeyMock = "agent:planner:subagent:00000000-0000-4000-8000-000000000000"; + state.isThinkingLevelSupportedMock.mockReturnValue(false); + state.resolveSupportedThinkingLevelMock.mockReturnValue("high"); + + await agentCommand({ + message: "hello", + sessionKey: state.resolvedSessionKeyMock, + thinking: "xhigh", + lane: "subagent", + }); + + expect(state.resolveSupportedThinkingLevelMock).toHaveBeenCalled(); + expectRecordFields(mockCallArg(state.runAgentAttemptMock), { + resolvedThinkLevel: "high", + }); + }); + + it("rejects unsupported explicit thinking for interactive subagent-key runs", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); + state.resolvedSessionKeyMock = "agent:planner:subagent:00000000-0000-4000-8000-000000000000"; + state.isThinkingLevelSupportedMock.mockReturnValue(false); + + await expect( + agentCommand({ + message: "hello", + sessionKey: state.resolvedSessionKeyMock, + thinking: "xhigh", + }), + ).rejects.toThrow(/is not supported/u); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + + it("rejects unsupported explicit thinking for non-subagent sessions on the subagent lane", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); + state.resolvedSessionKeyMock = "agent:main:main"; + state.isThinkingLevelSupportedMock.mockReturnValue(false); + + await expect( + agentCommand({ + message: "hello", + sessionKey: state.resolvedSessionKeyMock, + thinking: "xhigh", + lane: "subagent", + }), + ).rejects.toThrow(/is not supported/u); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + + it("rejects unsupported explicit thinking for direct interactive runs", async () => { + setupSingleAttemptFallback(); + state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5")); + state.resolvedSessionKeyMock = "agent:main:main"; + state.isThinkingLevelSupportedMock.mockReturnValue(false); + + await expect( + agentCommand({ + message: "hello", + to: "+1234567890", + thinking: "xhigh", + }), + ).rejects.toThrow(/is not supported/u); + expect(state.runAgentAttemptMock).not.toHaveBeenCalled(); + }); + it("skips the initial session touch after gateway ingress already persisted activity", async () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 70b8b494e39e..c87697470502 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -819,6 +819,7 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run manifestMetadataSnapshot, modelManifestContext, runId, + isSubagentLane, acpManager, acpResolution, }; @@ -859,6 +860,7 @@ async function agentCommandInternal( cwd, agentDir, runId, + isSubagentLane, acpManager, acpResolution, pluginsEnabled, @@ -1597,7 +1599,11 @@ async function agentCommandInternal( }) ) { const explicitThink = Boolean(thinkOnce || thinkOverride); - if (explicitThink) { + const isSubagentSpawnRun = isSubagentLane && isSubagentSessionKey(sessionKey); + // Spawn-lane subagents are fire-and-forget; the orchestrator already got + // an "accepted" ack, so throwing here strands the run and half-fails fan-outs. + // Clamp like the embedded runner; interactive --thinking keeps the throw. + if (explicitThink && !isSubagentSpawnRun) { throw new Error( `Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model, ", ", thinkingCatalog)}.`, );