Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
8f212f8
feat(config): add runtime_fallback and fallback_models schema
Feb 3, 2026
6190d46
fix(runtime-fallback): add Category support and expand test coverage
Feb 3, 2026
6689ff2
feat(fallback_models): complete init-time and runtime integration
Feb 3, 2026
c62d8f1
docs: add runtime-fallback and fallback_models documentation
youming-ai Feb 3, 2026
7a6b8da
fix(delegate-task): restore overrideModel priority in resolveCategory…
youming-ai Feb 3, 2026
8e98f98
fix(runtime-fallback): per-model cooldown and stricter retry patterns
youming-ai Feb 4, 2026
8433b95
fix(session-category-registry): cleanup entries for task sessions
youming-ai Feb 4, 2026
708bcc1
test(delegate-task): stabilize browserProvider and default variant cases
youming-ai Feb 4, 2026
cea5f43
test(agents): update Atlas uiSelectedModel expectation
youming-ai Feb 4, 2026
4a64646
fix(runtime-fallback): use precise regex patterns for status code mat…
youming-ai Feb 5, 2026
be3c53b
refactor(shared): add normalizeFallbackModels utility function
youming-ai Feb 5, 2026
e61dd6d
refactor(agents): use normalizeFallbackModels utility across codebase
youming-ai Feb 5, 2026
bd7f2be
Merge branch 'dev' into feat/runtime-fallback-only
youming-ai Feb 5, 2026
c375961
Merge branch 'upstream/dev' into feat/runtime-fallback-only
youming-ai Feb 8, 2026
8f72f52
fix: resolve merge conflicts in PR #1408
youming-ai Feb 9, 2026
62fac11
feat(runtime-fallback): automatic model switching on API errors
youming-ai Feb 9, 2026
eaf52ca
fix(runtime-fallback): address cubic AI review issues
youming-ai Feb 9, 2026
e88340f
Merge branch 'feat/runtime-fallback-only' of github.com:youming-ai/oh…
youming-ai Feb 10, 2026
4aed41b
fix(runtime-fallback): sort agent names by length to fix hyphenated a…
youming-ai Feb 10, 2026
6796c6d
fix(runtime-fallback): 9 critical bug fixes for auto-retry, agent pre…
youngbinkim0 Feb 11, 2026
45d350c
refactor(runtime-fallback): extract auto-retry helper and fix provide…
youngbinkim0 Feb 11, 2026
e03b080
fix(runtime-fallback): harden fallback progression and success detection
youngbinkim0 Feb 12, 2026
f4df912
feat(runtime-fallback): add configurable session timeout controls
youngbinkim0 Feb 12, 2026
3d0e070
docs(runtime-fallback): document retry classes and timeout behavior
youngbinkim0 Feb 12, 2026
6c4e376
feat(runtime-fallback): generalize provider auto-retry signal detection
youngbinkim0 Feb 12, 2026
c430d96
feat(runtime-fallback): add timeout toggle for quota retry detection
youngbinkim0 Feb 12, 2026
7029ba2
fix(config): allow timeout_seconds to be 0 to disable fallback
youngbinkim0 Feb 12, 2026
fc1c4a9
fix(test): revert atlas test to use uiSelectedModel
youngbinkim0 Feb 17, 2026
a98633b
fix(plugin): add try/catch around runtimeFallback event handler
youngbinkim0 Feb 17, 2026
f09618e
docs(config): correct retry_on_errors default in schema comment
youngbinkim0 Feb 17, 2026
04f085c
docs(config): fix runtime fallback documentation
youngbinkim0 Feb 17, 2026
f700b81
refactor(runtime-fallback): decompose index.ts into focused modules
youngbinkim0 Feb 17, 2026
449ed9c
Merge remote-tracking branch 'upstream/dev' into feat/runtime-fallbac…
youngbinkim0 Feb 18, 2026
978ea61
chore: regenerate JSON schema after merge
youngbinkim0 Feb 18, 2026
b52c40c
Merge remote-tracking branch 'upstream/dev' into feat/runtime-fallbac…
youngbinkim0 Feb 18, 2026
dd78957
docs(runtime-fallback): clarify timeout_seconds=0 disables auto-retry…
iyoda Feb 19, 2026
b705c37
fix(runtime-fallback): detect type:error message parts for fallback p…
iyoda Feb 19, 2026
942ac24
fix(test): correct browserProvider assertion to match actual behavior
iyoda Feb 19, 2026
9a66e4f
fix(runtime-fallback): resolve fallback hang and manual model switch …
youngbinkim0 Feb 19, 2026
534568e
feat(runtime-fallback): add API-level error content detection as fall…
youngbinkim0 Feb 19, 2026
52d9d55
chore: background-agent stale fallback handler, test fixes, schema re…
youngbinkim0 Feb 19, 2026
044a250
feat(runtime-fallback): handle session.status retry events, on-demand…
youngbinkim0 Feb 19, 2026
ebe174d
merge: resolve conflicts with upstream/dev
youngbinkim0 Feb 19, 2026
319e5b1
fix(hashline-edit): cast context to ToolContextWithCallID for metadat…
youngbinkim0 Feb 19, 2026
ce31e6d
fix(runtime-fallback): trigger fallback on session.status retry, fix …
youngbinkim0 Feb 19, 2026
cd10f9a
fix(runtime-fallback): translate agent config key to display name for…
youngbinkim0 Feb 19, 2026
70244c1
fix(runtime-fallback): remove dead code for session.created model/age…
youngbinkim0 Feb 19, 2026
3200065
fix(runtime-fallback): fix agent resolution priority, add pinned agen…
youngbinkim0 Feb 20, 2026
fb7dc4e
fix: deduplicate agent override schemas using $ref
youngbinkim0 Feb 20, 2026
c0c59ee
test: fix flaky timer and dynamic-import isolation in CI
code-yeongyu Feb 20, 2026
550ad5d
@crazyrabbit0 has signed the CLA in code-yeongyu/oh-my-opencode#2012
github-actions[bot] Feb 20, 2026
330a4c7
merge: resolve conflicts between feat/runtime-fallback-only and upstr…
youngbinkim0 Feb 20, 2026
e1d6585
merge: resolve conflicts between feat/runtime-fallback-only and upstr…
youngbinkim0 Feb 21, 2026
0ec8361
fix(test): correct merge-conflict artifact in stale task test
youngbinkim0 Feb 21, 2026
989b713
chore: regenerate JSON schema
youngbinkim0 Feb 22, 2026
5587c93
merge: resolve conflicts with upstream/dev
youngbinkim0 Feb 22, 2026
f1e5bed
fix(test): update todo deny test to match implementation for all agents
youngbinkim0 Feb 22, 2026
7eb3133
fix(fallback): add quota protection to retryable error list and fix t…
youngbinkim0 Feb 22, 2026
7ebeac1
fix: address CI test failures and quota protection fallback
youngbinkim0 Feb 22, 2026
e5898f5
merge: resolve conflicts with upstream/dev, update stale task test fo…
youngbinkim0 Feb 22, 2026
aac7e1a
feat(runtime-fallback): add OpenRouter key.?limit.?exceeded pattern p…
youngbinkim0 Feb 22, 2026
9c4b1cc
fix: agent resolution race conditions in runtime-fallback
youngbinkim0 Feb 23, 2026
19ccfb3
chore: revert unrelated changes to minimize PR diff
youngbinkim0 Feb 23, 2026
d130577
fix: clear stale agent pin on tab-switch to prevent wrong fallback chain
youngbinkim0 Feb 23, 2026
f83d70b
fix: correct AGENTS_WITHOUT_TODO_DENY test expectations to match prod…
youngbinkim0 Feb 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions signatures/cla.json
Original file line number Diff line number Diff line change
Expand Up @@ -1687,6 +1687,14 @@
"created_at": "2026-02-22T10:57:33Z",
"repoId": 1108837393,
"pullRequestNo": 2045
},
{
"name": "DMax1314",
"id": 54206290,
"comment_id": 3943046087,
"created_at": "2026-02-23T07:06:14Z",
"repoId": 1108837393,
"pullRequestNo": 2068
}
]
}
12 changes: 6 additions & 6 deletions src/features/background-agent/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2554,8 +2554,8 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running")
})

