Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -1033,7 +1034,7 @@
}
},
"counts": {
"overlay_total": 962,
"overlay_total": 963,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<!doctype html><html><head><title>OpenCode</title></head></html>"

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 <Show when={liveHarnessState()}> 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<Response>((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(")
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<typeof useServer>): Promise<void> {
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) => {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<void> {
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)
}
}
Loading