Compare commits

...

6 Commits

Author SHA1 Message Date
Brendan Allan d5162d5491 Merge branch 'v2' into project-save-refresh 2026-08-26 11:38:33 +08:00
Brendonovich e606338468 fix(app): keep shared project query reactive 2026-08-26 03:37:22 +00:00
opencode-agent[bot] d01ac2069a chore: update nix node_modules hashes
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404, x86_64-linux) (push) Has been cancelled
nix-hashes / compute-hash (blacksmith-4vcpu-ubuntu-2404-arm, aarch64-linux) (push) Has been cancelled
nix-hashes / compute-hash (macos-15-intel, x86_64-darwin) (push) Has been cancelled
nix-hashes / compute-hash (macos-latest, aarch64-darwin) (push) Has been cancelled
publish / version (push) Has been cancelled
nix-hashes / update-hashes (push) Has been cancelled
publish / build-cli (push) Has been cancelled
publish / sign-cli-macos (push) Has been cancelled
publish / build-node-app-archive (push) Has been cancelled
publish / build-node-cli (map[bun_install_flags:--cpu=* host:blacksmith-4vcpu-windows-2025 target:windows-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404 target:linux-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-ubuntu-2404-arm target:linux-arm64]) (push) Has been cancelled
publish / build-node-cli (map[host:blacksmith-4vcpu-windows-2025 target:windows-x64]) (push) Has been cancelled
publish / build-node-cli (map[host:macos-26 target:darwin-arm64]) (push) Has been cancelled
publish / sign-cli-windows (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=arm64 host:macos-26 platform_flag:--mac --arm64 target:aarch64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[bun_install_flags:--os=darwin --cpu=x64 host:macos-26-intel platform_flag:--mac --x64 target:x86_64-apple-darwin]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404 platform_flag:--linux target:x86_64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-ubuntu-2404-arm platform_flag:--linux --arm64 target:aarch64-unknown-linux-gnu]) (push) Has been cancelled
publish / build-electron (map[host:blacksmith-4vcpu-windows-2025 platform_flag:--win target:x86_64-pc-windows-msvc]) (push) Has been cancelled
publish / build-electron (map[host:windows-2025 platform_flag:--win --arm64 target:aarch64-pc-windows-msvc]) (push) Has been cancelled
publish / publish (push) Has been cancelled
2026-08-26 03:32:00 +00:00
Aiden Cline a78d3c5438 fix(ai): recover Anthropic request_too_large as overflow (#45144) 2026-08-25 22:30:14 -05:00
Brendonovich 8efdc9028d refactor(app): use one shared project query 2026-08-26 02:41:38 +00:00
Brendonovich a562002616 fix(app): refresh saved projects and send partial updates 2026-08-26 02:28:42 +00:00
10 changed files with 107 additions and 62 deletions
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-oQnV96kE3lIqsQaaUrH4tiEX8/5xvBWizXMGxayFSHo=",
"aarch64-linux": "sha256-Hkl1xdCQ7voAllwtyrm3TjQT9cfej29fvPIP3lH7zVo=",
"aarch64-darwin": "sha256-s0WHRB13qcD0KlgWNXO7gLpsDOnfB8xny81GnN5YeQc=",
"x86_64-darwin": "sha256-fnYi1AxCrnO3byW9keDfBH2ueCfGZdaweOrV6AerrGs="
"x86_64-linux": "sha256-t7k0Nk9Z7FMkAKt2UReI1jD1KoPB+LhQQx1x1p5Htlc=",
"aarch64-linux": "sha256-tU7C2zYKiX+RpwBBBLjX7vrqQNLPvDc5Uz9EbgGJvSA=",
"aarch64-darwin": "sha256-A3o+2zNuFHV5FZx/azdXBjcKdS5o41b2e2Q1Qq8C1m0=",
"x86_64-darwin": "sha256-NXgvteaxJQqBBZIHnQbivghHTJnL2IMZiFPNssGp2Os="
}
}
+4 -2
View File
@@ -40,15 +40,16 @@ const patterns = [
/model_context_window_exceeded/i,
/too many tokens/i,
/token limit exceeded/i,
/request_too_large/i,
]
const payloadPatterns = [/request_too_large/i, /request entity too large/i, /payload too large/i, /request too large/i]
const payloadPatterns = [/request entity too large/i, /payload too large/i, /request too large/i]
const exclusions = [/^(throttling error|service unavailable):/i, /rate limit/i, /too many requests/i]
export const isContextOverflow = (message: string) =>
!exclusions.some((pattern) => pattern.test(message)) &&
(patterns.some((pattern) => pattern.test(message)) || /^400\s*(status code)?\s*\(no body\)/i.test(message))
(patterns.some((pattern) => pattern.test(message)) || /^4(?:00|13)\s*(status code)?\s*\(no body\)/i.test(message))
export const isPayloadTooLarge = (message: string) => payloadPatterns.some((pattern) => pattern.test(message))
@@ -106,6 +107,7 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
clientScoped &&
(codes.includes("context_length_exceeded") ||
codes.includes("model_context_window_exceeded") ||
codes.includes("request_too_large") ||
isContextOverflow(text))
)
return new InvalidRequestReason({ ...common, classification: "context-overflow" })
+22
View File
@@ -211,6 +211,28 @@ describe("RequestExecutor", () => {
}).pipe(Effect.provide(responsesLayer([new Response("request too large", { status: 413 })]))),
)
it.effect("classifies Anthropic request_too_large as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
classification: "context-overflow",
http: { response: { status: 413 } },
})
}).pipe(
Effect.provide(
responsesLayer([
new Response('{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}', {
status: 413,
}),
]),
),
),
)
it.effect("does not classify ordinary invalid requests as context overflow", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
+9 -4
View File
@@ -18,13 +18,19 @@ describe("provider error classification", () => {
expect(messages.every(isContextOverflow)).toBe(true)
})
test("classifies request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
test("classifies Anthropic request_too_large as recoverable overflow", () => {
expect(
classifyProviderFailure({
message: '{"error":{"type":"request_too_large","message":"Request exceeds the maximum size"}}',
status: 400,
}),
).toMatchObject({ _tag: "InvalidRequest", classification: "context-overflow" })
expect(isContextOverflow("413 status code (no body)")).toBe(true)
})
test("classifies generic request size failures separately from context overflow", () => {
const failures = [
classifyProviderFailure({ message: "request too large", status: 413 }),
classifyProviderFailure({ message: "upstream request entity too large", status: 502 }),
]
@@ -33,7 +39,6 @@ describe("provider error classification", () => {
expect.objectContaining({ _tag: "InvalidRequest", classification: "payload-too-large" }),
),
)
expect(isContextOverflow("413 status code (no body)")).toBe(false)
})
test("does not classify rate limits as context overflow", () => {
@@ -64,6 +64,25 @@ describe("query keys", () => {
expect(calls.toSorted()).toEqual(["a", "b"])
})
test("refreshes shared project metadata after invalidation", async () => {
let name = "Before"
const projects = {
list: async () => [{ id: "a", canonical: "/a", name, time: { created: 1, updated: 1 }, sandboxes: [] }],
} as unknown as ProjectApi
const worktrees = {
list: async () => [{ directory: "/a" }],
} as unknown as WorktreeApi
const queryClient = new QueryClient()
const query = loadProjectsQuery(ServerScope.local, projects, worktrees)
expect((await queryClient.fetchQuery(query))[0]?.name).toBe("Before")
name = "After"
await queryClient.invalidateQueries({ queryKey: query.queryKey, exact: true })
expect((await queryClient.fetchQuery(query))[0]?.name).toBe("After")
})
test("keeps projects whose directory inventory cannot load", async () => {
const projects = {
list: async () => [
@@ -1,4 +1,4 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types"
import type { Config, Path, Project } from "@/runtime/server/types"
import type {
LocationGetInput,
LocationGetOutput,
@@ -18,14 +18,6 @@ import type { ServerScope } from "@/runtime/server/scope"
import type { ServerApi } from "@/runtime/server/api"
import { sameDirectory } from "@/workspaces/paths"
type GlobalStore = {
path: Path
project: Project[]
provider_auth: ProviderAuthResponse
config: Config
reload: undefined | "pending" | "complete"
}
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
@@ -110,16 +102,13 @@ export async function bootstrapGlobal(input: {
readonly worktree: WorktreeApi
}
scope: ServerScope
setGlobalStore: SetStoreFunction<GlobalStore>
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree))
.then((data) => input.setGlobalStore("project", data)),
input.queryClient.fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project, input.serverAPI.worktree)),
]
await runAll(slow)
}
+15 -23
View File
@@ -5,7 +5,13 @@ import { getOwner, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { type ServerSDK } from "./client"
import { bootstrapDirectory, bootstrapGlobal, loadGlobalConfigQuery, loadPathQuery } from "./global-sync/bootstrap"
import {
bootstrapDirectory,
bootstrapGlobal,
loadGlobalConfigQuery,
loadPathQuery,
loadProjectsQuery,
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import type { ProjectMeta } from "./global-sync/types"
import { formatServerError } from "@/runtime/server/errors"
@@ -61,8 +67,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
{ ...queryOptionsApi.path(), enabled: connected() },
],
}))
const projectQuery = useQuery(() => ({
...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.api.worktree),
enabled: connected(),
}))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
project: [],
get project() {
return projectQuery.data ?? []
},
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
@@ -79,17 +91,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
})
const queryClient = useQueryClient()
const setProjects = (next: Project[] | ((draft: Project[]) => Project[])) => {
setGlobalStore("project", next)
}
const setBootStore = ((...input: unknown[]) => {
if (input[0] === "project" && Array.isArray(input[1])) {
setProjects(input[1] as Project[])
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: [serverSDK.scope, "bootstrap"],
@@ -97,7 +98,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
await bootstrapGlobal({
serverAPI: serverSDK.api,
scope: serverSDK.scope,
setGlobalStore: setBootStore,
queryClient,
})
return Date.now()
@@ -105,14 +105,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
enabled: connected(),
}))
const set = ((...input: unknown[]) => {
if (input[0] === "project" && (Array.isArray(input[1]) || typeof input[1] === "function")) {
setProjects(input[1] as Project[] | ((draft: Project[]) => Project[]))
return input[1]
}
return (setGlobalStore as (...args: unknown[]) => unknown)(...input)
}) as typeof setGlobalStore
const paused = () => untrack(() => globalStore.reload) !== undefined
const queue = createRefreshQueue({
@@ -269,7 +261,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
return {
data: globalStore,
set,
set: setGlobalStore,
child: children.child,
disableMcp: children.disableMcp,
// bootstrap,
@@ -1,6 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { useMutation, useQueryClient } from "@tanstack/solid-query"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,6 +10,7 @@ import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const dialog = useDialog()
const global = useGlobal()
const queryClient = useQueryClient()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
const folderName = createMemo(() => getFilename(props.project.worktree))
const defaultName = createMemo(() => props.project.name || folderName())
@@ -70,13 +71,19 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
const color = store.color ?? ""
const override = store.iconOverride ?? ""
const colorChanged = color !== (props.project.icon?.color ?? "")
const overrideChanged = override !== (props.project.icon?.override ?? "")
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
...(name !== (props.project.name ?? "") ? { name } : {}),
...(colorChanged || overrideChanged
? { icon: { ...(colorChanged ? { color } : {}), ...(overrideChanged ? { override } : {}) } }
: {}),
...(start !== (props.project.commands?.start ?? "") ? { commands: { start } } : {}),
})
dialog.close()
return
}
@@ -85,6 +92,11 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
},
onSuccess: async () => {
if (props.project.id && props.project.id !== "global") {
await queryClient.invalidateQueries({ queryKey: [serverCtx().sdk.scope, "project"], exact: true })
}
dialog.close()
},
}))
@@ -38,7 +38,7 @@ import {
} from "@/workspaces/paths"
import { listAllSessions } from "@/session/list"
import type { ServerScope } from "@/runtime/server/scope"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { loadProjectsQuery } from "@/runtime/server/global-sync/bootstrap"
import "@/settings/settings.css"
type Workspace = {
@@ -59,16 +59,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (prop
})
const projectQuery = useQuery(() => ({
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
queryFn: async () =>
Promise.all(
(await serverSDK.api.project.list()).map(async (project) => {
const worktrees = await serverSDK.api.worktree
.list({ projectID: project.id })
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
return normalizeProjectInfo({ ...project, worktrees })
}),
),
...loadProjectsQuery(serverSDK.scope, serverSDK.api.project, serverSDK.api.worktree),
refetchOnMount: "always",
}))
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
+13
View File
@@ -100,6 +100,19 @@ describe("Project.update", () => {
commands: { start: "bun install" },
})
expect(yield* project.update({ projectID: id, name: "Renamed" })).toMatchObject({
id,
name: "Renamed",
icon: { color: "blue", override: "data:image/png;base64,test" },
commands: { start: "bun install" },
})
expect(yield* project.update({ projectID: id, icon: { color: "green" } })).toMatchObject({
id,
name: "Renamed",
icon: { color: "green", override: "data:image/png;base64,test" },
commands: { start: "bun install" },
})
expect(
yield* project.update({
projectID: id,