Skip to content

Commit 4bb1406

Browse files
ShutovKSclaude
andauthored
test: in-process e2e via SDK server; fix recovery race with echoed chat.message (#12)
Add test/e2e-server.test.ts: boots a real headless OpenCode server (createOpencodeServer) with a fake OpenAI provider, drives it through the SDK client, and observes plugin decisions via the SSE event stream. Covers status-path fallback, retry_on_patterns, cascade fallback, and recovery. The recovery e2e exposed a real bug: OpenCode invokes the chat.message hook for the plugin's own retry prompt. Since pendingModel was set only after promptAsync resolved, that echoed hook call was treated as a manual model switch, overwriting originalModel with the fallback and silently disabling recover_original_model in the live runtime. Set pendingModel/awaitingModel before prompting (and roll back if the retry is not accepted). Also: chat.message now backfills state models via getState when a model is present (session.created carries no model in the live runtime), and cascade-model uses 400 (OpenCode self-retries 429/503 before emitting session.error, masking plugin-driven cascade). New unit test reproduces the echoed-prompt runtime behavior. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d0dde3b commit 4bb1406

5 files changed

Lines changed: 353 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,24 @@ behavior unless noted.
5050

5151
### Fixed
5252

53+
- **Recovery works in the live runtime.** OpenCode invokes the
54+
`chat.message` hook for the plugin's *own* retry prompt. `pendingModel` is
55+
now set *before* `session.promptAsync`, so that echoed hook call is no
56+
longer mistaken for a manual model switch (which overwrote
57+
`originalModel` with the fallback and silently disabled
58+
`recover_original_model`). Found by the new in-process e2e suite.
5359
- **Expired model cooldowns are pruned.** `state.failedUntil` no longer
5460
accumulates one entry per failed model for the whole session lifetime. (#2)
5561

62+
### Tests
63+
64+
- **In-process e2e suite against a real headless OpenCode server**
65+
(`test/e2e-server.test.ts`): boots `opencode serve` via
66+
`createOpencodeServer`, drives it through the SDK client, observes plugin
67+
decisions through the SSE event stream (`session.error`, `tui.toast.show`,
68+
`message.updated`). Covers status-path fallback, `retry_on_patterns`,
69+
cascade fallback, and recovery — the paths unit mocks could not validate.
70+
5671
## [1.0.8] - 2026-07-13
5772

5873
### Added

src/plugin.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -610,8 +610,16 @@ export function createModelFallbackPlugin(input: RuntimeInput, options: Options
610610
await sleep(backoff)
611611
}
612612

613+
// Mark the fallback as pending BEFORE prompting: promptAsync triggers
614+
// the chat.message hook synchronously for our own retry prompt, and
615+
// without pendingModel set that prompt would be mistaken for a manual
616+
// model switch (overwriting originalModel and wiping cooldowns).
617+
state.pendingModel = model
618+
state.awaitingModel = model
613619
const accepted = await retryWithModel(sessionID, state, model)
614620
if (!accepted) {
621+
state.pendingModel = undefined
622+
state.awaitingModel = undefined
615623
debug(`session ${sessionID}: retry with ${model} not accepted (no last user message)`)
616624
return
617625
}
@@ -620,8 +628,6 @@ export function createModelFallbackPlugin(input: RuntimeInput, options: Options
620628
state.attemptTimes.push(now)
621629
const previousModel = state.currentModel
622630
state.currentModel = model
623-
state.pendingModel = model
624-
state.awaitingModel = model
625631
recordSwitch(state, previousModel ?? null, model, "fallback", now)
626632
debug(`session ${sessionID}: switched to ${model} (attempt ${state.attemptTimes.length})`)
627633
await showToast(`Switched to ${model}`)
@@ -632,9 +638,13 @@ export function createModelFallbackPlugin(input: RuntimeInput, options: Options
632638

633639
"chat.message": async (chatInput: ChatMessageInput, output: ChatMessageOutput) => {
634640
const requestedModel = normalizeModel(chatInput.model)
635-
const state = states.get(chatInput.sessionID) ?? (requestedModel
641+
// Always go through getState when a model is present: in the live
642+
// runtime session.created carries no model, so the state created by the
643+
// event handler has originalModel/currentModel unset and getState
644+
// backfills them from the first prompt.
645+
const state = requestedModel
636646
? getState(states, chatInput.sessionID, requestedModel, chatInput.agent)
637-
: undefined)
647+
: states.get(chatInput.sessionID)
638648
if (!state || !isEnabled(config, state)) return
639649

640650
if (requestedModel === state.pendingModel) {

test/e2e-server.test.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
2+
import { mkdtempSync, rmSync, readFileSync, writeFileSync } from "node:fs"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
import { createOpencodeServer, createOpencodeClient } from "@opencode-ai/sdk"
6+
7+
const PLUGIN_PATH = new URL("../dist/index.js", import.meta.url).pathname
8+
const FAKE_PROVIDER_SCRIPT = new URL("./fake-provider.ts", import.meta.url).pathname
9+
10+
type Call = { path: string; model: string; status: number }
11+
12+
type Env = {
13+
server: { url: string; close(): void }
14+
client: ReturnType<typeof createOpencodeClient>
15+
calls: () => Call[]
16+
waitForCall: (pred: (c: Call) => boolean, ms?: number) => Promise<boolean>
17+
stop: () => void
18+
}
19+
20+
async function startFakeProvider(): Promise<{ port: number; proc: ReturnType<typeof Bun.spawn>; callLog: string }> {
21+
const callLog = join(tmpdir(), `mf-calls-${Math.random().toString(36).slice(2)}.json`)
22+
writeFileSync(callLog, "[]")
23+
const proc = Bun.spawn({
24+
cmd: ["bun", "run", FAKE_PROVIDER_SCRIPT],
25+
env: { ...process.env, FAKE_PORT: "0", FAKE_LOG: callLog },
26+
stdout: "pipe",
27+
stderr: "pipe",
28+
})
29+
const stderr = proc.stderr
30+
if (!stderr || typeof stderr === "number") throw new Error("no stderr")
31+
const reader = (stderr as ReadableStream<Uint8Array>).getReader()
32+
const decoder = new TextDecoder()
33+
let buf = ""
34+
while (true) {
35+
const { value, done } = await reader.read()
36+
if (done) throw new Error("fake provider exited before ready: " + buf)
37+
buf += decoder.decode(value, { stream: true })
38+
const match = buf.match(/READY (\d+)/)
39+
if (match) {
40+
reader.releaseLock()
41+
return { port: Number(match[1]), proc, callLog }
42+
}
43+
}
44+
}
45+
46+
function makeConfig(port: number, pluginOpts: Record<string, unknown>): string {
47+
return JSON.stringify({
48+
provider: {
49+
fake: {
50+
api: "openai",
51+
name: "Fake",
52+
options: { baseURL: `http://127.0.0.1:${port}/v1`, apiKey: "test-key" },
53+
models: {
54+
"fail-model": { name: "Fail" },
55+
"err-model": { name: "Error" },
56+
"ok-model": { name: "OK" },
57+
"ok-model-2": { name: "OK2" },
58+
"deny-model": { name: "Deny" },
59+
"retry-model": { name: "Retry" },
60+
"unavail-model": { name: "Unavail" },
61+
"cascade-model": { name: "Cascade" },
62+
},
63+
},
64+
},
65+
model: "fake/retry-model",
66+
permission: "allow",
67+
plugin: [[PLUGIN_PATH, pluginOpts]],
68+
})
69+
}
70+
71+
async function withServer(pluginOpts: Record<string, unknown>, fn: (env: Env) => Promise<void>): Promise<void> {
72+
const dir = mkdtempSync(join(tmpdir(), "mf-e2e-"))
73+
const dataDir = mkdtempSync(join(tmpdir(), "mf-data-"))
74+
const fake = await startFakeProvider()
75+
const config = JSON.parse(makeConfig(fake.port, pluginOpts))
76+
77+
const prevConfigDir = process.env.OPENCODE_CONFIG_DIR
78+
const prevDataHome = process.env.XDG_DATA_HOME
79+
const prevConfigHome = process.env.XDG_CONFIG_HOME
80+
process.env.OPENCODE_CONFIG_DIR = ""
81+
process.env.XDG_DATA_HOME = dataDir
82+
process.env.XDG_CONFIG_HOME = dataDir
83+
84+
let server: { url: string; close(): void } | undefined
85+
try {
86+
server = await createOpencodeServer({ config, port: 0, timeout: 15000 })
87+
const client = createOpencodeClient({ directory: dir, baseUrl: server.url })
88+
const calls = () => {
89+
try { return JSON.parse(readFileSync(fake.callLog, "utf-8")) as Call[] } catch { return [] }
90+
}
91+
const waitForCall = async (pred: (c: Call) => boolean, ms = 10000): Promise<boolean> => {
92+
const start = Date.now()
93+
while (Date.now() - start < ms) {
94+
if (calls().some(pred)) return true
95+
await new Promise((r) => setTimeout(r, 50))
96+
}
97+
return calls().some(pred)
98+
}
99+
await fn({
100+
server,
101+
client,
102+
calls,
103+
waitForCall,
104+
stop: () => {},
105+
})
106+
} finally {
107+
server?.close()
108+
fake.proc.kill()
109+
process.env.OPENCODE_CONFIG_DIR = prevConfigDir
110+
process.env.XDG_DATA_HOME = prevDataHome
111+
process.env.XDG_CONFIG_HOME = prevConfigHome
112+
rmSync(dir, { recursive: true, force: true })
113+
rmSync(dataDir, { recursive: true, force: true })
114+
rmSync(fake.callLog, { force: true })
115+
}
116+
}
117+
118+
// OpenCode event stream wraps each event as { payload: { type, properties } }.
119+
type Ev = { type: string; properties: any }
120+
121+
async function collectEvents(client: ReturnType<typeof createOpencodeClient>, _sessionID: string): Promise<{ stop: () => void; events: () => Ev[]; wait: (pred: (e: Ev) => boolean, ms?: number) => Promise<Ev | undefined>; attachSession: (sid: string) => void }> {
122+
const allEvents: Ev[] = []
123+
let filterSid: string | undefined
124+
const matches = (e: Ev) => !filterSid || e.properties?.sessionID === filterSid || !e.properties?.sessionID
125+
const sse = await client.global.event() as any
126+
const iterator = sse.stream[Symbol.asyncIterator]()
127+
let stopped = false
128+
const pump = (async () => {
129+
while (!stopped) {
130+
try {
131+
const { value, done } = await iterator.next()
132+
if (done) break
133+
const payload = value?.payload
134+
if (payload && payload.type) {
135+
allEvents.push({ type: payload.type, properties: payload.properties ?? {} })
136+
}
137+
} catch {
138+
break
139+
}
140+
}
141+
})()
142+
const events = () => allEvents.filter(matches)
143+
const wait = async (pred: (e: Ev) => boolean, ms = 8000): Promise<Ev | undefined> => {
144+
const start = Date.now()
145+
while (Date.now() - start < ms) {
146+
const found = events().find(pred)
147+
if (found) return found
148+
await new Promise((r) => setTimeout(r, 50))
149+
}
150+
return events().find(pred)
151+
}
152+
return {
153+
events,
154+
wait,
155+
attachSession: (sid: string) => { filterSid = sid },
156+
stop: () => { stopped = true; try { sse.stream?.return?.() } catch {} void pump },
157+
}
158+
}
159+
160+
async function createSession(client: ReturnType<typeof createOpencodeClient>) {
161+
const res = await client.session.create({ body: { title: "t" } })
162+
const id = (res.data as any)?.id
163+
if (!id) throw new Error("no session id: " + JSON.stringify(res))
164+
return id as string
165+
}
166+
167+
function modelRef(model: string) {
168+
const [providerID, ...parts] = model.split("/")
169+
return { providerID, modelID: parts.join("/") }
170+
}
171+
172+
describe("e2e-server: model fallback plugin (in-process)", () => {
173+
// Default plugin options used across tests; per-test overrides applied.
174+
const baseOpts = (): Record<string, unknown> => ({
175+
enabled: true,
176+
notify: true,
177+
})
178+
179+
test("status-path fallback: retry-model 400 -> ok-model, with switch toast", async () => {
180+
await withServer({
181+
...baseOpts(),
182+
fallback_models: ["fake/ok-model"],
183+
retry_on_errors: [400],
184+
}, async ({ client, waitForCall }) => {
185+
const ev = await collectEvents(client, "")
186+
const sid = await createSession(client)
187+
ev.attachSession(sid)
188+
await client.session.promptAsync({ path: { id: sid }, body: { model: modelRef("fake/retry-model"), parts: [{ type: "text", text: "hello" }] } })
189+
190+
const toast = await ev.wait((e) => e.type === "tui.toast.show" && e.properties?.message?.includes("Switched to"))
191+
expect(toast?.properties?.message).toContain("ok-model")
192+
193+
// a fallback retry prompt went to ok-model
194+
const ok = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "ok-model", 10000)
195+
expect(ok).toBeDefined()
196+
197+
expect(await waitForCall((c) => c.model === "retry-model" && c.path.endsWith("/chat/completions"))).toBe(true)
198+
expect(await waitForCall((c) => c.model === "ok-model" && c.path.endsWith("/chat/completions"))).toBe(true)
199+
ev.stop()
200+
})
201+
}, 30000)
202+
203+
test("retry_on_patterns: status-independent fallback via error text", async () => {
204+
await withServer({
205+
...baseOpts(),
206+
fallback_models: ["fake/ok-model"],
207+
retry_on_errors: [],
208+
retry_on_patterns: ["error from retry-model"],
209+
}, async ({ client, waitForCall }) => {
210+
const ev = await collectEvents(client, "")
211+
const sid = await createSession(client)
212+
ev.attachSession(sid)
213+
await client.session.promptAsync({ path: { id: sid }, body: { model: modelRef("fake/retry-model"), parts: [{ type: "text", text: "hello" }] } })
214+
215+
// status not configured as retryable; only the pattern triggers fallback
216+
const switched = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "ok-model", 12000)
217+
expect(switched).toBeDefined()
218+
219+
expect(await waitForCall((c) => c.model === "ok-model" && c.path.endsWith("/chat/completions"))).toBe(true)
220+
ev.stop()
221+
})
222+
}, 30000)
223+
224+
test("cascade fallback: retry-model -> cascade-model -> ok-model-2", async () => {
225+
await withServer({
226+
...baseOpts(),
227+
fallback_models: ["fake/cascade-model", "fake/ok-model-2"],
228+
retry_on_errors: [400, 429],
229+
}, async ({ client, waitForCall }) => {
230+
const ev = await collectEvents(client, "")
231+
const sid = await createSession(client)
232+
ev.attachSession(sid)
233+
await client.session.promptAsync({ path: { id: sid }, body: { model: modelRef("fake/retry-model"), parts: [{ type: "text", text: "hello" }] } })
234+
235+
// first switch to cascade-model
236+
const first = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "cascade-model", 12000)
237+
expect(first).toBeDefined()
238+
// then to ok-model-2
239+
const second = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "ok-model-2", 12000)
240+
expect(second).toBeDefined()
241+
242+
expect(await waitForCall((c) => c.model === "retry-model")).toBe(true)
243+
expect(await waitForCall((c) => c.model === "cascade-model")).toBe(true)
244+
expect(await waitForCall((c) => c.model === "ok-model-2")).toBe(true)
245+
ev.stop()
246+
})
247+
}, 40000)
248+
249+
test("recovery: returns to the original model after cooldown expires", async () => {
250+
await withServer({
251+
...baseOpts(),
252+
fallback_models: ["fake/ok-model"],
253+
retry_on_errors: [400],
254+
cooldown_ms: 1,
255+
recover_original_model: true,
256+
}, async ({ client }) => {
257+
const ev = await collectEvents(client, "")
258+
const sid = await createSession(client)
259+
ev.attachSession(sid)
260+
await client.session.promptAsync({ path: { id: sid }, body: { model: modelRef("fake/retry-model"), parts: [{ type: "text", text: "hello" }] } })
261+
262+
// switched to ok-model
263+
const switched = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "ok-model", 12000)
264+
expect(switched).toBeDefined()
265+
await ev.wait((e) => e.type === "session.status" && e.properties?.status?.type === "idle", 12000)
266+
await new Promise((r) => setTimeout(r, 10))
267+
268+
// send a follow-up user message on the CURRENT (fallback) model, so the
269+
// plugin enters the recovery branch (requestedModel == currentModel, but
270+
// currentModel != originalModel) and restores the original once cooldown
271+
// has expired.
272+
await client.session.promptAsync({ path: { id: sid }, body: { model: modelRef("fake/ok-model"), parts: [{ type: "text", text: "again" }] } })
273+
const recovered = await ev.wait((e) => e.type === "message.updated" && e.properties?.info?.role === "user" && e.properties?.info?.model?.modelID === "retry-model", 12000)
274+
expect(recovered).toBeDefined()
275+
276+
const toast = await ev.wait((e) => e.type === "tui.toast.show" && e.properties?.message?.includes("Recovered"), 8000)
277+
expect(toast).toBeDefined()
278+
expect(toast!.properties.message).toContain("retry-model")
279+
ev.stop()
280+
})
281+
}, 40000)
282+
})

test/fake-provider.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const server = Bun.serve({
2727
{ id: "deny-model", object: "model" },
2828
{ id: "retry-model", object: "model" },
2929
{ id: "unavail-model", object: "model" },
30+
{ id: "cascade-model", object: "model" },
3031
],
3132
})
3233
}
@@ -55,6 +56,10 @@ const server = Bun.serve({
5556
"deny-model": 401,
5657
"retry-model": 400,
5758
"unavail-model": 400,
59+
// 400 (not auto-retried by OpenCode) so the plugin cascades promptly;
60+
// OpenCode self-retries 429/503 with its own backoff before emitting
61+
// session.error, which would mask plugin-driven cascade.
62+
"cascade-model": 400,
5863
}
5964
const status = statusMap[model] ?? 500
6065
calls.push({ path: url.pathname, model, status })

0 commit comments

Comments
 (0)