diff --git a/desktop/main/agent-cli.ts b/desktop/main/agent-cli.ts index e6b86ec2..88a28444 100644 --- a/desktop/main/agent-cli.ts +++ b/desktop/main/agent-cli.ts @@ -1072,7 +1072,7 @@ export interface UserConfigShape { localModels?: { url?: string; mode?: string; - managed?: { modelId?: string | null; port?: number }; + managed?: { modelId?: string | null; port?: number; parallel?: number | string }; // r5 item 7 (setup wizard): the custom-endpoint branch writes modelId // as persistUserRemoteLlmUrls does, so the field has to exist here. embeddings?: { url?: string; enabled?: boolean; modelId?: string | null }; @@ -1158,11 +1158,20 @@ async function syncLocalLlamaProviderUrlInFileNow(): Promise { * the TUI does when it is absent (url only — no baseUrl), refuses an id * that names no provider, writes ONLY llm.activeTextProvider. */ -export function setActiveTextProvider(id: string): Promise { - return withConfigLock(() => setActiveTextProviderNow(id)); +export function setActiveTextProvider(id: string, opts: { leaveFusion?: boolean } = {}): Promise { + return withConfigLock(() => setActiveTextProviderNow(id, opts)); } -async function setActiveTextProviderNow(id: string): Promise { +/** + * `leaveFusion`: the TUI's activateCloud / activateLocal. A plain route + * switch writes `llm.activeTextProvider` alone, and `resolveRunMode` keeps + * honouring a stored `runMode.mode: "fusion"` for as long as the active + * provider is the orchestrator — which is exactly the provider "cloud" + * picks. So a switch that means to leave Fusion writes the stored mode in + * the same write (`RunModeOrchestrator.setMode`), or the window lands in + * effective Fusion while its chip says cloud. + */ +async function setActiveTextProviderNow(id: string, opts: { leaveFusion?: boolean } = {}): Promise { if (!/^[\w.-]{1,48}$/.test(id)) return { ok: false, changed: false, error: `not a provider id: ${id}` }; const read = await readWholeConfig(); if (!read.ok || !read.config) return { ok: false, changed: false, error: read.error }; @@ -1181,8 +1190,13 @@ async function setActiveTextProviderNow(id: string): Promise { if (!providers.some((p) => p.id === id)) { return { ok: false, changed: false, error: `provider "${id}" is not configured` }; } - if (llm.activeTextProvider === id && !synthesized) return { ok: true, changed: false }; + const run = llm.runMode; + const leaving = opts.leaveFusion === true && run?.mode === "fusion"; + if (llm.activeTextProvider === id && !synthesized && !leaving) return { ok: true, changed: false }; llm.activeTextProvider = id; + if (leaving && run) { + run.mode = providers.find((p) => p.id === id)?.kind === "llama-server" ? "local" : "cloud"; + } const w = await writeWholeConfig(cfg); return w.ok ? { ok: true, changed: true } : { ok: false, changed: false, error: w.error }; } @@ -1375,38 +1389,29 @@ export function providerHasKey(entry: ProviderEntry, names: KeyEnvNames = keyNam return false; } -/** Ids of the configured cloud providers that have a usable key, for the selector's row copy. */ /** - * The run mode, as the TUI's `/runmode` writes it. + * Read, plan and write the whole file under ONE hold of the config lock. * - * The desktop had no way to reach this at all: three run modes in the TUI - * (local, cloud, and fusion — a cloud model orchestrating local workers) and - * a window that could only pick a provider. It is ordinary config, so it goes - * through the same whole-file path every other write here uses; there is no - * route for it and inventing one would be a second source of truth. + * The run-mode writes (main/run-mode.ts) decide what to write from what the + * file says — which leg is active, which provider is pinned — so reading it + * outside the lock and writing it inside would let a write that landed in + * between be silently undone. `plan` mutates the object it is handed and + * says whether it did; nothing is written when it did not. */ -export async function setRunMode( - mode: "local" | "cloud" | "fusion", - fusion?: { workers?: number }, -): Promise { +export function rewriteWholeConfig( + plan: (cfg: UserConfigShape) => V, +): Promise<{ ok: boolean; changed: boolean; error?: string; verdict?: V }> { return withConfigLock(async () => { const read = await readWholeConfig(); if (!read.ok || !read.config) return { ok: false, changed: false, error: read.error }; - const cfg = read.config; - const llm = (cfg.llm ??= {}); - const run = (llm.runMode ??= {}); - const before = JSON.stringify(run); - run.mode = mode; - if (fusion?.workers !== undefined) { - const f = (run.fusion ??= {}); - f.workers = Math.max(1, Math.min(16, Math.floor(fusion.workers))); - } - if (JSON.stringify(run) === before) return { ok: true, changed: false }; - const w = await writeWholeConfig(cfg); - return w.ok ? { ok: true, changed: true } : { ok: false, changed: false, error: w.error }; + const verdict = plan(read.config); + if (!verdict.write) return { ok: true, changed: false, verdict }; + const w = await writeWholeConfig(read.config); + return w.ok ? { ok: true, changed: true, verdict } : { ok: false, changed: false, error: w.error, verdict }; }); } +/** Ids of the configured cloud providers that have a usable key, for the selector's row copy. */ export async function providersReady(): Promise<{ ok: boolean; ids?: string[]; error?: string }> { const read = await readWholeConfig(); if (!read.ok || !read.config) return { ok: false, error: read.error }; diff --git a/desktop/main/backend-switch.ts b/desktop/main/backend-switch.ts index ad369409..ff081e09 100644 --- a/desktop/main/backend-switch.ts +++ b/desktop/main/backend-switch.ts @@ -1,5 +1,6 @@ import { chatModelsList, + keyNamesAvailable, localDaemonRunning, modelsList, modelsStart, @@ -7,12 +8,22 @@ import { modelsUse, providerHasKey, readWholeConfig, + rewriteWholeConfig, setActiveTextProvider, setMemoryEmbeddingsEnabled, setProviderModel, useManagedMode, type ProviderEntry, } from "./agent-cli.js"; +import { + describeRunMode, + planEnterFusion, + planFusionWorkers, + planSwapLegs, + resolveRunMode, + type RunModeProvider, + type RunModeVerdict, +} from "./run-mode.js"; /** * Lane B — backend switch. @@ -65,6 +76,12 @@ export interface SwitchResult { /** selectLocalModel on a model that is not on disk — pull it first. */ needsDownload?: boolean; error?: string; + /** Run-mode switches: the one sentence a refused change is told in (nothing was written). */ + refusal?: string; + /** Run-mode switches: the effective mode either side of the write, and the TUI's `run mode: …` line. */ + runMode?: { before: string; after: string; line: string; enteredFusion: boolean }; + /** setFusionWorkers: the TUI's `fusion: N workers …` line. */ + notice?: string; } const LOCAL_ID = "local-llama"; @@ -92,7 +109,7 @@ function readyLine(stdout: string): string | undefined { * only a successful stop is followed by write 2 (memory.embeddings.enabled * = false), which is the order the TUI's stopDaemon does it. */ -export async function activateProvider(id: string): Promise { +export async function activateProvider(id: string, opts: { leaveFusion?: boolean } = {}): Promise { const read = await readWholeConfig(); if (!read.ok || !read.config) return { ok: false, error: read.error }; const entry = (read.config.llm?.providers ?? []).find((p) => p.id === id); @@ -101,7 +118,14 @@ export async function activateProvider(id: string): Promise { if (cloud && !providerHasKey(entry)) { return { ok: false, needsKey: true, providerId: id, error: "no API key" }; } - const w = await setActiveTextProvider(id); + /* Under effective Fusion the orchestrator IS the active provider, and its + own model chip re-activates it: that keeps the mode (the TUI's + selectChatModel on the active provider), and it must not stop the local + daemon the workers run on. Every other activation — another provider, + or the backend row's `cloud` — leaves Fusion in the same write. */ + const rm = resolveRunMode(read.config); + const keepFusion = !opts.leaveFusion && rm.effective === "fusion" && rm.orchestratorProviderId === id; + const w = await setActiveTextProvider(id, { leaveFusion: !keepFusion }); if (!w.ok) return { ok: false, error: w.error }; // `restart` says the file moved. main.ts also restarts when the file did // NOT move but `atag serve` booted on another route (the TUI or a hand @@ -109,7 +133,7 @@ export async function activateProvider(id: string): Promise { let restart = w.changed; let daemon: DaemonEffect = "untouched"; let daemonLine: string | undefined; - if (cloud && (await localDaemonRunning())) { + if (cloud && !keepFusion && (await localDaemonRunning())) { const s = await modelsStop(); if (s.ok) { daemon = "stopped"; @@ -150,7 +174,7 @@ async function routeToLocal(modelId: string): Promise { if (!used.ok) return { ok: false, error: used.error }; restart = true; } - const w = await setActiveTextProvider(LOCAL_ID); + const w = await setActiveTextProvider(LOCAL_ID, { leaveFusion: true }); if (!w.ok) return { ok: false, error: w.error }; if (w.changed) restart = true; @@ -200,7 +224,9 @@ export async function switchBackend(kind: "cloud" | "local"): Promise providerHasKey(p)) ?? cloud[0]; if (!provider) return { ok: false, needsProvider: true, error: "add a provider first" }; - return activateProvider(provider.id); + // Under Fusion the active provider is the orchestrator, so "cloud" picks + // it — and without leaveFusion the stored mode would keep it in Fusion. + return activateProvider(provider.id, { leaveFusion: true }); } // Embedding models are a separate daemon; the chat route never picks @@ -213,7 +239,7 @@ export async function switchBackend(kind: "cloud" | "local"): Promise boolean { + const names = keyNamesAvailable(); + return (p) => providerHasKey(p as ProviderEntry, names); +} + +/** Start the managed daemon when it is down (restart it when the model moved). */ +async function bringUpLocalDaemon(modelChanged: boolean): Promise<{ daemon: DaemonEffect; daemonLine?: string; error?: string }> { + const running = await localDaemonRunning(); + if (running && !modelChanged) return { daemon: "untouched" }; + if (running) { + const s = await modelsStop(); + if (!s.ok) return { daemon: "stop-failed", daemonLine: `local-llm: stop failed — ${s.error ?? "unknown error"}` }; + const st = await modelsStart(); + return st.ok ? { daemon: "restarted", daemonLine: readyLine(st.stdout) } : { daemon: "start-failed", error: st.error }; + } + const st = await modelsStart(); + return st.ok ? { daemon: "started", daemonLine: readyLine(st.stdout) } : { daemon: "start-failed", error: st.error }; +} + +async function afterRunModeWrite(res: { + ok: boolean; + changed: boolean; + error?: string; + verdict?: RunModeVerdict; +}): Promise { + if (!res.ok) return { ok: false, error: res.error }; + const v = res.verdict; + if (v?.refusal) return { ok: false, refusal: v.refusal, error: v.refusal }; + const read = await readWholeConfig(); + if (!read.ok || !read.config) return { ok: false, error: read.error }; + const now = resolveRunMode(read.config); + const leg = v?.leg ?? now.primaryProviderId; + const entry = (read.config.llm?.providers ?? []).find((p) => p.id === leg); + /* The worker daemon. autoStartIfReady keys on local-llama being the ACTIVE + provider, and under Fusion the active provider is the orchestrator — so + nothing else would bring a local leg up. Only for a model that is on + disk: a start for a file that is not there is a failure line about + nothing the operator chose. */ + let up: { daemon: DaemonEffect; daemonLine?: string; error?: string } = { daemon: "untouched" }; + const lm = read.config.localModels ?? {}; + const localLeg = now.effective === "fusion" && (now.workerProviderId === LOCAL_ID || now.orchestratorProviderId === LOCAL_ID); + if (localLeg && lm.mode === "managed" && lm.managed?.modelId) { + const list = await chatModelsList(); + if (list.ok && (list.models ?? []).some((m) => m.id === lm.managed?.modelId && m.downloaded)) { + up = await bringUpLocalDaemon(false); + } + } + return { + ok: true, + providerId: leg, + model: entry ? (entry.defaultChatModel ?? entry.model ?? null) : null, + transport: transportFor(leg), + ...up, + restart: res.changed, + runMode: { + before: v?.before.effective ?? now.effective, + after: now.effective, + line: `run mode: ${describeRunMode(now)}`, + enteredFusion: now.effective === "fusion" && (v?.before.effective ?? now.effective) !== "fusion", + }, + }; +} + +/** + * setMode("fusion", {fusion: pins}) — the backend row's `fusion`, the + * provider control under Fusion (orchestrator pin) and a cloud row in the + * workers control (worker pin). + */ +export async function enterFusion(pins: { orchestratorProvider?: string; workerProvider?: string } = {}): Promise { + const isKeyed = keyed(); + return afterRunModeWrite(await rewriteWholeConfig((cfg) => planEnterFusion(cfg, pins, isKeyed))); +} + +/** swapLegs — the composer's ⇄ and `/runmode swap`. */ +export async function swapFusionLegs(): Promise { + const isKeyed = keyed(); + return afterRunModeWrite(await rewriteWholeConfig((cfg) => planSwapLegs(cfg, isKeyed))); +} + +/** + * setWorkers — `fusion.workers` and `managed.parallel` together. The agent + * took the count at boot, so it restarts only while Fusion is what runs; + * off Fusion the count is remembered for the next time it is picked. + */ +export async function setFusionWorkers(workers: number): Promise { + const res = await rewriteWholeConfig((cfg) => planFusionWorkers(cfg, workers)); + if (!res.ok) return { ok: false, error: res.error }; + const v = res.verdict; + if (v?.refusal) return { ok: false, refusal: v.refusal, error: v.refusal }; + return { ok: true, notice: v?.notice, restart: res.changed && v?.after?.effective === "fusion" }; +} + +/** + * The workers control's model rows: activateComposerSwitchRow + * `fusionWorkerModel`. Picking a model for the worker slot claims the slot + * for the local provider, then the managed daemon moves to it through + * `models use` — which writes localModels.* only, never activeTextProvider, + * so Fusion survives the pick (the TUI deliberately avoids + * triggerLlmPrimary here for the same reason). + */ +export async function selectFusionWorkerModel(modelId: string): Promise { + if (!/^[\w.-]{1,96}$/.test(modelId)) return { ok: false, error: `not a model id: ${modelId}` }; + const list = await chatModelsList(); + if (!list.ok || !list.models) return { ok: false, error: list.error }; + const row = list.models.find((m) => m.id === modelId); + if (!row) return { ok: false, error: `unknown model id: ${modelId}` }; + if (!row.downloaded) { + return { ok: false, needsDownload: true, modelId, error: `local model ${modelId} is not downloaded` }; + } + const isKeyed = keyed(); + const pin = await rewriteWholeConfig((cfg): RunModeVerdict => { + const rm = resolveRunMode(cfg); + if (rm.effective === "fusion" && rm.workerProviderId === LOCAL_ID) return { write: false, before: rm }; + return planEnterFusion(cfg, { workerProvider: LOCAL_ID }, isKeyed); + }); + const settled = await afterRunModeWrite(pin); + if (!settled.ok) return settled; + const read = await readWholeConfig(); + if (!read.ok || !read.config) return { ok: false, error: read.error }; + const lm = read.config.localModels ?? {}; + const changed = lm.mode !== "managed" || (lm.managed?.modelId ?? null) !== modelId; + if (changed) { + const used = await modelsUse(modelId); + if (!used.ok) return { ok: false, error: used.error }; + } + const up = await bringUpLocalDaemon(changed); + return { + ...settled, + ...up, + modelId, + restart: !!settled.restart || changed, + }; +} + /** triggerLocalChatModel for a downloaded model; a pull is the renderer's job. */ export async function selectLocalModel(modelId: string): Promise { if (!/^[\w.-]{1,64}$/.test(modelId)) return { ok: false, error: `not a model id: ${modelId}` }; diff --git a/desktop/main/fusion-smoke.ts b/desktop/main/fusion-smoke.ts new file mode 100644 index 00000000..7cbea1e9 --- /dev/null +++ b/desktop/main/fusion-smoke.ts @@ -0,0 +1,328 @@ +import { Menu } from "electron"; + +import { configGet, configSetWhole, setActiveTextProvider, type UserConfigShape } from "./agent-cli.js"; +import { + describeFusionBlocker, + describeFusionIntro, + describeRunMode, + parseRunModeCommand, + planEnterFusion, + planFusionWorkers, + planSwapLegs, + resolveRunMode, + SWAP_NEEDS_FUSION, + type FusionFacts, + type RunModeConfig, +} from "./run-mode.js"; + +/** + * Run mode — Fusion, in the smoke. + * + * What can be asserted without a live cloud key: the resolver, the + * pre-flight and every sentence on both sides of the IPC (main's port and + * the renderer's), the write each planner makes to a seeded config, the + * switch the composer draws for that config, what `fusion_worker` frames + * become, the labels, `/runmode`, the native menu, and that a route switch + * leaving Fusion really leaves it in the file. The driven pass + * (test/fusion.drive.mjs) clicks the same switch for real. + */ + +type Js = (code: string) => Promise; +type Check = (name: string, ok: boolean, detail?: string) => void; +type RunModeBlock = NonNullable["runMode"]>; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); +const sorted = (v: unknown): unknown => + Array.isArray(v) ? v.map(sorted) + : v && typeof v === "object" + ? Object.fromEntries(Object.keys(v as object).sort().map((k) => [k, sorted((v as Record)[k])])) + : v; +const sameSorted = (a: unknown, b: unknown) => same(sorted(a), sorted(b)); + +const PROVIDERS = [ + { id: "local-llama", kind: "llama-server" }, + { id: "aimlapi", kind: "aimlapi", defaultChatModel: "x-ai/grok-4-6" }, + { id: "openrouter", kind: "openrouter", defaultChatModel: "qwen/qwen3.7-flash" }, +]; +function seed(active: string, runMode?: RunModeBlock, parallel: number | string = "auto"): RunModeConfig { + return { + llm: { activeTextProvider: active, providers: clone(PROVIDERS), ...(runMode ? { runMode } : {}) }, + localModels: { mode: "managed", managed: { modelId: "qwen-3.5-4b", parallel } }, + }; +} + +interface ProbeRow { type: string; id: string; label: string; detail: string; active: boolean } +interface Probe { + backend: string; + kinds: string[]; + chips: Array<[string, string]>; + swap: boolean; + rows: { backend: ProbeRow[]; provider: ProbeRow[]; workers: ProbeRow[] | null }; + settings: { active: string | null; status: string; workersOn: string; workerButtons: number }; +} + +export async function fusionSmokeTest(js: Js, check: Check): Promise { + const cfgs: Record = { + cloud: seed("aimlapi"), + local: seed("local-llama"), + fusion: seed("aimlapi", { mode: "fusion", fusion: { orchestratorProvider: "aimlapi", workerProvider: "openrouter", workers: 3 } }), + fusionHandSwitched: seed("openrouter", { mode: "fusion", fusion: { orchestratorProvider: "aimlapi", workerProvider: "openrouter" } }), + fusionLocalWorkers: seed("aimlapi", { mode: "fusion" }), + fusionOneProvider: { llm: { activeTextProvider: "local-llama", providers: [{ id: "local-llama", kind: "llama-server" }], runMode: { mode: "fusion" } } }, + noLlm: {}, + }; + + /* ---- the resolver, both sides ---- */ + const mains = Object.fromEntries(Object.entries(cfgs).map(([k, c]) => [k, resolveRunMode(c)])); + const rends = await js>( + `(() => { const c = ${JSON.stringify(cfgs)}; const o = {}; for (const k in c) o[k] = window.__rmResolve(c[k]); return o; })()`, + ); + const disagree = Object.keys(cfgs).filter((k) => !same(mains[k], rends[k])); + check("fusion: the renderer resolves the run mode as main does", disagree.length === 0, + disagree.length ? `differs for ${disagree.join(", ")}: ${JSON.stringify(rends[disagree[0]!])}` : `${Object.keys(cfgs).length} configs`); + check( + "fusion: effective only while the orchestrator is the active provider", + mains.fusion!.effective === "fusion" && mains.fusionLocalWorkers!.effective === "fusion" + && mains.fusionLocalWorkers!.workerProviderId === "local-llama" + && mains.fusionHandSwitched!.effective === "cloud" && mains.cloud!.effective === "cloud" + && mains.local!.effective === "local" && mains.fusionOneProvider!.degraded?.reason === "no-cloud-provider", + Object.entries(mains).map(([k, v]) => `${k}=${v.effective}`).join(" "), + ); + + const status = Object.fromEntries(Object.entries(cfgs).map(([k, c]) => [k, describeRunMode(resolveRunMode(c))])); + const rStatus = await js>( + `(() => { const c = ${JSON.stringify(cfgs)}; const o = {}; for (const k in c) o[k] = window.__rmDescribe(c[k]); return o; })()`, + ); + check("fusion: /runmode status reads the same on both sides", same(status, rStatus), status.fusion); + check( + "fusion: /runmode status names both legs, and a stored mode that is not in force", + status.fusion === "Fusion — orchestrator aimlapi (x-ai/grok-4-6), 3 workers on openrouter (qwen/qwen3.7-flash)" + && /stored fusion, effective cloud — the orchestrator provider is not the active one; pick the mode again to re-apply$/.test(status.fusionHandSwitched!) + && status.fusionOneProvider!.includes("Fusion needs a cloud orchestrator"), + status.fusionHandSwitched, + ); + + /* ---- the pre-flight's one line ---- */ + const cases: Array<[FusionFacts, string | null]> = [ + [{ readyIds: ["aimlapi"], localLoaded: true, localDownloaded: false }, "needs a second provider for the workers — Manage › LLM"], + [{ readyIds: [], localLoaded: true, localDownloaded: true }, "needs a second provider to orchestrate — Manage › LLM › Cloud"], + [{ readyIds: [], localLoaded: true, localDownloaded: false }, "needs two providers, one per leg — Manage › LLM"], + [{ readyIds: ["aimlapi"], localLoaded: true, localDownloaded: true }, null], + [{ readyIds: ["aimlapi", "openrouter"], localLoaded: true, localDownloaded: false }, null], + [{ readyIds: ["aimlapi"], localLoaded: false, localDownloaded: false }, null], + ]; + const mBlock = cases.map(([f]) => describeFusionBlocker(cfgs.cloud, f)); + const rBlock = await js>( + `(${JSON.stringify(cases.map(([f]) => f))}).map((f) => window.__fzBlocker(${JSON.stringify(cfgs.cloud)}, f))`, + ); + check("fusion: pre-flight copy is the TUI's, on both sides (6 cases)", + same(mBlock, cases.map(([, want]) => want)) && same(rBlock, mBlock), JSON.stringify(rBlock)); + + /* ---- intro and parse ---- */ + const intro = describeFusionIntro(resolveRunMode(cfgs.fusion)); + const rIntro = await js(`window.__fzIntro(${JSON.stringify(cfgs.fusion)})`); + check("fusion: the first-switch intro names the two resolved legs, on both sides", + same(intro, rIntro) && intro[1]!.startsWith("Right now — x-ai/grok-4-6 plans.") && intro[1]!.includes(" qwen/qwen3.7-flash executes:") + && !intro.join(" ").includes("ctrl+r"), + intro[1]!.slice(0, 60)); + const inputs = ["", "status", "swap", "workers 3", "workers 9", "Fusion", " cloud ", "local", "bogus"]; + const mParse = inputs.map(parseRunModeCommand); + const rParse = await js(`(${JSON.stringify(inputs)}).map((s) => window.__fzParse(s))`); + check("fusion: /runmode parses the TUI verbs the same on both sides", + same(mParse, rParse) && mParse[0]!.openSwitch && mParse[3]!.workers === 3 && mParse[5]!.mode === "fusion" + && /^workers must be 1-8 — usage: \/runmode/.test(mParse[4]!.error ?? "") && /^unknown run mode "bogus"/.test(mParse[8]!.error ?? ""), + JSON.stringify(rParse[4])); + + /* ---- the writes, through the planners ---- */ + const a = clone(cfgs.cloud); + const va = planEnterFusion(a, {}, (p) => p.id === "aimlapi"); + check("fusion: entering writes the mode, the active orchestrator and both pins in one change", + va.write && !va.refusal && a.llm?.activeTextProvider === "aimlapi" + && same(a.llm?.runMode, { mode: "fusion", fusion: { orchestratorProvider: "aimlapi", workerProvider: "local-llama" } }) + && va.after?.effective === "fusion" && same(a.llm?.providers, PROVIDERS) && same(a.localModels, cfgs.cloud!.localModels), + JSON.stringify(a.llm?.runMode)); + const b = clone(cfgs.local); + planEnterFusion(b, {}, (p) => p.id === "openrouter"); + check("fusion: from the local route the orchestrator is the first cloud provider with a key", + b.llm?.activeTextProvider === "openrouter" && b.llm?.runMode?.fusion?.orchestratorProvider === "openrouter" + && b.llm?.runMode?.fusion?.workerProvider === "local-llama", + JSON.stringify(b.llm?.runMode)); + const c = clone(cfgs.cloud); + planEnterFusion(c, { workerProvider: "openrouter" }, () => true); + check("fusion: a workers pin keeps the orchestrator where it is", + c.llm?.activeTextProvider === "aimlapi" && c.llm?.runMode?.fusion?.workerProvider === "openrouter", JSON.stringify(c.llm?.runMode)); + const one = clone(cfgs.fusionOneProvider); + const vOne = planEnterFusion(one, {}, () => true); + check("fusion: one provider is refused in the resolver's words and writes nothing", + !vOne.write && vOne.refusal?.startsWith("Fusion needs two providers") === true && same(one, cfgs.fusionOneProvider), vOne.refusal); + + const notFusion = clone(cfgs.cloud); + const vs0 = planSwapLegs(notFusion, () => true); + const pinned = seed("aimlapi", { mode: "fusion", fusion: { orchestratorProvider: "aimlapi", workerProvider: "openrouter", orchestratorModel: "m-o", workerModel: "m-w" } }); + const vs1 = planSwapLegs(pinned, () => true); + check("fusion: swap refuses off Fusion with the TUI's words, and trades both pins and model labels", + !vs0.write && vs0.refusal === SWAP_NEEDS_FUSION && vs1.write && pinned.llm?.activeTextProvider === "openrouter" + && sameSorted(pinned.llm?.runMode?.fusion, { orchestratorProvider: "openrouter", workerProvider: "aimlapi", orchestratorModel: "m-w", workerModel: "m-o" }), + `${vs0.refusal} · ${JSON.stringify(pinned.llm?.runMode?.fusion)}`); + + const w = clone(cfgs.fusion); + const vw = planFusionWorkers(w, 4); + const w1 = seed("aimlapi", { mode: "fusion" }, 1); + const vw1 = planFusionWorkers(w1, 1); + const vw9 = planFusionWorkers(clone(cfgs.fusion), 9); + check("fusion: workers write fusion.workers and managed.parallel together, with the TUI's notice", + vw.write && w.llm?.runMode?.fusion?.workers === 4 && w.localModels?.managed?.parallel === 4 + && w.llm?.runMode?.mode === "fusion" && w.llm?.activeTextProvider === "aimlapi" + && vw.notice === "fusion: 4 workers — restart the local model (Manage › LLM › Local) so it runs 4 at once" + && vw1.notice === "fusion: 1 worker" && !vw9.write && vw9.refusal === "workers must be an integer 1-8, got 9", + `${vw.notice} · ${vw1.notice} · ${vw9.refusal}`); + + /* ---- the switch the composer draws for a seeded config ---- */ + const probe = (cfg: RunModeConfig, facts: object) => + js(`window.__fzProbe(${JSON.stringify(cfg)}, ${JSON.stringify(facts)})`); + const chip = (p: Probe, kind: string) => p.chips.find(([k]) => k === kind)?.[1] ?? null; + + const pBlocked = await probe(cfgs.cloud, { readyIds: ["aimlapi"], localLoaded: true, local: [] }); + const rowBlocked = pBlocked.rows.backend.find((r) => r.id === "fusion"); + check("fusion: the backend popover lists fusion last, carrying the pre-flight's line", + same(pBlocked.rows.backend.map((r) => r.id), ["cloud", "local", "custom", "fusion"]) + && rowBlocked?.detail === "needs a second provider for the workers — Manage › LLM" && !rowBlocked.active + && pBlocked.backend === "cloud" && pBlocked.kinds.length === 3 && !pBlocked.swap && chip(pBlocked, "workers") === null, + JSON.stringify(rowBlocked)); + const pReady = await probe(cfgs.cloud, { readyIds: ["aimlapi", "openrouter"], localLoaded: true, local: [] }); + check("fusion: unblocked, the row says what it would run", + pReady.rows.backend.find((r) => r.id === "fusion")?.detail === "cloud plans · 2 local workers", + pReady.rows.backend.find((r) => r.id === "fusion")?.detail); + + const pF = await probe(cfgs.fusion, { readyIds: ["aimlapi", "openrouter"], localLoaded: true, local: [] }); + check("fusion: chips follow the effective mode — fusion · orchestrator · its model ⇄ workers", + pF.backend === "fusion" && same(pF.kinds, ["backend", "provider", "model", "workers"]) + && chip(pF, "backend") === "fusion" && chip(pF, "provider") === "aimlapi" + && /grok-4-6/.test(chip(pF, "model") ?? "") && /qwen3\.7-flash/.test(chip(pF, "workers") ?? "") && pF.swap + && pF.rows.backend.find((r) => r.id === "fusion")?.active === true, + JSON.stringify(pF.chips)); + check("fusion: provider rows under Fusion are the orchestrator seat", + same(pF.rows.provider.map((r) => [r.id, r.detail, r.active]), [["aimlapi", "orchestrator", true], ["openrouter", "orchestrator", false], ["add", "opens the wizard", false]]), + JSON.stringify(pF.rows.provider.map((r) => [r.id, r.detail, r.active]))); + check("fusion: workers rows — every cloud provider but the orchestrator, then the download link", + same(pF.rows.workers?.map((r) => [r.id, r.detail, r.active]), [["openrouter", "workers · in the cloud", true], ["downloadMore", "opens the local models pane", false]]), + JSON.stringify(pF.rows.workers?.map((r) => [r.id, r.detail, r.active]))); + check("fusion: Settings › LLM marks Fusion active, states it, and offers workers 1–8 on the stored count", + pF.settings.active === "runmode:fusion" && pF.settings.status === status.fusion + && pF.settings.workersOn === "3" && pF.settings.workerButtons === 8, + JSON.stringify(pF.settings)); + + const onDisk = [{ id: "qwen-3.5-4b", family: "qwen", size: "2.7 GB", context: "256K", downloaded: true, active: true }]; + const pL = await probe(cfgs.fusionLocalWorkers, { readyIds: ["aimlapi"], localLoaded: true, local: onDisk }); + check("fusion: with a model on disk the workers run it and local-llama may orchestrate", + same(pL.rows.workers?.map((r) => [r.id, r.detail, r.active]), [["qwen-3.5-4b", "workers · on this machine", true], ["openrouter", "no API key", false], ["downloadMore", "opens the local models pane", false]]) + && pL.rows.provider.some((r) => r.id === "local-llama" && r.detail === "orchestrator · runs on this machine" && !r.active) + && chip(pL, "workers") === "qwen-3.5-4b", + JSON.stringify(pL.rows.workers?.map((r) => [r.id, r.detail, r.active]))); + + const pH = await probe(cfgs.fusionHandSwitched, { readyIds: ["aimlapi", "openrouter"], localLoaded: true, local: [] }); + const pD = await probe(cfgs.cloud, { readyIds: ["aimlapi"], localLoaded: true, local: [] }); + check("fusion: a stored fusion that is not in force draws cloud, and Settings says so; the count defaults to 2", + pH.backend === "cloud" && chip(pH, "backend") === "cloud" && pH.settings.active === "runmode:cloud" + && pH.settings.status.includes("stored fusion, effective cloud") && pD.settings.workersOn === "2", + `${pH.backend} · ${pH.settings.status}`); + + /* ---- fusion_worker frames ---- */ + const frames = [ + { object: "atomic.fusion_worker", task_id: "fusion.delegate", title: "2 tasks", phase: "tool", role: "orchestrator", model: "x-ai/grok-4-6", tool: "fusion.delegate" }, + { object: "atomic.fusion_worker", task_id: "t1", title: "write the parser", phase: "started", role: "worker", model: "qwen-3.5-4b" }, + { object: "atomic.fusion_worker", task_id: "t1", title: "write the parser", phase: "tool", role: "worker", model: "qwen-3.5-4b", tool: "os.fs.write" }, + { object: "atomic.fusion_worker", task_id: "t2", title: "tests", phase: "started", role: "worker", model: "qwen-3.5-4b" }, + { object: "atomic.fusion_worker", task_id: "t1", title: "write the parser", phase: "finished", role: "worker", model: "qwen-3.5-4b", step_count: 7, summary: "wrote 3 files" }, + { object: "atomic.fusion_worker", task_id: "t2", title: "tests", phase: "failed", role: "worker", model: "qwen-3.5-4b", summary: "timed out" }, + ]; + type EvProbe = { live: string[]; strip: string[]; stripControls: number; lines: string[]; beforeReply: boolean }; + const mid = await js(`window.__fzEventProbe(${JSON.stringify(frames.slice(0, 4))})`); + const end = await js(`window.__fzEventProbe(${JSON.stringify(frames)})`); + check("fusion: the live list shows each worker as `title · model — tool|working|done`, drawn under the composer with no control", + same(mid.live, ["write the parser · qwen-3.5-4b — os.fs.write", "tests · qwen-3.5-4b — working"]) + && same(mid.strip, mid.live) && mid.stripControls === 0 + && same(end.live, ["write the parser · qwen-3.5-4b — done", "tests · qwen-3.5-4b — done"]), + `${JSON.stringify(mid.strip)} → ${JSON.stringify(end.live)} · controls ${mid.stripControls}`); + check("fusion: every frame is a transcript line in the TUI's words, placed before the reply", + same(end.lines, [ + "orchestrator · x-ai/grok-4-6 — fusion.delegate (2 tasks)", + "worker write the parser · qwen-3.5-4b: started", + "worker write the parser · qwen-3.5-4b — os.fs.write", + "worker tests · qwen-3.5-4b: started", + "worker write the parser · qwen-3.5-4b: done — 7 steps, wrote 3 files", + "worker tests · qwen-3.5-4b: failed — timed out", + ]) && end.beforeReply, + JSON.stringify(end.lines)); + + /* ---- labels, slash, leftovers, menu ---- */ + const cat = await js<{ label: string | null; level: number | null }>("window.__approvalCat('fusion_fanout')"); + check("fusion: the fan-out approval reads `fusion · fan-out` at level 4", cat.label === "fusion · fan-out" && cat.level === 4, JSON.stringify(cat)); + const slash = await js("window.__slashNames()"); + check("fusion: /runmode replaces the prototype /run", slash.includes("runmode") && !slash.includes("run"), slash.filter((s) => /^run/.test(s)).join(",")); + const leftovers = await js<{ blurb: string; dial: string; state: boolean; dialEl: boolean; toast: unknown; toastAfter: unknown }>( + `(() => { const t0 = window.__lastToast(); + document.dispatchEvent(new KeyboardEvent('keydown', {key:'r', ctrlKey:true, bubbles:true, cancelable:true})); + return {blurb: typeof shareBlurb, dial: typeof refreshDial, state: ('share' in S) || ('dialShare' in S) || ('mode' in S), + dialEl: !!document.getElementById('dial'), toast: t0, toastAfter: window.__lastToast()}; })()`, + ); + check("fusion: the prototype share slider, its state and ctrl+r cycling are gone", + leftovers.blurb === "undefined" && leftovers.dial === "undefined" && !leftovers.state && !leftovers.dialEl + && same(leftovers.toast, leftovers.toastAfter), + JSON.stringify(leftovers)); + + const runMenu = Menu.getApplicationMenu()?.items.find((i) => i.label === "Run"); + const where = runMenu?.submenu?.items.find((i) => i.label === "Where it runs…"); + check("fusion: Run › Where it runs… offers Local, Cloud and Fusion", + same(where?.submenu?.items.map((i) => i.label), ["Local", "Cloud", "Fusion"]), + JSON.stringify(where?.submenu?.items.map((i) => i.label) ?? null)); + + /* ---- /runmode through the composer's own slash path ---- */ + const live = (await configGet()).config as RunModeConfig | undefined; + const liveStatus = live ? describeRunMode(resolveRunMode(live)) : ""; + const routed = await js<{ bad: string; status: string; opened: boolean; kind: string; swapToast: string | null; storedFusion: boolean }>( + `(() => { const n0 = S.log.length; + const lastSys = () => { const s = S.log.filter((m) => m.k === 'system'); return s.length ? s[s.length - 1].text : ''; }; + window.__runSlash('/runmode workers 9'); const bad = lastSys(); + window.__runSlash('/runmode status'); const status = lastSys(); + window.__runSlash('/runmode'); const sel = window.__sel(); + closeSelector(); + const storedFusion = rmNow().stored === 'fusion'; + if (!storedFusion) window.__runSlash('/runmode swap'); + const t = window.__lastToast(); + S.log.splice(n0); render(); + return {bad, status, opened: sel.open, kind: sel.kind, swapToast: t ? t.t : null, storedFusion}; })()`, + ); + const escaped = await js(`esc(${JSON.stringify(liveStatus)})`); + check("fusion: /runmode routes — usage on a bad count, status from the resolver, bare opens Where it runs, swap refuses off Fusion", + routed.bad === await js(`esc(${JSON.stringify(parseRunModeCommand("workers 9").error)})`) + && routed.status === escaped && routed.opened && routed.kind === "backend" + && (routed.storedFusion || routed.swapToast === SWAP_NEEDS_FUSION), + JSON.stringify(routed)); + + /* ---- leaving Fusion writes the stored mode (the switch-to-cloud bug) ---- */ + const before = (await configGet()).config as UserConfigShape | undefined; + const activeId = before?.llm?.activeTextProvider; + if (!before?.llm || !activeId) { + check("fusion: a route switch that leaves Fusion writes the stored mode with the provider", false, "no llm block in this state dir"); + return; + } + try { + const staged = clone(before); + staged.llm!.runMode = { ...staged.llm!.runMode, mode: "fusion" }; + await configSetWhole(staged); + const plain = await setActiveTextProvider(activeId); + const afterPlain = ((await configGet()).config as UserConfigShape).llm?.runMode?.mode; + const leave = await setActiveTextProvider(activeId, { leaveFusion: true }); + const afterLeave = (await configGet()).config as UserConfigShape; + const kind = (afterLeave.llm?.providers ?? []).find((p) => p.id === activeId)?.kind; + check("fusion: a route switch that leaves Fusion writes the stored mode with the provider", + plain.ok && !plain.changed && afterPlain === "fusion" && leave.ok && leave.changed + && afterLeave.llm?.runMode?.mode === (kind === "llama-server" ? "local" : "cloud") + && afterLeave.llm?.activeTextProvider === activeId, + `without leaveFusion: ${afterPlain}; with it: ${afterLeave.llm?.runMode?.mode} on ${activeId}`); + } finally { + await configSetWhole(before); + } +} diff --git a/desktop/main/main.ts b/desktop/main/main.ts index 42476612..c4a83a99 100644 --- a/desktop/main/main.ts +++ b/desktop/main/main.ts @@ -44,7 +44,6 @@ import { configSetWhole, explainConfigWriteFailure, readWholeConfig, - setRunMode, localDaemonRunning, modelsStop, providerHasKey, @@ -72,11 +71,16 @@ import { } from "./huggingface.js"; import { activateProvider, + enterFusion, selectCloudModel, + selectFusionWorkerModel, selectLocalModel, + setFusionWorkers, + swapFusionLegs, switchBackend, type SwitchResult, } from "./backend-switch.js"; +import { fusionSmokeTest } from "./fusion-smoke.js"; // Lane B — context before the first message (item 3): the no-trace smoke dir. import { mkdirSync, rmSync } from "node:fs"; // Item 7 (settings surface) @@ -139,6 +143,8 @@ const SMOKE = process.argv.includes("--smoke"); const FORCE_ONBOARDING = process.argv.includes("--onboarding"); /** `--models` drives the Models pane end to end and asserts config changed. */ const MODELS_TEST = process.argv.includes("--models"); +/** `--smoke --smoke-fusion` runs only the run-mode (Fusion) checks — the whole smoke is ~30 minutes. */ +const FUSION_ONLY = process.argv.includes("--smoke-fusion"); /** * r5 item 9, review fix (minor) — `--first-run-probe`. * @@ -1397,12 +1403,28 @@ function wireIpc(client: AgentClient): void { } }); - ipcMain.handle("cli:runMode", (_event, payload: unknown) => { - const p = (payload || {}) as { mode?: unknown; workers?: unknown }; - if (p.mode !== "local" && p.mode !== "cloud" && p.mode !== "fusion") { - return { ok: false, error: "mode must be local, cloud or fusion" }; + /* Run mode — Fusion. The TUI's RunModeOrchestrator writes, each through + applySwitch like every other route change (serve reads its config once). + Local and Cloud are cli:switchBackend, which leaves Fusion in its write. */ + const providerIdOk = (v: unknown) => v === undefined || (typeof v === "string" && /^[\w.-]{1,48}$/.test(v)); + ipcMain.handle("cli:enterFusion", async (_event, payload: unknown) => { + const p = (payload ?? {}) as { orchestratorProvider?: unknown; workerProvider?: unknown }; + if (!providerIdOk(p.orchestratorProvider) || !providerIdOk(p.workerProvider)) { + return { ok: false, error: "orchestratorProvider and workerProvider must be provider ids" }; } - return setRunMode(p.mode, typeof p.workers === "number" ? { workers: p.workers } : undefined); + return applySwitch(await enterFusion({ + ...(typeof p.orchestratorProvider === "string" ? { orchestratorProvider: p.orchestratorProvider } : {}), + ...(typeof p.workerProvider === "string" ? { workerProvider: p.workerProvider } : {}), + })); + }); + ipcMain.handle("cli:swapFusionLegs", async () => applySwitch(await swapFusionLegs())); + ipcMain.handle("cli:fusionWorkers", async (_event, workers: unknown) => { + if (typeof workers !== "number") return { ok: false, error: "workers must be a number" }; + return applySwitch(await setFusionWorkers(workers)); + }); + ipcMain.handle("cli:fusionWorkerModel", async (_event, id: unknown) => { + if (typeof id !== "string") return { ok: false, error: "model id required" }; + return applySwitch(await selectFusionWorkerModel(id)); }); ipcMain.handle("app:build", () => ({ @@ -1625,6 +1647,13 @@ async function smokeTest(): Promise { } check("agent connected", state === "connected", `state=${state}`); + if (FUSION_ONLY) { + if (state === "connected") await fusionSmokeTest(js, check); + process.stdout.write(`SMOKE fusion-only failures=${fail.length}\n`); + app.exit(fail.length === 0 ? 0 : 1); + return; + } + if (state === "connected") { // Item 6: boot state. Nothing has been opened, so no row may be drawn as // the current one — the old code pointed at the newest session without @@ -1846,9 +1875,9 @@ async function smokeTest(): Promise { await js("window.__selOpen('backend')"); const back = await js<{ rows: number; backend: string }>("window.__sel()"); - // Three rows since the review fix put the TUI's `custom` back (cloud, - // local, custom — composer-switch-rows.ts backendRows). - check("selector: backend pane", back.rows === 3, `backend=${back.backend}, ${back.rows} rows`); + // Four rows: the review fix put the TUI's `custom` back, and round 2 added + // Fusion (cloud, local, custom, fusion — composer-switch-rows.ts backendRows). + check("selector: backend pane", back.rows === 4, `backend=${back.backend}, ${back.rows} rows`); await js("window.__selTab('model')"); await new Promise((r) => setTimeout(r, 9000)); @@ -2566,6 +2595,8 @@ async function smokeTest(): Promise { // all asserted. Everything is restored in finally — the whole file, // the daemon state, and a fresh agent — so an assertion throw cannot // leave the route changed. + // Run mode — Fusion: resolver, rows, writes through the planners, frames. No restart. + await fusionSmokeTest(js, check); await backendSwitchTest(js, check); /* r5 item 10 — "measure and report the real wall time of each switch". @@ -3790,7 +3821,7 @@ async function settingsTest( ["Session", "New session", "n"], ["Session", "Switch session…", "u"], ["Session", "Clear transcript", null], ["Session", "Context window", null], ["Session", "Show session id", null], ["Session", "New terminal window", null], ["Model", "Switch chat model…", "k"], - ["Run", "Coding mode…", "M"], ["Run", "Abort turn", "a"], ["Run", "Queued messages", null], + ["Run", "Where it runs…", null], ["Run", "Coding mode…", "M"], ["Run", "Abort turn", "a"], ["Run", "Queued messages", null], ["Run", "Steer the running turn", null], ["Run", "Expand all tool cards", null], ["Run", "Collapse all tool cards", null], ["Setup", "Theme…", "h"], ["Setup", "Mouse…", null], ["Setup", "Hide or show the sidebar", null], ["Setup", "Analytics", null], ["Setup", "Enable or disable a skill…", null], ["Setup", "Create, cancel or run a task…", null], @@ -3861,10 +3892,11 @@ async function settingsTest( ); check( "settings: Go, Observe and the debug pane left the tree", - /* 33 rows since `help.report` joined Help — the count is here to catch a - Go/Observe node creeping back in, so it moves with a deliberate - addition rather than pinning the menu's size forever. */ - gone.ids.length === 0 && gone.subs === 0 && gone.rows === 33, + /* 33 rows since `help.report` joined Help, 34 since Run › Where it runs… + (round 2, Fusion) — the count is here to catch a Go/Observe node + creeping back in, so it moves with a deliberate addition rather than + pinning the menu's size forever. */ + gone.ids.length === 0 && gone.subs === 0 && gone.rows === 34, JSON.stringify(gone), ); const viaNode = await js<{ settings: boolean; pane: string | null }>( @@ -5276,7 +5308,9 @@ async function hfAndDeltaTest( waitStrip.shown && /waiting/i.test(waitStrip.ann ?? "") && /attempt 5/.test(waitStrip.readout ?? "") && /next try \d+s/.test(waitStrip.readout ?? "") - && waitStrip.stop, + // r2 (DMG feedback): no Stop pill on the strip — the composer's own + // button is the one Stop while a turn runs. + && !waitStrip.stop, JSON.stringify(waitStrip), ); check( @@ -7074,7 +7108,9 @@ async function isolationAndSwitchTest( } const inSwx = (at: number) => swxRanges.some(([a, b]) => at > a && at < b); const stray = [...rendererSrc.matchAll(/SWXBR\.\w+\(/g)].filter((m) => !inSwx(m.index ?? 0)).map((m) => m[0]); - const onceOnly = ["switchBackend", "activateProvider", "selectCloudModel", "selectLocalModel"] + const onceOnly = ["switchBackend", "activateProvider", "selectCloudModel", "selectLocalModel", + // Run mode — Fusion: the four RunModeOrchestrator writes go through the same funnel. + "enterFusion", "swapFusionLegs", "fusionWorkers", "fusionWorkerModel"] // The lookbehind matters: `SWXBR.switchBackend(` contains the substring // `BR.switchBackend(`, so a naive count would find the funnel plus every // call site and this check would never go green. @@ -7427,7 +7463,7 @@ async function backendSwitchTest( const localRow = customRows.rows.find((r) => r.id === "local"); check( "backend: an external route reads as custom, not as the managed local one", - managedRows.backend === "local" && managedRows.rows.length === 3 + managedRows.backend === "local" && managedRows.rows.length === 4 // cloud, local, custom, fusion && customRows.backend === "custom" && /custom/.test(customRows.chip) && !customRows.modelChip && !!custom && custom.active && custom.detail.includes("http://127.0.0.1:19199") && custom.detail.includes("Settings › LLM › External") && !!localRow && !localRow.active, @@ -8070,12 +8106,16 @@ async function onboardingTest( None of that exists any more, and none of it is a regression: the visual system rules starfields out, and a card that has to be hurried is a card nobody reads. What the card owes the person in front of it is - what is asserted now — the mark, the product's name, one rule, WHICH - BUILD they are running, and a single input that leaves. */ + what is asserted now — the mark, the product's name, one rule and a + single input that leaves. + + r2 (DMG feedback): the build line and the keycap hint strip are gone + from every first-run screen, at the operator's request — so the card + has four children, and neither a build nor a strip is drawn. */ let ob = await js("window.__obOpen('intro')"); await new Promise((r) => setTimeout(r, 300)); const card = await js<{ - canvas: boolean; head: boolean; word: string; rule: number; build: string; + canvas: boolean; head: boolean; word: string; rule: number; build: number; hints: number; dismissLabel: string; extras: number; }>(`(() => { const root = document.querySelector('#ob-intro'); @@ -8086,17 +8126,18 @@ async function onboardingTest( word: t('.ob-word'), rule: root && root.querySelector('.ob-rule') ? parseFloat(getComputedStyle(root.querySelector('.ob-rule')).borderTopWidth) : 0, - build: t('.ob-build'), + build: document.querySelectorAll('#onboarding .ob-build, #onboarding .ob-railbuild').length, + hints: document.querySelectorAll('#onboarding .ob-hints').length, dismissLabel: t('.ob-any'), extras: root ? root.querySelectorAll('.ob-introc > *').length : -1, }; })()`); check( - "wizard: the title card is the mark, the name, one rule and the build — and nothing else", + "wizard: the title card is the mark, the name and one rule — no build line, no hint strip", ob.step === "intro" && !card.canvas && !card.head && card.word === "Atomic Agent" && card.rule === 3 - && /^\d+\.\d+\.\d+ · /.test(card.build) - && card.dismissLabel.length > 0 && card.extras === 5, + && card.build === 0 && card.hints === 0 + && card.dismissLabel.length > 0 && card.extras === 4, JSON.stringify(card), ); check( @@ -8595,11 +8636,13 @@ async function onboardingTest( await js("window.__obOpen('local_download')"); /* `cloudReady:true` on purpose (review fix): the TUI hides the on-screen `press c` BLOCK once a cloud provider is configured - (offerCloudMeanwhile) but keeps the KEY live, and this step's - footer names the chord unconditionally. The desktop had gated the - key too, so the hint strip advertised a chord that did nothing. */ + (offerCloudMeanwhile) but keeps the KEY live. The desktop had gated + the key too, so a chord it advertised did nothing. + r2 (DMG feedback): the desktop no longer hides the card either — the + operator asked for the cloud card beside "skip the wait" on this + screen, provider or not. */ await js("window.__obSeed({cloudReady:true})"); - const blockHidden = await js( + const blockShown = await js( "document.querySelectorAll('#onboarding .ob-offer.cloud').length", ); await js
("window.__dlSeed([{kind:'weights', id:'qwen3.5-4b'}])"); @@ -8607,11 +8650,11 @@ async function onboardingTest( const toCloud = await js("window.__obKey('c')"); const dlOnCloud = await js
("window.__dl()"); check( - "wizard: `c` opens the cloud wizard mid-download even with the block hidden, and the strip keeps ticking", - blockHidden === 0 && + "wizard: the cloud card stays offered with a provider configured, `c` opens the cloud wizard mid-download, and the strip keeps ticking", + blockShown === 1 && toCloud.step === "cloud" && toCloud.resumeAfterCloud === "local_download" && dlOnCloud.visible && dlOnCloud.label === "qwen3.5-4b", - `block=${blockHidden} step=${toCloud.step} resume=${toCloud.resumeAfterCloud} strip=${dlOnCloud.visible}/${dlOnCloud.label}`, + `block=${blockShown} step=${toCloud.step} resume=${toCloud.resumeAfterCloud} strip=${dlOnCloud.visible}/${dlOnCloud.label}`, ); const back = await js("window.__obKey('esc')"); const dlBack = await js
("window.__dl()"); @@ -8915,9 +8958,12 @@ async function onboardingTest( while there is something to clear, and the TUI recomputes its footer on every keystroke. The desktop has no ambient render loop while the wizard is up, so the review found both missing until some unrelated - repaint happened. Asserted on the RENDERED strip — reading - obFooter() directly would pass without any repaint at all. */ - const hintsEmpty = (await js("window.__obCopy()")).hints; + repaint happened. Asserted on the RENDERED control — reading + obFooter() directly would pass without any repaint at all. + r2 (DMG feedback): the keycap strip is no longer drawn, so the chord + half is read from the step's chord table and the control half from + the screen. */ + const hintsEmpty = await js("window.__obFooterFor('local_hf_ref')"); const clearEmpty = await js( "document.querySelectorAll('#onboarding [data-obact=\"hf:clear\"]').length", ); @@ -8925,14 +8971,14 @@ async function onboardingTest( "(function(){const i=document.getElementById('ob-hf-ref'); i.value='u'; " + "i.dispatchEvent(new Event('input',{bubbles:true}));})()", ); - const hintsTyped = (await js("window.__obCopy()")).hints; + const hintsTyped = await js("window.__obFooterFor('local_hf_ref')"); const clearTyped = await js( "document.querySelectorAll('#onboarding [data-obact=\"hf:clear\"]').length", ); check( "wizard: the hugging face clear chord and control appear on the first keystroke", !hintsEmpty.includes("ctrl+l") && clearEmpty === 0 && - hintsTyped.includes("ctrl+l") && hintsTyped.includes("clear") && clearTyped === 1, + hintsTyped.includes("ctrl+l") && clearTyped === 1, `empty=${JSON.stringify(hintsEmpty)}/${clearEmpty} typed=${JSON.stringify(hintsTyped)}/${clearTyped}`, ); await js("window.__obKey('esc')"); @@ -9753,6 +9799,41 @@ async function planHandoffTest( check("plan bar session-switch check skipped: the agent has no other session", !other, "sessions=0"); } + // ---- turn order: the agent's reply is the last row of its turn ---- + /* The operator's report on the 2026-09-15 DMG: "end agent results should + be the last message within the turn. At this moment approvals are the + last ones". The frames of a gated turn go through the real onChatEvent + and onApprovalEvent in wire order and are read while the turn is live; + the stored rows of the same turn go through openSession's own mapping. + Mutation-checked: with onApprovalEvent back on `S.log.push` the first + check fails (the approval lands under the reply). */ + { + const live = await js<{ rows: string[]; replyLast: boolean }>("window.__turnOrderLive()"); + check( + "turn order: an approval sits under the call that asked for it, and the reply is the turn's last row", + live.replyLast + && JSON.stringify(live.rows) === JSON.stringify(["tool:os.fs.write", "approval:os.fs.write", "tool:os.shell.run", "system", "assistant"]), + JSON.stringify(live), + ); + const storedTurns = [ + { kind: "user", text: "write it", at: 1 }, + { kind: "assistant_tool_call", tool: "os.fs.write", args: { path: "a.txt" }, at: 2 }, + { kind: "tool_result", tool: "os.fs.write", status: "ok", summary: "wrote", at: 3, + approvals: [{ verdict: "approved", category: "fs_write_workspace", at: 3 }] }, + { kind: "assistant_tool_call", tool: "os.shell.run", args: { cmd: "ls" }, at: 4 }, + { kind: "tool_result", tool: "os.shell.run", status: "error", summary: "approval denied", at: 5, + approvals: [{ verdict: "denied", category: "shell", at: 5 }] }, + { kind: "assistant_reply", text: "Done.", at: 6 }, + ]; + const stored = await js(`window.__turnsToLog(${JSON.stringify(storedTurns)})`); + check( + "turn order: a reopened chat puts each stored approval under its call and ends on the reply", + JSON.stringify(stored) === JSON.stringify(["user", "tool:os.fs.write", "approval:os.fs.write:approved:file write · workspace", + "tool:os.shell.run", "approval:os.shell.run:denied:shell command", "assistant"]), + JSON.stringify(stored), + ); + } + // ---- the chords, and the failed-mode-change path they exercise ---- await js("window.__newSession()"); await js("window.__planRaise({})"); diff --git a/desktop/main/menu.ts b/desktop/main/menu.ts index dc50ada6..cc19cd11 100644 --- a/desktop/main/menu.ts +++ b/desktop/main/menu.ts @@ -98,6 +98,13 @@ export function buildMenu(send: (command: string) => void): void { item("Clear Transcript", "clear", "CommandOrControl+Backspace"), sep, item("Choose Model…", "selector:model", "Shift+CommandOrControl+M"), + // The TUI's "Where it runs…" submenu (menu-registry.ts run.type): the + // same switch the composer's Backend control and /runmode run. + { label: "Where it runs…", submenu: [ + item("Local", "runmode:local"), + item("Cloud", "runmode:cloud"), + item("Fusion", "runmode:fusion"), + ] }, ], }, { diff --git a/desktop/main/run-mode.ts b/desktop/main/run-mode.ts new file mode 100644 index 00000000..8ad43208 --- /dev/null +++ b/desktop/main/run-mode.ts @@ -0,0 +1,371 @@ +/** + * Run mode — Local / Cloud / Fusion — main-process side. + * + * Ports of the agent's own logic (v0.6.1), kept pure so the smoke can pin + * every sentence and every write payload without a live provider: + * + * resolveRunMode ← src/llm/run-mode/resolve-run-mode.ts + * describeRunModeDegradation ← src/llm/run-mode/run-mode-degradation.ts + * describeRunMode ← src/llm/run-mode/run-mode-summary.ts + * describeFusionBlocker ← src/tui/run-mode/fusion-preflight.ts + * fusionDetail ← src/tui/composer-switch/composer-switch-rows.ts + * describeFusionIntro ← src/tui/run-mode/fusion-intro.ts + * parseRunModeCommand ← src/tui/commands/dispatch-run-mode.ts + * planEnterFusion ← RunModeOrchestrator.setMode("fusion") + setRunModeInConfig + * planSwapLegs ← RunModeOrchestrator.swapLegs + * planFusionWorkers ← RunModeOrchestrator.setWorkers + setFusionWorkersInConfig + * + * The planners MUTATE the config object they are given — it is the one the + * caller read under the config lock and is about to write whole — and say + * whether anything moved. The renderer is one classic script and carries its + * own copy of the read-side functions; the smoke asserts both copies answer + * the same on seeded configs. + */ + +export type RunModeName = "local" | "cloud" | "fusion"; + +export interface RunModeProvider { + id: string; + kind?: string; + defaultChatModel?: string; + model?: string; +} + +export interface FusionPins { + orchestratorProvider?: string; + orchestratorModel?: string; + workerProvider?: string; + workerModel?: string; + workers?: number; + workerMaxSteps?: number; + workerTimeoutMs?: number; +} + +export interface RunModeConfig { + llm?: { + activeTextProvider?: string; + providers?: RunModeProvider[]; + runMode?: { mode?: RunModeName; fusion?: FusionPins }; + }; + localModels?: { + mode?: string; + managed?: { modelId?: string | null; parallel?: number | string }; + }; +} + +export const LOCAL_PROVIDER_ID = "local-llama"; +export const LOCAL_PROVIDER_KIND = "llama-server"; +export const FUSION_WORKERS_MIN = 1; +export const FUSION_WORKERS_MAX = 8; +export const DEFAULT_FUSION_WORKERS = 2; +const DEFAULT_FUSION_WORKER_MAX_STEPS = 40; +const DEFAULT_FUSION_WORKER_TIMEOUT_MS = 2_700_000; +const RUN_MODE_NAMES: readonly RunModeName[] = ["local", "cloud", "fusion"]; + +export type RunModeDegradation = { + reason: "no-cloud-provider" | "no-second-provider"; + requested: RunModeName; +}; + +export interface ResolvedRunMode { + stored: RunModeName | null; + effective: RunModeName; + orchestratorProviderId: string | null; + orchestratorModel: string | null; + workerProviderId: string | null; + workerModel: string | null; + workers: number; + workerMaxSteps: number; + workerTimeoutMs: number; + primaryProviderId: string; + degraded: RunModeDegradation | null; +} + +const isLocalKind = (p: RunModeProvider | undefined): boolean => p?.kind === LOCAL_PROVIDER_KIND; + +function providersOf(cfg: RunModeConfig | null | undefined): RunModeProvider[] { + const list = cfg?.llm?.providers; + // resolveLlmConfig: a file with no llm block runs on the synthesized local entry. + return Array.isArray(list) ? list : [{ id: LOCAL_PROVIDER_ID, kind: LOCAL_PROVIDER_KIND }]; +} + +/** resolveRunMode — `llm.activeTextProvider` stays authoritative; `runMode.mode` is additive. */ +export function resolveRunMode(cfg: RunModeConfig | null | undefined): ResolvedRunMode { + const runMode = cfg?.llm?.runMode; + const fusion = runMode?.fusion; + const stored = runMode?.mode ?? null; + const providers = providersOf(cfg); + const activeId = cfg?.llm?.activeTextProvider ?? LOCAL_PROVIDER_ID; + const byId = (id: string | undefined) => (id === undefined ? undefined : providers.find((p) => p.id === id)); + const managedModelId = cfg?.localModels?.managed?.modelId ?? null; + + const active = byId(activeId); + const derived: RunModeName = active === undefined || isLocalKind(active) ? "local" : "cloud"; + + const orchestrator = + byId(fusion?.orchestratorProvider) + ?? (active !== undefined && !isLocalKind(active) ? active : undefined) + ?? providers.find((p) => !isLocalKind(p)); + const worker = + byId(fusion?.workerProvider) + ?? providers.find((p) => isLocalKind(p) && p.id !== orchestrator?.id) + ?? providers.find((p) => p.id !== orchestrator?.id); + + const orchestratorProviderId = orchestrator?.id ?? null; + const workerProviderId = worker?.id ?? null; + + let effective: RunModeName = derived; + let degraded: RunModeDegradation | null = null; + if (stored === "fusion") { + if (orchestratorProviderId === null) degraded = { reason: "no-cloud-provider", requested: stored }; + else if (workerProviderId === null) degraded = { reason: "no-second-provider", requested: stored }; + else if (activeId === orchestratorProviderId) effective = "fusion"; + } else if (stored === "cloud" && orchestratorProviderId === null) { + degraded = { reason: "no-cloud-provider", requested: stored }; + } + + const primaryProviderId = (effective === "local" ? workerProviderId : orchestratorProviderId) ?? activeId; + return { + stored, + effective, + orchestratorProviderId, + orchestratorModel: fusion?.orchestratorModel ?? orchestrator?.defaultChatModel ?? orchestrator?.model ?? null, + workerProviderId, + workerModel: + fusion?.workerModel + ?? (worker !== undefined && isLocalKind(worker) + ? (managedModelId ?? worker.model ?? null) + : (worker?.defaultChatModel ?? worker?.model ?? null)), + workers: fusion?.workers ?? DEFAULT_FUSION_WORKERS, + workerMaxSteps: fusion?.workerMaxSteps ?? DEFAULT_FUSION_WORKER_MAX_STEPS, + workerTimeoutMs: fusion?.workerTimeoutMs ?? DEFAULT_FUSION_WORKER_TIMEOUT_MS, + primaryProviderId, + degraded, + }; +} + +export function describeRunModeDegradation(degraded: RunModeDegradation): string { + if (degraded.reason === "no-cloud-provider") { + return degraded.requested === "fusion" + ? "Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)." + : "Cloud mode needs a cloud provider — none is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm)."; + } + return "Fusion needs two providers — one to orchestrate and one to run the workers. Only one is configured. Add another in Manage → LLM (or /llm)."; +} + +export function runModeLabel(mode: RunModeName): string { + return mode === "fusion" ? "Fusion" : mode === "cloud" ? "Cloud" : "Local"; +} + +/** The body of `/runmode status`. */ +export function describeRunMode(rm: ResolvedRunMode): string { + const parts: string[] = []; + if (rm.effective === "fusion") { + parts.push( + `Fusion — orchestrator ${rm.orchestratorProviderId}${rm.orchestratorModel ? ` (${rm.orchestratorModel})` : ""}, ` + + `${rm.workers} worker${rm.workers === 1 ? "" : "s"} on ${rm.workerProviderId}${rm.workerModel ? ` (${rm.workerModel})` : ""}`, + ); + } else { + parts.push(`${runModeLabel(rm.effective)} — active provider ${rm.primaryProviderId}`); + } + if (rm.degraded) { + parts.push(describeRunModeDegradation(rm.degraded)); + } else if (rm.stored !== null && rm.stored !== rm.effective) { + parts.push( + `stored ${rm.stored}, effective ${rm.effective} — the ${rm.stored === "fusion" ? "orchestrator" : rm.stored} ` + + "provider is not the active one; pick the mode again to re-apply", + ); + } + return parts.join(". "); +} + +export interface FusionFacts { + /** Cloud provider ids that have a usable key (`providersReady`). */ + readyIds: readonly string[]; + /** The local catalogue snapshot has landed. */ + localLoaded: boolean; + /** Something is on disk. */ + localDownloaded: boolean; +} + +/** describeFusionBlocker — why Fusion cannot be switched on, or null. */ +export function describeFusionBlocker(cfg: RunModeConfig | null | undefined, facts: FusionFacts): string | null { + const providers = providersOf(cfg); + const cloudReady = providers.filter((p) => !isLocalKind(p) && facts.readyIds.includes(p.id)).length; + // Abstains until the first snapshot lands: an empty list is indistinguishable + // from "nothing downloaded" before then. + const localReady = !facts.localLoaded || facts.localDownloaded ? providers.filter(isLocalKind).length : 0; + if (cloudReady + localReady >= 2) return null; + if (cloudReady + localReady === 1 && localReady === 1) { + return "needs a second provider to orchestrate — Manage › LLM › Cloud"; + } + if (cloudReady + localReady === 1) return "needs a second provider for the workers — Manage › LLM"; + return "needs two providers, one per leg — Manage › LLM"; +} + +/** The fusion backend row's detail when nothing blocks it. */ +export function fusionDetail(rm: ResolvedRunMode): string { + return `cloud plans · ${rm.workers} local worker${rm.workers === 1 ? "" : "s"}`; +} + +export const FUSION_MARK = [ + " ● orchestrator", + " │", + " ┌────┼────┐", + " ○ ○ ○ workers", +].join("\n"); + +/** + * describeFusionIntro, as paragraphs after the mark. One sentence is the + * desktop's: the TUI's last paragraph names `ctrl+r`, a terminal chord this + * window does not have — the seats are picked with the Provider and Workers + * controls here. + */ +export function describeFusionIntro(rm: ResolvedRunMode): string[] { + const orchestrator = rm.orchestratorModel ?? rm.orchestratorProviderId ?? "your cloud provider"; + const worker = rm.workerModel ?? rm.workerProviderId ?? "the local model"; + return [ + "Fusion splits the work between two models: one decides, the other does.", + `Right now — ${orchestrator} plans. It reads enough to choose an approach, breaks the job into self-contained parts, writes the brief for each, then reads what comes back, judges it, and sends anything weak out again.` + + ` ${worker} executes: each worker takes one part and reports. They cannot reach you or ask for approval, so anything needing a person comes back up.`, + "How many run at once is not a setting. The orchestrator sizes each fan-out to the job at hand, up to what this machine can serve.", + "Either seat takes either kind, and the pairing is the interesting part. Cloud planning with local workers is the usual one: sharp judgement, cheap bulk. Invert it and a local model plans while cloud workers execute — your reasoning never leaves the machine and you rent only the lifting. Two cloud models work as well, a careful one directing a fast one; so does a big local model directing a small one.", + "Worth playing with: a result is only as good as the model that did the work, and only as sensible as the model that planned it. Move that line and the output changes character.", + "The Provider and Workers controls pick both seats — each row says whether it runs local or in the cloud. /runmode status says what is resolved right now; /runmode cloud or /runmode local leaves fusion.", + ]; +} + +export const RUN_MODE_USAGE = + "usage: /runmode (opens the switch) · /runmode local|cloud|fusion · /runmode swap · /runmode workers N · /runmode status"; + +export interface RunModeCommand { + openSwitch: boolean; + mode?: RunModeName; + status?: boolean; + swap?: boolean; + workers?: number; + error?: string; +} + +export function parseRunModeCommand(rawArgs: string): RunModeCommand { + const args = rawArgs.trim().toLowerCase(); + if (args.length === 0) return { openSwitch: true }; + if (args === "status") return { openSwitch: false, status: true }; + if (args === "swap") return { openSwitch: false, swap: true }; + const workers = /^workers\s+(\d+)$/.exec(args); + if (workers) { + const n = Number(workers[1]); + if (n < FUSION_WORKERS_MIN || n > FUSION_WORKERS_MAX) { + return { openSwitch: false, error: `workers must be ${FUSION_WORKERS_MIN}-${FUSION_WORKERS_MAX} — ${RUN_MODE_USAGE}` }; + } + return { openSwitch: false, workers: n }; + } + if ((RUN_MODE_NAMES as readonly string[]).includes(args)) return { openSwitch: false, mode: args as RunModeName }; + return { openSwitch: false, error: `unknown run mode "${rawArgs.trim()}" — ${RUN_MODE_USAGE}` }; +} + +export const SWAP_NEEDS_FUSION = "swap needs fusion — pick it first (`/runmode fusion`)"; + +export interface RunModeVerdict { + /** The config object was changed and must be written. */ + write: boolean; + /** One sentence; nothing was changed. */ + refusal?: string; + /** The provider that is now `llm.activeTextProvider`. */ + leg?: string; + before: ResolvedRunMode; + after?: ResolvedRunMode; + /** setWorkers' runtime_info line. */ + notice?: string; +} + +function withoutUndefined(o: T): T { + return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T; +} + +/** + * setMode("fusion"): pick both legs the way the TUI does, then write the mode, + * the orchestrator as the active provider and BOTH pins in one change. + * `isKeyed` is resolveLlmProviderApiKey || usesExternalCliAuth. + */ +export function planEnterFusion( + cfg: RunModeConfig, + pins: Partial, + isKeyed: (p: RunModeProvider) => boolean, +): RunModeVerdict { + const before = resolveRunMode(cfg); + const snapshot = JSON.stringify(cfg); + const llm = cfg.llm ?? {}; + const providers = providersOf(cfg); + const active = llm.activeTextProvider ?? LOCAL_PROVIDER_ID; + const activeIsCloud = providers.some((p) => p.id === active && !isLocalKind(p)); + const cloud = providers.filter((p) => !isLocalKind(p)); + const firstUsableCloud = (cloud.find(isKeyed) ?? cloud[0])?.id ?? null; + // Cloud orchestrator is the default, not the rule: an explicit pin wins whatever its kind. + const leg = pins.orchestratorProvider ?? (activeIsCloud ? active : null) ?? firstUsableCloud + ?? before.orchestratorProviderId ?? active; + const workerLeg = pins.workerProvider + ?? (before.workerProviderId !== leg ? before.workerProviderId : null) + ?? providers.find((p) => p.id !== leg)?.id + ?? null; + if (workerLeg === null) { + return { write: false, before, refusal: describeRunModeDegradation({ reason: "no-second-provider", requested: "fusion" }) }; + } + if (!Array.isArray(llm.providers) || !llm.providers.some((p) => p.id === leg)) { + return { write: false, before, refusal: `provider "${leg}" is not configured` }; + } + const fusion = withoutUndefined({ ...llm.runMode?.fusion, ...pins, orchestratorProvider: leg, workerProvider: workerLeg }); + cfg.llm = { + ...llm, + activeTextProvider: leg, + runMode: { ...llm.runMode, mode: "fusion", ...(Object.keys(fusion).length > 0 ? { fusion } : {}) }, + }; + return { write: JSON.stringify(cfg) !== snapshot, leg, before, after: resolveRunMode(cfg) }; +} + +/** swapLegs: the orchestrator becomes the worker and back, model pins riding along. */ +export function planSwapLegs(cfg: RunModeConfig, isKeyed: (p: RunModeProvider) => boolean): RunModeVerdict { + const before = resolveRunMode(cfg); + if (before.stored !== "fusion") return { write: false, before, refusal: SWAP_NEEDS_FUSION }; + const o = before.orchestratorProviderId; + const w = before.workerProviderId; + if (o === null || w === null || o === w) { + return { write: false, before, refusal: describeRunModeDegradation({ reason: "no-second-provider", requested: "fusion" }) }; + } + const pinned = cfg.llm?.runMode?.fusion; + return planEnterFusion(cfg, { + orchestratorProvider: w, + workerProvider: o, + orchestratorModel: pinned?.workerModel, + workerModel: pinned?.orchestratorModel, + }, isKeyed); +} + +/** + * setWorkers: `llm.runMode.fusion.workers` and `localModels.managed.parallel` + * in one change — how many workers, and how many llama-server slots for them. + * Valid off the fusion route too: the count is remembered. + */ +export function planFusionWorkers(cfg: RunModeConfig, workers: number): RunModeVerdict { + const before = resolveRunMode(cfg); + if (!Number.isInteger(workers) || workers < FUSION_WORKERS_MIN || workers > FUSION_WORKERS_MAX) { + return { write: false, before, refusal: `workers must be an integer ${FUSION_WORKERS_MIN}-${FUSION_WORKERS_MAX}, got ${workers}` }; + } + if (!cfg.llm) return { write: false, before, refusal: "no provider is configured yet — Manage › LLM" }; + const snapshot = JSON.stringify(cfg); + const parallelBefore = cfg.localModels?.managed?.parallel; + const llm = cfg.llm; + cfg.llm = { ...llm, runMode: { ...llm.runMode, fusion: { ...llm.runMode?.fusion, workers } } }; + cfg.localModels = { ...cfg.localModels, managed: { ...cfg.localModels?.managed, parallel: workers } }; + const hint = cfg.localModels.mode === "managed" && parallelBefore !== workers + // The TUI's notice names its `s` chord and the `--parallel` flag; the + // desktop says what to do in its own terms. + ? ` — restart the local model (Manage › LLM › Local) so it runs ${workers} at once` + : ""; + return { + write: JSON.stringify(cfg) !== snapshot, + before, + after: resolveRunMode(cfg), + notice: `fusion: ${workers} worker${workers === 1 ? "" : "s"}${hint}`, + }; +} diff --git a/desktop/package.json b/desktop/package.json index 9350a509..5b4a9894 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -21,6 +21,7 @@ "drive:integration": "npm run build && node test/integration.drive.mjs", "drive:hover": "npm run build && node test/download-hover.drive.mjs", "drive:wizard": "npm run build && node test/wizard-resume.drive.mjs", + "drive:fusion": "npm run build && node test/fusion.drive.mjs", "dist": "npm run build && node -e \"require('node:fs').rmSync('release',{recursive:true,force:true})\" && electron-builder --mac --arm64 --publish never", "postinstall": "node scripts/ensure-electron.mjs" }, diff --git a/desktop/preload/preload.ts b/desktop/preload/preload.ts index 75dc18aa..dfb73d12 100644 --- a/desktop/preload/preload.ts +++ b/desktop/preload/preload.ts @@ -142,8 +142,6 @@ contextBridge.exposeInMainWorld("atomic", { platform: process.platform, build: () => ipcRenderer.invoke("app:build"), - setRunMode: (mode: string, workers?: number) => - ipcRenderer.invoke("cli:runMode", { mode, workers }), debugBundle: () => ipcRenderer.invoke("app:debugBundle"), unverified: () => ipcRenderer.invoke("app:unverified"), unverifiedSet: (id: string, on: boolean) => ipcRenderer.invoke("app:unverifiedSet", { id, on }), @@ -167,6 +165,12 @@ contextBridge.exposeInMainWorld("atomic", { activateProvider: (id: string) => ipcRenderer.invoke("cli:activateProvider", id), selectCloudModel: (id: string, model: string) => ipcRenderer.invoke("cli:selectCloudModel", { id, model }), selectLocalModel: (id: string) => ipcRenderer.invoke("cli:selectLocalModel", id), + /** Run mode — Fusion: RunModeOrchestrator's writes (enter / swap / workers / worker model), each a whole-file write + agent restart. */ + enterFusion: (pins?: { orchestratorProvider?: string; workerProvider?: string }) => + ipcRenderer.invoke("cli:enterFusion", pins ?? {}), + swapFusionLegs: () => ipcRenderer.invoke("cli:swapFusionLegs"), + fusionWorkers: (workers: number) => ipcRenderer.invoke("cli:fusionWorkers", workers), + fusionWorkerModel: (id: string) => ipcRenderer.invoke("cli:fusionWorkerModel", id), useManagedMode: () => ipcRenderer.invoke("cli:useManagedMode"), setExternalLlamaUrl: (url: string) => ipcRenderer.invoke("cli:setExternalLlamaUrl", url), providersReady: () => ipcRenderer.invoke("cli:providersReady"), diff --git a/desktop/renderer/css/chat.css b/desktop/renderer/css/chat.css index daf917a5..0588b5d1 100644 --- a/desktop/renderer/css/chat.css +++ b/desktop/renderer/css/chat.css @@ -277,6 +277,10 @@ .sysrow .sysact{display:inline;margin-left:6px;color:var(--blue);font-weight:600;text-decoration:underline;text-underline-offset:2px;white-space:nowrap} .sysrow .sysact:hover{color:var(--blue-deep)} .sysrow code{font:400 12px var(--font-mono);color:var(--ink)} +/* Fusion's first-switch intro (fusion-intro.ts): the tree mark, then paragraphs. */ +.sysrow .fz-intro{display:flex;flex-direction:column;gap:8px;padding:6px 0 4px;max-width:72ch} +.sysrow .fz-mark{display:block;white-space:pre;overflow-x:auto;font:400 12px/1.35 var(--font-mono);letter-spacing:0;color:var(--ink)} +.sysrow .fz-p{display:block} .sysrow .tk-spin{display:inline-block;vertical-align:-2px;margin-right:8px;width:13px;height:13px} /* a repeated warning is one row with a count, not a wall */ .sysrow .sysrep{display:inline-flex;align-items:center;height:18px;margin-left:6px;padding:0 7px;border-radius:var(--r-pill); diff --git a/desktop/renderer/css/composer.css b/desktop/renderer/css/composer.css index d3eaacdc..4fe25a6c 100644 --- a/desktop/renderer/css/composer.css +++ b/desktop/renderer/css/composer.css @@ -195,6 +195,39 @@ .cfoot .cchip.needsmodel.is-open{background:color-mix(in srgb, var(--amber) 28%, transparent)} .cfoot .cchip.needsmodel > svg,.cfoot .cchip.dlchip > svg{color:var(--amber-text)} +/* Fusion: the ⇄ between the two seats and the fourth control, `workers` + (composer-meta-controls.tsx). Neutral like the rest of the row — the + backend chip's own icon says which route this is. */ +.cfoot .fzswap{padding:0 7px;color:var(--ink-2)} +.cfoot .fzswap > svg{width:14px;height:14px;color:currentColor} +.cfoot .fzswap:hover{color:var(--ink)} +.cfoot .workerschip{flex:0 1 auto} +.cfoot .workerschip .cval{max-width:24ch;font:500 12px/1 var(--font-mono);letter-spacing:0} +.cfoot .workerschip .cval[data-cap]::before{font-family:var(--font-ui)} +/* The Fusion row is seven controls in the width cloud gives three: the + captions go (each pill keeps its icon or mark, and its popover title names + it), the seat chips drop their chevrons, and the pills tighten — otherwise + the two model ids shrink to one letter each. */ +.cfoot.is-fusion .cval[data-cap]::before{content:none} +.cfoot.is-fusion .cchip{padding:0 7px 0 9px;gap:5px} +.cfoot.is-fusion .providerchip > svg.chev,.cfoot.is-fusion .modelchip > svg.chev,.cfoot.is-fusion .workerschip > svg.chev{display:none} +.cfoot.is-fusion .fzswap{padding:0 4px} +.cfoot.is-fusion .modelchip .cval,.cfoot.is-fusion .workerschip .cval{max-width:18ch} + +/* The fan-out while the turn runs (fusion-live-workers.ts): one line per + worker, the status dot in the app's grammar — pulsing brand while it + works, green done, red failed, amber cancelled. No control of its own. */ +.fzlive{display:flex;flex-direction:column;gap:1px;min-width:0;padding:0 6px 4px} +.fzlive .fzw{display:flex;align-items:center;gap:8px;min-width:0;min-height:20px; + font:400 11.5px/18px var(--font-mono);letter-spacing:0;color:var(--ink-2)} +.fzlive .fzt{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fzlive .fzdot{flex:none;width:7px;height:7px;border-radius:50%;background:var(--brand);animation:fzpulse 1.4s var(--ease) infinite} +.fzlive .fzw.done .fzdot{background:var(--success);animation:none} +.fzlive .fzw.failed .fzdot{background:var(--critical);animation:none} +.fzlive .fzw.cancelled .fzdot{background:var(--amber);animation:none} +@keyframes fzpulse{0%,100%{opacity:1}50%{opacity:.35}} +@media (prefers-reduced-motion: reduce){.fzlive .fzdot{animation:none}} + /* Context gauge: a 20px ring and used / window in mono. */ .cfoot .ctxbtn{padding:0 10px;color:var(--ink-2)} .ctxring{width:20px;height:20px;flex:none} diff --git a/desktop/renderer/css/onboarding.css b/desktop/renderer/css/onboarding.css index 0cecd3ae..40cd1c50 100644 --- a/desktop/renderer/css/onboarding.css +++ b/desktop/renderer/css/onboarding.css @@ -41,6 +41,9 @@ .prow .nm{font-size:14px;font-weight:600;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .prow .ep{font:400 12px/1.4 var(--font-mono);color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .prow .ann{flex:none} +/* r2: the picked model's tick, at the right end of its row. */ +.prow-tick{flex:none;display:grid;place-items:center;width:22px;height:22px;color:var(--brand)} +.prow-tick svg{width:15px;height:15px;stroke-width:2.2} .prow-more{font:600 12.5px/1.4 var(--font-ui);color:var(--ink-2);letter-spacing:0;text-transform:none;margin:16px 0 8px 10px} /* ---------------- the layer ---------------- */ @@ -70,7 +73,6 @@ .ob-introc .ob-markbig svg{display:block;width:96px;height:96px} .ob-introc .ob-word{position:relative;margin:24px 0 0;font:700 64px/1.05 var(--font-ui);letter-spacing:-.055em;color:var(--on-strong)} .ob-introc .ob-rule{position:relative;width:0;height:0;margin:0;border:0;border-top:3px solid transparent} -.ob-introc .ob-build{position:relative;margin-top:17px;font:400 12.5px/1.4 var(--font-mono);letter-spacing:0;color:var(--on-indigo)} .ob-introc .ob-any{position:absolute;left:0;right:0;bottom:34px;font:400 13px/1.4 var(--font-ui); letter-spacing:0;text-transform:none;color:var(--indigo-muted)} @@ -108,7 +110,6 @@ #onboarding .ob-stepmark.on .n{background:var(--on-strong);color:var(--indigo);box-shadow:none} #onboarding .ob-stepmark.done{color:var(--on-strong)} #onboarding .ob-stepmark.done .n{background:var(--glass-20);box-shadow:none} -#onboarding .ob-railbuild{font:400 11px/1.4 var(--font-mono);letter-spacing:0;color:var(--indigo-muted)} /* ---------------- the step column ---------------- Title, then a body that owns the flexible height (its lists scroll @@ -283,15 +284,28 @@ onboarding-mouse.mjs still measures it. */ #onboarding .ob-foot .btn-g{padding:0 18px 0 14px;border:1px solid var(--bd-2)} #onboarding .ob-foot .btn-g:hover{background:var(--hover);color:var(--ink)} -#onboarding .ob-hints{position:static;flex:none;display:flex;align-items:center;justify-content:center;flex-wrap:wrap; - gap:2px 12px;min-height:40px;height:auto;padding:10px 0 0;border:0;background:transparent;font-size:12px;color:var(--ink-2)} -#onboarding .ob-hints .hint{display:inline-flex;align-items:center;gap:6px;height:24px;margin:0;padding:0 6px;border-radius:8px; - font-size:12px;letter-spacing:0} -#onboarding .ob-hints .hint-live{font:inherit;color:inherit;background:transparent;border:0;cursor:pointer; - transition:background var(--t-state) var(--ease),color var(--t-state) var(--ease)} -#onboarding .ob-hints .hint-live:hover{background:var(--hover);color:var(--ink)} -#onboarding .ob-hints .kc{margin:0} -#onboarding .ob-hints .kc + .kc{margin-left:-2px} +/* r2: the action bar is the last thing in the column now (no hint strip). */ +#onboarding .ob > .ob-foot{padding-bottom:28px} + +/* ---------------- the closing screen (r2) ---------------- + A small comet crosses the middle of the column while setup settles: a + brand head and a tail that fades to nothing. Reduced motion holds it + still in the centre. */ +#onboarding .ob-body > .ob-comet{position:relative;flex:1 1 auto;min-height:220px;max-width:none;overflow:hidden;container-type:inline-size} +#onboarding .ob-comet i{position:absolute;left:0;top:50%;width:150px;height:2px;margin-top:-1px;border-radius:var(--r-pill); + background:linear-gradient(90deg,transparent,color-mix(in srgb,var(--brand) 45%,transparent) 65%,var(--brand)); + animation:ob-comet 2.6s cubic-bezier(.45,.05,.4,1) infinite;will-change:transform,opacity} +#onboarding .ob-comet i::after{content:"";position:absolute;right:-4px;top:50%;width:8px;height:8px;margin-top:-4px;border-radius:50%; + background:var(--brand);box-shadow:0 0 12px 3px color-mix(in srgb,var(--brand) 40%,transparent)} +@keyframes ob-comet{ + 0%{transform:translate(-170px,22px) rotate(-5deg);opacity:0} + 18%{opacity:1} + 82%{opacity:1} + 100%{transform:translate(calc(100cqw + 20px),-22px) rotate(-5deg);opacity:0} +} +@media (prefers-reduced-motion:reduce){ + #onboarding .ob-comet i{animation:none;left:50%;transform:translateX(-50%) rotate(-5deg)} +} /* ---------------- the download (ON-07, ON-08, ON-18) ---------------- `.ob-dlprog` is the one box a progress sample repaints (refreshDlProgress); @@ -332,6 +346,9 @@ #onboarding .ob-row.ob-check:hover .box:not(.on){box-shadow:inset 0 0 0 1.5px var(--brand)} #onboarding .ob-check .d{font:400 12px/1.45 var(--font-mono)} #onboarding .ob-models > .ob-explain{padding:10px 12px;font-size:13.5px} +/* r2: the local list while `atag models list` is out — a spinner, centred. */ +#onboarding .ob-loading{display:grid;place-items:center;min-height:140px} +#onboarding .ob-loading .tk-spin{width:22px;height:22px} #onboarding .ob-explain.ob-help{font-size:12.5px;line-height:1.45;max-width:62ch;padding-left:4px} #onboarding .prow > .tk-ico{width:30px;height:30px} #onboarding .ob-h.ob-lede{font:400 15.5px/1.5 var(--font-ui);letter-spacing:0;color:var(--ink-2)} diff --git a/desktop/renderer/css/overlays.css b/desktop/renderer/css/overlays.css index 9a0665e9..c5e7bd64 100644 --- a/desktop/renderer/css/overlays.css +++ b/desktop/renderer/css/overlays.css @@ -136,6 +136,9 @@ .toast-t{font-size:13.5px;line-height:18px;font-weight:600;color:var(--ink);overflow-wrap:anywhere} .toast-s{font-size:12.5px;line-height:17px;color:var(--ink-2);overflow-wrap:anywhere; display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden} +/* r2: the dismiss cross, top right, sitting in the card's padding. */ +.toast .toast-x{flex:none;margin:-4px -10px 0 0;color:var(--ink-2)} +.toast .toast-x:hover{color:var(--ink)} /* ---- Short windows ---- */ @media (max-height:700px){ diff --git a/desktop/renderer/css/settings-llm.css b/desktop/renderer/css/settings-llm.css index a113dc13..71f1ea15 100644 --- a/desktop/renderer/css/settings-llm.css +++ b/desktop/renderer/css/settings-llm.css @@ -116,6 +116,10 @@ .llm-pane .llm-workers{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;padding:0 2px} .llm-pane .llm-workers .tk-help{flex:1;min-width:220px} .llm-pane .llm-workerseg button{min-width:34px;padding:0 10px} +/* Fusion's card while its pre-flight refuses: the reason, in the waiting colour. */ +.llm-pane .llm-rm.blocked .llm-rm-d{color:var(--amber-text)} +/* /runmode status under the cards — machine text. */ +.llm-pane .llm-rm-status{margin:0;padding:0 2px;font:400 12px/1.45 var(--font-mono);letter-spacing:0;color:var(--ink-2);overflow-wrap:anywhere} /* ---- Cloud: provider, filter, price facet ---- */ .llm-pane .llm-prov{min-width:0;font-size:12.5px;color:var(--ink-2)} diff --git a/desktop/renderer/renderer.js b/desktop/renderer/renderer.js index 69a368e5..e0387a41 100644 --- a/desktop/renderer/renderer.js +++ b/desktop/renderer/renderer.js @@ -537,6 +537,21 @@ const OB_TUI_AGENT_ID = 'atomic-tui'; whether the key facts have landed at all: until they have, the rows say "checking keys…" rather than a "no API key" that is not known yet. */ const BSW = { line:'', readyIds:[], readyLoaded:false, localLoaded:false, gating:false }; +/* Run mode — Fusion. `live` is the fan-out's legs while a turn runs, the way + src/tui/fusion-live-workers.ts keeps them: ordered by first sight, finished + legs kept (done) until the turn ends. Declared here, before the first + render(), because composer() reads it. */ +const FZ = { live:[] }; +/* src/tui/run-mode/fusion-intro.ts FUSION_MARK: one model on top deciding, + several underneath doing. */ +const FUSION_MARK = [ + ' ● orchestrator', + ' │', + ' ┌────┼────┐', + ' ○ ○ ○ workers', +].join('\n'); +const SWAP_NEEDS_FUSION = 'swap needs fusion — pick it first (`/runmode fusion`)'; +const RUN_MODE_USAGE = 'usage: /runmode (opens the switch) · /runmode local|cloud|fusion · /runmode swap · /runmode workers N · /runmode status'; /* ---- SELECTOR LANE — the custom route's model label ---- selectPromptLlmMeta's external branch is @@ -643,6 +658,11 @@ const SWXBR = { selectCloudModel: (id, model) => { SWX.route = 'selectCloudModel'; return BR.selectCloudModel(id, model); }, selectLocalModel: (id) => { SWX.route = 'selectLocalModel'; return BR.selectLocalModel(id); }, codingMode: (id) => { SWX.route = 'codingMode'; return BR.codingMode(id); }, + // Run mode — Fusion: the TUI's RunModeOrchestrator writes (main/backend-switch.ts). + enterFusion: (pins) => { SWX.route = 'enterFusion'; return BR.enterFusion(pins || {}); }, + swapFusionLegs: () => { SWX.route = 'swapFusionLegs'; return BR.swapFusionLegs(); }, + fusionWorkers: (n) => { SWX.route = 'fusionWorkers'; return BR.fusionWorkers(n); }, + fusionWorkerModel: (id) => { SWX.route = 'fusionWorkerModel'; return BR.fusionWorkerModel(id); }, }; /* ---- Item 7: settings surface — the TUI menu tree + the Manage tabs ---- @@ -689,6 +709,9 @@ const MENU_GROUPS = [ {id:'model.chat', label:'Switch chat model…', chord:'k'}, ]], ['Run', [ + // menu-registry.ts run.type — a submenu there (Local 1 · Cloud 2 · Fusion 3); + // here it opens the composer's own "Where it runs" switch, as bare /runmode does. + {id:'run.type', label:'Where it runs…'}, {id:'run.mode', label:'Coding mode…', chord:'M'}, {id:'run.abort', label:'Abort turn', chord:'a'}, {id:'run.queue', label:'Queued messages', na:true}, @@ -730,7 +753,7 @@ const MENU_ACTS = { 'session.new':'session:new', 'session.switch':'session:switch', 'session.clear':'clear', 'session.context':'context', 'session.id':'session:id', 'model.chat':'selector:model', - 'run.mode':'modes', 'run.abort':'stop', 'run.expand':'cards:expand', 'run.collapse':'cards:collapse', + 'run.type':'runmode', 'run.mode':'modes', 'run.abort':'stop', 'run.expand':'cards:expand', 'run.collapse':'cards:collapse', 'run.steer':'steer', 'setup.theme':'palette:theme', 'setup.sidebar':'toggle:sidebar', 'setup.analytics':'settings:privacy', 'setup.skill':'settings:skills', 'setup.task':'settings:tasks', @@ -1085,6 +1108,7 @@ const P = { up:'', arrowR:'', copy:'', + home:'', gear:'', cloud:'', cpu:'', @@ -1101,6 +1125,9 @@ const P = { filter:'', atom:'', bolt:'', + // Fusion's mark: one node deciding, three doing (fusion-intro.ts draws the same tree). + fusion:'', + swap:'', play:'', pause:'', trash:'', @@ -1237,7 +1264,7 @@ const SLASH = [ ['quit','exit Atomic Agent'], ['debug','toggle debug pane (feed / logs / world …)'], ['chat','return to single-view chat mode'], - ['run','run mode — fusion orchestrates on cloud, executes locally','local|cloud|fusion [0-100]'], + ['runmode','where the chat runs: `/runmode` opens the switch · `/runmode local|cloud|fusion` sets one · `/runmode status`','local|cloud|fusion|swap|workers N|status'], ['observe','switch to the Observe section'], ['manage','switch to the Manage section'], ['feed','jump to the Observe → Feed tab'], @@ -1272,6 +1299,7 @@ const CATS = [ ['shell','shell command',4], ['script','skill script',4], ['proc_kill','process kill',4], + ['fusion_fanout','fusion · fan-out',4], ['browser_nonweb','browser · non-web URL',5], ['trust_config','agent trust config',5], ['other','uncategorised',5], @@ -1306,6 +1334,7 @@ const PAL = [ ['cloud','Switch chat model…','pull | use | status','⇧ ⌘ M','selector:model'], ]], ['Run', [ + ['fusion','Where it runs…','/runmode','','runmode'], ['stop','Abort turn','','⌘ .','stop'], ['chevD','Expand all tool cards','','⌥ ⌘ E','cards:expand'], ['chevR','Collapse all tool cards','','⌥ ⌘ K','cards:collapse'], @@ -1333,7 +1362,6 @@ const S = { q:'', cur:0, scope:null, slash:false, slashCur:0, draft:'', - mode:'fusion', share:40, dialShare:40, localModel:'qwen3-8b-instruct', cloudModel:'claude-opus-5', modelQuery:'', level:3, grants:[], busy:false, pending:null, queued:[], phase:'', elapsed:0, @@ -1530,7 +1558,7 @@ function renderSidebar() { // is not collapsed by the user (CSS hides it on the responsive rail too). + '
' + '
' @@ -2045,7 +2073,10 @@ function composer() { drivers and the ticker read is kept — `.statusstrip` (+ gated / waiting / appstatus), the waiting strip's `.ann` / `.readout` / `.ob-help`, the busy strip's FIRST `.tnum` (the 100 ms ticker writes the elapsed time into it), - `data-act="stop"` and `data-act="jump:appr"`. */ + and `data-act="jump:appr"`. + r2 (DMG feedback): no Stop pill on the busy or waiting strip. The one + Stop is the composer's own button (sendButton: `.sendbtn.stop` whenever + S.busy || S.pending), and ⌘ . still aborts while a steer is drafted. */ const status = S.pending ? '
' + '' + ic('alert') + 'Waiting for your approval' @@ -2059,15 +2090,13 @@ function composer() { + 'Waiting' + '' + esc(waitReadout()) + '' + (WAIT.reason ? '' + esc(humanWaitReason(WAIT.reason)) + '' : '') - + '' - + '
' + + '' : S.busy ? '
' + '' + '' + S.phase + '' + '' + (S.elapsed / 10).toFixed(1) + 's' - + '' - + '
' + + '' // r5 item 10: where the lock was, the reason it ended. A toast fades; // the operator needs this next to the button that was disabled. The // 45 s watchdog's line is a wait, not a failure, so it keeps Caution. @@ -2077,20 +2106,16 @@ function composer() { ? '' + ic('alert') + 'Caution' : '' + ic('alert') + 'Switch failed') + '' + esc(SWX.err) + '' - /* F10 — where the app reports on itself. Lowest priority: a running turn, - a pending approval or a failed switch all matter more than the last - thing that changed. */ - : APPSTATUS.text - ? '
' - + '' - + (APPSTATUS.tone === 'caution' ? 'Caution' : 'Ready') + '' - + '' + esc(APPSTATUS.text) + '
' + /* r2 (DMG feedback): the F10 "Ready · " strip is + no longer drawn under the transcript — it read as noise. APPSTATUS is + still kept (and logged) for diagnostics; only the strip is gone. The + strips above stay: each carries a control (Jump, Stop) or a failure. */ : ''; const q = S.queued.length ? '
' + S.queued.map((t, i) => '
Queued' + esc(t) + '' + '
').join('') + '
' : ''; const backend = selBackend(); - return '
' + status + q + return '
' + status + fzLiveHTML() + q // Item 2 (voice input): the strip is ALWAYS emitted, hidden and empty // when there is nothing to say, so refreshVoice() can repaint it by // outerHTML without a render() that would move the caret. @@ -2121,9 +2146,9 @@ function composer() { (::before), so each chip's textContent stays exactly the id the drivers compare. Every chip is a direct child of `.cfoot`, so the popovers can anchor to `#composer .cfoot [data-sel-open=…]`. */ - + '
' + + '
' + '' // SELECTOR LANE: the visible control set follows composerSwitchKindsFor, // not a hard-coded `cloud` test — see selKinds(). Cloud and custom draw @@ -2139,6 +2164,8 @@ function composer() { // a chatModel, or local before the snapshot lands); the pane stays // reachable through the provider chip and the backend rows. + modelChipHtml() + // Run mode — Fusion: the ⇄ between the two seats and the fourth control, `workers`. + + fzChipsHtml() + '' + contextChip() + codingModeChip() @@ -2148,7 +2175,7 @@ function composer() { /** ' is-open' while the popover a composer chip opens is up (presentation only). */ function cchipOpen(kind) { - if (kind === 'backend' || kind === 'provider' || kind === 'model') { + if (kind === 'backend' || kind === 'provider' || kind === 'model' || kind === 'workers') { return SEL.open && !OB.open && SEL.kind === kind ? ' is-open' : ''; } return S.overlay === kind ? ' is-open' : ''; @@ -2703,33 +2730,25 @@ function paletteHTML() { }); } const sc = S.scope ? SCOPES[S.scope] : null; - const dial = (sc && sc.dial) ? '
' - + '
cloud share' - + '' + S.dialShare + '
' - + '' - + '
' + esc(shareBlurb(S.dialShare)) + '
' : ''; return '
'; } -function shareBlurb(v) { - if (v === 0) return 'everything local'; - if (v === 100) return 'everything cloud'; - return 'cloud handles steps scoring ≥ ' + (100 - v); -} /* ---------------- slash completion ---------------- */ function slashMatches() { - const q = S.draft.replace(/^\//, '').toLowerCase(); + // The command word only: with arguments typed (`/runmode status`) the list + // keeps showing that command and its hint instead of "no matching command". + const q = S.draft.replace(/^\//, '').toLowerCase().split(/\s+/)[0]; if (!q) return SLASH; return SLASH.filter(([n, d]) => n.startsWith(q)) .concat(SLASH.filter(([n, d]) => !n.startsWith(q) && n.includes(q))); } @@ -3096,7 +3115,7 @@ function shortcutsSheet() { ['Toggle sidebar','⌘ 0','Ctrl 0'],['Toggle console','⇧ ⌘ Y','Ctrl ⇧ Y'], ['Send','↩',''],['Newline','⇧ ↩',''],['Stop','⌘ .','Ctrl .'], ['Expand all cards','⌥ ⌘ E',''],['Collapse all cards','⌥ ⌘ K',''], - ['Cycle run mode','⌃ R',''],['Approve / deny / abort','Y N ⎋',''], + ['Approve / deny / abort','Y N ⎋',''], ['Settings','⌘ ,','Ctrl ,'],['Shortcuts','⌘ /',''], ]; return sheet('Keyboard shortcuts', @@ -3537,7 +3556,10 @@ function renderToasts() { return '
' + '' + ic(bad ? 'alert' : 'check') + '' + '' + esc(t.t) + '' - + (t.s ? '' + esc(t.s) + '' : '') + '
'; + + (t.s ? '' + esc(t.s) + '' : '') + '' + // r2: every toast can be dismissed before its 6 s are up. Icon only, so + // the toast's textContent (what the drivers read) is unchanged. + + '
'; }).join(''); } function toast(t, s, kind) { @@ -3584,6 +3606,7 @@ function act(a) { // Item 2 (voice input): one seam for every voice verb. if (a === 'voice' || a.indexOf('voice:') === 0) { voiceAct(a); return; } if (a === 'close') { close(); render(); return; } + if (k === 'toastx') { S.toasts = S.toasts.filter((x) => String(x.id) !== v); renderToasts(); return; } if (a === 'palette') { close(); S.overlay = 'palette'; render(); return; } if (a === 'palette:slash') { close(); S.overlay = 'palette'; S.q = ''; render(); toast('Slash commands', 'Type / in the composer for the in-context list'); return; } if (a === 'shortcuts') { close(); S.overlay = 'shortcuts'; render(); return; } @@ -3616,18 +3639,17 @@ function act(a) { if (BR && BR.openExternal) BR.openExternal(url); return; } + /* Run mode — every route to it lands here or in fzSlash: the native menu's + Run › Where it runs…, the Settings › LLM cards and worker count, the + composer's ⇄, and the palette row. One switch path per verb, shared with + the composer's Backend control (selChooseBackend / selChooseFusion). */ + if (a === 'runmode') { close(); S.settings = null; openSelector('backend'); return; } if (a.startsWith('runmode:')) { const rest = a.slice('runmode:'.length); - const workers = rest.startsWith('workers:') ? Number(rest.slice('workers:'.length)) : null; - const cur = (LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.runMode) || {}; - const mode = workers === null ? rest : (cur.mode || 'fusion'); - if (!BR || !BR.setRunMode) return; - BR.setRunMode(mode, workers === null ? undefined : workers).then(async (res) => { - if (!res || !res.ok) { LLMP.msg = {text: (res && res.error) || 'could not set the run mode'}; render(); return; } - await refreshLiveConfig(); - LLMP.msg = {text: 'Run mode: ' + mode + (workers === null ? '' : ' · ' + workers + ' workers'), restart: true}; - render(); - }); + if (rest === 'swap') { fzSwap(); return; } + if (rest === 'status') { fzStatus(); return; } + if (rest.startsWith('workers:')) { fzSetWorkers(Number(rest.slice('workers:'.length))); return; } + if (rest === 'local' || rest === 'cloud' || rest === 'fusion') { fzActivateBackend(rest); return; } return; } /* F6 — both ways out of the model step run the SAME save path: wizNext @@ -3655,8 +3677,6 @@ function act(a) { if (a === 'sel:closeAdd') { SEL.addOpen = false; render(); return; } if (a === 'sel:savePreset') { selSavePreset(); return; } if (a === 'sel:cancelPull') { BR.cancelPull(); SEL.pulling = null; render(); return; } - if (a === 'runmode') { close(); S.dialShare = S.share; S.overlay = 'runmode'; render(); return; } - if (a === 'applydial') { S.share = S.dialShare; if (S.mode !== 'fusion' && S.dialShare > 0) S.mode = 'fusion'; close(); render(); toast('Run type applied', S.mode + (S.mode === 'fusion' ? ' · cloud share ' + S.share : '')); return; } if (a === 'session:new') { close(); S.log = []; S.history = []; S.agentSession = null; S.busy = false; forgetApprovalCard(); // item 6 review fix: a fresh thread does not answer the open gate — the other chat's dot keeps saying it is waiting clearPlanOffer(); // Item 1: the plan belonged to the thread being left (see openSession) @@ -3790,7 +3810,6 @@ function act(a) { else document.documentElement.setAttribute('data-theme', v); try { localStorage.setItem('atag.theme', v); } catch (e) { /* no storage: the choice lasts this launch */ } render(); return; } - if (k === 'mode') { S.mode = v; if (S.overlay === 'palette') close(); render(); return; } if (k === 'cards') { close(); S.log.forEach((m) => { if (m.k === 'tool') m.open = v === 'expand'; }); render(); return; } if (k === 'ses') { close(); openSession(v); return; } if (k === 'delask') { const ss = SESSIONS.find((x) => x.id === v); if (!ss) return; @@ -3836,7 +3855,7 @@ function act(a) { PREFS.seen['task:' + v] = Math.max(PREFS.seen['task:' + v] || 0, (t && t.updatedAt) || 0, Date.now()); savePrefs(); close(); act('settings:tasks'); tkFocusTask(v); return; } - if (k === 'scope') { S.scope = v; S.q = ''; S.cur = 0; S.dialShare = S.share; render(); return; } + if (k === 'scope') { S.scope = v; S.q = ''; S.cur = 0; render(); return; } if (k === 'taskfilter'){ S.taskFilter = v; render(); return; } if (k === 'skillstab') { S.skillsTab = v; render(); return; } if (k === 'memtab') { S.memTab = v; render(); return; } @@ -3912,8 +3931,10 @@ async function steerOrQueueRun(text, post) { // did take it, and reopening that chat reads it back from the store. if (here) { STEER.mine.push(text); + // Under the steer it explains, inside the turn — appended, it landed + // under the reply the turn went on to write (see placeInLiveTurn). pushSteerEntry(text); - S.log.push({id:nid(), k:'system', text:'steering the running turn — the agent reads it at the next step'}); + placeInLiveTurn({id:nid(), k:'system', text:'steering the running turn — the agent reads it at the next step'}); render(); } return; @@ -3930,14 +3951,14 @@ async function steerOrQueueRun(text, post) { if (e) { e.value = text; autosize(e); } ctxDraftChanged(); } - if (here) S.log.push({id:nid(), k:'system', text:'queue: full at ' + MAX_QUEUED + ' — the steer could not be parked (returned to the editor)'}); + if (here) placeInLiveTurn({id:nid(), k:'system', text:'queue: full at ' + MAX_QUEUED + ' — the steer could not be parked (returned to the editor)'}); render(); return; } S.queued.splice(STEER.ahead, 0, text); STEER.ahead += 1; // The queue tray is window-global and shows the parked text either way; // the sentence explaining it is only true in the chat it was typed in. - if (here) S.log.push({id:nid(), k:'system', text: asked + if (here) placeInLiveTurn({id:nid(), k:'system', text: asked ? 'steering the running turn — it cannot take this one, so it runs as the next turn' : 'the running turn has not reported its session yet — it could not be asked, so this runs as the next turn'}); render(); @@ -4123,11 +4144,7 @@ function runSlash(parts) { theme:'palette:theme', sessions:'session:switch', new:'session:new', clear:'clear', abort:'stop', session:'session:id', dump:'dump', tools:'tools', quit:'quit', help:'palette', debug:'toggle:console', expand:'cards:expand', collapse:'cards:collapse', mode:'modes', context:'context', sidebar:'toggle:sidebar'}; - if (name === 'run') { - if (parts[1]) { S.mode = parts[1]; if (parts[2]) { S.share = Math.max(0, Math.min(100, +parts[2])); S.dialShare = S.share; } render(); toast('Run type ' + S.mode); } - else act('runmode'); - return; - } + if (name === 'runmode') { fzSlash(parts.slice(1).join(' ')); return; } // Item 7: `/privacy [analytics ]` and `/analytics ` as // slash-command-handler.ts dispatchPrivacySub / dispatchAnalyticsSub. const rest = parts.slice(1).filter(Boolean); @@ -4270,7 +4287,17 @@ document.addEventListener('click', (e) => { when the flow is closed, and the composer's popover is the other place this step renders. */ const wizModel = e.target.closest && e.target.closest('[data-wizmodel]'); - if (wizModel) { WIZ.modelPick = wizModel.dataset.wizmodel; render(); return; } + if (wizModel) { + /* The first-run layer has its own click listener for these rows. Both + listeners sit on document, so without this the double click fired + "Use this model" twice while the flow was open (caught by a driven + double click, not by a single one: picking twice is harmless). */ + if (OB.open) return; + WIZ.modelPick = wizModel.dataset.wizmodel; + // r2: double click = Use this model (see the first-run handler). + if (e.detail >= 2) { act('wiz:model'); return; } + render(); return; + } if (wizKind) { /* A key belongs to the provider it was typed for. WIZ.apiKey survived a Back and a different pick, so the field came up pre-filled with the @@ -4351,7 +4378,6 @@ document.addEventListener('input', (e) => { return; } if (e.target.id === 'wiz-url') { WIZ.baseUrl = e.target.value; return; } - if (e.target.id === 'dial') { S.dialShare = +e.target.value; refreshDial(); return; } }); @@ -4852,12 +4878,6 @@ function refreshPalette() { const l = $('#pallist'); if (l && !S.q) l.scrollTop = scroll; const cur = $('#overlays').querySelector('.palrow.on'); if (cur) cur.scrollIntoView({block:'nearest'}); } -function refreshDial() { - const wrap = $('#dial') ? $('#dial').parentElement : null; - if (!wrap) return; - const n = wrap.querySelector('.mono.tnum'); if (n) n.textContent = S.dialShare; - const c = wrap.querySelector('.cap'); if (c && S.mode === 'fusion') c.textContent = shareBlurb(S.dialShare); -} function flatPalRows() { const rows = palRows(); @@ -4961,12 +4981,6 @@ document.addEventListener('keydown', (e) => { if (k === 'Enter') { e.preventDefault(); submit(); return; } } if (mod && e.shiftKey && k.toLowerCase() === 'y') { e.preventDefault(); act('toggle:console'); return; } - if (e.ctrlKey && !e.metaKey && k.toLowerCase() === 'r' && !e.shiftKey) { - e.preventDefault(); - const order = ['local','cloud','fusion']; - S.mode = order[(order.indexOf(S.mode) + 1) % 3]; - render(); toast('Run type ' + S.mode); return; - } // palette if (S.overlay === 'palette') { @@ -4997,10 +5011,17 @@ document.addEventListener('keydown', (e) => { } // slash completion owns the arrows while open - if (S.slash && e.target.id === 'entry') { + /* A line that already carries arguments (`/runmode status`, `/model use x`) + is a command to RUN, not a name to complete: Enter used to be swallowed + here — accept had no row to accept — so no slash command with arguments + could be sent with the keyboard at all. It falls through to submit(), as + does a line that matches no command (submit says "unknown command"). */ + const slashArgs = /^\/\S+\s+\S/.test(S.draft); + if (S.slash && e.target.id === 'entry' && !(k === 'Enter' && !e.shiftKey && (slashArgs || !slashMatches().length))) { const m = slashMatches(); if (k === 'ArrowDown') { e.preventDefault(); S.slashCur = Math.min(S.slashCur + 1, m.length - 1); refreshSlash(); return; } if (k === 'ArrowUp') { e.preventDefault(); S.slashCur = Math.max(S.slashCur - 1, 0); refreshSlash(); return; } + if (k === 'Tab' && slashArgs) { e.preventDefault(); return; } // completing the name would drop the arguments if (k === 'Tab' || (k === 'Enter' && !e.shiftKey)) { e.preventDefault(); if (m[S.slashCur]) acceptSlash(m[S.slashCur][0]); return; } if (k === 'Escape') { e.preventDefault(); S.slash = false; render(); return; } } @@ -5117,7 +5138,6 @@ async function loadResources() { const provider = (LIVE_CONFIG.llm && (LIVE_CONFIG.llm.providers || []) .find((p) => p.id === LIVE_CONFIG.llm.activeTextProvider)) || null; if (provider) { - S.mode = provider.kind === 'llama-server' ? 'local' : 'cloud'; if (provider.defaultChatModel) S.cloudModel = provider.defaultChatModel; } const managed = LIVE_CONFIG.localModels && LIVE_CONFIG.localModels.managed; @@ -5281,6 +5301,7 @@ function startLiveTurn(text) { PLAN.startedMode = MODE.known ? currentMode() : null; S.history.push({role:'user', content:text}); S.reasonId = null; + FZ.live = []; S.busy = true; S.stick = true; S.elapsed = 0; S.phase = 'Thinking'; const streaming = {id:nid(), k:'assistant', text:''}; S.streamId = streaming.id; @@ -5453,6 +5474,26 @@ function onChatEvent(ev) { render(); return; } + /* Run mode — Fusion. One leg of a fan-out started, ran a tool, ended or was + cut short (`event: fusion_worker`, agent ≥ desktop/fusion-stream). The + live list under the composer says what is happening; the transcript + keeps what happened, one line per event in the TUI's feed words + (format-fusion-worker-line.ts), placed before the reply like tool cards. */ + if (ev.kind === 'fusion_worker') { + const p = ev.payload || {}; + const e = { + taskId: String(p.task_id || ''), title: String(p.title || ''), phase: String(p.phase || ''), + role: p.role === 'orchestrator' ? 'orchestrator' : 'worker', + model: typeof p.model === 'string' ? p.model : undefined, + tool: typeof p.tool === 'string' ? p.tool : undefined, + stepCount: typeof p.step_count === 'number' ? p.step_count : undefined, + summary: typeof p.summary === 'string' ? p.summary : undefined, + }; + FZ.live = fzReduceLive(FZ.live, e); + if (item) S.log.splice(S.log.indexOf(item), 0, {id:nid(), k:'system', note:true, fusion:true, text: esc(fzWorkerLine(e))}); + render(); + return; + } if (ev.kind === 'reasoning_progress') { const text = pick(ev.payload, 'delta', 'text', 'content') || ''; if (!text || !item) return; // review fix: no streaming item on screen, nothing to splice against @@ -5525,6 +5566,7 @@ function onChatEvent(ev) { if (ev.kind === 'finish') return; S.busy = false; S.turnId = null; clearInterval(ticker); S.reasonId = null; + FZ.live = []; // the fan-out readout belongs to the turn that is over /* Item 1 (plan hand-off): finishTurn's rule, restated — src/tui/reducer-helpers.ts: "if (state.codingMode !== 'plan' || outcome !== 'completed') return next; return { ...next, planHandoff: true }". @@ -5601,6 +5643,51 @@ function onChatEvent(ev) { } } +/* Turn order — "end agent results should be the last message within the + turn" (operator, 2026-09-15 DMG). + + startLiveTurn pushes the streaming assistant item the moment a turn opens, + and every delta lands in THAT item. Tool cards, reasoning and steers were + already spliced in ahead of it; the approval card and the notices raised + while the turn runs were S.log.push()ed after it, so the finished turn read + user → tools → reply → approvals: the reply sat above the approvals it came + after, and the transcript ended on a receipt instead of the agent's answer. + + A row raised mid-turn therefore goes into the turn, before the streaming + item. `afterTool` puts an approval under the newest card of the call that + asked for it (after any receipts already hanging off that card), which is + also where a reopened chat puts it (sessionTurnsToLog). Anything that is not + this window's live turn — no stream on screen, or a request raised by + another session — is appended exactly as before. */ +function placeInLiveTurn(entry, opts) { + const o = opts || {}; + const item = S.streamId ? S.log.find((m) => m.id === S.streamId) : null; + const ours = !o.sessionId || !S.agentSession || o.sessionId === S.agentSession; + if (!item || !S.turnId || !ours) { S.log.push(entry); return; } + let at = S.log.indexOf(item); + if (o.afterTool) { + for (let i = at - 1; i >= 0; i--) { + const c = S.log[i]; + if (c.k === 'user' && !c.steered) break; // the turn's own question: stop + if (c.k === 'tool' && c.name === o.afterTool) { + at = i + 1; + while (at < S.log.length && S.log[at] !== item && (S.log[at].k === 'approval' || (S.log[at].k === 'system' && S.log[at].apprNote))) at++; + break; + } + } + } + S.log.splice(at, 0, entry); +} + +/** A notice about an approval card, directly under that card (or appended when the card is gone). */ +function placeAfterRow(row, entry) { + const at = row ? S.log.indexOf(row) : -1; + if (at < 0) { placeInLiveTurn(entry); return; } + let i = at + 1; + while (i < S.log.length && S.log[i].k === 'system' && S.log[i].apprNote) i++; + S.log.splice(i, 0, entry); +} + function onApprovalEvent(payload) { if (!payload || !payload.approvalId) return; const affects = Array.isArray(payload.affectedResources) ? payload.affectedResources : []; @@ -5626,7 +5713,7 @@ function onApprovalEvent(payload) { }; if (req.sessionId) PENDING_APPROVALS.set(req.sessionId, req.approvalId); S.pending = req; - S.log.push(req); + placeInLiveTurn(req, {afterTool: req.tool, sessionId: req.sessionId}); S.apprFocused = false; S.busy = false; render(); @@ -5669,7 +5756,7 @@ const CATEGORY_LABEL = { fs_write_workspace:'file write · workspace', fs_write_home:'file write · home', fs_trash:'move to Trash', http:'HTTP request', shell:'shell command', script:'skill script', proc_kill:'process kill', browser_nonweb:'browser · non-web URL', - trust_config:'agent trust config', other:'uncategorised', + trust_config:'agent trust config', fusion_fanout:'fusion · fan-out', other:'uncategorised', }; function answerLive(req, key) { @@ -5679,7 +5766,7 @@ function answerLive(req, key) { req.state = approve ? 'approved' : 'denied'; req.at = new Date().toTimeString().slice(0, 8); if (key === 's' || key === 'a') { - S.log.push({id:nid(), k:'system', + placeAfterRow(req, {id:nid(), k:'system', apprNote:true, text:'granted once — session-wide grants are not exposed by the agent\u2019s HTTP API yet, so this behaved as “allow once”.'}); } /* r6 (human-scenario round): the window has to go back to LOOKING busy. @@ -5704,7 +5791,7 @@ function answerLive(req, key) { S.phase = approve ? (req.tool || 'Working') : 'Thinking'; } BR.approve(req.approvalId, approve ? 'allow-once' : 'deny').then((res) => { - if (res && !res.ok) S.log.push({id:nid(), k:'system', text:'could not resolve the approval: ' + esc(res.error || '')}); + if (res && !res.ok) placeAfterRow(req, {id:nid(), k:'system', apprNote:true, text:'could not resolve the approval: ' + esc(res.error || '')}); render(); }); if (key === 'esc') { S.busy = false; if (S.turnId) BR.cancel(S.turnId); } @@ -5764,7 +5851,7 @@ async function denyByProse(req, text, post) { S.busy = true; S.phase = 'Thinking'; } - S.log.push({id:nid(), k:'system', text: landed + placeAfterRow(req, {id:nid(), k:'system', apprNote:true, text: landed ? 'that call was denied with your message as the reason' : 'could not deny that call with your message: ' + esc(why)}); render(); @@ -5854,6 +5941,9 @@ function activeModel() { word for ~10s. Paint nothing instead; the config replaces it the moment the switch lands. */ if (SWX.want && SWX.want.backend && SWX.want.backend !== liveBackend()) return ''; + /* Run mode — Fusion: the model control addresses the orchestrator leg + (composer-switch-rows.ts modelRows; the left half of selectPromptLlmMeta). */ + if (BR && S.live.state === 'connected' && selBackend() === 'fusion') return fzLegLabel(rmNow(), 'orchestrator'); if (BR && S.live.state === 'connected') { // Lane B — backend switch: the TUI's selectPromptLlmMeta. A cloud // provider shows its chatModel (defaultChatModel ?? model) and, @@ -5881,7 +5971,7 @@ function activeModel() { if (managed.modelId) return managed.modelId; return ''; } - return S.mode === 'local' ? S.localModel : S.cloudModel; + return liveBackend() === 'local' ? S.localModel : S.cloudModel; } /* ============================================================ @@ -6320,29 +6410,9 @@ function obFooter() { } -/** The hint strip, split back into chords and sentences by OB_KEY_TOKEN. */ -function obHintsHTML() { - const footer = obFooter(); - if (!footer) return ''; - const hints = footer.split(/\s{3,}/).filter(Boolean).map((chunk) => { - const words = chunk.split(' '); - let n = 0; - while (n < words.length && OB_KEY_TOKEN.test(words[n])) n += 1; - const caps = words.slice(0, n).join(' '); - const rest = words.slice(n).join(' '); - const body = keycaps(caps) + (rest ? '' + esc(rest) + '' : ''); - /* r6 cloud item 1 — `esc back` is the ONLY way off the local-model - list, and the strip drew it as dead text. A mouse-only operator who - opened `Local models` to look at the picks could not get back to - `Cloud models` at all: no Back control on that screen, no clickable - hint, and the choose screen unreachable. The chord is unambiguous - on every step that advertises it, so the hint becomes a real - button routed through the same key router the keyboard uses. */ - if (caps === 'esc') return ''; - return '' + body + ''; - }).join(''); - return '
' + hints + '
'; -} +/* r2 (DMG feedback): the hint strip is no longer drawn. obFooter stays as the + step → chord table (the smoke reads it through __obFooterFor); every chord + it names is also a button on the action bar or a card in the body. */ /** The header lockup (onboarding-header.tsx:42-72), rebuilt as the top of a * checklist card: the product's own name, the two phases with the current one @@ -6381,14 +6451,12 @@ function obRailHTML() { return '' + '' + (state === 'done' ? ic('check') : p.n) + ' ' + esc(p.label) + ''; }).join('') + '
'; - const build = obBuildLine(); return ''; } @@ -6803,25 +6871,19 @@ function obIntroHTML() { `.ob-introc` keeps exactly its five children — the glow belongs to the card, not the column — and the rule stays as a spacer: the smoke reads both, and Soft Tactile draws no 3px rules (see onboarding.css). */ - const build = obBuildLine(); + /* r2 (DMG feedback): the build line is gone from the card and the rail — + `.ob-introc` now holds four children. The build is still in Settings + and on the empty chat's card. */ return '
' + '' + '
' + '' + MARK_COLOR.replace('width="16" height="16"', 'width="96" height="96"') + '' + '

' + esc(OB_COPY.wordmark) + '

' + '
' - + (build ? '' + esc(build) + '' : '') + '' + esc(OB_COPY.pressAnyKey) + '' + '
'; } -/** Which build this is — `0.5.5 · macOS arm64` — for the title card and the rail. */ -function obBuildLine() { - const b = BUILD || {}; - return b.version - ? b.version + ' · ' + (b.platform === 'darwin' ? 'macOS' : b.platform) + ' ' + b.arch - : ''; -} /* ============================================================ @@ -7234,7 +7296,11 @@ function obLocalPickHTML() { obModelRowLabel(model, best && model.id === best.id), obModelRowDetail(model), '', '', modelMark(model.id)); }).join('') - : '
' + (OB.busy ? 'reading the catalogue…' : obNothingFitsLine()) + '
'; + /* r2: while `atag models list` is out, a spinner where the list will be + rather than a sentence about reading a catalogue. */ + : OB.busy + ? '
' + : '
' + obNothingFitsLine() + '
'; const hf = obRow(models.length, onHf, esc(HF_ROW_LABEL), esc('paste an owner/repo id or a huggingface.co URL'), 'ob-hfrow', '', logoHTML('huggingface', 'sm')); return '
' @@ -7387,17 +7453,18 @@ function obUrlHTML(kind) { function obDownloadHTML() { const failed = dlStatus() === 'failed'; const label = obModelLabel(); - // offerCloudMeanwhile: "hidden once a cloud provider is configured — - // nothing left to offer" (:110-111). - const offerCloud = !OB.cloudReady; /* r6 UX: these two ARE this screen's buttons — the only way off it short of waiting — so they are cards a mouse can see, not the terminal's `┃` rule around a paragraph. The copy and the `c` / `s` chords are the - TUI's, unchanged. */ - const cloud = offerCloud - ? obOfferHTML(' cloud', 'key:c', '' + ic('cloud') + '', - failed ? [OB_COPY.cloudOfferFailed] : OB_COPY.cloudOffer, OB_COPY.cloudOfferKey) - : ''; + TUI's, unchanged. + + r2 (DMG feedback): the cloud card is ALWAYS offered, like the skip card + beside it. The TUI hides the block once a cloud provider exists + (offerCloudMeanwhile), but its `c` chord stays live — and on a machine + that already had one the screen showed a single way off it. Adding a + cloud model from here is a real choice either way. */ + const cloud = obOfferHTML(' cloud', 'key:c', '' + ic('cloud') + '', + failed ? [OB_COPY.cloudOfferFailed] : OB_COPY.cloudOffer, OB_COPY.cloudOfferKey); const skip = obOfferHTML('', 'key:s', '' + ic('arrowR') + '', failed ? [OB_COPY.skipOfferFailed] : OB_COPY.skipOffer, OB_COPY.skipOfferKey); return '
' @@ -7674,6 +7741,8 @@ function wizModelStepHTML(withFoot) { + '' + esc(m.name || m.id) + '' + '' + esc(m.id) + '' + (m.id === WIZ.defaultModel ? 'Default' : '') + // r2: the picked row carries a tick on the right; a double click uses it. + + (m.id === WIZ.modelPick ? '' : '') + '').join('') + '
' + (rows.length > shown.length @@ -7878,7 +7947,7 @@ function obFootHTML() { function obHTML() { if (OB.step === 'intro') { return ''; + + obIntroHTML() + '
'; } let body = ''; if (OB.step === 'choose') body = obChooseHTML(); @@ -7894,6 +7963,10 @@ function obHTML() { else if (OB.step === 'import_pick') body = obImportPickHTML(); else if (OB.step === 'import_preview') body = obImportReportHTML(false); else if (OB.step === 'import_done') body = obImportReportHTML(true); + /* r2 (DMG feedback): the closing screen keeps its title and draws a small + comet crossing the middle of the column instead of a second, smaller + "setting up…" line. */ + else if (OB.step === 'finished') body = '
'; else body = '
' + esc(OB_SUBTITLES[OB.step] || '') + '
'; /* r6 UX: an error belongs beside the control that produced it. The two URL steps and the Hugging Face reference draw their own, directly @@ -7904,11 +7977,14 @@ function obHTML() { /* r6 UX: it IS a modal — the app's chrome is behind it and cannot be operated — so it says so, and Tab is trapped inside it to match. Soft Tactile: the indigo rail on the left; on the right the title, a - body that owns the flexible height, the action bar and the hint strip. */ + body that owns the flexible height and the action bar. + r2 (DMG feedback): no keycap hint strip under the action bar. Every verb + it named is a button on the bar or a card in the body (r6), and the + chords still work. */ return ''; + + obFootHTML() + '
'; } /* ============================================================ @@ -8698,8 +8774,16 @@ async function obReadiness() { async function obSettle() { if (OB.settling || !BR) return; OB.settling = true; + /* The flow this settle belongs to. A settle outlives its flow when the + flow is closed and opened again while one of the awaits below is out + (the menu's `onboarding`, or a driven re-stage), and it then closed the + NEW flow or raised a step on it. Found by onboarding-mouse.mjs after the + round-2 merges made the readiness reads slower. */ + const gen = OB.openGen || 0; + const stale = () => (OB.openGen || 0) !== gen; const outcome = OB.outcome || 'skipped'; const state = await obReadiness(); + if (stale()) return; if (!OB.open) { OB.settling = false; return; } /* r8: `handOver` is the operator saying "put me in the agent now", from one of the two rows that promised exactly that. Neither remaining offer @@ -8733,6 +8817,7 @@ async function obSettle() { } if (!OB.handOver && !state.stamps.importOfferedAt && !OB_STAMPED.importOfferedAt) { const agents = await obDetectAgents(); + if (stale()) return; if (!OB.open) { OB.settling = false; return; } if (agents.length > 0) { OB.settling = false; @@ -8757,6 +8842,7 @@ async function obSettle() { OB_STAMP_LOG.push({leaf: closing, at: stamp, step: 'finished', written: !OB.testClose}); if (OB.testClose) { OB.open = false; OB.settling = false; obSkyStop(); render(); return; } const res = await BR.configSet('tui.onboarding.' + closing, stamp); + if (stale()) return; if (res && res.ok === false) { OB.settling = false; obDispatch({type:'onboarding_error_set', error: 'could not write the setup stamp: ' + (res.error || 'unknown error')}); @@ -8780,6 +8866,8 @@ async function openOnboarding() { hfReference: '', hfRepo: null, importAgents: [], importOptions: [], importReport: null, introTyped: false, settling: false, testClose: false, pendingMmproj: null, restarted: false, }); + // A new flow: any settle still out for the previous one must not touch it (obSettle). + OB.openGen = (OB.openGen || 0) + 1; // A re-run (the menu's `onboarding`, or --onboarding) stamps again. for (const leaf of Object.keys(OB_STAMPED)) delete OB_STAMPED[leaf]; // The one place a fresh intro starts from zero — obSkyStart itself @@ -9252,9 +9340,16 @@ document.addEventListener('click', (e) => { const ctl = e.target.closest && e.target.closest('[data-obact]'); if (ctl) { obControlClick(ctl.dataset.obact); return; } /* F6 — a click on a model row selects it; the verb is on the action bar, - the way every other step in this flow works. */ + the way every other step in this flow works. + r2 (DMG feedback): a double click is that verb — "Use this model" on the + row just picked. `detail` counts the clicks of one gesture, so the second + click still counts after the first one's repaint replaced the row. */ const wm = e.target.closest && e.target.closest('[data-wizmodel]'); - if (wm) { WIZ.modelPick = wm.dataset.wizmodel; render(); return; } + if (wm) { + WIZ.modelPick = wm.dataset.wizmodel; + if (e.detail >= 2) { act('wiz:model'); return; } + render(); return; + } const wr = e.target.closest && e.target.closest('[data-obwiz]'); if (wr) { obWizRowClick(+wr.dataset.obwiz); return; } }); @@ -9369,7 +9464,6 @@ async function refreshLiveConfig() { const provider = LIVE_CONFIG && LIVE_CONFIG.llm && (LIVE_CONFIG.llm.providers || []).find((p) => p.id === LIVE_CONFIG.llm.activeTextProvider); if (provider) { - S.mode = provider.kind === 'llama-server' ? 'local' : 'cloud'; if (provider.defaultChatModel) S.cloudModel = provider.defaultChatModel; } const managed = LIVE_CONFIG && LIVE_CONFIG.localModels && LIVE_CONFIG.localModels.managed; @@ -9658,6 +9752,9 @@ async function swxRun(label, want, run, refuse) { /** The backend the live config describes, with no optimistic override — selBackend() answers what the operator CHOSE, this answers what is. */ function liveBackend() { + // Fusion first, from the resolver (selectComposerBackend): under Fusion the + // active provider IS a cloud one, so the rows alone would call it `cloud`. + if (rmNow().effective === 'fusion') return 'fusion'; const p = activeProvider(); if (!(p && p.kind === 'llama-server')) return 'cloud'; return ((LIVE_CONFIG && LIVE_CONFIG.localModels && LIVE_CONFIG.localModels.mode) === 'external') ? 'custom' : 'local'; @@ -9666,6 +9763,7 @@ function liveBackend() { function selBackend() { // r5 item 10: the operator's choice paints first. See swxRun. if (SWX.want && SWX.want.backend) return SWX.want.backend; + if (rmNow().effective === 'fusion') return 'fusion'; const p = activeProvider(); if (!(p && p.kind === 'llama-server')) return 'cloud'; // Review fix: composer-switch-rows.ts selectComposerBackend — `local` and @@ -9686,7 +9784,11 @@ function selLocalRoute() { return selBackend() === 'local'; } control on the custom route — and, because the composer's own chip row was gated the same way, an operator pointed at their own llama-server saw ONE control where the TUI draws three. */ -function selKinds() { return selBackend() === 'local' ? ['backend','model'] : ['backend','provider','model']; } +function selKinds() { + const b = selBackend(); + // composerSwitchKindsFor: `if (backend === "fusion") return [...COMPOSER_SWITCH_KINDS, "workers"]`. + return b === 'local' ? ['backend','model'] : b === 'fusion' ? ['backend','provider','model','workers'] : ['backend','provider','model']; +} /** True when the route offers `kind` — the chips and the switch read the same rule. */ function selHasKind(kind) { return selKinds().indexOf(kind) >= 0; } /** @@ -9700,6 +9802,8 @@ function selProviderLabel() { const backend = selBackend(); if (backend === 'local') return null; if (backend === 'custom') return 'llama.cpp'; + // Under Fusion this control is the ORCHESTRATOR seat. + if (backend === 'fusion') return (SWX.want && SWX.want.providerId) || rmNow().orchestratorProviderId || 'no provider'; return selActiveProviderId() || 'no provider'; } function selProviders() { @@ -9724,6 +9828,12 @@ function openSelector(kind) { render(); if (SEL.kind === 'model') selEnterModelPane(); if (SEL.kind === 'backend' && selBackend() !== 'cloud' && !SEL.local.length) selLoadLocal(); + /* Run mode — Fusion: the fusion row's pre-flight, the local orchestrator row + and the workers rows read the key list and the on-disk snapshot. */ + if (!BSW.localLoaded && (SEL.kind === 'backend' || SEL.kind === 'workers' || (SEL.kind === 'provider' && selBackend() === 'fusion'))) bswSnapshot(); + // Re-read which providers have a key on every open: a key added since the + // last read (a terminal export, the .env) is what unblocks Fusion's row. + bswRefreshFacts(); } function closeSelector() { SEL.open = false; SEL.addOpen = false; render(); } @@ -9748,9 +9858,10 @@ async function selLoadModels(providerId) { function selEnterModelPane() { // SELECTOR LANE: custom reads the same list as local (see selRows). - if (selBackend() !== 'cloud') { if (!SEL.local.length) selLoadLocal(); return; } + // Run mode — Fusion: the model pane is the orchestrator's catalogue (a cloud one's). + if (selBackend() !== 'cloud' && selBackend() !== 'fusion') { if (!SEL.local.length) selLoadLocal(); return; } const id = selActiveProviderId(); - if (id && SEL.modelsFor !== id) selLoadModels(id); + if (id && SEL.modelsFor !== id && selProviders().some((p) => p.id === id)) selLoadModels(id); } /** Rows for the current pane, as objects the delegate can act on by index. */ @@ -9775,8 +9886,16 @@ function selRows() { {type:'backend', id:'custom', label:'custom', detail: 'llama.cpp you run' + (customUrl ? ' · ' + customUrl : '') + ' · Settings › LLM › External', active: here === 'custom'}, + // Last on purpose (backendRows): the three above are routes, this one is a + // mode built on two of them. The detail is the pre-flight's one line, or + // what it would run. + {type:'backend', id:'fusion', label:'fusion', + detail: !BSW.readyLoaded ? 'checking keys…' : (fzBlocker() || fzDetail(rmNow())), + active: here === 'fusion'}, ]; } + if (SEL.kind === 'provider' && selBackend() === 'fusion') return fzProviderRows(); + if (SEL.kind === 'workers') return fzWorkerRows(); if (SEL.kind === 'provider') { const activeId = selActiveProviderId(); // providerRows: hasApiKey ? (chatModel ?? 'default model') : 'no API key'. @@ -9802,7 +9921,7 @@ function selRows() { `not downloaded` spelled out on the ones that are not, and NO deep link. Sending the custom route down the cloud branch, as this did, offered an operator running their own llama-server the cloud provider's catalogue. */ - if (selBackend() !== 'cloud') { + if (selBackend() !== 'cloud' && selBackend() !== 'fusion') { const custom = selBackend() === 'custom'; const rows = SEL.local .filter((m) => !SEL.filter || modelMatches(m.id, m.family, SEL.filter)) @@ -9822,6 +9941,8 @@ function selRows() { if (!custom) rows.push({type:'action', id:'downloadMore', label:'Download more models…', detail:'opens the local models pane', active:false}); return rows; } + // A local orchestrator has no cloud catalogue: the TUI's modelRows lists nothing there. + if (selBackend() === 'fusion' && !selProviders().some((p) => p.id === selActiveProviderId())) return []; const entry = selProviders().find((p) => p.id === selActiveProviderId()); const chosen = entry && entry.defaultChatModel; const f = SEL.filter.toLowerCase(); @@ -9844,6 +9965,7 @@ async function selActivate(row) { // the operator runs needs the URL probed first, which is the External // pane's job. Open it instead of writing anything here. if (row.id === 'custom') { closeSelector(); act('settings:llm'); llmSetMode('external'); return; } + if (row.id === 'fusion') { selChooseFusion(); return; } selChooseBackend(row.id); return; } // The TUI's trailing rows: "Add a new provider" opens the wizard, @@ -9860,6 +9982,31 @@ async function selActivate(row) { // port of the TUI's persist helpers and ends in an agent restart, so // none of them may run while a turn is in flight. if (S.busy) { toast('Not while a turn is running'); return; } + /* Run mode — Fusion (activateComposerSwitchRow). Under Fusion the provider + control re-pins the ORCHESTRATOR: the plain activation would move + activeTextProvider away from the pin and drop the mode. A provider with + no key opens its configure step, as the TUI's triggerLlmPrimary does. */ + if (row.type === 'provider' && row.fusion) { + if (!BSW.readyIds.includes(row.id)) { bswOpenKey(row.id); return; } + const before = fzBefore('switching…'); + fzAfter(await swxRun(BSW.line, {backend:'fusion', providerId: row.id, model: fzProviderModel(row.id)}, + () => SWXBR.enterFusion({orchestratorProvider: row.id})), before); + return; + } + // Either seat, either kind: a local orchestrator, or a cloud provider for the workers. + if (row.type === 'fusionLeg') { + const pins = row.leg === 'orchestrator' ? {orchestratorProvider: row.id} : {workerProvider: row.id}; + const before = fzBefore('switching…'); + fzAfter(await swxRun(BSW.line, row.leg === 'orchestrator' ? {backend:'fusion', providerId: row.id} : {backend:'fusion'}, + () => SWXBR.enterFusion(pins)), before); + return; + } + // The model the workers run on: claims the slot for local-llama and moves the managed daemon. + if (row.type === 'workerModel') { + const before = fzBefore('starting ' + row.id + '…'); + fzAfter(await swxRun(BSW.line, {backend:'fusion'}, () => SWXBR.fusionWorkerModel(row.id)), before); + return; + } if (row.type === 'provider') { SEL.busy = true; SEL.err = null; BSW.line = 'switching…'; render(); // r5 item 10: the provider chip names row.id from this frame on; the @@ -9935,11 +10082,12 @@ function selPull(id) { function selRowLead(r) { if (r.type === 'backend') { return '' - + ic(r.id === 'cloud' ? 'cloud' : r.id === 'local' ? 'laptop' : 'server') + ''; + + ic(r.id === 'cloud' ? 'cloud' : r.id === 'local' ? 'laptop' : r.id === 'fusion' ? 'fusion' : 'server') + ''; } - if (r.type === 'provider') return providerMark(r.id, 'sm'); + if (r.type === 'provider' || r.type === 'fusionLeg') return providerMark(r.id, 'sm'); + if (r.type === 'action' && r.id === 'loading') return ''; if (r.type === 'action') return '' + ic(r.id === 'add' ? 'plus' : 'download') + ''; - if (r.type === 'localModel' && SEL.busy && BSW.line === 'starting ' + r.id + '…') { + if ((r.type === 'localModel' || r.type === 'workerModel') && SEL.busy && BSW.line === 'starting ' + r.id + '…') { return ''; } return modelMark(r.id, 'sm'); @@ -10008,7 +10156,7 @@ function selectorHTML() { } const title = SEL.kind === 'backend' ? 'Where it runs' - : SEL.kind === 'provider' ? 'Provider' : 'Model'; + : SEL.kind === 'provider' ? 'Provider' : SEL.kind === 'workers' ? 'Workers' : 'Model'; // An empty list is not a list — it is one action. The provider and // local model panes always end in the TUI's action row ("Add a new @@ -10031,7 +10179,7 @@ function selectorHTML() { + (SEL.modelsBusy || SEL.localBusy ? '
reading the catalogue…
' : '') + (SEL.modelsErr ? '
' + ic('alert') + '' + esc(SEL.modelsErr) + '
' : '') + rows.map((r, i) => { - const model = r.type === 'cloudModel' || r.type === 'localModel'; + const model = r.type === 'cloudModel' || r.type === 'localModel' || r.type === 'workerModel'; const right = (r.type === 'backend' ? '' : '') + (r.type === 'localModel' && !r.downloaded ? '' + ic('download') + 'download' : '') /* F1 — an unlit cell, not a lit one: this is a state we could not @@ -10040,7 +10188,7 @@ function selectorHTML() { return (r.type === 'action' && i > 0 ? '
' : '') + ''; @@ -10520,9 +10668,9 @@ function modesHTML() { of that is actionable by a person. Say which version is needed and offer the one thing that helps. */ ? '

' + esc(MODE_NEEDS_NEWER) + '

' - : '

' - + 'A stance for this session. It moves the live approval ladder and plan flag and writes nothing to config.' - + '

' + /* r2 (DMG feedback): the "a stance for this session …" paragraph is + gone — the four rows and their captions already say it. */ + : '' // The disclosure that stops a level-5 operator reading a working // chip as a broken one: three of the four choices genuinely do // not change what the agent does at that base. @@ -10958,10 +11106,492 @@ async function selChooseBackend(id) { return res; } +/* ============================================================ + Run mode — Local / Cloud / Fusion + + The TUI's composer switch (composer-switch-rows.ts, -worker-rows.ts, + -activate.ts), its `/runmode` command (dispatch-run-mode.ts, + run-mode-verb.ts) and RunModeOrchestrator's refusals and lines. Writes + are main's (backend-switch.ts enterFusion / swapFusionLegs / + setFusionWorkers / selectFusionWorkerModel over main/run-mode.ts); this + side resolves the config the way the agent does and paints. Local and + Cloud stay selChooseBackend, whose write leaves Fusion. + main/run-mode.ts carries the same read-side ports; the smoke asserts the + two answer the same on seeded configs. + ============================================================ */ + +/** resolveRunMode (src/llm/run-mode/resolve-run-mode.ts). */ +function rmResolve(cfg) { + const llm = (cfg && cfg.llm) || null; + const runMode = llm && llm.runMode; + const fusion = (runMode && runMode.fusion) || {}; + const stored = (runMode && runMode.mode) ?? null; + const providers = llm && Array.isArray(llm.providers) ? llm.providers : [{id:'local-llama', kind:'llama-server'}]; + const activeId = (llm && llm.activeTextProvider) ?? 'local-llama'; + const isLocal = (p) => !!p && p.kind === 'llama-server'; + const byId = (id) => (id === undefined || id === null ? undefined : providers.find((p) => p.id === id)); + const managedModelId = (cfg && cfg.localModels && cfg.localModels.managed && cfg.localModels.managed.modelId) ?? null; + const active = byId(activeId); + const derived = active === undefined || isLocal(active) ? 'local' : 'cloud'; + const orch = byId(fusion.orchestratorProvider) + ?? (active !== undefined && !isLocal(active) ? active : undefined) + ?? providers.find((p) => !isLocal(p)); + const worker = byId(fusion.workerProvider) + ?? providers.find((p) => isLocal(p) && p.id !== (orch && orch.id)) + ?? providers.find((p) => p.id !== (orch && orch.id)); + const orchestratorProviderId = (orch && orch.id) ?? null; + const workerProviderId = (worker && worker.id) ?? null; + let effective = derived; + let degraded = null; + if (stored === 'fusion') { + if (orchestratorProviderId === null) degraded = {reason:'no-cloud-provider', requested:stored}; + else if (workerProviderId === null) degraded = {reason:'no-second-provider', requested:stored}; + else if (activeId === orchestratorProviderId) effective = 'fusion'; + } else if (stored === 'cloud' && orchestratorProviderId === null) { + degraded = {reason:'no-cloud-provider', requested:stored}; + } + const primaryProviderId = (effective === 'local' ? workerProviderId : orchestratorProviderId) ?? activeId; + return { + stored, effective, orchestratorProviderId, + orchestratorModel: fusion.orchestratorModel ?? (orch && orch.defaultChatModel) ?? (orch && orch.model) ?? null, + workerProviderId, + workerModel: fusion.workerModel + ?? (worker !== undefined && isLocal(worker) + ? (managedModelId ?? worker.model ?? null) + : ((worker && worker.defaultChatModel) ?? (worker && worker.model) ?? null)), + workers: fusion.workers ?? 2, + workerMaxSteps: fusion.workerMaxSteps ?? 40, + workerTimeoutMs: fusion.workerTimeoutMs ?? 2700000, + primaryProviderId, degraded, + }; +} +function rmNow() { return rmResolve(LIVE_CONFIG); } + +/** run-mode-degradation.ts */ +function rmDegradationLine(d) { + if (d.reason === 'no-cloud-provider') { + return d.requested === 'fusion' + ? 'Fusion needs a cloud orchestrator — no cloud provider is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm).' + : 'Cloud mode needs a cloud provider — none is configured. Staying on local. Add one in Manage → LLM → Cloud (or /llm).'; + } + return 'Fusion needs two providers — one to orchestrate and one to run the workers. Only one is configured. Add another in Manage → LLM (or /llm).'; +} +/** run-mode-summary.ts describeRunMode — the body of `/runmode status`. */ +function rmDescribe(rm) { + const label = rm.effective === 'fusion' ? 'Fusion' : rm.effective === 'cloud' ? 'Cloud' : 'Local'; + const parts = []; + if (rm.effective === 'fusion') { + parts.push('Fusion — orchestrator ' + rm.orchestratorProviderId + (rm.orchestratorModel ? ' (' + rm.orchestratorModel + ')' : '') + ', ' + + rm.workers + ' worker' + (rm.workers === 1 ? '' : 's') + ' on ' + rm.workerProviderId + (rm.workerModel ? ' (' + rm.workerModel + ')' : '')); + } else { + parts.push(label + ' — active provider ' + rm.primaryProviderId); + } + if (rm.degraded) parts.push(rmDegradationLine(rm.degraded)); + else if (rm.stored !== null && rm.stored !== rm.effective) { + parts.push('stored ' + rm.stored + ', effective ' + rm.effective + ' — the ' + (rm.stored === 'fusion' ? 'orchestrator' : rm.stored) + + ' provider is not the active one; pick the mode again to re-apply'); + } + return parts.join('. '); +} + +/** fusion-preflight.ts describeFusionBlocker — facts: {readyIds, localLoaded, localDownloaded}. */ +function fzBlockerFor(cfg, facts) { + const llm = (cfg && cfg.llm) || null; + const providers = llm && Array.isArray(llm.providers) ? llm.providers : [{id:'local-llama', kind:'llama-server'}]; + const cloudReady = providers.filter((p) => p.kind !== 'llama-server' && facts.readyIds.includes(p.id)).length; + const localReady = !facts.localLoaded || facts.localDownloaded ? providers.filter((p) => p.kind === 'llama-server').length : 0; + if (cloudReady + localReady >= 2) return null; + if (cloudReady + localReady === 1 && localReady === 1) return 'needs a second provider to orchestrate — Manage › LLM › Cloud'; + if (cloudReady + localReady === 1) return 'needs a second provider for the workers — Manage › LLM'; + return 'needs two providers, one per leg — Manage › LLM'; +} +function fzBlocker() { + return fzBlockerFor(LIVE_CONFIG, {readyIds: BSW.readyIds, localLoaded: BSW.localLoaded, localDownloaded: SEL.local.some((m) => m.downloaded)}); +} +/** composer-switch-rows.ts fusionDetail. */ +function fzDetail(rm) { return 'cloud plans · ' + rm.workers + ' local worker' + (rm.workers === 1 ? '' : 's'); } + +function fzProviderModel(id) { + const p = ((LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.providers) || []).find((x) => x.id === id); + return (p && (p.defaultChatModel || p.model)) || ''; +} +function fzLegIsLocal(rm, leg) { + const id = leg === 'orchestrator' ? rm.orchestratorProviderId : rm.workerProviderId; + const row = ((LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.providers) || []).find((p) => p.id === id) || null; + return leg === 'worker' ? (row === null || row.kind === 'llama-server') : (!!row && row.kind === 'llama-server'); +} +/** selectPromptLlmMeta's two halves: each leg labelled from ITS OWN provider row. */ +function fzLegLabel(rm, leg) { + const fz = (LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.runMode && LIVE_CONFIG.llm.runMode.fusion) || {}; + const pinned = leg === 'orchestrator' ? fz.orchestratorModel : fz.workerModel; + const id = leg === 'orchestrator' ? rm.orchestratorProviderId : rm.workerProviderId; + if (fzLegIsLocal(rm, leg)) { + return pinned || (LIVE_CONFIG && LIVE_CONFIG.localModels && LIVE_CONFIG.localModels.managed && LIVE_CONFIG.localModels.managed.modelId) || 'local'; + } + return pinned || fzProviderModel(id) || id || 'cloud'; +} +/** The orchestrator RunModeOrchestrator.setMode will pick with no pin — painted while the switch lands. */ +function fzPredictLeg() { + const providers = (LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.providers) || []; + const active = providers.find((p) => p.id === (LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.activeTextProvider)); + if (active && active.kind !== 'llama-server') return active.id; + const cloud = providers.filter((p) => p.kind !== 'llama-server'); + const keyed = cloud.find((p) => BSW.readyIds.includes(p.id)) || cloud[0]; + return (keyed && keyed.id) || rmNow().orchestratorProviderId; +} + +/** providerRows under Fusion: this control is the orchestrator seat. */ +function fzProviderRows() { + const rm = rmNow(); + const rows = selProviders().map((p) => ({type:'provider', fusion:true, id:p.id, label:p.id, + detail: !BSW.readyLoaded ? 'checking keys…' : BSW.readyIds.includes(p.id) ? 'orchestrator' : 'no API key', + unverified: UNVERIFIED.indexOf(p.id) >= 0, + active: p.id === rm.orchestratorProviderId})); + if (SEL.local.some((m) => m.downloaded)) { + rows.push({type:'fusionLeg', leg:'orchestrator', id:'local-llama', label:'local-llama', + detail:'orchestrator · runs on this machine', active: rm.orchestratorProviderId === 'local-llama'}); + } + rows.push({type:'action', id:'add', label:'Add a new provider', detail:'opens the wizard', active:false}); + return rows; +} +/** composer-switch-worker-rows.ts selectWorkerRows: fusion's second seat. No count rows. */ +function fzWorkerRows() { + const rm = rmNow(); + const localHolds = rm.workerProviderId === 'local-llama'; + const rows = []; + if (!BSW.localLoaded && !SEL.local.length) rows.push({type:'action', id:'loading', label:'loading…', detail:'reading what is on disk', active:false}); + SEL.local.filter((m) => m.downloaded).forEach((m) => rows.push({type:'workerModel', id:m.id, label:m.id, + detail:'workers · on this machine', active: localHolds && !!m.active})); + selProviders().filter((p) => p.id !== rm.orchestratorProviderId).forEach((p) => rows.push({type:'fusionLeg', leg:'worker', id:p.id, label:p.id, + detail: !BSW.readyLoaded ? 'checking keys…' : BSW.readyIds.includes(p.id) ? 'workers · in the cloud' : 'no API key', + active: rm.workerProviderId === p.id})); + rows.push({type:'action', id:'downloadMore', label:'Download more models…', detail:'opens the local models pane', active:false}); + return rows; +} + +/** The ⇄ between the seats and the `workers` control, drawn only on the Fusion route. */ +function fzChipsHtml() { + if (!selHasKind('workers')) return ''; + const rm = rmNow(); + const label = fzLegLabel(rm, 'worker'); + return '' + + ''; +} +/* Fusion puts five controls where cloud has three, in the same 700px: a seat + names its model without the vendor prefix (`grok-4-6`, not `x-ai/grok-4-6`), + and the chip's tooltip carries the full id. */ +function fzSeatId(label) { + const s = String(label || ''); + const at = s.lastIndexOf('/'); + return at >= 0 && at < s.length - 1 ? s.slice(at + 1) : s; +} + +/** fusion-intro.ts describeFusionIntro — one paragraph is the desktop's (see main/run-mode.ts). */ +function fzIntroParagraphs(rm) { + const orchestrator = rm.orchestratorModel ?? rm.orchestratorProviderId ?? 'your cloud provider'; + const worker = rm.workerModel ?? rm.workerProviderId ?? 'the local model'; + return [ + 'Fusion splits the work between two models: one decides, the other does.', + 'Right now — ' + orchestrator + ' plans. It reads enough to choose an approach, breaks the job into self-contained parts, writes the brief for each, then reads what comes back, judges it, and sends anything weak out again.' + + ' ' + worker + ' executes: each worker takes one part and reports. They cannot reach you or ask for approval, so anything needing a person comes back up.', + 'How many run at once is not a setting. The orchestrator sizes each fan-out to the job at hand, up to what this machine can serve.', + 'Either seat takes either kind, and the pairing is the interesting part. Cloud planning with local workers is the usual one: sharp judgement, cheap bulk. Invert it and a local model plans while cloud workers execute — your reasoning never leaves the machine and you rent only the lifting. Two cloud models work as well, a careful one directing a fast one; so does a big local model directing a small one.', + 'Worth playing with: a result is only as good as the model that did the work, and only as sensible as the model that planned it. Move that line and the output changes character.', + 'The Provider and Workers controls pick both seats — each row says whether it runs local or in the cloud. /runmode status says what is resolved right now; /runmode cloud or /runmode local leaves fusion.', + ]; +} +/** Only on the way IN — re-applying Fusion (another orchestrator) is not a moment to explain it again. */ +function fzIntro(rm) { + S.log.push({id:nid(), k:'system', note:true, fusionIntro:true, + text: '' + esc(FUSION_MARK) + '' + + fzIntroParagraphs(rm).map((p) => '' + esc(p) + '').join('') + ''}); +} + +/** dispatch-run-mode.ts parseRunModeCommand. */ +function fzParse(rawArgs) { + const args = String(rawArgs || '').trim().toLowerCase(); + if (args.length === 0) return {openSwitch:true}; + if (args === 'status') return {openSwitch:false, status:true}; + if (args === 'swap') return {openSwitch:false, swap:true}; + const w = /^workers\s+(\d+)$/.exec(args); + if (w) { + const n = Number(w[1]); + if (n < 1 || n > 8) return {openSwitch:false, error:'workers must be 1-8 — ' + RUN_MODE_USAGE}; + return {openSwitch:false, workers:n}; + } + if (['local', 'cloud', 'fusion'].includes(args)) return {openSwitch:false, mode:args}; + return {openSwitch:false, error:'unknown run mode "' + String(rawArgs || '').trim() + '" — ' + RUN_MODE_USAGE}; +} + +/** The key list and the on-disk snapshot, when the pre-flight has to answer before they landed. */ +async function fzLoadFacts() { + if (!BR) return; + const jobs = []; + if (!BSW.readyLoaded) { + jobs.push(BR.providersReady().then((r) => { + if (r && r.ok && Array.isArray(r.ids)) { BSW.readyIds = r.ids; BSW.readyLoaded = true; } + }).catch(() => {})); + } + if (!BSW.localLoaded) jobs.push(bswSnapshot()); + await Promise.all(jobs); +} +/** RunModeOrchestrator.refuse / the pre-flight: composer_notice + runtime_info. */ +function fzNotice(text, runtimeLine) { + if (SEL.open && !OB.open) SEL.err = text; else toast(text, '', 'bad'); + appSay(runtimeLine || text, 'caution'); + render(); +} +function fzBefore(label) { + SEL.err = null; SEL.busy = SEL.open; BSW.line = label; render(); + return rmNow().effective; +} +function fzAfter(res, before) { + SEL.busy = false; BSW.line = ''; + if (!res || !res.ok) { + if (res && res.needsKey) { bswOpenKey(res.providerId); return res; } + if (res && res.needsDownload && res.modelId) { SWX.err = null; selPull(res.modelId); return res; } + // A refusal wrote nothing and failed nothing: a notice, not the composer's "Switch failed". + if (res && res.refusal) { SWX.err = null; fzNotice(res.refusal, 'run mode: ' + res.refusal); return res; } + if (res && (res.error === 'a turn is running' || res.error === 'a switch is already running')) { render(); return res; } + fzNotice('run mode: ' + ((res && res.error) || 'the change did not complete')); + return res; + } + bswReport(res); + if (res.runMode && res.runMode.line) appSay(res.runMode.line); + const now = rmNow(); + const entered = res.runMode ? !!res.runMode.enteredFusion : (now.effective === 'fusion' && before !== 'fusion'); + if (entered) fzIntro(now); + closeSelector(); + return res; +} + +/** activateFusion: the pre-flight's one line, or RunModeOrchestrator.setMode("fusion"). */ +async function selChooseFusion() { + if (S.busy) { toast('Not while a turn is running'); return {ok:false, error:'a turn is running'}; } + if (!BSW.readyLoaded || !BSW.localLoaded) await fzLoadFacts(); + const blocker = fzBlocker(); + if (blocker) { fzNotice('fusion: ' + blocker); return {ok:false, error:blocker, blocker:true}; } + const leg = fzPredictLeg(); + const before = fzBefore('switching to fusion…'); + return fzAfter(await swxRun(BSW.line, {backend:'fusion', providerId: leg || undefined, model: fzProviderModel(leg)}, + () => SWXBR.enterFusion({})), before); +} +/** swapLegs — the composer's ⇄ and `/runmode swap`. */ +async function fzSwap() { + const rm = rmNow(); + if (rm.stored !== 'fusion') { fzNotice(SWAP_NEEDS_FUSION, 'run mode: ' + SWAP_NEEDS_FUSION); return {ok:false, refusal:SWAP_NEEDS_FUSION}; } + const before = fzBefore('swapping…'); + return fzAfter(await swxRun(BSW.line, {backend:'fusion', providerId: rm.workerProviderId || undefined, model: rm.workerModel || ''}, + () => SWXBR.swapFusionLegs()), before); +} +/** setWorkers — Settings › LLM's count and `/runmode workers N`. */ +async function fzSetWorkers(n) { + const res = await swxRun('fusion: ' + n + ' worker' + (n === 1 ? '' : 's') + '…', {}, () => SWXBR.fusionWorkers(n)); + if (!res || !res.ok) { + if (res && res.refusal) { SWX.err = null; fzNotice(res.refusal, 'run mode: ' + res.refusal); } + return res; + } + if (res.notice) { + appSay(res.notice); + if (S.settings && settingsPaneId(S.settingsPane) === 'llm') LLMP.msg = {text: res.notice}; + else toast(res.notice); + } + render(); + return res; +} +/** `/runmode status` — a system message, from the resolver. */ +function fzStatus() { + S.log.push({id:nid(), k:'system', note:true, + text: esc(LIVE_CONFIG ? rmDescribe(rmNow()) : 'run mode: not resolved yet — open Manage › LLM once')}); + render(); +} +/** `/runmode ` and the menu's Local / Cloud / Fusion: the backend row's own activation. */ +function fzActivateBackend(id) { + if (id === 'fusion') return selChooseFusion(); + return selChooseBackend(id); +} +function fzSlash(args) { + const cmd = fzParse(args); + if (cmd.error) { S.log.push({id:nid(), k:'system', text: esc(cmd.error)}); render(); return cmd; } + if (cmd.openSwitch) { S.settings = null; openSelector('backend'); return cmd; } + if (cmd.workers !== undefined) { fzSetWorkers(cmd.workers); return cmd; } + if (cmd.swap) { fzSwap(); return cmd; } + if (cmd.status) { fzStatus(); return cmd; } + fzActivateBackend(cmd.mode); + return cmd; +} + +/** fusion-live-workers.ts reduceFusionLiveWorkers. */ +function fzReduceLive(current, e) { + if (e.role === 'orchestrator') return current; + const at = current.findIndex((w) => w.taskId === e.taskId); + const done = e.phase === 'finished' || e.phase === 'failed' || e.phase === 'cancelled'; + const prev = at >= 0 ? current[at] : null; + const next = {taskId: e.taskId, title: e.title, phase: e.phase, + model: e.model ?? (prev && prev.model) ?? null, + tool: done ? null : (e.tool ?? (prev && prev.tool) ?? null), done}; + if (at < 0) return current.concat([next]); + const copy = current.slice(); + copy[at] = next; + return copy; +} +/** formatFusionLiveWorker: ` · <model> — <tool|working|done>`. */ +function fzLiveLine(w) { + return w.title + ' · ' + (w.model ?? 'local') + ' — ' + (w.done ? 'done' : (w.tool ?? 'working')); +} +/** format-fusion-worker-line.ts, without the feed's `» ` glyph. */ +function fzWorkerLine(e) { + const model = e.model ? ' · ' + e.model : ''; + const who = e.role === 'orchestrator' ? 'orchestrator' + model : 'worker ' + e.title + model; + if (e.phase === 'started') return who + ': started'; + if (e.phase === 'tool') { + return e.role === 'orchestrator' + ? who + ' — ' + (e.tool ?? 'working') + ' (' + e.title + ')' + : who + ' — ' + (e.tool ?? 'working'); + } + if (e.phase === 'cancelled') return who + ': cancelled'; + if (e.phase === 'failed') return who + ': failed — ' + (e.summary ?? 'no detail'); + if (e.phase === 'finished') { + const steps = e.stepCount === undefined ? '' : ' — ' + e.stepCount + ' steps'; + const detail = e.summary === undefined ? '' : (steps === '' ? ' — ' : ', ') + e.summary; + return who + ': done' + steps + detail; + } + return who; +} +/** The fan-out under the busy strip while the turn runs. Read-only: the one Stop is the send button. */ +function fzLiveHTML() { + if (!FZ.live.length || !(S.busy || S.pending)) return ''; + return '<div class="fzlive" aria-live="polite">' + FZ.live.map((w) => + '<div class="fzw' + (w.done ? ' done' : '') + (w.phase === 'failed' ? ' failed' : w.phase === 'cancelled' ? ' cancelled' : '') + '">' + + '<span class="fzdot"></span><span class="fzt">' + esc(fzLiveLine(w)) + '</span></div>').join('') + '</div>'; +} + +/* Smoke hooks — read-side ports and a probe that draws the switch for a + seeded config without writing anything. */ +if (typeof window !== 'undefined') { + window.__rmResolve = (cfg) => rmResolve(cfg); + window.__rmDescribe = (cfg) => rmDescribe(rmResolve(cfg)); + window.__fzBlocker = (cfg, facts) => fzBlockerFor(cfg, facts); + window.__fzIntro = (cfg) => fzIntroParagraphs(rmResolve(cfg)); + window.__fzParse = (args) => fzParse(args); + window.__fzWorkerLine = (e) => fzWorkerLine(e); + window.__slashNames = () => SLASH.map((s) => s[0]); + window.__approvalCat = (cat) => ({label: CATEGORY_LABEL[cat] || null, level: (CATS.find((c) => c[0] === cat) || [null, null, null])[2]}); + window.__fzProbe = (cfg, facts) => { + const keep = {cfg: LIVE_CONFIG, ids: BSW.readyIds, rl: BSW.readyLoaded, ll: BSW.localLoaded, local: SEL.local, want: SWX.want, kind: SEL.kind}; + try { + LIVE_CONFIG = cfg; + BSW.readyIds = facts.readyIds || []; BSW.readyLoaded = true; BSW.localLoaded = facts.localLoaded !== false; + SEL.local = facts.local || []; SWX.want = null; + const rows = (kind) => { SEL.kind = kind; return selRows().map((r) => ({type:r.type, id:r.id, label:r.label, detail:r.detail || '', active:!!r.active})); }; + const tpl = document.createElement('template'); + tpl.innerHTML = composer(); + const chips = Array.from(tpl.content.querySelectorAll('.cfoot [data-sel-open]')).map((b) => [b.dataset.selOpen, b.textContent.trim()]); + const set = document.createElement('template'); + set.innerHTML = llmRunModeHTML(); + const on = set.content.querySelector('.llm-rm.on'); + return { + backend: selBackend(), kinds: selKinds(), chips, + swap: !!tpl.content.querySelector('.cfoot [data-act="runmode:swap"]'), + rows: {backend: rows('backend'), provider: rows('provider'), workers: selHasKind('workers') ? rows('workers') : null}, + settings: {active: on ? on.dataset.act : null, + status: (set.content.querySelector('.llm-rm-status') || {}).textContent || '', + workersOn: (set.content.querySelector('.llm-workerseg .on') || {}).textContent || '', + workerButtons: set.content.querySelectorAll('.llm-workerseg button').length}, + }; + } finally { + LIVE_CONFIG = keep.cfg; BSW.readyIds = keep.ids; BSW.readyLoaded = keep.rl; BSW.localLoaded = keep.ll; + SEL.local = keep.local; SWX.want = keep.want; SEL.kind = keep.kind; + render(); + } + }; + /* Frames through the real onChatEvent, on a stand-in turn that is removed + again: what the live list and the transcript make of them. */ + window.__fzEventProbe = (payloads) => { + const keep = {turnId: S.turnId, streamId: S.streamId, busy: S.busy, live: FZ.live}; + const item = {id: nid(), k:'assistant', text:''}; + S.turnId = 'fz-probe'; S.streamId = item.id; S.busy = true; S.log.push(item); FZ.live = []; + try { + payloads.forEach((payload) => onChatEvent({turnId:'fz-probe', kind:'fusion_worker', payload})); + const strip = document.querySelector('.composerwrap .fzlive'); + return { + live: FZ.live.map(fzLiveLine), + strip: strip ? Array.from(strip.querySelectorAll('.fzt')).map((n) => n.textContent) : [], + stripControls: strip ? strip.querySelectorAll('button, [data-act]').length : -1, + lines: S.log.filter((m) => m.fusion).map((m) => m.text), + beforeReply: S.log.indexOf(item) > 0 && !!S.log[S.log.indexOf(item) - 1].fusion, + }; + } finally { + S.log = S.log.filter((m) => m !== item && !m.fusion); + S.turnId = keep.turnId; S.streamId = keep.streamId; S.busy = keep.busy; FZ.live = keep.live; + render(); + } + }; +} + /* ============================================================ Opening a session — the transcript comes from the agent's store ============================================================ */ +/* GET /api/sessions/{id}.turns → the transcript rows, in stored order. + + Turn order: a `tool_result` row carries `approvals` (agent ≥ desktop/ + turn-fixes) — every approval the operator answered while that call ran. + Each becomes a finished approval receipt directly under the call's card, + which is where the live view puts the card (placeInLiveTurn), so a + reopened chat and the one watched live read the same: question, tool, + approval, …, reply. An older agent writes no `approvals`, and its reopened + chats simply show no receipt, as before. */ +function sessionTurnsToLog(turns) { + const log = []; + (Array.isArray(turns) ? turns : []).forEach((t) => { + if (!t || typeof t !== 'object') return; + if (t.kind === 'user') { log.push({id:nid(), k:'user', text:t.text || ''}); return; } + if (t.kind === 'assistant_reply') { log.push({id:nid(), k:'assistant', text:t.text || ''}); return; } + if (t.kind === 'assistant_tool_call') { + if (t.reasoning) log.push({id:nid(), k:'reason', steps:1, open:false, text:t.reasoning}); + log.push({id:nid(), k:'tool', name:t.tool || 'tool', + arg: summariseArgs(t.args), args: JSON.stringify(t.args ?? {}, null, 2), + argsKey: JSON.stringify(t.args ?? {}), at: t.at, // item 4: what the trace merge matches on + where:'local', ok:null, open:false}); + return; + } + if (t.kind === 'tool_result') { + // Pair it with the call that is still open, so a loaded session + // shows what the tool actually returned — which the live stream + // does not carry. + let card = null; + for (let i = log.length - 1; i >= 0; i--) { + if (log[i].k === 'tool' && log[i].ok === null) { + card = log[i]; + card.ok = t.status === 'ok'; + card.out = t.summary || ''; + card.truncated = !!t.truncated; + card.ms = undefined; card.msSource = null; // item 4: the store carries no duration; the trace does + break; + } + } + if (!card) { + card = {id:nid(), k:'tool', name:t.tool || 'tool', arg:'', ok:t.status === 'ok', out:t.summary || '', truncated:!!t.truncated, open:false, where:'local'}; + log.push(card); + } + const receipts = (Array.isArray(t.approvals) ? t.approvals : []) + .filter((a) => a && (a.verdict === 'approved' || a.verdict === 'denied')) + .map((a) => ({id:nid(), k:'approval', stored:true, tool:t.tool || 'tool', + cat:a.category || 'other', kind:CATEGORY_LABEL[a.category] || a.category || 'action', + state:a.verdict, at: Number.isFinite(a.at) ? new Date(a.at).toTimeString().slice(0, 8) : ''})); + if (receipts.length) { + let at = log.indexOf(card) + 1; + while (at < log.length && log[at].k === 'approval') at++; + log.splice(at, 0, ...receipts); + } + } + }); + return log; +} + async function openSession(id) { if (!BR || !id) return; // item 6: is a turn of this session streaming into this window right now? @@ -11004,33 +11634,7 @@ async function openSession(id) { } const data = res.data; const turns = Array.isArray(data.turns) ? data.turns : []; - const log = []; - turns.forEach((t) => { - if (t.kind === 'user') { log.push({id:nid(), k:'user', text:t.text || ''}); return; } - if (t.kind === 'assistant_reply') { log.push({id:nid(), k:'assistant', text:t.text || ''}); return; } - if (t.kind === 'assistant_tool_call') { - if (t.reasoning) log.push({id:nid(), k:'reason', steps:1, open:false, text:t.reasoning}); - log.push({id:nid(), k:'tool', name:t.tool || 'tool', - arg: summariseArgs(t.args), args: JSON.stringify(t.args ?? {}, null, 2), - argsKey: JSON.stringify(t.args ?? {}), at: t.at, // item 4: what the trace merge matches on - where:'local', ok:null, open:false}); - return; - } - if (t.kind === 'tool_result') { - // Pair it with the call that is still open, so a loaded session - // shows what the tool actually returned — which the live stream - // does not carry. - for (let i = log.length - 1; i >= 0; i--) { - if (log[i].k === 'tool' && log[i].ok === null) { - log[i].ok = t.status === 'ok'; - log[i].out = t.summary || ''; - log[i].ms = undefined; log[i].msSource = null; // item 4: the store carries no duration; the trace does - return; - } - } - log.push({id:nid(), k:'tool', name:t.tool || 'tool', arg:'', ok:t.status === 'ok', out:t.summary || '', open:false, where:'local'}); - } - }); + const log = sessionTurnsToLog(turns); S.log = log.length ? log : [{id:nid(), k:'system', text:'this session has no turns yet'}]; // Review fix: the streaming item of a turn that is still running elsewhere // did not survive this reload, so no frame may position a card against it. @@ -12204,8 +12808,9 @@ function modelChipHtml() { // Soft Tactile: the model family's real mark (CPU badge when there is none), // the download icon on the call to action; the id itself in DM Mono. return '<button class="cchip modelchip' + (cta ? ' dlchip' : '') + cchipOpen('model') + '" data-sel-open="model"' - + (cta ? ' data-sel-dl="1"' : '') + '>' + (cta ? ic('download') : modelMark(label, 'xs')) - + '<span class="cval">' + esc(shortModel(label)) + '</span>' + ic('chevD', 'chev') + '</button>'; + + (cta ? ' data-sel-dl="1"' : '') + + (selHasKind('workers') ? ' title="' + esc(label) + '"' : '') + '>' + (cta ? ic('download') : modelMark(label, 'xs')) + + '<span class="cval">' + esc(selHasKind('workers') ? fzSeatId(label) : shortModel(label)) + '</span>' + ic('chevD', 'chev') + '</button>'; } /** * What the two facts change on screen, repainted in place. These land @@ -14996,42 +15601,41 @@ function llmKeyBtn(key, rest, act) { (`llm.runMode`), additive to the active provider, so it sits above the route card rather than replacing it — the route still says who answers. */ function llmRunModeHTML() { - const cfg = (LIVE_CONFIG && LIVE_CONFIG.llm && LIVE_CONFIG.llm.runMode) || {}; - const mode = cfg.mode || 'cloud'; - const workers = (cfg.fusion && cfg.fusion.workers) || 3; + /* The EFFECTIVE mode, resolved as the agent resolves it (rmResolve): a + stored `fusion` whose orchestrator is no longer the active provider is not + Fusion, and the Active chip must not say it is. The cards and the count run + the composer's own switch (act 'runmode:*' → selChooseBackend / + selChooseFusion / fzSetWorkers) — one write path for both surfaces. */ + const rm = rmNow(); + const mode = rm.effective; + const blocker = BSW.readyLoaded ? fzBlocker() : null; const MODES = [ ['local', 'Local', 'everything runs on this Mac', 'laptop'], ['cloud', 'Cloud', 'everything runs on the provider', 'cloud'], - ['fusion', 'Fusion', 'a cloud model plans, local workers do the work', 'bolt'], + ['fusion', 'Fusion', blocker && mode !== 'fusion' ? blocker : 'a cloud model plans, local workers do the work', 'fusion'], ]; - const localReady = !!(LIVE_CONFIG && LIVE_CONFIG.localModels - && LIVE_CONFIG.localModels.managed && LIVE_CONFIG.localModels.managed.modelId); // ST-20 / ST-22: three selectable cards, the worker count as a segmented choice. return '<section class="llm-section llm-runmode"><div class="tk-sh llm-sh"><span class="llm-sh-t">Run mode</span></div>' + '<div class="llm-rm-grid">' + MODES.map(([id, label, why, icon]) => { const on = id === mode; - return '<button class="llm-rm' + (on ? ' on' : '') + '" data-act="runmode:' + id + '" aria-pressed="' + on + '">' + const blocked = id === 'fusion' && !on && !!blocker; + return '<button class="llm-rm' + (on ? ' on' : '') + (blocked ? ' blocked' : '') + '" data-act="runmode:' + id + '" aria-pressed="' + on + '">' + '<span class="tk-ico tk-ico--sm' + (on ? ' tk-ico--brand' : '') + '">' + ic(icon) + '</span>' + '<span class="llm-rm-body"><span class="llm-rm-t">' + esc(label) + '</span><span class="llm-rm-d">' + esc(why) + '</span></span>' + (on ? '<span class="tk-chip tk-chip--sm tk-chip--green">Active</span>' : '') + '</button>'; }).join('') + '</div>' - + (mode === 'fusion' - ? '<div class="llm-workers">' - + '<span class="tk-help' + (localReady ? '' : ' tk-help--warn') + '">' - + esc('Workers: ' + workers + '. ') - + esc(localReady - ? 'The orchestrator uses the provider above; the workers use the local model.' - : 'Fusion needs a local model as well — choose one under Local, or the workers have nothing to run on.') - + '</span>' - + '<span class="tk-seg llm-workerseg" role="group" aria-label="Workers">' - + [1, 2, 3, 4, 6, 8].map((n) => - '<button class="' + (n === workers ? 'on' : '') + '" data-act="runmode:workers:' + n + '" aria-pressed="' + (n === workers) + '">' - + n + '</button>').join('') - + '</span></div>' - : '') + // /runmode status, where the stored and the effective mode can disagree. + + '<p class="llm-rm-status">' + esc(rmDescribe(rm)) + '</p>' + + '<div class="llm-workers">' + + '<span class="tk-help">' + esc('Workers: ' + rm.workers + ' — the default fan-out. The orchestrator can ask for more or fewer per job.') + '</span>' + + '<span class="tk-seg llm-workerseg" role="group" aria-label="Workers">' + + [1, 2, 3, 4, 5, 6, 7, 8].map((n) => + '<button class="' + (n === rm.workers ? 'on' : '') + '" data-act="runmode:workers:' + n + '" aria-pressed="' + (n === rm.workers) + '">' + + n + '</button>').join('') + + '</span></div>' + '</section>'; } @@ -17469,6 +18073,40 @@ if (typeof window !== 'undefined') { render(); return turnId; }; + /* Turn order (2026-09-15 DMG report): the frames of a gated turn fed + through the real onChatEvent / onApprovalEvent, in the order the wire + delivers them — two tool calls, an approval for the first, a steer + notice, then the reply's deltas. Returns the kinds of the rows the turn + produced, read while the turn is still live (the moment the operator saw + the approval under the reply), and removes every trace of itself. */ + window.__turnOrderLive = () => { + const at = S.log.length; + const sid = S.agentSession || null; + const turnId = window.__fakeTurn(); + onChatEvent({turnId, kind:'tool_progress', payload:{tool:'os.fs.write', label:'{"path":"a.txt"}'}}); + onChatEvent({turnId, kind:'tool_progress', payload:{tool:'os.shell.run', label:'{"cmd":"ls"}'}}); + onApprovalEvent({approvalId:'smoke-order-1', tool:'os.fs.write', category:'fs_write_workspace', + reason:'smoke fixture', sessionId:sid}); + const card = S.log.find((m) => m.approvalId === 'smoke-order-1'); + if (card) { S.pending = null; card.state = 'approved'; card.at = '00:00:00'; } + placeInLiveTurn({id:nid(), k:'system', text:'steering the running turn — the agent reads it at the next step'}); + onChatEvent({turnId, kind:'delta', text:'All done.'}); + const rows = S.log.slice(at).map((m) => m.k === 'tool' ? 'tool:' + m.name : m.k === 'approval' ? 'approval:' + m.tool : m.k); + const lastId = S.log.length ? S.log[S.log.length - 1].id : null; + const replyLast = lastId === S.streamId; + // leave nothing behind + if (sid) PENDING_APPROVALS.delete(sid); + RUNNING.delete(turnId); + S.pending = null; S.turnId = null; S.busy = false; S.streamId = null; S.reasonId = null; + S.log.length = at; + clearInterval(ticker); + render(); + return {rows, replyLast}; + }; + /* The reopened half: the stored rows of that same turn, as the agent + writes them, mapped by the function openSession uses. */ + window.__turnsToLog = (turns) => sessionTurnsToLog(turns).map((m) => m.k === 'tool' ? 'tool:' + m.name + : m.k === 'approval' ? 'approval:' + m.tool + ':' + m.state + ':' + m.kind : m.k); } /* ------------------------------------------------------------------ @@ -17997,6 +18635,7 @@ if (typeof window !== 'undefined') { */ window.__obOpen = (step, opts) => { OB.open = true; + OB.openGen = (OB.openGen || 0) + 1; Object.assign(OB, {offer: null, resumeAfterCloud: null, localModelId: null, outcome: null, skipSecondOffer: false, handOver: false, cursor: 0, busy: false, error: null, hfReference: '', hfRepo: null, importAgents: [], importOptions: [], importReport: null, introTyped: false, diff --git a/desktop/test/README.md b/desktop/test/README.md index 882a99d1..775a6886 100644 --- a/desktop/test/README.md +++ b/desktop/test/README.md @@ -62,6 +62,7 @@ run falls back to the installed agent as before. | `scenarios/01`…`07` + `run-all.mjs` | seven end-to-end human errands: build a website, write a document, arrange files, hold a conversation, answer an approval, survive a Force Quit, and be told what went wrong when the agent will not answer | `npm run scenarios` | | `onboarding-mouse.mjs` | the whole first-run wizard with the mouse, plus a resting-state design review of every screen | `npm run drive:onboarding` | | `drive-selector.mjs` | the composer's parameter controls across the three backends (`composerSwitchKindsFor`), and the pane Settings › LLM opens on | `npm run drive:selector` | +| `fusion.drive.mjs` | Run mode · Fusion on a configured COPY: the fusion row's pre-flight refusal, entering Fusion from the Backend control (one config write, the intro), the Workers and Provider controls under Fusion, ⇄, `/runmode status` and `/runmode workers N`, Settings › LLM's cards and count, and Backend › cloud really leaving Fusion in the file. Screenshots light + dark at 1470×923; the live worker list shot is taken on synthetic frames and says so. Run with `env -u OPENROUTER_API_KEY -u AIMLAPI_API_KEY` | `npm run drive:fusion -- <state copy> <workspace> [shots]` | | `model-picks.drive.mjs` | the local-model recommendation on three machine sizes: the picker and Settings › LLM › Local ordered for the host's RAM, each model's own blurb, one best fit, the small-model and tight-fit cautions, and the models held back as out of reach | `npm run drive:models` | | `cloud-setup.drive.mjs` | the cloud providers end to end against the real OpenRouter and AI/ML API | `npm run drive:cloud` | | `integration.drive.mjs` | **the four lanes in one window**: first run with the mouse, a cloud provider with a real key, a message and a reply, local, back to cloud, a second provider added from the composer chip, a model switch, and a reply from the model chosen last | `npm run drive:integration` | diff --git a/desktop/test/cloud-setup.drive.mjs b/desktop/test/cloud-setup.drive.mjs index 96dd9a2f..fc0c296b 100644 --- a/desktop/test/cloud-setup.drive.mjs +++ b/desktop/test/cloud-setup.drive.mjs @@ -258,9 +258,10 @@ try { w = await wizard(); check('the local model list is up', /Recommended models/.test(w.head || ''), JSON.stringify(w.head)); - step(4, 'the local list must have a MOUSE way back (it advertises `esc back`)'); - check('the `esc back` hint is a live control', w.hints.some((h) => /esc/.test(h)), JSON.stringify(w.hints)); - await app.clickText('back', { within: '.ob-hints', tags: 'button', settleMs: 1500 }); + step(4, 'the local list must have a MOUSE way back'); + // r2: no keycap hint strip any more — the action bar's Back is the way. + check('the action bar carries a Back button', w.buttons.some((b) => /^Back$/.test(b)), JSON.stringify(w.buttons)); + await app.clickText('Back', { within: '.ob-foot', tags: 'button', settleMs: 1500 }); w = await wizard(); check('clicking it lands back on the backend choice', w.rows.length === 3 && w.rows.some((r) => /Cloud models/.test(r)), JSON.stringify(w.rows)); diff --git a/desktop/test/fusion.drive.mjs b/desktop/test/fusion.drive.mjs new file mode 100644 index 00000000..fd8667c9 --- /dev/null +++ b/desktop/test/fusion.drive.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node +/** + * fusion.drive.mjs — Run mode · Fusion, with trusted input. + * + * env -u OPENROUTER_API_KEY -u AIMLAPI_API_KEY \ + * ATOMIC_AGENT_BIN=<agent> node test/fusion.drive.mjs <state-dir COPY> <workspace> [shots-dir] + * + * The state dir must be a throwaway COPY of a configured fixture: a cloud + * provider with a key (aimlapi), a second one without (openrouter), the + * managed local route with nothing on disk. Unset the two key variables in + * the shell so the copy's .env decides, as it does for the app. + * + * Every step is a CDP mouse click or key press; `eval` only looks — at the + * DOM, and at the copy's config.json, which is where a switch either + * happened or did not. One exception, named as such: the live worker list + * needs a real fan-out to appear, so its screenshot (J) is taken on frames + * fed to the renderer's own onChatEvent; the smoke and the agent's unit test + * own that path's assertions. + * + * Before launch the driver empties every value in the COPY's .env (names + * kept), so only a provider with an inline key in config.json is keyed. + * Midway it writes a placeholder OPENROUTER_API_KEY there — the one thing + * that turns "needs a second provider" into a second provider. No message is + * ever sent, so no key is ever used. + */ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { launch, sleep } from './drive.mjs'; + +const [stateDir, workspace, shotsArg] = process.argv.slice(2); +if (!stateDir || !workspace) { + console.error('usage: node test/fusion.drive.mjs <state-dir copy> <workspace> [shots-dir]'); + process.exit(2); +} +const shots = shotsArg || join(stateDir, 'shots'); +const port = Number(process.env.FUSION_DRIVE_PORT || 9761); + +const results = []; +const check = (name, ok, detail = '') => { + results.push([name, !!ok]); + console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ' — ' + detail : ''}`); +}; +/* The fields a switch writes. Never the whole file: it carries an inline key. */ +const cfg = () => { + const c = JSON.parse(readFileSync(join(stateDir, 'config.json'), 'utf8')); + return { + active: c.llm && c.llm.activeTextProvider, + runMode: (c.llm && c.llm.runMode) || null, + parallel: c.localModels && c.localModels.managed && c.localModels.managed.parallel, + }; +}; + +/* Fixture prep, before the window exists: the COPY's .env loses every key + value (names kept, values emptied). A cloud provider keyed only through + the environment then has no key — which is the "needs a second provider" + state step A needs — and this driver never holds a real key in memory. + A provider with an inline key in config.json stays keyed. */ +const envPath = join(stateDir, '.env'); +if (existsSync(envPath)) { + writeFileSync(envPath, readFileSync(envPath, 'utf8') + .split(/\r?\n/) + .map((line) => line.replace(/^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=).*$/, '$1')) + .join('\n')); +} +const env0 = existsSync(envPath) ? readFileSync(envPath, 'utf8') : ''; + +const app = await launch({ port, stateDir, workspace, env: process.env.ATOMIC_AGENT_BIN ? { ATOMIC_AGENT_BIN: process.env.ATOMIC_AGENT_BIN } : {} }); + +const until = async (fn, label, timeout = 90000) => { + const end = Date.now() + timeout; + for (;;) { + let v = null; + try { v = await fn(); } catch { v = null; } + if (v) return v; + if (Date.now() > end) throw new Error(`gave up after ${timeout}ms waiting for ${label}`); + await sleep(300); + } +}; +const chip = (kind) => app.eval(`(() => { const b = document.querySelector('#composer .cfoot [data-sel-open="${kind}"]'); return b ? b.textContent.trim() : null; })()`); +const chips = () => app.eval(`[...document.querySelectorAll('#composer .cfoot [data-sel-open]')].map((b) => [b.dataset.selOpen, b.textContent.trim()])`); +const popRows = () => app.eval(`[...document.querySelectorAll('.selpop .modelrow')].map((r) => [ + (r.querySelector('.nm') || {}).textContent || '', (r.querySelector('.cap') || {}).textContent || '', r.classList.contains('on')])`); +const popTitle = () => app.eval(`(document.querySelector('.selpop .selttl') || {}).textContent || null`); +const fusionRowCap = () => app.eval(`(() => { const r = [...document.querySelectorAll('.selpop .modelrow')].find((n) => (n.querySelector('.nm') || {}).textContent === 'fusion'); return r ? r.querySelector('.cap').textContent : null; })()`); +/* A switch has landed when the file says so AND the composer lock is released on a connected agent. */ +const landed = (pred, label) => until(async () => pred(cfg()) + && await app.eval(`window.__swxState().pending === 0 && window.__live() === 'connected'`), label); +const theme = (scheme) => app.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: scheme }] }); +const shot = async (name) => { + for (const scheme of ['light', 'dark']) { + await theme(scheme); + await sleep(450); + await app.screenshot(join(shots, `${name}-${scheme}.png`)); + } + await theme('light'); + await sleep(200); +}; +const closePopover = async () => { + await app.press('Escape'); + await until(() => app.eval(`!document.querySelector('.selpop')`), 'the popover closed', 8000); +}; +const slash = async (line) => { + await app.clickSel('#entry', { scroll: false }); + await app.type(line); + await app.press('Enter'); + if (await app.eval(`document.querySelector('#entry').value !== ''`)) await app.press('Enter'); +}; +const lastSystemLine = () => app.eval(`(() => { const r = [...document.querySelectorAll('#scroller .sysrow')].pop(); return r ? r.textContent.trim() : null; })()`); + +try { + await app.send('Emulation.setDeviceMetricsOverride', { width: 1470, height: 923, deviceScaleFactor: 1, mobile: false }); + await app.waitFor(`window.__live && window.__live() === 'connected'`, 'agent connected', { timeout: 90000 }); + await app.waitFor(`!!document.querySelector('#composer .cfoot [data-sel-open="backend"]')`, 'the composer controls'); + check('boots on the cloud route', await chip('backend') === 'cloud', JSON.stringify(await chips())); + + /* A — one provider with a key, nothing on disk: Fusion says why not. */ + await app.clickSel('#composer .cfoot [data-sel-open="backend"]'); + const blocked = 'needs a second provider for the workers — Manage › LLM'; + await until(async () => (await fusionRowCap()) === blocked, 'the fusion row with its pre-flight line', 30000); + const rowsA = await popRows(); + check('A: Where it runs lists cloud · local · custom · fusion, fusion carrying the pre-flight line', + JSON.stringify(rowsA.map((r) => r[0])) === '["cloud","local","custom","fusion"]' && rowsA[3][1] === blocked, JSON.stringify(rowsA)); + await shot('A-where-it-runs-blocked'); + const beforeA = JSON.stringify(cfg()); + await app.clickSel('.selpop .modelrow', { nth: 3 }); + const errA = await until(() => app.eval(`(() => { const e = document.querySelector('.selpop .selerr'); return e ? e.textContent.trim() : null; })()`), 'the refusal in the popover', 10000); + check('A: clicking it refuses in the TUI words and writes nothing', errA === 'fusion: ' + blocked && JSON.stringify(cfg()) === beforeA, errA); + await closePopover(); + + /* B — a second provider has a key: the row unblocks, one click enters Fusion. */ + writeFileSync(envPath, /^OPENROUTER_API_KEY=/m.test(env0) + ? env0.replace(/^OPENROUTER_API_KEY=.*$/m, 'OPENROUTER_API_KEY=placeholder-not-a-key') + : env0 + '\nOPENROUTER_API_KEY=placeholder-not-a-key\n'); + await app.clickSel('#composer .cfoot [data-sel-open="backend"]'); + await until(async () => (await fusionRowCap()) === 'cloud plans · 2 local workers', 'the fusion row unblocked', 30000); + await shot('B-where-it-runs'); + await app.clickSel('.selpop .modelrow', { nth: 3 }); + const painted = await until(async () => (await chip('backend')) === 'fusion', 'the chip paints fusion on the click', 15000); + await landed((c) => c.runMode && c.runMode.mode === 'fusion', 'Fusion written and the agent back'); + const c1 = cfg(); + check('B: one write — mode fusion, the orchestrator active, both legs pinned', + painted && c1.active === 'aimlapi' && c1.runMode.fusion && c1.runMode.fusion.orchestratorProvider === 'aimlapi' && c1.runMode.fusion.workerProvider === 'local-llama', + JSON.stringify({ active: c1.active, runMode: c1.runMode })); + const ch1 = await chips(); + const has = (list, kind, re) => list.some(([k, t]) => k === kind && re.test(t)); + check('B: the chips are fusion · aimlapi · its model ⇄ workers', + has(ch1, 'backend', /^fusion$/) && has(ch1, 'provider', /^aimlapi$/) && has(ch1, 'model', /grok-4-6/) && has(ch1, 'workers', /qwen-3\.5-4b/) + && await app.eval(`!!document.querySelector('#composer .cfoot .fzswap')`), JSON.stringify(ch1)); + const intro = await app.eval(`[...document.querySelectorAll('.sysrow .fz-intro')].map((n) => n.textContent)`); + check('B: the intro is in the transcript once, naming both legs', + intro.length === 1 && intro[0].includes('orchestrator') && intro[0].includes('x-ai/grok-4-6 plans') && intro[0].includes('qwen-3.5-4b executes'), + intro.length + ' intro(s)'); + await shot('B-fusion-chips-intro'); + + /* C — the Workers control: pin the workers to the other cloud provider. */ + await app.clickSel('#composer .cfoot [data-sel-open="workers"]'); + await until(async () => (await popTitle()) === 'Workers', 'the Workers popover', 10000); + await until(async () => (await popRows()).length === 2, 'the workers rows', 15000); + const rowsC = await popRows(); + check('C: workers rows — openrouter in the cloud, then Download more models…', + JSON.stringify(rowsC.map((r) => [r[0], r[1]])) === '[["openrouter","workers · in the cloud"],["Download more models…","opens the local models pane"]]', JSON.stringify(rowsC)); + await shot('C-workers-popover'); + await app.clickText('openrouter', { scope: '.selpop' }); + await landed((c) => c.runMode && c.runMode.fusion && c.runMode.fusion.workerProvider === 'openrouter', 'the workers pinned to openrouter'); + const c2 = cfg(); + await until(async () => /qwen3\.7-flash/.test((await chip('workers')) || ''), 'the workers chip names the cloud model', 15000); + check('C: the workers pin moved, the orchestrator and the mode stayed', + c2.active === 'aimlapi' && c2.runMode.mode === 'fusion' && c2.runMode.fusion.orchestratorProvider === 'aimlapi', `${JSON.stringify(c2.runMode)} · workers chip ${await chip('workers')}`); + + /* D — the Provider control under Fusion is the orchestrator seat. */ + await app.clickSel('#composer .cfoot [data-sel-open="provider"]'); + await until(async () => (await popTitle()) === 'Provider' && (await popRows()).length >= 3, 'the Provider popover', 10000); + await until(async () => (await popRows()).every((r) => r[1] !== 'checking keys…'), 'the key facts', 15000); + const rowsD = await popRows(); + check('D: provider rows read orchestrator, the current one marked', + JSON.stringify(rowsD) === '[["aimlapi","orchestrator",true],["openrouter","orchestrator",false],["Add a new provider","opens the wizard",false]]', JSON.stringify(rowsD)); + await shot('D-provider-popover'); + await closePopover(); + + /* E — ⇄ trades the seats. */ + await app.clickSel('#composer .cfoot .fzswap'); + await landed((c) => c.active === 'openrouter' && c.runMode.fusion.orchestratorProvider === 'openrouter', 'the legs swapped'); + const c3 = cfg(); + await until(async () => (await chip('provider')) === 'openrouter', 'the provider chip follows the swap', 15000); + check('E: ⇄ trades both pins and the active provider in one write, with no second intro', + c3.runMode.mode === 'fusion' && c3.runMode.fusion.workerProvider === 'aimlapi' + && (await app.eval(`document.querySelectorAll('.sysrow .fz-intro').length`)) === 1, + `${JSON.stringify({ active: c3.active, runMode: c3.runMode })} · ${JSON.stringify(await chips())}`); + + /* F — /runmode status and /runmode workers N, typed. */ + await slash('/runmode status'); + const statusF = await until(async () => { const t = await lastSystemLine(); return t && t.startsWith('Fusion —') ? t : null; }, 'the status line', 10000); + check('F: /runmode status states both legs', statusF === 'Fusion — orchestrator openrouter (qwen/qwen3.7-flash), 2 workers on aimlapi (x-ai/grok-4-6)', statusF); + await slash('/runmode workers 3'); + await landed((c) => c.runMode.fusion.workers === 3, 'three workers written'); + const toastF = await until(() => app.eval(`(() => { const t = window.__lastToast(); return t && /^fusion: 3 workers/.test(t.t) ? t.t : null; })()`), 'the workers notice', 20000); + check('F: /runmode workers 3 writes the count and the llama-server slots together, with the TUI notice', + cfg().parallel === 3 && toastF === 'fusion: 3 workers — restart the local model (Manage › LLM › Local) so it runs 3 at once', toastF); + + /* G — Settings › LLM: the same state, the same write path. */ + await app.clickSel('.sb-settings'); + await app.clickText('LLM', { scope: '#settings' }); + await until(() => app.eval(`!!document.querySelector('#settings .llm-rm.on')`), 'the Run mode cards', 20000); + const card = await app.eval(`({on: document.querySelector('#settings .llm-rm.on').dataset.act, + workers: (document.querySelector('#settings .llm-workerseg .on') || {}).textContent, + n: document.querySelectorAll('#settings .llm-workerseg button').length, + status: (document.querySelector('#settings .llm-rm-status') || {}).textContent})`); + check('G: Settings › LLM — Fusion active, 3 of workers 1–8, the resolved status', + card.on === 'runmode:fusion' && card.workers === '3' && card.n === 8 && /^Fusion — orchestrator openrouter/.test(card.status), JSON.stringify(card)); + await app.eval(`(() => { const n = document.querySelector('#settings .llm-runmode'); if (n) n.scrollIntoView({block:'start'}); return true; })()`); + await shot('G-settings-llm-run-mode'); + await app.clickSel('#settings .llm-workerseg button', { nth: 1 }); + await landed((c) => c.runMode.fusion.workers === 2 && c.parallel === 2, 'two workers written from the card'); + const msgG = await until(() => app.eval(`(() => { const t = document.querySelector('#settings') ? document.querySelector('#settings').textContent : ''; return /fusion: 2 workers/.test(t) ? 'shown' : null; })()`), 'the notice on the pane', 20000); + check('G: the card writes through the composer\'s path and says so on the pane', msgG === 'shown'); + await app.clickSel('#settings .iconbtn[data-act="settings:close"]'); + + /* H — Backend › cloud leaves Fusion in the file, not only on the chip. */ + await app.clickSel('#composer .cfoot [data-sel-open="backend"]'); + await until(async () => (await popRows()).length === 4, 'the backend rows', 10000); + await app.clickSel('.selpop .modelrow', { nth: 0 }); + await landed((c) => c.runMode && c.runMode.mode !== 'fusion', 'Fusion left in the file'); + const c5 = cfg(); + await until(async () => (await chip('backend')) === 'cloud', 'the chip reads cloud', 15000); + check('H: Backend › cloud writes mode cloud with the provider — the chip and the file agree', + c5.runMode.mode === 'cloud' && c5.active === 'openrouter' && (await chip('workers')) === null, JSON.stringify({ active: c5.active, mode: c5.runMode.mode })); + + /* I — /runmode fusion from the composer comes back in. */ + await slash('/runmode fusion'); + await landed((c) => c.runMode.mode === 'fusion' && c.active === 'openrouter', 'Fusion re-entered'); + await until(async () => (await chip('backend')) === 'fusion', 'the chip reads fusion', 15000); + check('I: /runmode fusion re-enters on the pins it had', cfg().runMode.fusion.orchestratorProvider === 'openrouter', JSON.stringify(cfg().runMode)); + + /* J — the live worker list. SYNTHETIC: frames fed to the renderer's own + handler on a stand-in turn, for the screenshot only (see the header). */ + await app.eval(`(() => { + S.turnId = 'drive-synthetic'; S.busy = true; S.phase = 'fusion.delegate'; + const item = {id: nid(), k: 'assistant', text: ''}; S.streamId = item.id; S.log.push(item); + const f = (task_id, title, phase, extra) => onChatEvent({turnId: 'drive-synthetic', kind: 'fusion_worker', + payload: Object.assign({object: 'atomic.fusion_worker', task_id, title, phase, role: 'worker', model: 'qwen/qwen3.7-flash'}, extra || {})}); + onChatEvent({turnId: 'drive-synthetic', kind: 'fusion_worker', payload: {object: 'atomic.fusion_worker', task_id: 'fusion.delegate', title: '3 tasks', phase: 'tool', role: 'orchestrator', model: 'qwen/qwen3.7-flash', tool: 'fusion.delegate'}}); + f('t1', 'write the parser', 'started'); f('t1', 'write the parser', 'tool', {tool: 'os.fs.write'}); + f('t2', 'unit tests', 'started'); f('t2', 'unit tests', 'tool', {tool: 'os.shell.run'}); + f('t3', 'README section', 'started'); f('t3', 'README section', 'finished', {step_count: 4, summary: 'wrote README.md'}); + return true; })()`); + await sleep(300); + const strip = await app.eval(`[...document.querySelectorAll('.composerwrap .fzlive .fzt')].map((n) => n.textContent)`); + await shot('J-live-workers-SYNTHETIC'); + check('J (synthetic frames): the list sits under the composer, three lines, no control of its own', + strip.length === 3 && (await app.eval(`document.querySelectorAll('.composerwrap .fzlive button, .composerwrap .fzlive [data-act]').length`)) === 0, + JSON.stringify(strip)); + await app.eval(`(() => { S.log = S.log.filter((m) => !m.fusion && m.id !== S.streamId); S.busy = false; S.turnId = null; FZ.live = []; render(); return true; })()`); +} catch (err) { + check('the driven pass ran to the end', false, err && err.message ? err.message : String(err)); + try { await app.screenshot(join(shots, 'failure.png')); } catch { /* the window is gone */ } +} finally { + writeFileSync(envPath, env0); + await app.close(); +} + +const failed = results.filter(([, ok]) => !ok).length; +console.log(`\n${results.length - failed}/${results.length} passed · screenshots in ${shots}`); +process.exit(failed ? 1 : 0); diff --git a/desktop/test/turn-order.drive.mjs b/desktop/test/turn-order.drive.mjs new file mode 100644 index 00000000..c9894625 --- /dev/null +++ b/desktop/test/turn-order.drive.mjs @@ -0,0 +1,242 @@ +/** + * turn-order.drive.mjs — the agent's reply is the last row of its turn, live + * and after the chat is reopened, and each approval sits where it was asked. + * + * The report (2026-09-15 DMG): "end agent results should be the last message + * within the turn. At this moment approvals are the last ones even though it + * makes no sense." + * + * No real model is involved: a local OpenAI-compatible server stands in for + * the provider and answers the agent's turn with one `os.fs.write` call and, + * once the tool result is in the prompt, a plain reply. Everything a person + * does is a trusted CDP click or keystroke through drive.mjs; Runtime.evaluate + * only looks. The state directory is a scratch one built from the seed + * fixture's config with every provider replaced by the fake — no `.env`, no + * keys, nothing that could reach a real service. + * + * node test/turn-order.drive.mjs [--port 9781] [--bin <atomic-agent>] [--receipts] + * + * `--bin` is the agent to run (default: $ATOMIC_AGENT_BIN, else the bundled + * one). `--receipts` also requires the reopened chat to show the approval + * receipt, which needs an agent that stores `approvals` on tool_result rows + * (desktop/turn-fixes); the bundled 0.6.1 does not, and without the flag a + * reopened chat is only required to end on the reply. + */ + +import { createServer } from 'node:http'; +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { launch, sleep, DESKTOP_DIR } from './drive.mjs'; + +const argv = process.argv.slice(2); +const arg = (name, dflt) => { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : dflt; }; +const PORT = Number(arg('--port', 9781)); +const WANT_RECEIPTS = argv.includes('--receipts'); +const BIN = arg('--bin', process.env.ATOMIC_AGENT_BIN || join(DESKTOP_DIR, '..', 'bundle', 'darwin-arm64', 'atomic-agent')); +const SEED_CONFIG = process.env.ATAG_SEED_CONFIG + || '/private/tmp/claude-501/-Users-valerii-claudecode1/f54533b6-fc7f-408a-a975-1c3fffb17832/scratchpad/seed-configured/config.json'; +const REPLY = 'Done — approved.txt is written.'; + +let passed = 0; +const failed = []; +function check(ok, what, detail = '') { + console.log(`${ok ? 'PASS' : 'FAIL'} ${what}${detail ? ` — ${detail}` : ''}`); + if (ok) passed += 1; else failed.push(what); +} + +/* ---------------- the stand-in provider ---------------- */ +const wire = { requests: 0, toolCalls: 0, replies: 0 }; +function sse(res, chunks) { + res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + for (const c of chunks) res.write(`data: ${JSON.stringify(c)}\n\n`); + res.end('data: [DONE]\n\n'); +} +function chunk(delta, finish = null) { + return { id: 'fake-1', object: 'chat.completion.chunk', created: Math.floor(Date.now() / 1000), model: 'fake-model', + choices: [{ index: 0, delta, finish_reason: finish }] }; +} +function startFakeProvider(workspace) { + const server = createServer((req, res) => { + let body = ''; + req.on('data', (b) => { body += b; }); + req.on('end', () => { + if (req.method === 'GET' && req.url.startsWith('/v1/models')) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ object: 'list', data: [{ id: 'fake-model', object: 'model' }] })); + return; + } + wire.requests += 1; + let json = {}; + try { json = JSON.parse(body || '{}'); } catch { /* keep {} */ } + const text = JSON.stringify(json.messages || []); + const tools = Array.isArray(json.tools) ? json.tools : []; + // The wire name is the agent's own encoding of `os.fs.write` (dots are + // not allowed in a function name), so match loosely and say what matched. + const names = tools.map((t) => t && t.function && t.function.name).filter(Boolean); + const write = names.find((n) => /(^|[^a-z])fs[^a-z0-9]*write$/i.test(n)); + if (tools.length && !wire.toolName) wire.toolName = write || `none of ${names.length} matched`; + let message; + if (write && !/tool_result\[os\.fs\.write/.test(text)) { + wire.toolCalls += 1; + const args = JSON.stringify({ path: join(workspace, 'approved.txt'), content: 'moray-firth-2026\n' }); + message = { tool: { name: write, args } }; + } else if (tools.length) { + wire.replies += 1; + message = { content: REPLY }; + } else { + message = { content: 'none' }; // reflection / rewriter sub-calls + } + if (json.stream) { + if (message.tool) { + sse(res, [ + chunk({ role: 'assistant', content: null, tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: message.tool.name, arguments: '' } }] }), + chunk({ tool_calls: [{ index: 0, function: { arguments: message.tool.args } }] }), + chunk({}, 'tool_calls'), + { ...chunk({}), choices: [], usage: { prompt_tokens: 100, completion_tokens: 20, total_tokens: 120 } }, + ]); + } else { + sse(res, [ + chunk({ role: 'assistant', content: message.content.slice(0, 6) }), + chunk({ content: message.content.slice(6) }), + chunk({}, 'stop'), + { ...chunk({}), choices: [], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 } }, + ]); + } + return; + } + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: 'fake-1', object: 'chat.completion', created: Math.floor(Date.now() / 1000), model: 'fake-model', + choices: [{ index: 0, finish_reason: message.tool ? 'tool_calls' : 'stop', + message: message.tool + ? { role: 'assistant', content: null, tool_calls: [{ id: 'call_1', type: 'function', function: { name: message.tool.name, arguments: message.tool.args } }] } + : { role: 'assistant', content: message.content } }], + usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110 }, + })); + }); + }); + return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(server))); +} + +/* ---------------- the scratch state ---------------- */ +function buildState(base, providerPort) { + const stateDir = join(base, 'state'); + const workspace = join(base, 'workspace'); + mkdirSync(stateDir, { recursive: true }); + mkdirSync(workspace, { recursive: true }); + const cfg = JSON.parse(readFileSync(SEED_CONFIG, 'utf8')); + // Every cloud entry goes (keys, and a way out to a real service); the local + // llama-server entries stay because other config leaves name them + // (`activeEmbeddingProvider`), and they carry no key and are never called. + cfg.llm.providers = [ + ...(cfg.llm.providers || []).filter((p) => p && p.kind === 'llama-server'), + { id: 'fake', kind: 'openai-compatible', baseUrl: `http://127.0.0.1:${providerPort}/v1`, + apiKey: 'not-a-real-key', defaultChatModel: 'fake-model' }, + ]; + cfg.llm.activeTextProvider = 'fake'; + delete cfg.llm.fallback; + cfg.agent.approvalLevel = 1; + writeFileSync(join(stateDir, 'config.json'), JSON.stringify(cfg, null, 2)); + let bin = BIN; + if (BIN.endsWith('.js')) { // a dist/cli/index.js: run it with this node + bin = join(base, 'atomic-agent-shim'); + writeFileSync(bin, `#!/bin/sh\nexec "${process.execPath}" "${BIN}" "$@"\n`); + chmodSync(bin, 0o755); + } + return { stateDir, workspace, bin }; +} + +/* ---------------- looking at the transcript ---------------- */ +const ROWS = `[...document.querySelectorAll('#content .turn, #content .sysrow')].map((t) => { + if (t.classList.contains('sysrow')) return { k: 'system', text: t.innerText.trim().slice(0, 80) }; + if (t.classList.contains('usr')) return { k: 'user', text: t.innerText.trim().slice(0, 80) }; + if (t.querySelector('.appr')) { + const done = t.querySelector('.appr.done'); + return { k: 'approval', done: !!done, label: ((t.querySelector('.apprlbl b') || {}).textContent || '').trim(), + badge: ((t.querySelector('.badge') || {}).textContent || '').trim() }; + } + if (t.querySelector('.card')) return { k: 'tool', name: ((t.querySelector('.cardhead .nm') || {}).textContent || '').trim() }; + if (t.querySelector('.tk-asst')) return { k: 'assistant', text: ((t.querySelector('.prose') || {}).innerText || '').trim() }; + if (t.querySelector('.disc')) return { k: 'reason' }; + if (t.querySelector('.ubub, .user, .tk-user')) return { k: 'user', text: t.innerText.trim().slice(0, 80) }; + return { k: 'other', cls: t.className, text: t.innerText.trim().slice(0, 80) }; +})`; +const shape = (rows) => rows.map((r) => r.k === 'tool' ? `tool:${r.name}` : r.k === 'approval' ? `approval${r.done ? ':' + r.label : ':open'}` : r.k).join(' → '); + +async function main() { + const base = join(tmpdir(), `atag-turn-order-${Date.now()}`); + const provider = await startFakeProvider(join(base, 'workspace')); + const providerPort = provider.address().port; + const { stateDir, workspace, bin } = buildState(base, providerPort); + console.log(`state ${stateDir}\nagent ${BIN}\nprovider http://127.0.0.1:${providerPort}\ncdp ${PORT}`); + let app = null; + try { + app = await launch({ port: PORT, stateDir, workspace, env: { ATOMIC_AGENT_BIN: bin } }); + await app.waitFor(`!!document.querySelector('#entry') && !document.querySelector('#onboarding')`, 'the chat composer', { timeout: 90000 }); + // Sending before the agent is attached is refused with "the agent is + // still starting" (submit checks S.live.state) — wait for the attach. + await app.waitFor(`window.__live && window.__live() === 'connected'`, 'the agent attached', { timeout: 90000 }); + + await app.clickSel('#entry'); + await app.type('Please write approved.txt in the workspace.', { perChar: 1 }); + await app.clickSel('.sendbtn'); + + await app.waitFor(`!!document.querySelector('#apprcard')`, 'the approval card', { timeout: 90000 }); + await sleep(400); + const pendingRows = await app.eval(ROWS); + console.log(`while asking: ${shape(pendingRows)}`); + check(!existsSync(join(workspace, 'approved.txt')), 'the write waits for the verdict'); + + await app.clickSel('#apprcard [data-appr="y"]'); + await app.waitFor(`[...document.querySelectorAll('#content .tk-asst .prose')].some((p) => p.innerText.includes(${JSON.stringify(REPLY)}))` + + ` && !document.querySelector('.statusstrip') && !document.querySelector('.sendbtn.stop')`, 'the reply, turn over', { timeout: 90000 }); + await sleep(2500); // reconcileToolCards and the trace merge settle + const liveRows = await app.eval(ROWS); + console.log(`live, turn over: ${shape(liveRows)}`); + check(existsSync(join(workspace, 'approved.txt')), 'Approve released the write (the file exists)'); + const liveLast = liveRows[liveRows.length - 1] || {}; + check(liveLast.k === 'assistant' && liveLast.text.includes(REPLY), 'live: the reply is the last row of the turn', shape(liveRows)); + const lt = liveRows.findIndex((r) => r.k === 'tool' && r.name === 'os.fs.write'); + const la = liveRows.findIndex((r) => r.k === 'approval'); + check(lt >= 0 && la === lt + 1 && liveRows[la].label === 'Approved', 'live: the approval sits directly under the call that asked for it', shape(liveRows)); + const sid = await app.eval(`S.agentSession`); + check(typeof sid === 'string' && sid.length > 0, 'the turn has a stored session', String(sid)); + + // Reopen: quit, launch again on the same state, click the chat row. + await app.close(); + app = null; + await sleep(1500); + app = await launch({ port: PORT, stateDir, workspace, env: { ATOMIC_AGENT_BIN: bin } }); + await app.waitFor(`!!document.querySelector('[data-ses="${sid}"]')`, 'the chat row in the sidebar', { timeout: 90000 }); + await app.clickSel(`[data-ses="${sid}"]`); + await app.waitFor(`[...document.querySelectorAll('#content .tk-asst .prose')].some((p) => p.innerText.includes(${JSON.stringify(REPLY)}))`, 'the reopened transcript', { timeout: 30000 }); + await sleep(1500); + const storedRows = await app.eval(ROWS); + console.log(`reopened: ${shape(storedRows)}`); + const storedLast = storedRows[storedRows.length - 1] || {}; + check(storedLast.k === 'assistant' && storedLast.text.includes(REPLY), 'reopened: the reply is the last row of the turn', shape(storedRows)); + const st = storedRows.findIndex((r) => r.k === 'tool' && r.name === 'os.fs.write'); + const sa = storedRows.findIndex((r) => r.k === 'approval'); + if (WANT_RECEIPTS) { + check(st >= 0 && sa === st + 1 && storedRows[sa].label === 'Approved' && storedRows[sa].badge === 'file write · workspace', + 'reopened: the stored approval sits directly under its call, as it did live', shape(storedRows)); + check(JSON.stringify(shape(storedRows)) === JSON.stringify(shape(liveRows)), 'reopened and live transcripts have the same shape', + `live ${shape(liveRows)} | reopened ${shape(storedRows)}`); + } else { + check(sa === -1 || sa === st + 1, 'reopened: no approval row out of place', shape(storedRows)); + } + console.log(`wire: ${JSON.stringify(wire)}`); + } catch (e) { + failed.push(`driver: ${e.message}`); + console.log(`FAIL driver — ${e.message}`); + if (app) { try { await app.screenshot(join(base, 'failure.png')); console.log(`screenshot ${join(base, 'failure.png')}`); } catch { /* ignore */ } } + } finally { + if (app) await app.close().catch(() => {}); + provider.close(); + } + console.log(`\n${passed} passed, ${failed.length} failed${failed.length ? ': ' + failed.join('; ') : ''}`); + process.exit(failed.length ? 1 : 0); +} + +main();