Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline 6f3ec85d1e fix(core): clarify Code Mode tool boundary 2026-07-29 11:46:26 -05:00
Aiden Cline adcf010368 fix(ai): retry transient client statuses 2026-07-28 14:19:42 -05:00
12 changed files with 47 additions and 154 deletions
+1 -2
View File
@@ -135,7 +135,7 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
rateLimit: input.rateLimit,
})
}
if (input.status !== undefined && input.status >= 500)
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalReason({
...common,
status: input.status,
@@ -145,7 +145,6 @@ export function classifyProviderFailure(input: ProviderFailure): LLMError["reaso
if (
input.status === 400 ||
input.status === 404 ||
input.status === 409 ||
input.status === 413 ||
input.status === 422
)
+6
View File
@@ -58,6 +58,12 @@ describe("provider error classification", () => {
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
expect(
[408, 409].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(["ProviderInternal", "ProviderInternal"])
})
test("classifies nested provider codes when a top-level code is also present", () => {
expect(
[
+3 -1
View File
@@ -6,7 +6,9 @@ import { Instructions } from "../instructions/index"
import { CodeModeCatalog } from "./catalog"
// prettier-ignore
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.${hasMoreTools ? `
const prompt = (hasMoreTools: boolean) => `The Code Mode tool catalog below is ${hasMoreTools ? "partial" : "complete"}.
The Code Mode catalog and \`search\` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.${hasMoreTools ? `
## Search
+1 -1
View File
@@ -33,7 +33,7 @@ type CollectedFiles = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
@@ -44,6 +44,9 @@ describe("CodeModeInstructions", () => {
it.effect("renders the initial catalog, semantic deltas, and removal", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(CodeModeInstructions.make([echo]))
expect(initialized.text).toContain(
"The Code Mode catalog and `search` results are the complete set of tools available within Code Mode. Tools presented elsewhere are not available in this runtime.",
)
expect(initialized.text).toContain("## Available tools")
expect(initialized.text).not.toContain("## Search")
expect(initialized.text).toContain(` - ${echo.signature} // Echo text`)
+1 -1
View File
@@ -22,7 +22,7 @@ const createCodeMode = (tools: ReadonlyMap<string, Info>) =>
test("execute describes invariant Code Mode behavior", () => {
expect(createCodeMode(new Map()).description).toBe(
[
"Run JavaScript to orchestrate tool calls and compose their results through `{ code }` in a confined Code Mode runtime.",
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by `search`. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
+2 -6
View File
@@ -7,20 +7,16 @@ import { DialogVariant } from "./dialog-variant"
import * as fuzzysort from "fuzzysort"
import { useConnected } from "./use-connected"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
export function DialogModel(props: { providerID?: string }) {
const local = useLocal()
const data = useData()
const location = useLocation()
const dialog = useDialog()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createMemo(
() => new Map((data.location.provider.list(location.ref) ?? []).map((item) => [item.id, item])),
)
const models = createMemo(() => data.location.model.list(location.ref) ?? [])
const providers = createMemo(() => new Map((data.location.provider.list() ?? []).map((item) => [item.id, item])))
const models = createMemo(() => data.location.model.list() ?? [])
const showExtra = createMemo(() => connected() && !props.providerID)
+5 -2
View File
@@ -360,8 +360,7 @@ export function Prompt(props: PromptProps) {
),
)
// Initialize the agent from the durable V2 Session state. Model hydration is
// reactive in LocalProvider so it can wait for the location catalog.
// Initialize agent/model/variant from the durable V2 Session state.
let syncedSessionID: string | undefined
createEffect(() => {
const sessionID = props.sessionID
@@ -370,6 +369,10 @@ export function Prompt(props: PromptProps) {
if (!session) return
const agent = session.agent && local.agent.list().find((agent) => agent.id === session.agent)
if (agent && !args.agent) local.agent.set(agent.id)
if (session.model) {
local.model.set({ providerID: session.model.providerID, modelID: session.model.id })
local.model.variant.set(session.model.variant)
}
syncedSessionID = sessionID
})
+1 -3
View File
@@ -1,10 +1,8 @@
import { createMemo } from "solid-js"
import { useData } from "../context/data"
import { useLocation } from "../context/location"
import { hasConnectedProvider } from "../util/connected-provider"
export function useConnected() {
const data = useData()
const location = useLocation()
return createMemo(() => hasConnectedProvider(data.location.integration.list(location.ref) ?? []))
return createMemo(() => hasConnectedProvider(data.location.integration.list() ?? []))
}
+13 -40
View File
@@ -22,7 +22,6 @@ import { useToast } from "../ui/toast"
import { useRoute } from "./route"
import { useData } from "./data"
import { usePermission } from "./permission"
import { useLocation } from "./location"
export function parseModel(model: string) {
const [providerID, ...rest] = model.split("/")
@@ -57,12 +56,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const args = useArgs()
const event = useEvent()
const permission = usePermission()
const location = useLocation()
const models = () => data.location.model.list(location.ref)
function isModelValid(model: ModelPreferenceModel) {
return !!models()?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
return !!data.location.model
.list()
?.some((item) => item.providerID === model.providerID && item.id === model.modelID)
}
function getFirstValidModel(...modelFns: (() => ModelPreferenceModel | undefined)[]) {
@@ -75,11 +73,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
function createAgent() {
const agents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() =>
(data.location.agent.list(location.ref) ?? []).filter((agent) => !agent.hidden),
(data.location.agent.list() ?? []).filter((agent) => agent.mode !== "subagent" && !agent.hidden),
)
const visibleAgents = createMemo(() => (data.location.agent.list() ?? []).filter((agent) => !agent.hidden))
const [agentStore, setAgentStore] = createStore({
current: undefined as string | undefined,
})
@@ -194,7 +190,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
}
const model = models()?.[0]
const model = data.location.model.list()?.[0]
if (!model) return undefined
return {
providerID: model.providerID,
@@ -213,33 +209,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
)
})
let syncedSessionModel: string | undefined
createEffect(() => {
if (route.data.type !== "session") {
syncedSessionModel = undefined
return
}
const session = data.session.get(route.data.sessionID)
const selected = session?.model
const a = agent.current()
if (!selected || !a) return
if (args.model) {
const requested = parseModel(args.model)
if (isModelValid(requested)) return
}
const model = { providerID: selected.providerID, modelID: selected.id }
if (!isModelValid(model)) return
const fingerprint = [session.id, a.id, selected.providerID, selected.id, selected.variant ?? "default"].join(
":",
)
if (fingerprint === syncedSessionModel) return
syncedSessionModel = fingerprint
batch(() => {
setModelStore("model", a.id, model)
setModelStore("variant", modelPreferenceKey(model), normalizeModelVariant(selected.variant))
})
})
return {
current: currentModel,
get ready() {
@@ -260,8 +229,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
reasoning: false,
}
}
const provider = data.location.provider.list(location.ref)?.find((item) => item.id === value.providerID)
const info = models()?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
const provider = data.location.provider.list()?.find((item) => item.id === value.providerID)
const info = data.location.model
.list()
?.find((item) => item.providerID === value.providerID && item.id === value.modelID)
return {
provider: provider?.name ?? value.providerID,
model: info?.name ?? value.modelID,
@@ -369,7 +340,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
list() {
const m = currentModel()
if (!m) return []
const info = models()?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
const info = data.location.model
.list()
?.find((item) => item.providerID === m.providerID && item.id === m.modelID)
return info?.variants?.map((variant) => variant.id) ?? []
},
set(value: string | undefined) {
-4
View File
@@ -4,7 +4,6 @@ import { useClient } from "./client"
import { useData } from "./data"
const context = createContext<{
readonly ref: LocationRef | undefined
readonly current: LocationGetOutput | undefined
set: (location?: LocationRef) => void
}>()
@@ -35,9 +34,6 @@ export function LocationProvider(props: ParentProps) {
return (
<context.Provider
value={{
get ref() {
return ref()
},
get current() {
return current()
},
+11 -94
View File
@@ -138,87 +138,46 @@ test("session lifecycle updates the terminal title and prints the epilogue after
}
})
test("session startup waits for catalogs and follows durable model changes", async () => {
test("session startup prompt is submitted exactly once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const core = await import("@opentui/core")
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
const events = createEventStream()
const cwd = process.cwd()
const sessionDirectory = `${cwd}/session`
const location = (directory: string) => ({ directory, project: { id: "project", directory } })
const location = { directory: cwd, project: { id: "project", directory: cwd } }
const session = {
id: "dummy",
title: "Demo session",
projectID: "project",
location: { directory: sessionDirectory },
location: { directory: cwd },
agent: "build",
model: { providerID: "provider", id: "model", variant: "high" },
model: { providerID: "provider", id: "model" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
const bodies: unknown[] = []
const modelSwitches: unknown[] = []
const locationRequested = Promise.withResolvers<void>()
const targetLocation = Promise.withResolvers<void>()
const catalogRequested = Promise.withResolvers<void>()
const catalog = Promise.withResolvers<void>()
const promptSubmitted = Promise.withResolvers<void>()
const secondPromptSubmitted = Promise.withResolvers<void>()
const calls = createFetch(async (url, request) => {
const directory = url.searchParams.get("location[directory]") ?? cwd
if (url.pathname === "/api/location") {
if (directory === sessionDirectory) {
locationRequested.resolve()
await targetLocation.promise
}
return json(location(directory))
}
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
if (url.pathname === "/api/session/dummy") return json({ data: session })
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
if (url.pathname === "/api/session/dummy/message/msg_model_gamma")
return json({
data: {
id: "msg_model_gamma",
type: "model-switched",
previous: session.model,
model: { providerID: "provider", id: "gamma", variant: "medium" },
time: { created: 1 },
},
})
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
if (url.pathname === "/api/agent")
return json({
location: location(directory),
location,
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
})
if (url.pathname === "/api/model") {
if (directory !== sessionDirectory)
return json({
location: location(directory),
data: [{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] }],
})
catalogRequested.resolve()
await catalog.promise
if (url.pathname === "/api/model")
return json({
location: location(directory),
data: [
{ id: "model", providerID: "provider", name: "Model", variants: [{ id: "high" }] },
{ id: "fallback", providerID: "provider", name: "Fallback", variants: [] },
{ id: "gamma", providerID: "provider", name: "Gamma", variants: [{ id: "medium" }] },
],
location,
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
})
}
if (url.pathname === "/api/session/dummy/model") {
modelSwitches.push(await request.json())
return new Response(null, { status: 204 })
}
if (url.pathname === "/api/session/dummy/prompt") {
bodies.push(await request.json())
promptSubmitted.resolve()
if (bodies.length === 2) secondPromptSubmitted.resolve()
return json({ data: {} })
}
}, events)
@@ -237,22 +196,6 @@ test("session startup waits for catalogs and follows durable model changes", asy
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
)
await locationRequested.promise
await Promise.race([
promptSubmitted.promise.then(() => {
throw new Error("startup prompt was submitted before its location loaded")
}),
Bun.sleep(100),
])
targetLocation.resolve()
await catalogRequested.promise
await Promise.race([
promptSubmitted.promise.then(() => {
throw new Error("startup prompt was submitted before its model catalog loaded")
}),
Bun.sleep(100),
])
catalog.resolve()
await Promise.race([
promptSubmitted.promise,
Bun.sleep(2000).then(() => {
@@ -260,38 +203,12 @@ test("session startup waits for catalogs and follows durable model changes", asy
}),
])
await Bun.sleep(20)
setup.renderer.destroy()
await task
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
expect(modelSwitches).toEqual([])
events.emit({
id: "evt_model_gamma",
created: 1,
type: "session.model.selected",
durable: { aggregateID: session.id, seq: 1, version: 1 },
location: session.location,
data: {
sessionID: session.id,
model: { providerID: "provider", id: "gamma", variant: "medium" },
},
})
await Bun.sleep(20)
await setup.mockInput.typeText("SECOND")
setup.mockInput.pressEnter()
await Promise.race([
secondPromptSubmitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("second prompt was not submitted")
}),
])
expect(modelSwitches).toEqual([])
await Bun.sleep(20)
setup.renderer.destroy()
await task
} finally {
targetLocation.resolve()
catalog.resolve()
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
mock.restore()