Merge pull request #4067 from PeterPonyu/fix/3898-fallback-preserve-team-context

fix(team-mode): preserve team membership across model fallback (#3898)
This commit is contained in:
YeonGyu-Kim
2026-05-21 00:41:11 +09:00
committed by GitHub
2 changed files with 60 additions and 1 deletions
@@ -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"
import type { ProviderModelsCache } from "../../shared/connected-providers-cache"
import { QUESTION_DENIED_SESSION_PERMISSION } from "../../shared/question-denied-session-permission"
@@ -453,4 +453,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)
})
})
})
@@ -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 = {