diff --git a/src/features/background-agent/fallback-retry-handler.test.ts b/src/features/background-agent/fallback-retry-handler.test.ts index 53b7b7ef8..e208a707e 100644 --- a/src/features/background-agent/fallback-retry-handler.test.ts +++ b/src/features/background-agent/fallback-retry-handler.test.ts @@ -1,5 +1,5 @@ import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test" -import { tryFallbackRetry, type FallbackRetryHandlerDeps } from "./fallback-retry-handler" +import { tryFallbackRetry, TeamModeFallbackError, type FallbackRetryHandlerDeps } from "./fallback-retry-handler" import type { FallbackEntry } from "../../shared/model-requirements" const sharedLogMock = mock(() => {}) @@ -433,4 +433,41 @@ describe("tryFallbackRetry", () => { expect(args.task.model?.modelID).toBe("fallback-model-1") }) }) + + describe("#team-mode fallback", () => { + test("throws TeamModeFallbackError when teamRunId is set but onSessionCreated is absent", async () => { + // given: a team-mode task that somehow lost its onSessionCreated callback — + // without it the fallback session would not be registered in the team-session + // registry and every team tool call would silently fail with "not in team" + const args = createDefaultArgs({ + teamRunId: "team-run-abc", + onSessionCreated: undefined, + }) + + // when / then: a bounded structured error must surface instead + await expect(tryFallbackRetry(args)).rejects.toThrow(TeamModeFallbackError) + await expect(tryFallbackRetry(createDefaultArgs({ teamRunId: "team-run-abc", onSessionCreated: undefined }))).rejects.toThrow( + "team-mode fallback denied: cannot preserve team context", + ) + }) + + test("proceeds normally when teamRunId and onSessionCreated are both present", async () => { + // given: a properly-formed team-mode task with its session registration callback + const onSessionCreated = mock(async () => {}) + const args = createDefaultArgs({ + teamRunId: "team-run-abc", + onSessionCreated, + }) + + // when + const result = await tryFallbackRetry(args) + + // then: fallback is queued and the retry input preserves both team fields + expect(result).toBe(true) + const key = `${args.task.model!.providerID}/${args.task.model!.modelID}` + const retryInput = args.queuesByKey.get(key)?.[0]?.input + expect(retryInput?.teamRunId).toBe("team-run-abc") + expect(retryInput?.onSessionCreated).toBe(onSessionCreated) + }) + }) }) diff --git a/src/features/background-agent/fallback-retry-handler.ts b/src/features/background-agent/fallback-retry-handler.ts index 3cf1453ef..a52c13546 100644 --- a/src/features/background-agent/fallback-retry-handler.ts +++ b/src/features/background-agent/fallback-retry-handler.ts @@ -13,6 +13,13 @@ import { transformModelForProvider } from "../../shared/provider-model-id-transf import { abortWithTimeout } from "./abort-with-timeout" import { ensureCurrentAttempt, scheduleRetryAttempt } from "./attempt-lifecycle" +export class TeamModeFallbackError extends Error { + constructor(message: string) { + super(message) + this.name = "TeamModeFallbackError" + } +} + function canonicalizeModelID(modelID: string): string { return modelID.toLowerCase().replace(/\./g, "-") } @@ -183,6 +190,21 @@ export async function tryFallbackRetry(args: { nextModel: `${providerID}/${transformedModelId}`, }) + // Guard: a team-mode task (teamRunId set) MUST carry an onSessionCreated callback so + // the fallback session gets registered in the team-session registry under the original + // member slot. Without it the new session would not appear as a team participant and + // every subsequent team tool call would throw "not in team". Fail with a bounded + // structured error instead of silently entering that confusing runtime state. + if (task.teamRunId && !task.onSessionCreated) { + deps.log("[background-agent] team-mode fallback denied: task has teamRunId but no onSessionCreated; cannot preserve team membership", { + taskId: task.id, + teamRunId: task.teamRunId, + }) + throw new TeamModeFallbackError( + `team-mode fallback denied: cannot preserve team context for task ${task.id} (teamRunId=${task.teamRunId})`, + ) + } + const key = task.model ? `${task.model.providerID}/${task.model.modelID}` : task.agent const queue = queuesByKey.get(key) ?? [] const retryInput: LaunchInput = {