From 783ac127710bf518e2f2bc2b724ac26e4c62ef65 Mon Sep 17 00:00:00 2001 From: shawnkim Date: Mon, 28 Sep 2026 11:50:56 +0900 Subject: [PATCH 01/11] feat(droid): configure per-model reasoning defaults in integrations --- .../skills/verify-droid-integration/SKILL.md | 67 ++++ .../features/README.md | 26 ++ .../features/clear-refresh-rollback.md | 42 +++ .../features/connect-settings-lifecycle.md | 41 ++ .../features/per-model-defaults-precedence.md | 43 +++ .../scripts/harness.ts | 355 ++++++++++++++++++ .../src/content/docs/guides/integrations.md | 14 + .../content/docs/ko/guides/integrations.md | 13 + gui/src/i18n/de.ts | 10 + gui/src/i18n/en.ts | 10 + gui/src/i18n/fr.ts | 10 + gui/src/i18n/ja.ts | 10 + gui/src/i18n/ko.ts | 10 + gui/src/i18n/ru.ts | 10 + gui/src/i18n/tr.ts | 10 + gui/src/i18n/vi.ts | 10 + gui/src/i18n/zh-TW.ts | 10 + gui/src/i18n/zh.ts | 10 + .../DroidReasoningDefaultsPanel.tsx | 100 +++++ .../integrations/FileIntegrationPage.tsx | 50 ++- gui/src/pages/integrations/integration-api.ts | 25 +- gui/src/styles-integrations.css | 13 + gui/tests/integrations-surfaces.test.tsx | 61 ++- scripts/test-layout/layout.json | 3 + src/clients/config-export.ts | 4 +- src/clients/config-export/contracts.ts | 6 + src/clients/config-export/droid.ts | 55 ++- src/integrations/mutation-plan.ts | 6 +- src/integrations/state.ts | 63 +++- src/integrations/writer.ts | 22 +- src/server/chat-completions.ts | 2 + src/server/droid-reasoning-default.ts | 14 + src/server/management/integration-routes.ts | 70 +++- structure/clients/integrations.md | 18 + structure/data-planes/inbound-compat.md | 14 + structure/gui-and-management-api.md | 7 + .../droid-managed-reasoning-defaults.test.ts | 118 ++++++ tests/fixtures/test-layout-expected.json | 3 + .../droid-reasoning-defaults.test.ts | 133 +++++++ ...anagement-droid-reasoning-defaults.test.ts | 145 +++++++ 40 files changed, 1605 insertions(+), 28 deletions(-) create mode 100644 .agents/skills/verify-droid-integration/SKILL.md create mode 100644 .agents/skills/verify-droid-integration/features/README.md create mode 100644 .agents/skills/verify-droid-integration/features/clear-refresh-rollback.md create mode 100644 .agents/skills/verify-droid-integration/features/connect-settings-lifecycle.md create mode 100644 .agents/skills/verify-droid-integration/features/per-model-defaults-precedence.md create mode 100755 .agents/skills/verify-droid-integration/scripts/harness.ts create mode 100644 gui/src/pages/integrations/DroidReasoningDefaultsPanel.tsx create mode 100644 src/server/droid-reasoning-default.ts create mode 100644 tests/clients/droid-managed-reasoning-defaults.test.ts create mode 100644 tests/responses/droid-reasoning-defaults.test.ts create mode 100644 tests/server/management-droid-reasoning-defaults.test.ts diff --git a/.agents/skills/verify-droid-integration/SKILL.md b/.agents/skills/verify-droid-integration/SKILL.md new file mode 100644 index 00000000000..d6c6bf483bf --- /dev/null +++ b/.agents/skills/verify-droid-integration/SKILL.md @@ -0,0 +1,67 @@ +--- +name: verify-droid-integration +description: Verify Droid integration behavior in the opencodex dashboard and isolated proxy, especially per-model reasoning effort defaults. +--- + +# Verify Droid integration + +Use this skill when changing or reviewing the Droid connection, per-model effort defaults, or restore behavior. Read [the feature index](./features/README.md) and the matching recipe first. + +## Launch + +Run from the repository root with Bun on `PATH`: + +```sh +bun .agents/skills/verify-droid-integration/scripts/harness.ts launch +``` + +The harness currently supports macOS and Linux environments with `lsof` and +`ps` available on `PATH`. It uses these commands to verify listener and process +ownership before launch, during doctor, and during cleanup. Install/provide both +utilities before starting; do not substitute process-name matching. + +The helper allocates a unique scratch run under `.tmp/droid-integration-verification/runs/`, seeds a credential-free provider catalog and `.factory/settings.json`, then sets `OPENCODEX_HOME` and `CODEX_HOME` to that run. It isolates Droid client-file access through `setIntegrationPathTestHooks`. The dashboard and normal server use `127.0.0.1:12587`, the fixture provider boundary uses `12588`, and the server's Claude intercept listener uses `12687`. All three listeners must be free before launch and owned by the run afterward. Readiness is an identity-checked `/healthz` response with the expected PID and port. The helper prints the run ID and direct dashboard route `http://127.0.0.1:12587/#integrations/droid`. Save the ID for doctor, request, and cleanup. + +The model catalog and `.factory/settings.json` belong only to the disposable harness run. No credentials are configured and no real provider is contacted. + +## Doctor + +Before driving the dashboard, run: + +```sh +bun .agents/skills/verify-droid-integration/scripts/harness.ts doctor +``` + +Doctor checks the recorded process command, all three listeners, the health response identity, and dashboard HTTP status. Drive only the instance reported healthy by this command. If any check fails, stop and inspect the isolated run log. + +## Drive + +Use the direct dashboard route `http://127.0.0.1:12587/#integrations/droid`. The sidebar has an **Integrations** entry; there is no Settings parent. Drive the accessible controls by their visible names. + +In a fresh run, Apply Droid and confirm its reviewed plan before using +**Save / review changes**: the harness starts with an empty `.factory/settings.json`, +and the defaults workflow is for an active integration. When the dashboard is +localized, use the translated visible control and dialog labels corresponding +to the English names in feature recipes; do not expect English text verbatim. + +For screenshots and browser snapshots, use the installed `agent-browser` skill against this URL. Capture each action and its visible result. Save artifacts under `.tmp/droid-integration-verification/evidence//`; this directory is deliberately outside cleanup. + +The fixture provider sends only to the mock at `127.0.0.1:12588`. Use `request` for model-path and precedence checks. The mock records only model, effective effort, route, and whether the private Droid header is absent; it never stores or prints request bodies or headers. Use `fixture` to remove a model or change its effort ladder. It restarts only the isolated proxy while retaining Droid state. + +## Evidence + +Evidence must show the user action and resulting state, plus a read-only confirmation of persisted settings where relevant. Keep screenshots, ARIA snapshots, and sanitized response summaries under `.tmp/droid-integration-verification/evidence//`. Never include credentials or request bodies. The request helper stores only the sanitized mock-boundary summary. + +## Cleanup + +Stop only the run ID created by this helper: + +```sh +bun .agents/skills/verify-droid-integration/scripts/harness.ts cleanup +``` + +Cleanup verifies the recorded process identity, stops that PID, waits for all three listeners to close, then removes that run's scratch homes and log. It retains evidence. Launch failures also stop the child it started and remove that run's scratch directory. Never kill by process name or clean another run's directory. + +## Helpers + +The executable harness is `scripts/harness.ts`. Its request command is `bun .agents/skills/verify-droid-integration/scripts/harness.ts request `. Use `none` to test the saved Droid default, `top=...` to test explicit precedence, and `nested=...` to compare native Chat upstream behavior with and without the Droid header. For catalog scenarios, use `fixture remove ladder-beta` or `fixture efforts ladder-beta low`; `none` sets an empty ladder. For maintaining feature coverage as the UI changes, use `/maintain-verification-skill` when available. diff --git a/.agents/skills/verify-droid-integration/features/README.md b/.agents/skills/verify-droid-integration/features/README.md new file mode 100644 index 00000000000..664b9b7adfd --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/README.md @@ -0,0 +1,26 @@ +# Droid integration verification map + +Use the isolated harness in [the skill entrypoint](../SKILL.md), then select the relevant user workflow below. Evidence lives under `.tmp/droid-integration-verification/evidence//` and survives cleanup. + +## Baseline preconditions + +- Run `bun .agents/skills/verify-droid-integration/scripts/harness.ts launch` and save its run ID. +- Require `doctor ` to pass before opening `http://127.0.0.1:12587/#integrations/droid`. +- The helper seeds two models with low, medium, and high effort ladders, one model with no ladder, an empty isolated `.factory/settings.json`, and a local mock provider. +- The fixture provider can reach only the helper mock at `127.0.0.1:12588`; no credentials are configured. +- Capture each action and visible result. Confirm saved state after reload and keep browser artifacts outside the run directory. +- Run `cleanup ` after the recipe. Cleanup preserves evidence. + +## Driving conventions + +- The sidebar has an **Integrations** entry. Droid is directly reachable at `/#integrations/droid`; there is no Settings parent. +- The connection switch reads **Apply** or **Disable**. The defaults panel exposes selects named **Default reasoning effort for ** and a **Save / review changes** button. +- Review each consequence dialog before confirming. The relevant surfaces are `gui/src/pages/integrations/FileIntegrationPage.tsx` and `DroidReasoningDefaultsPanel.tsx`. +- The request helper captures only model, effort, route, and absence of the private Droid header. +- Record feature ID, run ID, action, visible result, and artifact paths. Never include credentials or request bodies. + +## Features + +- [Connect and settings lifecycle](./connect-settings-lifecycle.md) covers apply, refresh, disable, and restore. +- [Per-model defaults and precedence](./per-model-defaults-precedence.md) covers supported choices and request precedence at the local mock boundary. +- [Clear, refresh, and rollback](./clear-refresh-rollback.md) covers clearing values, capability changes, and restoring prior settings. diff --git a/.agents/skills/verify-droid-integration/features/clear-refresh-rollback.md b/.agents/skills/verify-droid-integration/features/clear-refresh-rollback.md new file mode 100644 index 00000000000..3fbe2a2e470 --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/clear-refresh-rollback.md @@ -0,0 +1,42 @@ +# Clear, refresh, and rollback + +Users can remove one model's default, review capability changes during refresh, and restore earlier Droid settings together with their defaults. + +## Sub-features + +- `droid-clear-default` removes one selected model default with No default. +- `droid-refresh-preserve` preserves defaults for models that remain available. +- `droid-refresh-removed` previews removal of models no longer exported. +- `droid-refresh-effort-change` asks the user to clear a choice that is no longer declared. +- `droid-rollback-defaults` restores previous Droid settings and defaults together. + +## How to get to it (user POV) + +- Open `/#integrations/droid` or choose **Integrations** in the sidebar, then **Factory Droid**. +- Choose **No default** in a model select and use **Save / review changes**. +- Use the rollback history's **Undo** or **Restore this point…** action. + +## Driving it with agent-browser + +Preconditions: + +- On macOS or Linux, make sure `lsof` and `ps` are available on `PATH`. +- Launch and doctor a fresh isolated harness run at `http://127.0.0.1:12587/`. +- Apply Droid and confirm the reviewed plan before editing defaults. The helper seeds an empty `.factory/settings.json`. +- The fixture seeds `ladder-alpha` and `ladder-beta`, each with **low**, **medium**, and **high**; `no-ladder` has no effort options. Set both alpha and beta defaults explicitly before the save steps below. +- Use only the `fixture` helper command to change the disposable catalog. It restarts this harness run and retains its Droid settings and history. + +- **Seed supported defaults.** Set alpha to **medium** and beta to **high**. Click **Save / review changes**, inspect the plan, confirm **Apply**, reload, and verify both values persist. +- **Clear and undo while both values are supported.** Set alpha to **No default**, review and confirm the save, then reload and verify alpha is clear while beta remains **high**. Immediately choose **Undo** for that clear operation, review and confirm **Restore**, reload, and verify alpha is **medium** and beta is **high** again. This is the supported-state rollback assertion. +- **Change the effort ladder, then clear the unsupported value.** Run `bun .agents/skills/verify-droid-integration/scripts/harness.ts fixture efforts ladder-beta low`. Return to Droid and reload if needed. Beta's saved **high** value is now unsupported; the panel reports **Some saved defaults are no longer supported. Clear them explicitly.** Use beta's **Clear default**, review the plan, confirm **Apply**, reload, and verify beta remains exported with no default while alpha remains **medium**. Do not Undo this clear: that would restore the saved **high** value against the current low-only ladder, so the resulting state must not be described as current/supported. +- **Remove the model after resolving its unsupported default.** Run `bun .agents/skills/verify-droid-integration/scripts/harness.ts fixture remove ladder-beta`. Return to Droid and reload, use **Save / review changes**, inspect and confirm the removal plan. After reload verify beta is absent and alpha remains exported with **medium**. This final state is the expected removal result; do not infer it from the dialog alone. +- **Proof.** Save before/after screenshots and sanitized read-only state snapshots under `.tmp/droid-integration-verification/evidence//clear-refresh-rollback/`. + +Use the English labels above as source names only. In a non-English dashboard, match the translated visible controls and dialog buttons; the accessible name will follow the selected locale. + +## Gotchas + +- The fixture command edits only this run's config and restarts only its recorded proxy process. +- A missing map on a mutation preserves defaults; an explicitly empty map clears them. +- Refresh preserves matching owned defaults and removes models only through the reviewed plan. +- Apply before the first defaults edit in a fresh run. Keep the supported clear/Undo check before lowering beta's ladder; restoring the later unsupported value would not prove a current supported state. diff --git a/.agents/skills/verify-droid-integration/features/connect-settings-lifecycle.md b/.agents/skills/verify-droid-integration/features/connect-settings-lifecycle.md new file mode 100644 index 00000000000..bf6248e586c --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/connect-settings-lifecycle.md @@ -0,0 +1,41 @@ +# Connect and settings lifecycle + +The Droid integration connects the dashboard's Droid client settings to the opencodex model catalog and presents a review step before each change. + +## Sub-features + +- `droid-connect` applies the integration after review and confirmation. +- `droid-status` shows whether the integration is connected and its current model choices. +- `droid-refresh` previews updated exported models and applies the confirmed refresh. +- `droid-disable` removes the integration-owned settings after review. +- `droid-restore` restores an earlier integration snapshot. + +## How to get to it (user POV) + +- Open `http://127.0.0.1:12587/#integrations/droid`, or choose **Integrations** in the sidebar and then **Factory Droid**. +- Use the connection switch labeled **Apply** or **Disable**. +- Use **Save / review changes** in the defaults panel to review an update. +- Use **Undo** or **Restore this point…** in rollback history to restore a previous change. + +## Driving it with agent-browser + +Preconditions: + +- On macOS or Linux, make sure `lsof` and `ps` are available on `PATH`; see the skill entrypoint for why the harness requires them. +- Launch and doctor a fresh harness run. The fixture begins with an empty `.factory/settings.json`. +- Open its direct Droid route at `http://127.0.0.1:12587/#integrations/droid` and capture the initial status. + +- **Apply.** Activate **Apply**. The dialog title is **Apply this integration?** Review its planned changes, then confirm with **Apply**. The connection switch changes to **Disable** and stays connected after reload. Apply must come before editing/saving defaults in a fresh run. +- **Refresh defaults.** Set a supported effort for an exported model, then activate **Save / review changes**. Review the plan and confirm with **Apply**. Reload and verify the selected value persists in the defaults panel/status. A changed value is needed to demonstrate a persisted update; an unchanged save can be a no-op. +- **Disable.** Activate **Disable**. The dialog title is **Disable this integration?** Review and confirm with **Disable**. The switch returns to **Apply** after reload. +- **Restore.** In rollback history activate **Undo** for the latest entry or **Restore this point…** for an older entry. Review the restore dialog and confirm with **Restore**. The previous state is visible after reload. +- **Proof.** Capture the initial state, each action and confirmation dialog, and the reloaded result. Keep screenshots and ARIA snapshots in `.tmp/droid-integration-verification/evidence//connect-settings-lifecycle/`. + +The control and dialog names above are the English source labels. If the dashboard uses another locale, locate and use the translated visible equivalents rather than expecting English text. + +## Gotchas + +- Do not confirm a plan whose content differs from the intended operation. +- The helper seeds an empty isolated `.factory/settings.json`; apply first so defaults are managed as part of an active integration. +- A completed dialog alone does not prove persistence. Reload and inspect the connection switch and defaults panel/status. +- The external provider boundary is not part of this lifecycle; use the fixture request recipe for request behavior. diff --git a/.agents/skills/verify-droid-integration/features/per-model-defaults-precedence.md b/.agents/skills/verify-droid-integration/features/per-model-defaults-precedence.md new file mode 100644 index 00000000000..691804aaadc --- /dev/null +++ b/.agents/skills/verify-droid-integration/features/per-model-defaults-precedence.md @@ -0,0 +1,43 @@ +# Per-model defaults and precedence + +The Droid settings editor lets a user select a declared reasoning effort for an exported model. The selection is used only when a request omits its own reasoning effort. + +## Sub-features + +- `droid-default-choice` chooses a supported effort or No default for an exported model. +- `droid-default-apply` persists the choice as part of the reviewed integration change. +- `droid-request-precedence` preserves an explicit request effort over the configured default. +- `droid-default-model-scope` applies a choice only to its selected model. + +## How to get to it (user POV) + +- Open `/#integrations/droid` or choose **Integrations** in the sidebar, then **Factory Droid**. Use the per-model reasoning defaults panel. +- Choose an exported model and one of its supported effort values, or No default. +- Review and confirm the integration change to persist an edit. +- Use the local `request` helper to verify the saved default and explicit request precedence at the mock boundary. + +## Driving it with agent-browser + +Preconditions: + +- On macOS or Linux, make sure `lsof` and `ps` are available on `PATH`. +- Launch and doctor a fresh isolated harness run at `http://127.0.0.1:12587/`. +- Apply Droid in the dashboard and confirm the reviewed plan. In a fresh run, Apply must precede editing/saving per-model defaults because the harness starts with an empty `.factory/settings.json`. +- Use `droid-fixture/ladder-alpha` and `droid-fixture/ladder-beta`. Both have low, medium, and high; `droid-fixture/no-ladder` has no effort choices. + +- **Inspect choices.** The select named **Default reasoning effort for Ladder Alpha** offers **No default**, **low**, **medium**, and **high**. The no-ladder model remains visible with **No default available** and no select. +- **Choose a default.** Select **medium** for alpha and click **Save / review changes**. Review the dialog and confirm **Apply**. After reload, the select remains **medium**. +- **Check omission behavior.** Run `bun .agents/skills/verify-droid-integration/scripts/harness.ts request ladder-alpha none`. Its sanitized output reports `effort: "medium"`, the expected model and route, and `privateDefaultHeaderAbsent: true`. +- **Check explicit precedence.** Run the helper with `top=high`. The capture reports `high`, overriding the saved default. Then run `nested=low`; the helper sends the identical native Chat body with and without the Droid header and asserts that upstream effort is unchanged. Native Chat's existing builder does not forward nested `reasoning.effort`, so this comparison verifies the header preserves the baseline behavior rather than claiming the nested field is forwarded. +- **Check model scope.** Run `request ladder-beta none` without saving a beta default. The capture reports `effort: null`. +- **Proof.** Capture the editor and review dialog. The helper keeps only the sanitized upstream summary as evidence. + +The control names above are the English source labels. When the dashboard is localized, use the matching translated visible labels and dialog buttons. + +## Gotchas + +- Editing is local until review and confirmation. A changed control alone is not persistence proof. +- Defaults apply only when the request omits both supported effort locations. Even an invalid, null, or `none` value counts as explicit presence under the contract. +- The helper accepts only the seeded model names and none/top/nested effort modes listed in the skill entrypoint. +- Pins and effort caps remain authoritative. +- The private header must be absent upstream. The mock reports only a boolean for this check and never exposes headers or bodies. diff --git a/.agents/skills/verify-droid-integration/scripts/harness.ts b/.agents/skills/verify-droid-integration/scripts/harness.ts new file mode 100755 index 00000000000..b77b0e076c5 --- /dev/null +++ b/.agents/skills/verify-droid-integration/scripts/harness.ts @@ -0,0 +1,355 @@ +#!/usr/bin/env bun +import { spawn } from "node:child_process"; +import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; + +const root = resolve(import.meta.dir, "../../../../"); +const scratchRoot = resolve(root, ".tmp/droid-integration-verification"); +const runsRoot = resolve(scratchRoot, "runs"); +const evidenceRoot = resolve(scratchRoot, "evidence"); +const scriptPath = resolve(import.meta.path); +const servicePort = 12587; +const guiPort = 12687; +const mockPort = 12588; +const fixtureModels = ["ladder-alpha", "ladder-beta", "no-ladder"] as const; +const args = process.argv.slice(2); + +type Run = { id: string; pid: number; servicePort: number; guiPort: number; mockPort: number; startedAt: string }; +let latestCapture: { model: string; effort: string | null; privateDefaultHeaderAbsent: boolean; route: string } | null = null; + +function fail(message: string): never { + console.error(message); + process.exit(1); +} + +function validId(value: string | undefined): value is string { + return value !== undefined && /^[a-z0-9-]{8,64}$/.test(value); +} + +function paths(id: string) { + const dir = resolve(runsRoot, id); + return { dir, state: resolve(dir, "run.json"), log: resolve(dir, "server.log") }; +} + +async function readRun(id: string): Promise { + const run = JSON.parse(await readFile(paths(id).state, "utf8")) as Run; + if (run.id !== id || run.servicePort !== servicePort || run.guiPort !== guiPort || run.mockPort !== mockPort) fail("Run metadata does not match this harness."); + return run; +} + +async function freePort(port: number): Promise { + return (await listenerPids(port)).length === 0; +} + +async function listenerPids(port: number): Promise { + const proc = Bun.spawn(["lsof", "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { stdout: "pipe", stderr: "ignore" }); + const output = (await new Response(proc.stdout).text()).trim(); + await proc.exited; + return output.split(/\s+/).filter(Boolean).map(Number).filter(Number.isInteger); +} + +async function processCommand(pid: number): Promise { + const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "command="], { stdout: "pipe", stderr: "ignore" }); + const output = (await new Response(proc.stdout).text()).trim(); + await proc.exited; + return output || null; +} + +async function doctor(id: string, record = true, print = true): Promise { + const run = await readRun(id); + const command = await processCommand(run.pid); + if (!command?.includes(scriptPath) || !command.includes(`serve ${id}`)) fail(`Recorded process ${run.pid} is not this harness run.`); + for (const port of [servicePort, guiPort, mockPort]) { + const owners = await listenerPids(port); + if (owners.length !== 1 || owners[0] !== run.pid) fail(`Port ${port} is not owned exclusively by run ${id}.`); + } + const response = await fetch(`http://127.0.0.1:${servicePort}/healthz`, { signal: AbortSignal.timeout(1500) }); + const health = await response.json() as { service?: string; pid?: number; port?: number }; + if (!response.ok || health.service !== "opencodex" || health.pid !== run.pid || health.port !== servicePort) { + fail(`Health identity mismatch for run ${id}.`); + } + const gui = await fetch(`http://127.0.0.1:${servicePort}/`, { signal: AbortSignal.timeout(1500) }); + if (!gui.ok) fail(`Dashboard listener returned HTTP ${gui.status}.`); + const result = { runId: id, pid: run.pid, serviceUrl: `http://127.0.0.1:${servicePort}`, dashboardUrl: `http://127.0.0.1:${servicePort}/#integrations/droid`, interceptPort: guiPort, mockPort, health: "healthy", dashboardStatus: gui.status }; + if (record) await writeEvidence(id, "doctor.json", result); + if (print) console.log(JSON.stringify(result, null, 2)); +} + +async function writeEvidence(id: string, name: string, value: unknown): Promise { + const dir = resolve(evidenceRoot, id); + await mkdir(dir, { recursive: true }); + await writeFile(resolve(dir, name), `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); +} + +async function startRun(id: string, startedAt = new Date().toISOString()): Promise { + const { dir } = paths(id); + const child = spawn(process.execPath, ["run", scriptPath, "serve", id], { + cwd: root, + detached: true, + stdio: "ignore", + env: { + ...process.env, + OPENCODEX_HOME: resolve(dir, "opencodex-home"), + CODEX_HOME: resolve(dir, "codex-home"), + DROID_VERIFY_RUN_ID: id, + }, + }); + if (!child.pid) throw new Error("Could not start the isolated server process."); + child.unref(); + const run: Run = { id, pid: child.pid, servicePort, guiPort, mockPort, startedAt }; + try { + await writeFile(paths(id).state, `${JSON.stringify(run, null, 2)}\n`, { mode: 0o600 }); + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${servicePort}/healthz`, { signal: AbortSignal.timeout(500) }); + const health = await response.json() as { service?: string; pid?: number; port?: number }; + const owners = await Promise.all([servicePort, guiPort, mockPort].map(listenerPids)); + if (response.ok && health.service === "opencodex" && health.pid === child.pid && health.port === servicePort + && owners.every(pids => pids.length === 1 && pids[0] === child.pid)) return run; + } catch { /* Wait for this child to bind its listeners. */ } + if (Date.now() + 500 >= deadline) break; + await Bun.sleep(250); + } + throw new Error("Server did not become ready with the expected identity and listeners."); + } catch (error) { + await stopChild(child.pid, id); + throw error; + } +} + +async function launch(): Promise { + if (!(await freePort(servicePort)) || !(await freePort(guiPort)) || !(await freePort(mockPort))) fail(`Refusing to use occupied verification ports ${servicePort}/${guiPort}/${mockPort}.`); + const id = `${new Date().toISOString().replace(/[^0-9]/g, "").slice(0, 14)}-${crypto.randomUUID().slice(0, 8)}`.toLowerCase(); + const { dir } = paths(id); + const opencodexHome = resolve(dir, "opencodex-home"); + const codexHome = resolve(dir, "codex-home"); + try { + await mkdir(opencodexHome, { recursive: true }); + await mkdir(codexHome, { recursive: true }); + await seedFixtures(dir); + const run = await startRun(id); + await writeEvidence(id, "launch.json", { runId: id, pid: run.pid, servicePort, guiPort, mockPort, fixtureModels, opencodexHome: "isolated scratch", codexHome: "isolated scratch", ready: true }); + console.log(JSON.stringify({ runId: id, pid: run.pid, serviceUrl: `http://127.0.0.1:${servicePort}`, dashboardUrl: `http://127.0.0.1:${servicePort}/#integrations/droid`, interceptPort: guiPort, mockPort, evidence: resolve(evidenceRoot, id) }, null, 2)); + } catch (error) { + await rm(dir, { recursive: true, force: true }); + fail(error instanceof Error ? `${error.message} Launch scratch state was cleaned.` : "Launch failed; its process and scratch state were cleaned."); + } +} + +async function cleanup(id: string): Promise { + const run = await readRun(id); + const command = await processCommand(run.pid); + if (command) await stopChild(run.pid, id); + const remainingOwners = (await Promise.all([servicePort, guiPort, mockPort].map(listenerPids))).flat(); + if (remainingOwners.includes(run.pid)) fail(`Run ${id} still owns a listener; scratch state retained.`); + const health = await fetch(`http://127.0.0.1:${servicePort}/healthz`, { signal: AbortSignal.timeout(300) }).then(r => r.json().catch(() => null)).catch(() => null) as { pid?: number } | null; + if (health?.pid === run.pid) fail(`Run ${id} still owns the service listener; scratch state retained.`); + const evidence = resolve(evidenceRoot, id); + await writeEvidence(id, "cleanup.json", { runId: id, pid: run.pid, stopped: true, evidenceRetainedAt: evidence }); + await rm(paths(id).dir, { recursive: true, force: true }); + console.log(JSON.stringify({ runId: id, stopped: true, scratchRemoved: true, evidenceRetainedAt: evidence }, null, 2)); +} + +async function serve(id: string): Promise { + const { dir, log } = paths(id); + const opencodexHome = resolve(dir, "opencodex-home"); + const codexHome = resolve(dir, "codex-home"); + process.env.OPENCODEX_HOME = opencodexHome; + process.env.CODEX_HOME = codexHome; + const { setIntegrationPathTestHooks } = await import("../../../../src/server/management/integration-routes"); + const { startServer } = await import("../../../../src/server"); + const { createReadinessGate } = await import("../../../../src/server/readiness"); + setIntegrationPathTestHooks({ home: resolve(dir, "client-files"), env: process.env }); + const gate = createReadinessGate(); + gate.markReady(); + const originalLog = console.log; + const originalError = console.error; + const logFile = Bun.file(log).writer(); + console.log = (...values: unknown[]) => { logFile.write(`${values.map(String).join(" ")}\n`); }; + console.error = (...values: unknown[]) => { logFile.write(`${values.map(String).join(" ")}\n`); }; + const mock = Bun.serve({ hostname: "127.0.0.1", port: mockPort, async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/__capture/latest" && request.method === "GET") { + return latestCapture ? Response.json(latestCapture) : Response.json({ error: "no fixture request captured" }, { status: 404 }); + } + if (url.pathname !== "/v1/chat/completions" || request.method !== "POST") return new Response("Not found", { status: 404 }); + const body = await request.json() as Record; + const model = typeof body.model === "string" ? body.model : ""; + if (!fixtureModels.some(id => model === id || model.endsWith(`/${id}`))) return Response.json({ error: "unknown fixture model" }, { status: 404 }); + const nested = body.reasoning && typeof body.reasoning === "object" ? body.reasoning as Record : {}; + latestCapture = { + model, + effort: typeof body.reasoning_effort === "string" ? body.reasoning_effort : typeof nested.effort === "string" ? nested.effort : null, + privateDefaultHeaderAbsent: !request.headers.has("x-opencodex-droid-default-effort"), + route: `${request.method} ${url.pathname}`, + }; + return Response.json({ id: "fixture-response", object: "chat.completion", created: 0, model, + choices: [{ index: 0, message: { role: "assistant", content: "fixture-ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 } }); + } }); + const server = startServer(servicePort, { readinessGate: gate }); + process.on("SIGTERM", async () => { + await server.stop(true); + mock.stop(true); + await logFile.end(); + console.log = originalLog; + console.error = originalError; + process.exit(0); + }); + await new Promise(() => {}); +} + +async function seedFixtures(dir: string): Promise { + const config = { + port: servicePort, + defaultProvider: "droid-fixture", + providers: { + "droid-fixture": { + adapter: "openai-chat", + baseUrl: `http://127.0.0.1:${mockPort}/v1`, + allowPrivateNetwork: true, + liveModels: false, + models: [...fixtureModels], + modelReasoningEfforts: { + "ladder-alpha": ["low", "medium", "high"], + "ladder-beta": ["low", "medium", "high"], + "no-ladder": [], + }, + }, + }, + }; + const clientHome = resolve(dir, "client-files"); + await mkdir(resolve(clientHome, ".factory"), { recursive: true }); + await writeFile(resolve(dir, "opencodex-home/config.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await writeFile(resolve(clientHome, ".factory/settings.json"), `${JSON.stringify({ customModels: [] }, null, 2)}\n`, { mode: 0o600 }); +} + +async function stopChild(pid: number, id: string): Promise { + const ownsProcess = (command: string | null) => command?.includes(scriptPath) === true && command.includes(`serve ${id}`); + let command = await processCommand(pid); + if (!command) return; + if (!ownsProcess(command)) throw new Error(`Refusing to stop PID ${pid}; its command does not match run ${id}.`); + process.kill(pid, "SIGTERM"); + const deadline = Date.now() + 8_000; + while (Date.now() < deadline) { + command = await processCommand(pid); + if (!command) return; + if (!ownsProcess(command)) throw new Error(`PID ${pid} changed identity while stopping run ${id}.`); + await Bun.sleep(100); + } + command = await processCommand(pid); + if (!command) return; + if (!ownsProcess(command)) throw new Error(`PID ${pid} changed identity while stopping run ${id}.`); + process.kill(pid, "SIGKILL"); + const forcedDeadline = Date.now() + 2_000; + while (Date.now() < forcedDeadline && await processCommand(pid)) await Bun.sleep(100); + if (await processCommand(pid)) throw new Error(`PID ${pid} could not be stopped; scratch state retained.`); +} + +async function request(id: string, model: string, mode: string): Promise { + await doctor(id, false, false); + if (!fixtureModels.includes(model as typeof fixtureModels[number])) fail("Use one of the three seeded fixture model IDs."); + if (mode !== "none" && !/^(top|nested)=(low|medium|high)$/.test(mode)) { + fail("Effort mode must be none, top=low|medium|high, or nested=low|medium|high."); + } + const selectedEffort = mode === "none" ? null : mode.slice(mode.indexOf("=") + 1); + const savedEffort = await fixtureDefault(id, `droid-fixture/${model}`); + const body: Record = { + model: `droid-fixture/${model}`, + messages: [{ role: "user", content: "verification fixture" }], + }; + if (mode.startsWith("top=")) body.reasoning_effort = selectedEffort; + else if (mode.startsWith("nested=")) body.reasoning = { effort: selectedEffort }; + const capture = async (headers: Record = {}) => { + const response = await fetch(`http://127.0.0.1:${servicePort}/v1/chat/completions`, { + method: "POST", headers: { "content-type": "application/json", ...headers }, + body: JSON.stringify(body), signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) fail(`Fixture request returned HTTP ${response.status}; inspect the isolated server log.`); + const captured = await fetch(`http://127.0.0.1:${mockPort}/__capture/latest`, { signal: AbortSignal.timeout(1000) }); + return { ok: captured.ok, summary: await captured.json() as { model: string; effort: string | null; privateDefaultHeaderAbsent: boolean; route: string } }; + }; + const baselineHeaders = mode.startsWith("nested=") || !savedEffort + ? {} + : { "x-opencodex-droid-default-effort": savedEffort }; + const baseline = await capture(baselineHeaders); + let summary = baseline.summary; + let headerEffort: string | null | undefined; + const expectedEffort = mode === "none" ? savedEffort : selectedEffort; + if (mode.startsWith("nested=")) { + const fallbackEffort = ["high", "medium", "low"].find(effort => effort !== selectedEffort)!; + const comparisonHeader = savedEffort && savedEffort !== selectedEffort ? savedEffort : fallbackEffort; + const withHeader = await capture({ "x-opencodex-droid-default-effort": comparisonHeader }); + headerEffort = withHeader.summary.effort; + if (!withHeader.ok || withHeader.summary.model !== baseline.summary.model + || withHeader.summary.route !== baseline.summary.route + || withHeader.summary.privateDefaultHeaderAbsent !== baseline.summary.privateDefaultHeaderAbsent + || withHeader.summary.effort !== baseline.summary.effort) { + fail("Nested-effort boundary behavior changed when the Droid header was present."); + } + summary = withHeader.summary; + } else if (summary.effort !== expectedEffort) { + fail("Fixture boundary effort did not match the expected safe summary."); + } + if (!baseline.ok || !baseline.summary.privateDefaultHeaderAbsent + || (summary.model !== model && !summary.model.endsWith(`/${model}`)) + || !summary.privateDefaultHeaderAbsent || summary.route !== "POST /v1/chat/completions") { + fail("Fixture boundary capture did not match the expected safe summary."); + } + const proof = headerEffort === undefined ? summary : { ...summary, headerEffort }; + await writeEvidence(id, `request-${Date.now()}.json`, proof); + console.log(JSON.stringify(proof, null, 2)); +} + +async function fixtureDefault(id: string, model: string): Promise { + const settingsPath = resolve(paths(id).dir, "client-files/.factory/settings.json"); + const settings = JSON.parse(await readFile(settingsPath, "utf8")) as { customModels?: Array<{ model?: string; extraHeaders?: Record }> }; + const entry = settings.customModels?.find(row => row.model === model); + return entry?.extraHeaders?.["x-opencodex-droid-default-effort"] ?? null; +} + +async function changeFixture(id: string, action: string, model: string, efforts = ""): Promise { + const run = await readRun(id); + await doctor(id, false); + if (!fixtureModels.includes(model as typeof fixtureModels[number])) fail("Use one of the three seeded fixture model IDs."); + const configPath = resolve(paths(id).dir, "opencodex-home/config.json"); + const config = JSON.parse(await readFile(configPath, "utf8")) as { + providers: Record }>; + }; + const provider = config.providers["droid-fixture"]!; + if (action === "remove") { + provider.models = provider.models.filter(candidate => candidate !== model); + delete provider.modelReasoningEfforts[model]; + } else if (action === "efforts") { + const ladder = efforts === "none" ? [] : efforts.split(","); + if (ladder.some(effort => !["low", "medium", "high"].includes(effort))) fail("Fixture efforts may contain only low, medium, and high, or none."); + if (!provider.models.includes(model)) provider.models.push(model); + provider.modelReasoningEfforts[model] = ladder; + } else fail("Fixture action must be remove or efforts."); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + await stopChild(run.pid, id); + let restarted: Run; + try { + restarted = await startRun(id, run.startedAt); + } catch (error) { + throw new Error(error instanceof Error ? `Fixture server restart failed: ${error.message}` : "Fixture server restart failed."); + } + const proof = { runId: id, action, model, efforts: action === "efforts" ? (efforts || "none") : null, restartedPid: restarted.pid }; + await writeEvidence(id, `fixture-${Date.now()}.json`, proof); + console.log(JSON.stringify(proof, null, 2)); +} + +const [command, id] = args; +if (command === "serve") { + if (!validId(id)) fail("Invalid internal run ID."); + await serve(id); +} else if (command === "launch" && id === undefined) await launch(); +else if ((command === "doctor" || command === "cleanup") && validId(id)) { + if (command === "doctor") await doctor(id); + else await cleanup(id); +} else if (command === "request" && validId(id) && args[2] && args[3]) { + await request(id, args[2], args[3]); +} else if (command === "fixture" && validId(id) && args[2] && args[3]) { + await changeFixture(id, args[2], args[3], args[4]); +} else fail("Usage: harness.ts launch | doctor | request | fixture remove|efforts [low,medium,high|none] | cleanup "); diff --git a/docs-site/src/content/docs/guides/integrations.md b/docs-site/src/content/docs/guides/integrations.md index 4cb6ead058c..fefd78312d5 100644 --- a/docs-site/src/content/docs/guides/integrations.md +++ b/docs-site/src/content/docs/guides/integrations.md @@ -559,6 +559,20 @@ Chat Completions endpoint. Choose a row from Droid's `/model` picker. Disable removes the managed rows; Undo restores the exact saved file. Other settings and custom models remain yours. +Open **Integrations → Factory Droid** (`/#integrations/droid`) to set a reasoning +default for each connected model. Choose from the model's supported efforts, +review the changes, then confirm. **No default** clears that model's setting. +Models without a declared effort list show that no default is available. + +The default applies only when Droid omits an effort from its request. An explicit +request effort takes precedence over this default; existing OpenCodex pins and +caps still apply. Droid may continue to display **Dynamic** even when OpenCodex +applies the configured default. Other clients' requests are unaffected. + +Refresh preserves defaults for models that remain connected. Disable removes +the defaults with the managed model rows, and Undo restores the saved rows and +their defaults together. + Models whose IDs or display names contain `,` or `]` are skipped because the managed selector cannot address them safely; export and managed settings show the same rows. A nonempty catalog with no addressable models is refused. diff --git a/docs-site/src/content/docs/ko/guides/integrations.md b/docs-site/src/content/docs/ko/guides/integrations.md index e39df9ea9da..f55261ad90f 100644 --- a/docs-site/src/content/docs/ko/guides/integrations.md +++ b/docs-site/src/content/docs/ko/guides/integrations.md @@ -258,4 +258,17 @@ GitHub Copilot 데스크톱 앱에서 opencodex를 OpenAI 호환 모델 프로 ## Factory Droid +**연동 → Factory Droid** (`/#integrations/droid`)에서 연결된 모델별 추론 기본값을 +설정할 수 있습니다. 모델이 지원하는 effort를 선택하고 변경 내용을 검토한 뒤 +확인하세요. **기본값 없음**을 선택하면 해당 모델의 설정을 지웁니다. 지원 effort +목록이 없는 모델에는 사용 가능한 기본값이 없다고 표시됩니다. + +이 값은 Droid 요청에 effort가 없을 때만 적용됩니다. 요청이 effort를 명시하면 +그 값을 사용하며, 기존 OpenCodex pin과 cap 정책은 계속 적용됩니다. OpenCodex가 +기본값을 적용하더라도 Droid에는 **Dynamic**으로 표시될 수 있습니다. 다른 +클라이언트의 요청에는 영향을 주지 않습니다. + +새로고침은 계속 연결된 모델의 기본값을 유지합니다. 연동을 해제하면 관리되는 +모델 행과 기본값이 함께 제거되며, 되돌리기는 저장된 행과 기본값을 함께 복원합니다. + Factory Droid는 `~/.factory/settings.json`(Windows에서는 `%USERPROFILE%\.factory\settings.json`)을 사용합니다. `ocx integration client enable --client droid`로 명시적으로 활성화한 다음 `/model`에서 사용자 지정 모델을 선택하세요. 관리되는 항목에는 키가 없으며 루프백에서만 동작합니다. 비활성화하면 관리되는 항목이 제거되고, Undo는 저장된 원본 바이트를 복원합니다. 기존 `config.json`에 OpenCodex 항목이 있거나 `settings.local.json`이 `customModels`를 덮어쓰면 활성화 전에 충돌을 해결하세요. [Factory BYOK 문서](https://docs.factory.ai/model-independence/byok)를 참고하세요. diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 794bc463245..c526e93d212 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1450,6 +1450,16 @@ export const de: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Standard für Reasoning-Aufwand", + "integrations.droidReasoning.description": "Der Standard gilt nur, wenn eine Anfrage keinen Reasoning-Aufwand angibt. Deaktiviere Factory Droid, um diese gespeicherten Standards zu entfernen.", + "integrations.droidReasoning.noDefault": "Kein Standard", + "integrations.droidReasoning.noEfforts": "Kein Standard verfügbar", + "integrations.droidReasoning.noModels": "Keine exportierten Modelle verfügbar.", + "integrations.droidReasoning.modelDefault": "Standard-Reasoning-Aufwand für {model}", + "integrations.droidReasoning.unsupportedTitle": "Einige gespeicherte Standards werden nicht mehr unterstützt. Entferne sie ausdrücklich.", + "integrations.droidReasoning.unsupportedEffort": "Nicht unterstützter Aufwand: {effort}", + "integrations.droidReasoning.clear": "Standard entfernen", + "integrations.droidReasoning.saveReview": "Änderungen speichern / prüfen", "integrations.aside.profilesTitle": "Aside-Profile", "integrations.aside.profilesHint": "Wähle, welche Profile die ausgewählten Modelle erhalten. Das aktive Aside-Profil bleibt unverändert.", "integrations.aside.all": "Alle Profile synchronisieren", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index c61d8d17cc9..b3b11ff5531 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -2000,6 +2000,16 @@ export const en = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Default reasoning effort", + "integrations.droidReasoning.description": "A default is used only when a request omits its reasoning effort. Disable Factory Droid to remove these saved defaults.", + "integrations.droidReasoning.noDefault": "No default", + "integrations.droidReasoning.noEfforts": "No default available", + "integrations.droidReasoning.noModels": "No exported models are available.", + "integrations.droidReasoning.modelDefault": "Default reasoning effort for {model}", + "integrations.droidReasoning.unsupportedTitle": "Some saved defaults are no longer supported. Clear them explicitly.", + "integrations.droidReasoning.unsupportedEffort": "Unsupported effort: {effort}", + "integrations.droidReasoning.clear": "Clear default", + "integrations.droidReasoning.saveReview": "Save / review changes", "integrations.aside.profilesTitle": "Aside profiles", "integrations.aside.profilesHint": "Choose which profiles receive the selected models. Aside’s active profile stays unchanged.", "integrations.aside.all": "Sync all profiles", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index d306535db96..8899d178702 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -1972,6 +1972,16 @@ export const fr: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Niveau de raisonnement par défaut", + "integrations.droidReasoning.description": "Le niveau par défaut s’applique uniquement si la requête ne précise aucun niveau de raisonnement. Désactivez Factory Droid pour supprimer ces valeurs enregistrées.", + "integrations.droidReasoning.noDefault": "Aucune valeur par défaut", + "integrations.droidReasoning.noEfforts": "Aucune valeur par défaut disponible", + "integrations.droidReasoning.noModels": "Aucun modèle exporté n’est disponible.", + "integrations.droidReasoning.modelDefault": "Niveau de raisonnement par défaut pour {model}", + "integrations.droidReasoning.unsupportedTitle": "Certaines valeurs enregistrées ne sont plus prises en charge. Supprimez-les explicitement.", + "integrations.droidReasoning.unsupportedEffort": "Niveau non pris en charge : {effort}", + "integrations.droidReasoning.clear": "Supprimer la valeur", + "integrations.droidReasoning.saveReview": "Enregistrer / vérifier les changements", "integrations.aside.profilesTitle": "Profils Aside", "integrations.aside.profilesHint": "Choisissez les profils qui recevront les modèles sélectionnés. Le profil actif dans Aside reste inchangé.", "integrations.aside.all": "Synchroniser tous les profils", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 2efc98321ba..d8aa24f46e6 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1875,6 +1875,16 @@ export const ja: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "推論レベルの既定値", + "integrations.droidReasoning.description": "リクエストに推論レベルが指定されていない場合にのみ既定値が使われます。Factory Droid を無効にすると、保存済みの既定値も削除されます。", + "integrations.droidReasoning.noDefault": "既定値なし", + "integrations.droidReasoning.noEfforts": "設定可能な既定値はありません", + "integrations.droidReasoning.noModels": "エクスポートされたモデルはありません。", + "integrations.droidReasoning.modelDefault": "{model} の推論レベルの既定値", + "integrations.droidReasoning.unsupportedTitle": "一部の保存済み既定値はサポートされなくなりました。明示的に削除してください。", + "integrations.droidReasoning.unsupportedEffort": "未サポートのレベル: {effort}", + "integrations.droidReasoning.clear": "既定値を削除", + "integrations.droidReasoning.saveReview": "変更を保存 / 確認", "integrations.aside.profilesTitle": "Asideのプロファイル", "integrations.aside.profilesHint": "選択したモデルを同期するプロファイルを選んでください。Asideで使用中のプロファイルは変わりません。", "integrations.aside.all": "すべてのプロファイルを同期", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 8aa52852935..b0c499f09d3 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1486,6 +1486,16 @@ export const ko: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "기본 추론 강도", + "integrations.droidReasoning.description": "요청에 추론 강도가 지정되지 않은 경우에만 기본값을 사용합니다. Factory Droid를 비활성화하면 저장된 기본값도 제거됩니다.", + "integrations.droidReasoning.noDefault": "기본값 없음", + "integrations.droidReasoning.noEfforts": "사용 가능한 기본값 없음", + "integrations.droidReasoning.noModels": "내보낸 모델이 없습니다.", + "integrations.droidReasoning.modelDefault": "{model}의 기본 추론 강도", + "integrations.droidReasoning.unsupportedTitle": "저장된 기본값 중 일부가 더 이상 지원되지 않습니다. 명시적으로 지워 주세요.", + "integrations.droidReasoning.unsupportedEffort": "지원하지 않는 강도: {effort}", + "integrations.droidReasoning.clear": "기본값 지우기", + "integrations.droidReasoning.saveReview": "변경 사항 저장 / 검토", "integrations.aside.profilesTitle": "Aside 프로필", "integrations.aside.profilesHint": "선택한 모델을 동기화할 프로필을 고르세요. Aside에서 사용 중인 프로필은 바뀌지 않습니다.", "integrations.aside.all": "모든 프로필 동기화", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2a9f58cc03d..f44a09204b7 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1966,6 +1966,16 @@ export const ru: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Уровень рассуждений по умолчанию", + "integrations.droidReasoning.description": "Значение по умолчанию применяется, только если запрос не задаёт уровень рассуждений. Отключите Factory Droid, чтобы удалить сохранённые значения.", + "integrations.droidReasoning.noDefault": "Без значения по умолчанию", + "integrations.droidReasoning.noEfforts": "Нет доступного значения по умолчанию", + "integrations.droidReasoning.noModels": "Нет доступных экспортированных моделей.", + "integrations.droidReasoning.modelDefault": "Уровень рассуждений по умолчанию для {model}", + "integrations.droidReasoning.unsupportedTitle": "Некоторые сохранённые значения больше не поддерживаются. Удалите их явно.", + "integrations.droidReasoning.unsupportedEffort": "Неподдерживаемый уровень: {effort}", + "integrations.droidReasoning.clear": "Удалить значение", + "integrations.droidReasoning.saveReview": "Сохранить / проверить изменения", "integrations.aside.profilesTitle": "Профили Aside", "integrations.aside.profilesHint": "Выберите профили, в которые будут добавлены выбранные модели. Активный профиль Aside не изменится.", "integrations.aside.all": "Синхронизировать все профили", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a40d6f537ef..8311eda31b2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1985,6 +1985,16 @@ export const tr: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Varsayılan akıl yürütme düzeyi", + "integrations.droidReasoning.description": "Varsayılan yalnızca istek akıl yürütme düzeyi belirtmediğinde kullanılır. Kaydedilmiş varsayılanları kaldırmak için Factory Droid'u devre dışı bırakın.", + "integrations.droidReasoning.noDefault": "Varsayılan yok", + "integrations.droidReasoning.noEfforts": "Kullanılabilir varsayılan yok", + "integrations.droidReasoning.noModels": "Dışa aktarılmış model yok.", + "integrations.droidReasoning.modelDefault": "{model} için varsayılan akıl yürütme düzeyi", + "integrations.droidReasoning.unsupportedTitle": "Kaydedilmiş bazı varsayılanlar artık desteklenmiyor. Bunları açıkça temizleyin.", + "integrations.droidReasoning.unsupportedEffort": "Desteklenmeyen düzey: {effort}", + "integrations.droidReasoning.clear": "Varsayılanı temizle", + "integrations.droidReasoning.saveReview": "Değişiklikleri kaydet / incele", "integrations.aside.profilesTitle": "Aside profilleri", "integrations.aside.profilesHint": "Seçili modellerin hangi profillere aktarılacağını seçin. Aside’ın etkin profili değişmez.", "integrations.aside.all": "Tüm profilleri eşitle", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 766c7c39bc0..ee5aae3d14f 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -1955,6 +1955,16 @@ export const vi: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "Mức suy luận mặc định", + "integrations.droidReasoning.description": "Chỉ dùng mức mặc định khi yêu cầu không chỉ định mức suy luận. Tắt Factory Droid để xóa các mức mặc định đã lưu.", + "integrations.droidReasoning.noDefault": "Không có mặc định", + "integrations.droidReasoning.noEfforts": "Không có mức mặc định khả dụng", + "integrations.droidReasoning.noModels": "Không có mô hình nào được xuất.", + "integrations.droidReasoning.modelDefault": "Mức suy luận mặc định cho {model}", + "integrations.droidReasoning.unsupportedTitle": "Một số mức mặc định đã lưu không còn được hỗ trợ. Hãy xóa chúng một cách rõ ràng.", + "integrations.droidReasoning.unsupportedEffort": "Mức không được hỗ trợ: {effort}", + "integrations.droidReasoning.clear": "Xóa mặc định", + "integrations.droidReasoning.saveReview": "Lưu / xem lại thay đổi", "integrations.aside.profilesTitle": "Cấu hình Aside", "integrations.aside.profilesHint": "Chọn profile nào sẽ nhận các model được chọn. Profile đang hoạt động của Aside sẽ được giữ nguyên.", "integrations.aside.all": "Đồng bộ tất cả profile", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index d01b4effebc..622f0e7eb31 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2777,6 +2777,16 @@ export const zhTW: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "預設推理強度", + "integrations.droidReasoning.description": "只有在要求未指定推理強度時才會使用預設值。停用 Factory Droid 會移除這些已儲存的預設值。", + "integrations.droidReasoning.noDefault": "無預設值", + "integrations.droidReasoning.noEfforts": "沒有可用的預設值", + "integrations.droidReasoning.noModels": "沒有可匯出的模型。", + "integrations.droidReasoning.modelDefault": "{model} 的預設推理強度", + "integrations.droidReasoning.unsupportedTitle": "部分已儲存的預設值已不再支援,請明確清除。", + "integrations.droidReasoning.unsupportedEffort": "不支援的強度:{effort}", + "integrations.droidReasoning.clear": "清除預設值", + "integrations.droidReasoning.saveReview": "儲存 / 檢查變更", "integrations.aside.profilesTitle": "Aside 設定檔", "integrations.aside.profilesHint": "選擇要接收所選模型的設定檔。Aside 目前使用的設定檔不會改變。", "integrations.aside.all": "同步所有設定檔", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 16c2d322da9..b3ec5435e1a 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1467,6 +1467,16 @@ export const zh: Record = { "integrations.tab.cline": "Cline CLI", "integrations.tab.kilo": "Kilo", "integrations.tab.droid": "Factory Droid", + "integrations.droidReasoning.title": "默认推理强度", + "integrations.droidReasoning.description": "仅当请求未指定推理强度时才使用默认值。停用 Factory Droid 会删除这些已保存的默认值。", + "integrations.droidReasoning.noDefault": "无默认值", + "integrations.droidReasoning.noEfforts": "没有可用的默认值", + "integrations.droidReasoning.noModels": "没有可导出的模型。", + "integrations.droidReasoning.modelDefault": "{model} 的默认推理强度", + "integrations.droidReasoning.unsupportedTitle": "部分已保存的默认值已不再受支持,请明确清除。", + "integrations.droidReasoning.unsupportedEffort": "不支持的强度:{effort}", + "integrations.droidReasoning.clear": "清除默认值", + "integrations.droidReasoning.saveReview": "保存 / 检查更改", "integrations.aside.profilesTitle": "Aside 配置文件", "integrations.aside.profilesHint": "选择要接收所选模型的配置文件。Aside 当前使用的配置文件不会改变。", "integrations.aside.all": "同步所有配置文件", diff --git a/gui/src/pages/integrations/DroidReasoningDefaultsPanel.tsx b/gui/src/pages/integrations/DroidReasoningDefaultsPanel.tsx new file mode 100644 index 00000000000..8aae55968f9 --- /dev/null +++ b/gui/src/pages/integrations/DroidReasoningDefaultsPanel.tsx @@ -0,0 +1,100 @@ +import { useT } from "../../i18n/shared"; +import { Select } from "../../ui"; +import type { DroidReasoningStatus } from "./integration-api"; + +export default function DroidReasoningDefaultsPanel({ + reasoning, + defaults, + disabled, + onChange, + onReview, +}: { + reasoning: DroidReasoningStatus; + defaults: Record; + disabled: boolean; + onChange: (next: Record) => void; + onReview: () => void; +}) { + const t = useT(); + const modelById = new Map(reasoning.models.map(model => [model.model, model])); + const unsupported = Object.entries(defaults).filter(([modelId, effort]) => { + const model = modelById.get(modelId); + return !model || !model.efforts.includes(effort); + }); + + const setDefault = (model: string, effort: string) => { + const next = { ...defaults }; + if (effort) next[model] = effort; + else delete next[model]; + onChange(next); + }; + + return ( +
+
+
+

{t("integrations.droidReasoning.title")}

+

{t("integrations.droidReasoning.description")}

+
+ +
+ + {reasoning.models.length > 0 ? ( +
    + {reasoning.models.map(model => { + const selected = defaults[model.model] ?? ""; + const declared = model.efforts.includes(selected); + const options = [ + { value: "", label: t("integrations.droidReasoning.noDefault") }, + ...(!declared && selected ? [{ + value: selected, + label: t("integrations.droidReasoning.unsupportedEffort", { effort: selected }), + }] : []), + ...model.efforts.map(effort => ({ value: effort, label: effort })), + ]; + return ( +
  • +
    + {model.label} + {model.model} +
    + {model.efforts.length > 0 ? ( +