From 2c17903caf45fe58c77a1066f7b87a59f443e712 Mon Sep 17 00:00:00 2001 From: Aaron Trowbridge Date: Wed, 14 Oct 2026 17:04:13 -0400 Subject: [PATCH] fix(app): the harness control's instance ask carries the placement directory (#1799) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer's harness control never rendered in the hub-client posture: its GET/POST /amicode/harness were the app's only instance-scoped amicode asks with no ?directory= placement key. In the local posture the panel origin's extension proxy forwards the bare ask to the bound engine, so it worked; at the hub origin the frontdoor cannot place a directory-less instance ask and answers 200 text/html with the SPA index, so the parse collapses and the control's gate stays never-true (live-verified: bare /amicode/harness -> text/html, ?directory=… -> application/json registry). - harness.ts: harnessRoute(directory) builds the placement-correct ask; refreshHarnessState (extracted from the component, the mount effects' body) is sequenced so only the latest ask adopts — the two mount effects can race and a stale SPA-index answer clobbered a fresh registry during hub member switches (observed live). - harness-control.tsx: the mount effects keep the server.current and stream-connected guards and thread the per-directory SDK context's directory; the switch POST carries the same route. - new harness-control.test.tsx drives the mount-path seam under both postures' server contexts (hub frontdoor placement semantics mocked as observed live) + pins the wiring; suite delta: zero new failures (byte-identical failure set vs base), +12 passing. --- packages/app-bundle/manifest.json | 12 +- .../prompt-input/harness-control.test.tsx | 188 ++++++++++++++++++ .../prompt-input/harness-control.tsx | 34 ++-- .../src/components/prompt-input/harness.ts | 37 ++++ 4 files changed, 252 insertions(+), 19 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 5d9f8f162..f8e4e8fa6 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -694,9 +694,10 @@ "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "15f76dca1a25d024128718e4099220b79ca029263410aab55502cf9caf9b2914", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "4b5a1edf59d4c85268d6ff72aa9f9d66f82d4fdb887121fb978c09caf4c5be65", "packages/app/src/components/prompt-input/context-items.tsx": "027b02e394ccd14849052bf3fcacdae1e090c71d164fa8955addfe3f7abf1758", - "packages/app/src/components/prompt-input/harness-control.tsx": "3711e0b3ca5d0b041e956e67c41594609faf134edc3ede803f09ff8b574667cc", + "packages/app/src/components/prompt-input/harness-control.test.tsx": "859cdacbc71988e8ae2b02840bb98a9b7442b63d6f88c0671648bddccfc4e6fb", + "packages/app/src/components/prompt-input/harness-control.tsx": "6f3825cb6b312e6b1d85b7af0da99976cbb15082822a92e869f956f5ec2e91fd", "packages/app/src/components/prompt-input/harness.test.ts": "35739529b06829dd9fa169209c54ae668e0fc06d6968b00cfce638a221ce1361", - "packages/app/src/components/prompt-input/harness.ts": "8ae6a783c18a906383061cb1254101015f025080b39be3786c27be9bcd0ba9d1", + "packages/app/src/components/prompt-input/harness.ts": "2a9406b14da9de68a172e61b6ddb4e49442a90f770b1b88669761f2ec201f9ab", "packages/app/src/components/prompt-input/image-attachments.tsx": "aa451ed6aa61a68ba0e8dacd8c45137f84c62566797a5242b75a89487ef0e9bf", "packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3", "packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d", @@ -984,10 +985,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-10-13T15:58:01.063Z", + "extracted_at": "2026-10-14T21:01:06.170Z", "per_package": { "packages/app": { - "A": 244, + "A": 245, "M": 159, "D": 0 }, @@ -1033,7 +1034,7 @@ } }, "counts": { - "overlay_total": 962, + "overlay_total": 963, "deletions": 7, "server_coupled": 0 }, @@ -1724,6 +1725,7 @@ "packages/app/src/components/prompt-input/clipboard-bridge.test.ts": "A", "packages/app/src/components/prompt-input/clipboard-bridge.ts": "A", "packages/app/src/components/prompt-input/context-items.tsx": "M", + "packages/app/src/components/prompt-input/harness-control.test.tsx": "A", "packages/app/src/components/prompt-input/harness-control.tsx": "A", "packages/app/src/components/prompt-input/harness.test.ts": "A", "packages/app/src/components/prompt-input/harness.ts": "A", diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx new file mode 100644 index 000000000..5946f16bb --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.test.tsx @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import type { ServerConnection } from "@/context/server" +import { adoptHarnessState, harnessRoute, liveHarnessState, refreshHarnessState } from "./harness" + +// amicode#1799 — the harness composer control never rendered in the hub-client +// posture. The composer's mount-path fetch was the ONLY instance-scoped ask in +// the app that carried no ?directory= placement key: /amicode/harness is an +// INSTANCE route (routes/instance/httpapi), and every other instance-scoped +// fetch the composer makes (/agent, /provider, /command, /question…) carries +// the location directory — the key the hub frontdoor routes instance requests +// by. In the LOCAL posture the panel origin is the extension service, whose +// #1549 exception forwards the bare ask to its bound engine (one engine, one +// instance), so the bare route worked. In the hub-client posture (#1784) the +// panel origin IS the hub frontdoor: a directory-less instance ask cannot be +// placed and lands on the default backend, which answers 200 text/html with +// the SPA index document (verified live: bare /amicode/harness → text/html, +// /amicode/harness?directory=… → application/json registry). The HTML answer +// fails amicodeGet's .json() parse, the catch adopts undefined, and +// liveHarnessState() — the control's render gate — stays never-true: zero +// renders while /agent and /provider fetch fine. +// +// The component's behavioral substance (route construction + fetch + adopt) +// lives in the state module (./harness) so the mount path is testable without +// a DOM (the solver-toggle decision-helper split; bun's transpiler runs no +// Solid JSX transform — see lineage-ledger-panel.test.tsx). This suite drives +// that seam under BOTH postures' server contexts and pins the composer wiring. + +const READY_MENU = { + ok: true, + harness: "opencode", + status: "ready", + options: [ + { id: "opencode", displayName: "opencode (default)", state: "ready", detail: "The default harness.", disabled: false }, + { id: "telaio", displayName: "telaio", state: "needs-setup", detail: "Requires the telaio binary.", disabled: true, reason: "telaio binary not installed" }, + ], +} + +// The P4 hub-client posture's server context: the framed app derives its +// ENTIRE registry from the document origin — registry IS {hub}, a single http +// connection, no local sidecar (#1784). The context object the control reads +// is structurally its `.current` accessor. +const HUB_ORIGIN = "http://127.0.0.1:4097" +const HUB_CONN = { type: "http", http: { url: HUB_ORIGIN } } as ServerConnection.Any +const DIRECTORY = "/home/aaron/harmoniqs/opencode" + +// What the hub answered the bare ask with, verbatim shape (200 text/html). +const SPA_INDEX = "OpenCode" + +const registryJson = () => + new Response(JSON.stringify(READY_MENU), { status: 200, headers: { "content-type": "application/json" } }) +const spaIndexHtml = () => new Response(SPA_INDEX, { status: 200, headers: { "content-type": "text/html" } }) + +/** The hub frontdoor's placement semantics for the harness route, as observed + * live (#1799): a directory-carrying ask is placed to the directory's member + * engine and answered with the registry JSON; a directory-less ask lands on + * the default backend and is answered with the SPA index document. */ +const hubFrontdoor = (recorded: string[]) => + (async (input: RequestInfo | URL) => { + const url = new URL(String(input)) + recorded.push(url.pathname + url.search) + if (url.pathname !== "/amicode/harness") throw new Error(`unexpected probe route: ${url.pathname}`) + return url.searchParams.has("directory") ? registryJson() : spaIndexHtml() + }) as typeof fetch + +describe("#1799 harnessRoute — the placement-correct ask", () => { + test("carries the instance placement directory, the key every other instance-scoped composer fetch rides", () => { + expect(harnessRoute(DIRECTORY)).toBe(`/amicode/harness?directory=${encodeURIComponent(DIRECTORY)}`) + }) + + test("a missing directory keeps the bare ask — the local posture's original behavior", () => { + expect(harnessRoute("")).toBe("/amicode/harness") + }) +}) + +describe("#1799 the mount-path refresh in the hub-client posture (the framed app's registry IS {hub})", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + adoptHarnessState(undefined) + }) + + test("renders the control: the ask is placed by directory and the registry adopts (RED on the bare ask)", async () => { + const recorded: string[] = [] + globalThis.fetch = hubFrontdoor(recorded) + await refreshHarnessState({ current: HUB_CONN }, DIRECTORY) + // The live RED: the old bare ask got the SPA index → no state → the + // control's gate stayed never-true. + expect(liveHarnessState()?.harness).toBe("opencode") + expect(recorded).toHaveLength(1) + expect(recorded[0]).toContain("directory=") + }) + + test("the old bare ask collapses: the hub's 200 text/html SPA answer adopts no state — the never-render, reproduced", async () => { + const recorded: string[] = [] + globalThis.fetch = hubFrontdoor(recorded) + await refreshHarnessState({ current: HUB_CONN }, "") + expect(liveHarnessState()).toBeUndefined() + expect(recorded).toEqual(["/amicode/harness"]) + }) + + test("no connection → stays hidden, no fetch (the server.current guard's degradation, unchanged)", async () => { + const recorded: string[] = [] + globalThis.fetch = hubFrontdoor(recorded) + await refreshHarnessState({ current: undefined }, DIRECTORY) + expect(liveHarnessState()).toBeUndefined() + expect(recorded).toHaveLength(0) + }) +}) + +describe("#1799 the mount-path refresh in the local posture — unchanged", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + adoptHarnessState(undefined) + }) + + test("the service origin forwards the ask to the bound engine (query preserved) — the control mounts", async () => { + const recorded: string[] = [] + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = new URL(String(input)) + recorded.push(url.pathname + url.search) + if (url.pathname !== "/amicode/harness") throw new Error(`unexpected probe route: ${url.pathname}`) + return registryJson() + }) as typeof fetch + await refreshHarnessState({ current: { type: "http", http: { url: "http://127.0.0.1:4095" } } as ServerConnection.Any }, DIRECTORY) + expect(liveHarnessState()?.harness).toBe("opencode") + expect(recorded).toHaveLength(1) + }) + + test("route absent → stays hidden (the stock-engine degradation contract)", async () => { + globalThis.fetch = (async () => + new Response(JSON.stringify({ ok: false, error: "no route" }), { status: 404 })) as typeof fetch + await refreshHarnessState({ current: HUB_CONN }, DIRECTORY) + expect(liveHarnessState()).toBeUndefined() + }) +}) + +describe("#1799 the refresh sequence — only the LATEST ask adopts", () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + adoptHarnessState(undefined) + }) + + test("a stale in-flight answer never clobbers a newer ask's state (the two mount effects can race)", async () => { + // Live shape: the mount effects (server-change + stream-connect) can both + // have asks in flight; during a member switch one answer came back the + // SPA index while the other carried the registry — last-writer-wins on + // the module singleton made renders racy. Only the latest ask may adopt. + const gates: Array<(response: Response) => void> = [] + globalThis.fetch = (async () => new Promise((resolve) => gates.push(resolve))) as typeof fetch + const first = refreshHarnessState({ current: HUB_CONN }, DIRECTORY) + const second = refreshHarnessState({ current: HUB_CONN }, DIRECTORY) + gates[1]?.(registryJson()) + await second + gates[0]?.(spaIndexHtml()) + await first + expect(liveHarnessState()?.harness).toBe("opencode") + }) +}) + +describe("#1799 the composer control's wiring (the mount effects + the switch write)", () => { + const source = readFileSync(resolve(__dirname, "harness-control.tsx"), "utf8") + + test("the mount effects keep the server.current and stream-connected guards AND thread the placement directory", () => { + expect(source).toContain("if (server.current) void refreshHarnessState(server, directory())") + expect(source).toContain('if (sdk().event.status() === "connected") void refreshHarnessState(server, directory())') + }) + + test("the placement directory comes from the per-directory SDK context — the same source every other instance-scoped composer fetch rides", () => { + expect(source).toContain("const dirSdk = useSDK()") + expect(source).toContain("dirSdk().directory") + }) + + test("the switch POST carries the placement route too (the write path is the same instance route)", () => { + expect(source).toContain("amicodePost(conn, harnessRoute(directory())") + }) + + test("the fetch/parse/adopt body lives in the testable state module, not inlined in the component", () => { + expect(source).not.toContain("amicodeGet(server.current") + expect(source).not.toContain("adoptHarnessState(parseHarnessState(") + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx index 5d5e2f612..2399bb569 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness-control.tsx @@ -5,9 +5,10 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { useServer } from "@/context/server" import { useServerSDK } from "@/context/server-sdk" -import { amicodeGet, amicodePost } from "@/utils/amicode-fetch" +import { useSDK } from "@/context/sdk" +import { amicodePost } from "@/utils/amicode-fetch" import { showToast } from "@/utils/toast" -import { adoptHarnessState, liveHarnessState, parseHarnessState } from "@/components/prompt-input/harness" +import { liveHarnessState, refreshHarnessState, harnessRoute } from "@/components/prompt-input/harness" import { beginHarnessSwitch } from "@/components/harness-switch-banner" // amicode#1549 — the harness switcher in the chat box. A fourth composer @@ -18,31 +19,36 @@ import { beginHarnessSwitch } from "@/components/harness-switch-banner" // An unresolvable or unentitled harness renders disabled WITH its reason — // never selectable-then-failing. No published registry (stock opencode, or // the extension not yet booted) renders nothing at all. +// +// amicode#1799 — /amicode/harness is an INSTANCE route, so both the mount-path +// GET and the switch POST carry the placement directory (harnessRoute) — the +// key every other instance-scoped fetch the composer makes rides. The bare +// ask only ever worked in the local posture (the panel origin's extension +// proxy forwards to its bound engine); at the hub origin it landed on the +// default backend and was answered with the SPA index, so the control never +// rendered. The fetch/parse/adopt body lives in harness.ts (testable without +// a DOM); this component is the wiring. /** The palette's disclosure, carried verbatim to the point of switching. */ const DISCLOSURE = "Sessions are harness-local — switching harnesses switches session history." -async function refreshHarnessState(server: ReturnType): Promise { - try { - adoptHarnessState(parseHarnessState(await amicodeGet(server.current, "/amicode/harness"))) - } catch { - // No answer (no extension yet, route absent) → the control stays hidden. - adoptHarnessState(undefined) - } -} - export function HarnessComposerControl() { const server = useServer() const sdk = useServerSDK() + // The instance placement directory — the per-directory SDK context the + // composer already lives under (the same source the agent/model fetches + // ride), so the harness ask routes exactly like every other composer fetch. + const dirSdk = useSDK() + const directory = () => dirSdk().directory // Refetch on server change and on every (re)connect — a harness switch // restarts the server, so the post-restart reconnect is when the new // current lands here. createEffect(() => { - if (server.current) void refreshHarnessState(server) + if (server.current) void refreshHarnessState(server, directory()) }) createEffect(() => { - if (sdk().event.status() === "connected") void refreshHarnessState(server) + if (sdk().event.status() === "connected") void refreshHarnessState(server, directory()) }) const onPick = async (id: string) => { @@ -51,7 +57,7 @@ export function HarnessComposerControl() { const conn = server.current if (!conn) return try { - const res = (await amicodePost(conn, "/amicode/harness", { harness: id })) as { + const res = (await amicodePost(conn, harnessRoute(directory()), { harness: id })) as { ok?: unknown noop?: unknown harness?: unknown diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts index 2c41e8251..43a82b456 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts +++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/harness.ts @@ -6,6 +6,8 @@ // behavior, unchanged. Pure helpers so the view contract is testable without // a DOM (the solver-toggle decision-helper split). import { createSignal } from "solid-js" +import type { ServerConnection } from "@/context/server" +import { amicodeGet } from "@/utils/amicode-fetch" export interface HarnessOptionView { id: string @@ -78,3 +80,38 @@ export function adoptHarnessState(state: HarnessViewState | undefined): void { export function liveHarnessState(): HarnessViewState | undefined { return harnessState() } + +/** The placement-correct ask for the engine's harness route (#1799). + * /amicode/harness is an INSTANCE route — the composer's only instance-scoped + * amicode ask — so it carries ?directory= like every other instance-scoped + * fetch the app makes (/agent, /provider, /command…): the key the hub + * frontdoor routes instance requests by. The bare route is placeable only in + * the local posture (the panel origin's extension proxy forwards to its + * bound engine); at the hub origin a directory-less instance ask lands on + * the default backend, which answers 200 text/html with the SPA index — the + * control never rendered (#1799). A missing directory keeps the bare ask + * (the local posture's original behavior). */ +export function harnessRoute(directory: string): string { + return directory ? `/amicode/harness?directory=${encodeURIComponent(directory)}` : "/amicode/harness" +} + +/** Refresh the composer's harness state from the engine — the body both mount + * effects call. Sequenced: the state is a module singleton and the two mount + * effects (server-change + stream-connect) can have asks in flight at once; + * only the LATEST ask's answer may adopt, or a stale response clobbers a + * fresh one (observed live: during a hub member switch one answer arrived + * the SPA index while the other carried the registry). */ +let harnessRefreshSeq = 0 +export async function refreshHarnessState( + server: { current: ServerConnection.Any | undefined }, + directory: string, +): Promise { + const seq = ++harnessRefreshSeq + try { + const state = parseHarnessState(await amicodeGet(server.current, harnessRoute(directory))) + if (seq === harnessRefreshSeq) adoptHarnessState(state) + } catch { + // No answer (no extension yet, route absent) → the control stays hidden. + if (seq === harnessRefreshSeq) adoptHarnessState(undefined) + } +}