test("should NOT interrupt running session with no progress (undefined lastUpdate)", async () => {
//#given — no progress at all, but session is running
test("should interrupt running session with no progress after messageStalenessTimeout (API hang detection)", async () => {
//#given — no progress at all, session is running but exceeded stale timeout
const client = {
session: {
prompt: async () => ({}),
Expand All @@ -2580,11 +2580,11 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {

getTaskMap(manager).set(task.id, task)

//#when — session is running despite no progress
//#when — session is running despite no progress for 15min (exceeds 10min timeout)
await manager["checkAndInterruptStaleTasks"]({ "session-rnp": { type: "running" } })

//#then — running sessions are NEVER killed
expect(task.status).toBe("running")
//#then — running sessions with no progress ARE interrupted after stale timeout
expect(task.status).toBe("cancelled")
expect(task.error).toContain("possible API hang")
})

test("should interrupt task with no lastUpdate after messageStalenessTimeout", async () => {
Expand Down
238 changes: 238 additions & 0 deletions src/features/background-agent/stale-fallback-handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { describe, it, expect, mock } from "bun:test"

import { resolveNextFallbackModel, buildFallbackLaunchInput, createStaleFallbackHandler } from "./stale-fallback-handler"
import type { BackgroundTask, LaunchInput } from "./types"
import type { OhMyOpenCodeConfig } from "../../config"

function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: "ses-1",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
description: "test task",
prompt: "test prompt",
agent: "explore",
status: "cancelled",
startedAt: new Date(),
model: { providerID: "kimi", modelID: "kimi-k2.5-free" },
...overrides,
}
}

function createConfig(overrides: Partial<OhMyOpenCodeConfig> = {}): OhMyOpenCodeConfig {
return {
agents: {
explore: {
fallback_models: ["kimi/kimi-k2.5-free", "glm/glm-4-flash-250414"],
},
},
...overrides,
} as unknown as OhMyOpenCodeConfig
}

describe("resolveNextFallbackModel", () => {
it("should use task.fallbackModels when present", () => {
//#given
const task = createTask({
fallbackModels: ["openai/gpt-4o", "anthropic/claude-sonnet-4-20250514"],
})
const config = createConfig()

//#when
const result = resolveNextFallbackModel(task, config)

//#then
expect(result).toEqual({
nextModel: "openai/gpt-4o",
remainingModels: ["anthropic/claude-sonnet-4-20250514"],
})
})

it("should resolve from agent config when task.fallbackModels is empty", () => {
//#given
const task = createTask({
agent: "explore",
model: { providerID: "kimi", modelID: "kimi-k2.5-free" },
fallbackModels: undefined,
})
const config = createConfig()

//#when
const result = resolveNextFallbackModel(task, config)

//#then
expect(result).toEqual({
nextModel: "glm/glm-4-flash-250414",
remainingModels: [],
})
})

it("should resolve from category config when agent has no fallback_models", () => {
//#given
const task = createTask({
agent: "unknown-agent",
category: "quick",
model: { providerID: "kimi", modelID: "kimi-k2.5-free" },
fallbackModels: undefined,
})
const config = {
agents: {},
categories: {
quick: {
fallback_models: ["kimi/kimi-k2.5-free", "openai/gpt-4o-mini"],
},
},
} as unknown as OhMyOpenCodeConfig

//#when
const result = resolveNextFallbackModel(task, config)

//#then
expect(result).toEqual({
nextModel: "openai/gpt-4o-mini",
remainingModels: [],
})
})

it("should return undefined when no fallback models available", () => {
//#given
const task = createTask({
agent: "unknown-agent",
fallbackModels: undefined,
})
const config = { agents: {} } as unknown as OhMyOpenCodeConfig

//#when
const result = resolveNextFallbackModel(task, config)

//#then
expect(result).toBeUndefined()
})

it("should return undefined when current model is the last in the chain", () => {
//#given
const task = createTask({
agent: "explore",
model: { providerID: "glm", modelID: "glm-4-flash-250414" },
fallbackModels: undefined,
})
const config = createConfig()

//#when
const result = resolveNextFallbackModel(task, config)

//#then
expect(result).toBeUndefined()
})
})

describe("buildFallbackLaunchInput", () => {
it("should build a valid LaunchInput with the new model", () => {
//#given
const task = createTask({
parentModel: { providerID: "anthropic", modelID: "claude-opus-4-6" },
parentAgent: "sisyphus",
isUnstableAgent: true,
category: "quick",
})

//#when
const result = buildFallbackLaunchInput(task, "openai/gpt-4o", ["anthropic/claude-sonnet-4-20250514"])

//#then
expect(result).toEqual({
description: "test task",
prompt: "test prompt",
agent: "explore",
parentSessionID: "parent-ses-1",
parentMessageID: "msg-1",
parentModel: { providerID: "anthropic", modelID: "claude-opus-4-6" },
parentAgent: "sisyphus",
parentTools: undefined,
model: { providerID: "openai", modelID: "gpt-4o" },
isUnstableAgent: true,
category: "quick",
fallbackModels: ["anthropic/claude-sonnet-4-20250514"],
})
})

it("should return undefined for invalid model format", () => {
//#given
const task = createTask()

//#when
const result = buildFallbackLaunchInput(task, "no-slash-model", [])

//#then
expect(result).toBeUndefined()
})

it("should handle model IDs with multiple slashes", () => {
//#given
const task = createTask()

//#when
const result = buildFallbackLaunchInput(task, "anthropic/claude-sonnet-4-20250514/latest", [])

//#then
expect(result?.model).toEqual({
providerID: "anthropic",
modelID: "claude-sonnet-4-20250514/latest",
})
})
})

describe("createStaleFallbackHandler", () => {
it("should launch a new task with the next fallback model", async () => {
//#given
const mockLaunch = mock(() =>
Promise.resolve(createTask({ id: "task-2", status: "pending" })),
)
const config = createConfig()
const task = createTask({
fallbackModels: ["openai/gpt-4o"],
})
const handler = createStaleFallbackHandler(config, mockLaunch)

//#when
await handler(task)

//#then
expect(mockLaunch).toHaveBeenCalledTimes(1)
const launchInput = mockLaunch.mock.calls[0][0] as LaunchInput
expect(launchInput.model).toEqual({ providerID: "openai", modelID: "gpt-4o" })
expect(launchInput.fallbackModels).toEqual([])
})

it("should not launch when no fallback models are available", async () => {
//#given
const mockLaunch = mock(() => Promise.resolve(createTask()))
const config = { agents: {} } as unknown as OhMyOpenCodeConfig
const task = createTask({
agent: "unknown",
fallbackModels: undefined,
})
const handler = createStaleFallbackHandler(config, mockLaunch)

//#when
await handler(task)

//#then
expect(mockLaunch).not.toHaveBeenCalled()
})

it("should not throw when launch fails", async () => {
//#given
const mockLaunch = mock(() => Promise.reject(new Error("launch failed")))
const config = createConfig()
const task = createTask({
fallbackModels: ["openai/gpt-4o"],
})
const handler = createStaleFallbackHandler(config, mockLaunch)

//#when + then (should not throw)
await handler(task)
expect(mockLaunch).toHaveBeenCalledTimes(1)
})
})
Loading