Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/actions/setup-project-bun/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ description: >-
# (#6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9). It moved from 1.4.0 to
# 1.4.3 for oven-sh/bun#44581: 1.4.0's Bun.spawnSync could lose a child's exit after
# finalizers ran inside an earlier call and spin past its own timeout (#4956).
# Rejoin only after the upstream runner crash is fixed and a green lane=all
# dispatch proves the unified version; then delete testRunnerBun and this role input.
# Both roles now default to the package's 1.4.4 security transition, retaining role validation.
# Promotion requires a green full cross-platform lane=all run on the exact candidate head.
inputs:
role:
description: Bun role to install (runtime or test-runner).
Expand Down
10 changes: 4 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -578,12 +578,10 @@ jobs:
printf 'Scheduled run: workflow from %s, testing dev at %s\n' "$GITHUB_SHA" "$sha" >> "$GITHUB_STEP_SUMMARY"
printf 'scheduled run tests dev at %s (workflow %s)\n' "$sha" "$GITHUB_SHA"

# Bun roles (#6713): runtime 1.4.2 fixes #6684's Windows Bun.fetch streaming;
# the test runner skipped 1.4.2's multi-file --isolate preload segfault (#4821
# reverted #4064; run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and runs
# 1.4.3, whose oven-sh/bun#44581 stops Bun.spawnSync losing a child's exit (#4956).
# Rejoin after the upstream runner crash is fixed and a green lane=all dispatch
# proves the unified version, then delete testRunnerBun and the action's role input.
# Bun runtime and test-runner roles both read their 1.4.4 defaults from package.json.
# Keep role validation after the historical isolate preload and spawnSync failures
# (#6713, #4956). Promotion requires a green full cross-platform lane=all run on
# the exact candidate head; focused local results do not replace that proof.
# The suite, split by file across four Linux runners.
#
# `scripts/ci/run-bun-test-batches.sh` assigns files to shards by the per-file
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,11 @@ A ready-for-review PR is the author's claim that the change is complete, underst

## Local validation and hooks

