fix: resolve 3 community-reported bugs (#2915, #2917, #2918)

- background_output: snapshot read cursor before consuming, restore on
  /undo message removal so re-reads return data (fixes #2915)
- MCP loader: preserve oauth field in transformMcpServer, add scope/
  projectPath filtering so local-scoped MCPs only load in matching
  directories (fixes #2917)
- runtime-fallback: add 'reached your usage limit' to retryable error
  patterns so quota exhaustion triggers model fallback (fixes #2918)

Verified: bun test (4606 pass / 0 fail), tsc --noEmit clean
This commit is contained in:
YeonGyu-Kim
2026-03-29 04:53:36 +09:00
parent 9fc56ab544
commit b2497f1327
16 changed files with 575 additions and 6 deletions
@@ -10,11 +10,13 @@ import { formatTaskResult } from "./task-result-format"
import { formatTaskStatus } from "./task-status-format"
import { getAgentDisplayName } from "../../shared/agent-display-names"
import { recordBackgroundOutputConsumption } from "../../shared/background-output-consumption"
const SISYPHUS_JUNIOR_AGENT = getAgentDisplayName("sisyphus-junior")
type ToolContextWithMetadata = {
sessionID: string
messageID?: string
metadata?: (input: { title?: string; metadata?: Record<string, unknown> }) => void
callID?: string
callId?: string
@@ -139,6 +141,7 @@ export function createBackgroundOutput(manager: BackgroundOutputManager, client:
}
if (resolvedTask.status === "completed") {
recordBackgroundOutputConsumption(ctx.sessionID, ctx.messageID, resolvedTask.sessionID)
return await formatTaskResult(resolvedTask, client)
}
@@ -0,0 +1,129 @@
/// <reference types="bun-types" />
import { afterEach, describe, expect, test } from "bun:test"
import type { ToolContext } from "@opencode-ai/plugin/tool"
import type { BackgroundTask } from "../../features/background-agent"
import { createEventHandler } from "../../plugin/event"
import { clearBackgroundOutputConsumptionState } from "../../shared/background-output-consumption"
import { resetMessageCursor } from "../../shared/session-cursor"
import type { BackgroundOutputClient, BackgroundOutputManager } from "./clients"
import { createBackgroundOutput } from "./create-background-output"
const projectDir = "/Users/yeongyu/local-workspaces/oh-my-opencode"
const parentSessionID = "parent-session"
const taskSessionID = "task-session"
type ToolContextWithCallID = ToolContext & {
callID: string
}
const baseContext = {
sessionID: parentSessionID,
agent: "test-agent",
directory: projectDir,
worktree: projectDir,
abort: new AbortController().signal,
metadata: () => {},
ask: async () => {},
callID: "call-1",
} as const satisfies Partial<ToolContextWithCallID>
function createTask(overrides: Partial<BackgroundTask> = {}): BackgroundTask {
return {
id: "task-1",
sessionID: taskSessionID,
parentSessionID,
parentMessageID: "msg-parent",
description: "background task",
prompt: "do work",
agent: "test-agent",
status: "completed",
...overrides,
}
}
function createMockClient(): BackgroundOutputClient {
return {
session: {
messages: async () => ({
data: [
{
id: "m1",
info: { role: "assistant", time: "2026-01-01T00:00:00Z" },
parts: [{ type: "text", text: "final result" }],
},
],
}),
},
}
}
function createMockEventHandler() {
return createEventHandler({
ctx: {} as never,
pluginConfig: {} as never,
firstMessageVariantGate: {
markSessionCreated: () => {},
clear: () => {},
},
managers: {
skillMcpManager: {
disconnectSession: async () => {},
},
tmuxSessionManager: {
onSessionCreated: async () => {},
onSessionDeleted: async () => {},
},
} as never,
hooks: {} as never,
})
}
afterEach(() => {
resetMessageCursor(taskSessionID)
clearBackgroundOutputConsumptionState()
})
describe("createBackgroundOutput undo regression", () => {
test("#given consumed background output #when undo removes the parent message #then output can be consumed again", async () => {
// #given
const task = createTask()
const manager: BackgroundOutputManager = {
getTask: id => (id === task.id ? task : undefined),
}
const tool = createBackgroundOutput(manager, createMockClient())
const eventHandler = createMockEventHandler()
// #when
const firstOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, messageID: "msg-result-1" } as ToolContextWithCallID
)
const secondOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, callID: "call-2", messageID: "msg-result-2" } as ToolContextWithCallID
)
await eventHandler({
event: {
type: "message.removed",
properties: {
sessionID: parentSessionID,
messageID: "msg-result-1",
},
},
})
const thirdOutput = await tool.execute(
{ task_id: task.id },
{ ...baseContext, callID: "call-3", messageID: "msg-result-3" } as ToolContextWithCallID
)
// #then
expect(firstOutput).toContain("final result")
expect(secondOutput).toContain("No new output since last check")
expect(thirdOutput).toContain("final result")
})
})