The suite uses Bun 1.4.3 (`testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses
The suite uses Bun 1.4.4 (`testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses
`OCX_TEST_RUNNER_BUN`; install that version locally if it is missing.
The roles remain independently checked even when their versions match. Promotion requires
a successful full cross-platform `lane=all` run on the candidate head.

Run `bun run test` before review readiness. If the full local suite is too costly
for the task or available resources, run at least focused regression tests for
Expand Down
12 changes: 6 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

# Keep the runtime aligned with package.json and pin the multi-platform image index.
# Runtime 1.4.4 keeps 1.4.2's #6684 Windows Bun.fetch streaming fix and ships NSS
# 3.131.1 roots without the compromised eMudhra CAs; CI tests run on 1.4.3
# because 1.4.2 segfaults reloading preloads in multi-file --isolate (#4821;
# #6713 run 37631558263: Linux 1/4, macOS 2/2, Windows 9/9) and 1.4.0's spawnSync
# can lose a child's exit (#4956, oven-sh/bun#44581). Rejoin after the
# upstream crash fix passes a unified-version lane=all dispatch, then delete
# package.json's testRunnerBun pin and setup-project-bun's role input.
# 3.131.1 roots without the compromised eMudhra CAs. Runtime and test-runner
# defaults are both 1.4.4; keep package.json's testRunnerBun pin and
# setup-project-bun's role input independently validated. Promotion requires
# an exact-head cross-platform lane=all dispatch, including multi-file --isolate:
# historical 1.4.2 preload crashes (#4821; #6713 run 37631558263) and 1.4.0
# spawnSync exit loss (#4956, oven-sh/bun#44581) remain regression risks.
ARG BUN_IMAGE=oven/bun:1.4.4@sha256:1d22548f177f4ff38e0c15baa3c2c46c6c2060ba2d1bc573bb4720b407548f1d

FROM ${BUN_IMAGE} AS manifest
Expand Down
6 changes: 4 additions & 2 deletions docs-site/src/content/docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,11 @@ bun run privacy:scan # credential/privacy scan used by CI
bun run prepare:package # refresh package launchers/assets
```

The suite uses Bun 1.4.3 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.3 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
The suite uses Bun 1.4.4 (`package.json`'s `testRunnerBun`) while the shipped runtime is 1.4.4.
`scripts/test.ts` finds a 1.4.4 binary on PATH or in `~/.bun/bin`, or uses `OCX_TEST_RUNNER_BUN`.
Install that version locally if it is missing; the resolver never downloads it.
The roles remain independently checked even when their versions match. Promotion requires
a successful full cross-platform `lane=all` run on the candidate head.

Run `bun run test` by default. If a full run is disproportionately expensive for the task size,
available machine resources, or concurrent worktrees, you must at least run focused regression tests
Expand Down
5 changes: 5 additions & 0 deletions docs-site/src/content/docs/guides/sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ closed. Explicit `xai` requires a usable stored Grok OAuth account and does not
requires both ChatGPT login auth and an enabled `forward` provider.
:::

On file load, a retired OpenCode Free model target in global or `claudeCode` overrides of
`visionSidecar` or `webSearchSidecar` is removed and that sidecar is explicitly disabled, retaining
its other settings. Select a supported target before re-enabling it. Healthy targets and the defaults
for absent sidecar sections are unchanged.

### Additional web-search backends (explicit-only)

Three more web-search backends exist beyond the ChatGPT and Claude paths. Each is
Expand Down
28 changes: 24 additions & 4 deletions gui/src/pages/claude-code-save.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { serializeSidecarOverride } from "./claude-code-sidecar";
import { confirmSidecarOverride, serializeSidecarOverride } from "./claude-code-sidecar";
import type { ClaudeCodeState, MapRow } from "./claude-code-types";
import { newClientId } from "./claude-code-types";

Expand Down Expand Up @@ -118,7 +118,10 @@ export function applyServerRead(current: ClaudeCodeEditState | null, next: Claud
*/
export function savedCopy(serverState: ClaudeCodeState, submitted: ClaudeCodeEditable): ClaudeCodeEditable {
return {
state: { ...serverState, ...pickEditable(submitted.state) },
state: { ...serverState, ...pickEditable(submitted.state),
visionSidecar: confirmSidecarOverride(submitted.state.visionSidecar),
webSearchSidecar: confirmSidecarOverride(submitted.state.webSearchSidecar),
},
rows: Object.entries(normalizedModelMap(submitted.rows)).map(([from, to]) => ({ id: newClientId(), from, to })),
};
}
Expand All @@ -136,6 +139,23 @@ export function acknowledgeSave(current: ClaudeCodeEditState | null, submitted:
const baseline = savedCopy(current.baseline.state, submitted);
const rows = baseline.rows;
const unchanged = claudeCodeDraftKey(current.draft) === claudeCodeDraftKey(submitted);
// Untouched since submission: show what was stored. Edited meanwhile: keep the edits, dirty.
return { draft: unchanged ? { state: current.draft.state, rows } : current.draft, baseline, adoptNextRead: unchanged };
if (unchanged) {
// Untouched since submission: show what was stored.
return { draft: { state: { ...current.draft.state,
visionSidecar: baseline.state.visionSidecar, webSearchSidecar: baseline.state.webSearchSidecar,
}, rows }, baseline, adoptNextRead: true };
}
/*
* Edited while the PUT was out: keep the edits, dirty. A sidecar is still confirmed —
* ending its draft-only activation provenance — when its serialized shape is exactly what
* the Save stored; one edited after submission keeps its marker until its own save lands.
*/
let draft = current.draft;
for (const key of ["visionSidecar", "webSearchSidecar"] as const) {
const stored = JSON.stringify(serializeSidecarOverride(submitted.state[key]));
if (JSON.stringify(serializeSidecarOverride(draft.state[key])) !== stored) continue;
const confirmed = confirmSidecarOverride(draft.state[key]);
if (confirmed !== draft.state[key]) draft = { ...draft, state: { ...draft.state, [key]: confirmed } };
}
return { draft, baseline, adoptNextRead: false };
}
35 changes: 26 additions & 9 deletions gui/src/pages/claude-code-sidecar.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
/**
* Claude Code sidecar override helpers — keep Auto as an in-memory draft so
* users can enter a model before persist. Empty Auto persists only when it
* carries an explicit pool edit.
* carries an explicit activation state or pool edit.
*/

import type { AnthropicInstanceId, SidecarOverride, VisionOverrideBackend } from "./claude-manual-env";

export type SidecarSelectValue = "inherit" | "auto" | VisionOverrideBackend;

// Draft-only provenance follows object spreads but cannot enter JSON or the PUT shape.
const modelEditStartedDisabled = Symbol("modelEditStartedDisabled");
type SidecarDraft = SidecarOverride & { [modelEditStartedDisabled]?: true };

/** A confirmed save starts a new editing baseline; drafts without a pending marker keep their identity. */
export function confirmSidecarOverride(override?: SidecarOverride): SidecarOverride | undefined {
if (!override || !(override as SidecarDraft)[modelEditStartedDisabled]) return override;
const next = { ...override } as SidecarDraft;
delete next[modelEditStartedDisabled];
return next;
}

export type PersistedSidecarOverride = {
backend: VisionOverrideBackend | null;
model: string;
enabled?: boolean;
backend?: VisionOverrideBackend | null;
model?: string;
anthropicInstance?: AnthropicInstanceId | null;
};

Expand Down Expand Up @@ -38,28 +51,32 @@ export function applySidecarPoolChange(override: SidecarOverride | undefined, va
return next;
}

/** Model typing updates the draft in place; empty Auto stays selectable until save. */
/** Model edits preserve activation unless this draft started disabled. */
export function applySidecarModelChange(
override: SidecarOverride | undefined,
model: string,
): SidecarOverride {
return { ...override, model };
const startedDisabled = override?.enabled === false || (override as SidecarDraft | undefined)?.[modelEditStartedDisabled];
return { ...override, model, ...(startedDisabled ? {
enabled: model.trim().length > 0, [modelEditStartedDisabled]: true as const,
} : {}) };
}

/**
* Persist shape for PUT /api/claude-code.
* Empty Auto drafts ({ backend: undefined } / blank model) become null → reload as Inherit.
* Empty Auto drafts become null → Inherit unless an explicit activation state must survive.
*/
export function serializeSidecarOverride(
override?: SidecarOverride,
): PersistedSidecarOverride | null {
if (!override) return null;
const trimmed = (override.model ?? "").trim();
const activation = override.enabled === undefined ? {} : { enabled: override.enabled };
if (!override.backend) {
if (!trimmed && override.anthropicInstance === undefined) return null;
return { backend: null, model: trimmed,
if (!trimmed && override.anthropicInstance === undefined) return override.enabled === undefined ? null : { ...activation, backend: null, model: "" };
return { ...activation, backend: null, model: trimmed,
...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) };
}
return { backend: override.backend, model: trimmed,
return { ...activation, backend: override.backend, model: trimmed,
...(override.anthropicInstance !== undefined ? { anthropicInstance: override.anthropicInstance } : {}) };
}
2 changes: 1 addition & 1 deletion gui/src/pages/claude-manual-env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface AnthropicPoolOptions {
available: AnthropicInstanceId[];
code?: "anthropic_helper_unavailable";
}
export interface SidecarOverride { backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null }
export interface SidecarOverride { enabled?: boolean; backend?: VisionOverrideBackend; model?: string; anthropicInstance?: AnthropicInstanceId | null }

export interface ClaudeManualEnvState {
/**
Expand Down
68 changes: 68 additions & 0 deletions gui/tests/claude-code-save.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type ClaudeCodeEditable,
} from "../src/pages/claude-code-save";
import { normalizeContextAccounting, type ClaudeCodeState, type MapRow } from "../src/pages/claude-code-types";
import { applySidecarBackendChange, applySidecarModelChange, applySidecarPoolChange } from "../src/pages/claude-code-sidecar";
import type { SidecarOverride } from "../src/pages/claude-manual-env";

const STATE = {
enabled: true,
Expand Down Expand Up @@ -38,6 +40,26 @@ const STATE = {
const row = (from: string, to: string, id = `${from}->${to}`): MapRow => ({ id, from, to });
const editable = (state: Partial<ClaudeCodeState> = {}, rows: MapRow[] = []): ClaudeCodeEditable => ({ state: { ...STATE, ...state }, rows });

test("model-edit activation provenance survives drafts but ends at a confirmed save or revert", () => {
const base = editable({ visionSidecar: { enabled: false } });
const typed = applySidecarModelChange(base.state.visionSidecar, "gpt-5.6-luna");
const submitted = editable({ visionSidecar: applySidecarPoolChange(applySidecarBackendChange(typed, "openai"), "") });
const body = claudeCodeSaveBody(submitted.state, []);
expect(body.visionSidecar).toEqual({ enabled: true, backend: "openai", model: "gpt-5.6-luna" });
expect(Object.getOwnPropertySymbols(body.visionSidecar!)).toHaveLength(0);
expect(JSON.parse(JSON.stringify(body)).visionSidecar).toEqual(body.visionSidecar);
const polled = applyServerRead({ draft: submitted, baseline: base, adoptNextRead: false }, base);
expect(applySidecarModelChange(polled.draft.state.visionSidecar, "").enabled).toBe(false);
expect(revertEditable(submitted.state, base.state).visionSidecar).toEqual({ enabled: false });
const acked = acknowledgeSave({ draft: submitted, baseline: base, adoptNextRead: false }, submitted)!;
// Even if the acknowledging GET fails, the successful PUT is the new activation baseline.
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true);
const reverted = revertEditable(base.state, acked.baseline.state);
expect(applySidecarModelChange(reverted.visionSidecar, "").enabled).toBe(true);
const fresh = editable({ visionSidecar: { enabled: true, backend: "openai", model: "gpt-5.6-luna" } });
expect(applySidecarModelChange(applyServerRead(acked, fresh).draft.state.visionSidecar, "").enabled).toBe(true);
});

test("tier defaults participate in save, polling preservation and revert", () => {
const tiers = { opus: "combo/opus", sonnet: "combo/sonnet", haiku: "combo/haiku" };
const draft = editable({ tierModels: tiers });
Expand Down Expand Up @@ -148,3 +170,49 @@ test("context accounting is saved, dirties the draft, and older states read as t
expect(normalizeContextAccounting("bogus")).toBe("1m");
expect(normalizeContextAccounting("200k")).toBe("200k");
});

const sidecarTyped = (disabled: SidecarOverride | undefined, model: string) =>
applySidecarBackendChange(applySidecarModelChange(disabled, model), "openai");

test("a stored sidecar is confirmed even when an unrelated field was edited while the PUT was out", () => {
const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } });
const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5");
const submitted = editable({ visionSidecar: vision, webSearchSidecar: web });
// Only an unrelated field changed mid-flight; the sidecars the server stored are unchanged.
const pending = editable({ visionSidecar: vision, webSearchSidecar: web, authMode: "proxy" });
const acked = acknowledgeSave({ draft: pending, baseline: base, adoptNextRead: false, savePending: true }, submitted)!;
expect(acked.adoptNextRead).toBe(false);
expect(acked.draft.state.authMode).toBe("proxy");
// Confirmed enabled=true: clearing the model afterwards keeps the saved activation.
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(true);
expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true);
});

test("a sidecar edited after submission keeps its disabled-start marker until its own save", () => {
const base = editable({ visionSidecar: { enabled: false } });
const typed = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const submitted = editable({ visionSidecar: typed });
// Cleared mid-flight: the serialized shape no longer matches what the PUT stored.
const cleared = applySidecarModelChange(typed, "");
const acked = acknowledgeSave({ draft: editable({ visionSidecar: cleared }), baseline: base, adoptNextRead: false }, submitted)!;
// The post-submit edit itself is preserved, not overwritten by the confirmed copy.
expect(acked.draft.state.visionSidecar).toBe(cleared);
expect(isClaudeCodeDraftDirty(acked.draft, acked.baseline)).toBe(true);
// Retyping and clearing again still returns to disabled: this draft never reached the server.
expect(applySidecarModelChange(applySidecarModelChange(acked.draft.state.visionSidecar, "gpt-5.6-luna"), "").enabled).toBe(false);
});

test("stored and post-submit-edited sidecars are confirmed independently", () => {
const base = editable({ visionSidecar: { enabled: false }, webSearchSidecar: { enabled: false } });
const vision = sidecarTyped(base.state.visionSidecar, "gpt-5.6-luna");
const web = sidecarTyped(base.state.webSearchSidecar, "claude-haiku-4-5");
const submitted = editable({ visionSidecar: vision, webSearchSidecar: web });
// Vision's model changed mid-flight (still enabled), webSearch did not: each is decided on its own.
const visionRetyped = applySidecarModelChange(vision, "gpt-5.6-astra");
const acked = acknowledgeSave({ draft: editable({ visionSidecar: visionRetyped, webSearchSidecar: web }), baseline: base, adoptNextRead: false }, submitted)!;
expect(applySidecarModelChange(acked.draft.state.webSearchSidecar, "").enabled).toBe(true);
// Vision stays enabled in the draft, so only its retained marker turns a clear back into disabled.
expect(acked.draft.state.visionSidecar).toBe(visionRetyped);
expect(applySidecarModelChange(acked.draft.state.visionSidecar, "").enabled).toBe(false);
});
Loading
Loading