Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3489fa77bf | |||
| d5aac23603 | |||
| 816e64907f | |||
| b7e29ff4e8 | |||
| eef1e38bec |
@@ -11,7 +11,6 @@
|
||||
"bin"
|
||||
],
|
||||
"exports": {
|
||||
"./daemon": "./src/daemon.ts",
|
||||
"./run": "./src/run/index.ts",
|
||||
"./server-process": "./src/server-process.ts"
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
|
||||
Flag.withDescription("Session ID to continue"),
|
||||
Flag.optional,
|
||||
),
|
||||
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
|
||||
},
|
||||
commands: [
|
||||
Spec.make("acp", { description: "Start an Agent Client Protocol server" }),
|
||||
|
||||
@@ -55,7 +55,11 @@ export default Runtime.handler(Commands, (input) =>
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
args: { continue: input.continue, sessionID: Option.getOrUndefined(input.session) },
|
||||
args: {
|
||||
continue: input.continue,
|
||||
sessionID: Option.getOrUndefined(input.session),
|
||||
prompt: Option.getOrUndefined(input.prompt),
|
||||
},
|
||||
config: {
|
||||
path: config.path,
|
||||
get: () => runPromise(config.get()),
|
||||
|
||||
+20
-22
@@ -2,7 +2,6 @@ import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
import type { MiniFrontendInput } from "@opencode-ai/tui/mini"
|
||||
import { setTimeout } from "node:timers/promises"
|
||||
import { waitForCatalogReady } from "./services/catalog"
|
||||
import { readStdin } from "./util/io"
|
||||
import { createMiniHost, INTERACTIVE_INPUT_ERROR, usingInteractiveStdin } from "./mini-host"
|
||||
import { parseSessionTargetModel, resolveSessionTarget, type SessionTargetPreparation } from "./session-target"
|
||||
@@ -215,14 +214,17 @@ function parseModel(value?: string) {
|
||||
|
||||
function prepareTarget(requestedAgent?: string): SessionTargetPreparation {
|
||||
return async (input) => {
|
||||
if (input.model)
|
||||
await waitForCatalogReady({
|
||||
sdk: input.client,
|
||||
directory: input.location.directory,
|
||||
workspace: input.location.workspaceID,
|
||||
model: { providerID: input.model.providerID, modelID: input.model.id },
|
||||
signal: input.signal,
|
||||
})
|
||||
if (input.model || requestedAgent)
|
||||
await input.client.location
|
||||
.wait(
|
||||
{
|
||||
location: { directory: input.location.directory, workspace: input.location.workspaceID },
|
||||
},
|
||||
{ signal: input.signal },
|
||||
)
|
||||
.catch((error) => {
|
||||
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
})
|
||||
return {
|
||||
model: input.model,
|
||||
agent: requestedAgent
|
||||
@@ -246,26 +248,22 @@ async function validateAgent(
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
if (!name) return
|
||||
const deadline = Date.now() + 5_000
|
||||
let agents: Awaited<ReturnType<OpenCodeClient["agent"]["list"]>> | undefined
|
||||
while (Date.now() < deadline && !signal?.aborted) {
|
||||
agents = await sdk.agent.list({ location: { directory, workspace } }, { signal }).catch((error) => {
|
||||
const agents = await sdk.agent
|
||||
.list({ location: { directory, workspace } }, { signal })
|
||||
.catch((error) => {
|
||||
if (signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
return undefined
|
||||
})
|
||||
const agent = agents?.data.find((item) => item.id === name)
|
||||
if (agent?.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent) return name
|
||||
await setTimeout(25, undefined, { signal }).catch(() => {})
|
||||
}
|
||||
if (signal?.aborted) return
|
||||
if (!agents) {
|
||||
warning("failed to list agents. Falling back to default agent")
|
||||
return
|
||||
}
|
||||
const agent = agents.data.find((item) => item.id === name)
|
||||
if (agent?.mode === "subagent") {
|
||||
warning(`agent "${name}" is a subagent, not a primary agent. Falling back to default agent`)
|
||||
return
|
||||
}
|
||||
if (agent) return name
|
||||
warning(`agent "${name}" not found. Falling back to default agent`)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { readStdin } from "../util/io"
|
||||
import { ServerConnection } from "../services/server-connection"
|
||||
import { waitForCatalogReady } from "../services/catalog"
|
||||
import { parseSessionTargetModel, resolveSessionTarget } from "../session-target"
|
||||
import { toolInlineInfo } from "@opencode-ai/tui/mini/tool"
|
||||
import { runNonInteractivePrompt } from "./noninteractive"
|
||||
@@ -93,10 +92,12 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
: undefined,
|
||||
agent: input.agent,
|
||||
prepare: async (next) => {
|
||||
const location = { location: { directory: next.location.directory, workspace: next.location.workspaceID } }
|
||||
if (next.model) await client.location.wait(location)
|
||||
const selected =
|
||||
next.model ??
|
||||
(await client.model
|
||||
.default({ location: { directory: next.location.directory, workspace: next.location.workspaceID } })
|
||||
.default(location)
|
||||
.then((result) => result.data))
|
||||
const model = selected
|
||||
? {
|
||||
@@ -105,21 +106,11 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
variant: options.variant ?? ("variant" in selected ? selected.variant : undefined),
|
||||
}
|
||||
: undefined
|
||||
const models = await client.model.list(location).then((result) => result.data)
|
||||
if ((options.variant ?? explicit?.variant) && !model)
|
||||
throw new RunTargetError("Cannot select a variant before selecting a model", next.session?.id)
|
||||
if (model) {
|
||||
await waitForCatalogReady({
|
||||
sdk: client,
|
||||
directory: next.location.directory,
|
||||
workspace: next.location.workspaceID,
|
||||
model: { providerID: model.providerID, modelID: model.id },
|
||||
})
|
||||
const available = await client.model.list({
|
||||
location: { directory: next.location.directory, workspace: next.location.workspaceID },
|
||||
})
|
||||
if (!available.data.some((item) => item.providerID === model.providerID && item.id === model.id))
|
||||
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
|
||||
}
|
||||
if (model && !models.some((item) => item.providerID === model.providerID && item.id === model.id))
|
||||
throw new RunTargetError(`Model unavailable: ${model.providerID}/${model.id}`, next.session?.id)
|
||||
return {
|
||||
model,
|
||||
agent: input.agent
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import { ClientError, type OpenCodeClient } from "@opencode-ai/client/promise"
|
||||
|
||||
// Location plugins initialize asynchronously, so explicit model selection must
|
||||
// wait for that exact model before prompt admission. The execution path owns
|
||||
// the authoritative error if readiness times out.
|
||||
export async function waitForCatalogReady(input: {
|
||||
sdk: OpenCodeClient
|
||||
directory: string
|
||||
workspace?: string
|
||||
model: { providerID: string; modelID: string }
|
||||
timeoutMs?: number
|
||||
signal?: AbortSignal
|
||||
}) {
|
||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||
while (Date.now() < deadline && !input.signal?.aborted) {
|
||||
const models = await input.sdk.model
|
||||
.list(
|
||||
{ location: { directory: input.directory, workspace: input.workspace } },
|
||||
{ signal: input.signal },
|
||||
)
|
||||
.then((result) => result.data)
|
||||
.catch((error) => {
|
||||
if (input.signal && error instanceof ClientError && error.reason === "Transport") throw error
|
||||
return undefined
|
||||
})
|
||||
if (models?.some((model) => model.providerID === input.model.providerID && model.id === input.model.modelID)) return
|
||||
await wait(25, input.signal)
|
||||
}
|
||||
}
|
||||
|
||||
function wait(delay: number, signal?: AbortSignal) {
|
||||
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -16,7 +16,30 @@ export default defineScript({
|
||||
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
||||
const session = `mini-stage2-${process.pid}`
|
||||
const snapshots = path.join(artifacts, "mini-stage2")
|
||||
yield* Effect.promise(() => mkdir(snapshots, { recursive: true }))
|
||||
const explicitDirectory = path.join(artifacts, "explicit-model")
|
||||
yield* Effect.promise(() => Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))))
|
||||
/** @param {string} directory @param {string | undefined} model */
|
||||
const mini = (directory, model) => [
|
||||
"env",
|
||||
`PWD=${directory}`,
|
||||
`OPENCODE_PASSWORD=${registration.password}`,
|
||||
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DIRECT_TRACE=1",
|
||||
process.execPath,
|
||||
"--conditions=browser",
|
||||
`--preload=${preload}`,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"mini",
|
||||
"--server",
|
||||
registration.url,
|
||||
...(model ? ["--model", model] : []),
|
||||
]
|
||||
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
@@ -42,26 +65,7 @@ export default defineScript({
|
||||
"-y",
|
||||
"30",
|
||||
"--",
|
||||
"env",
|
||||
`PWD=${path.join(artifacts, "files")}`,
|
||||
`OPENCODE_PASSWORD=${registration.password}`,
|
||||
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DIRECT_TRACE=1",
|
||||
process.execPath,
|
||||
"--conditions=browser",
|
||||
`--preload=${preload}`,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"mini",
|
||||
"--server",
|
||||
registration.url,
|
||||
"--model",
|
||||
"simulation/gpt-sim-model",
|
||||
...mini(path.join(artifacts, "files"), undefined),
|
||||
]),
|
||||
),
|
||||
)
|
||||
@@ -153,6 +157,63 @@ export default defineScript({
|
||||
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
||||
throw new Error("Mini exit splash was not rendered before teardown")
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
|
||||
|
||||
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
|
||||
yield* Effect.promise(() =>
|
||||
tmux([
|
||||
"respawn-pane",
|
||||
"-k",
|
||||
"-t",
|
||||
session,
|
||||
"--",
|
||||
...mini(explicitDirectory, "simulation/gpt-sim-model"),
|
||||
]),
|
||||
)
|
||||
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "Press ctrl+c again to exit"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForDeadPane(session))
|
||||
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
|
||||
throw new Error("Explicit-model Mini did not exit cleanly")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
"--model",
|
||||
"simulation/definitely-missing",
|
||||
"catalog readiness check",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: path.join(artifacts, "files"),
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
await Bun.write(path.join(snapshots, "08-unavailable-model.txt"), stdout + stderr)
|
||||
if (exitCode !== 1) throw new Error(`Unavailable model run exited with status ${exitCode}`)
|
||||
if (!stderr.includes("Model unavailable: simulation/definitely-missing"))
|
||||
throw new Error(`Unavailable model was not diagnosed after catalog settlement: ${stderr}`)
|
||||
})
|
||||
})
|
||||
|
||||
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
|
||||
|
||||
@@ -18,10 +18,12 @@ describe("CLI frontend import boundaries", () => {
|
||||
test("exposes only the intentional package entrypoints", async () => {
|
||||
const run = await import("@opencode-ai/cli/run")
|
||||
const mini = await import("@opencode-ai/tui/mini")
|
||||
const tool = await import("@opencode-ai/tui/mini/tool")
|
||||
const cli = await Bun.file(path.join(root, "packages/cli/package.json")).json()
|
||||
|
||||
expect(Object.keys(run).sort()).toEqual(["runNonInteractive", "runV1Bridge"])
|
||||
expect(Object.keys(mini).sort()).toEqual(["runMiniFrontend"])
|
||||
expect(Object.keys(tool).sort()).toEqual(["toolInlineInfo", "toolOutputText"])
|
||||
expect(Object.keys(cli.exports).filter((key) => key === "./mini" || key.startsWith("./mini/"))).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -138,8 +138,9 @@ describe("mini command", () => {
|
||||
expect(result.stderr).not.toContain("You must provide a message")
|
||||
})
|
||||
|
||||
test("preserves a run failure exit code", async () => {
|
||||
test("validates an explicit model after one readiness wait", async () => {
|
||||
let modelRequests = 0
|
||||
let waitRequests = 0
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
@@ -148,11 +149,15 @@ describe("mini command", () => {
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/location/wait") {
|
||||
waitRequests++
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
}
|
||||
if (url.pathname === "/api/model") {
|
||||
modelRequests++
|
||||
return Response.json({
|
||||
location: { directory: process.cwd(), project: { id: "global", directory: process.cwd() } },
|
||||
data: modelRequests === 1 ? [{ id: "missing", providerID: "definitely" }] : [],
|
||||
data: [],
|
||||
})
|
||||
}
|
||||
return new Response(undefined, { status: 404 })
|
||||
@@ -164,6 +169,41 @@ describe("mini command", () => {
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Model unavailable: definitely/missing")
|
||||
expect(waitRequests).toBe(1)
|
||||
expect(modelRequests).toBe(1)
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("distinguishes catalog initialization timeout from settled model absence", async () => {
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request) {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname === "/api/health")
|
||||
return Response.json({ healthy: true, version: OPENCODE_VERSION, pid: process.pid })
|
||||
if (url.pathname === "/api/location")
|
||||
return Response.json({ directory: process.cwd(), project: { id: "global", directory: process.cwd() } })
|
||||
if (url.pathname === "/api/location/wait")
|
||||
return Response.json(
|
||||
{
|
||||
_tag: "ServiceUnavailableError",
|
||||
message: "Location catalog initialization timed out",
|
||||
service: "location.catalog",
|
||||
},
|
||||
{ status: 503 },
|
||||
)
|
||||
return new Response(undefined, { status: 404 })
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await cli(["run", "--server", server.url.toString(), "--model", "definitely/missing", "hi"])
|
||||
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toContain("Location catalog initialization timed out")
|
||||
expect(result.stderr).not.toContain("Model unavailable")
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
|
||||
@@ -32,8 +32,14 @@ export type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]
|
||||
export type Endpoint2_0Output = EffectValue<ReturnType<RawClient["server.location"]["location.get"]>>
|
||||
export type LocationGetOperation<E = never> = (input?: Endpoint2_0Input) => Effect.Effect<Endpoint2_0Output, E>
|
||||
|
||||
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
|
||||
export type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
|
||||
export type Endpoint2_1Output = EffectValue<ReturnType<RawClient["server.location"]["location.wait"]>>
|
||||
export type LocationWaitOperation<E = never> = (input?: Endpoint2_1Input) => Effect.Effect<Endpoint2_1Output, E>
|
||||
|
||||
export interface LocationApi<E = never> {
|
||||
readonly get: LocationGetOperation<E>
|
||||
readonly wait: LocationWaitOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
|
||||
@@ -33,7 +33,12 @@ type Endpoint2_0Input = { readonly location?: Endpoint2_0Request["query"]["locat
|
||||
const Endpoint2_0 = (raw: RawClient["server.location"]) => (input?: Endpoint2_0Input) =>
|
||||
raw["location.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw) })
|
||||
type Endpoint2_1Request = Parameters<RawClient["server.location"]["location.wait"]>[0]
|
||||
type Endpoint2_1Input = { readonly location?: Endpoint2_1Request["query"]["location"] }
|
||||
const Endpoint2_1 = (raw: RawClient["server.location"]) => (input?: Endpoint2_1Input) =>
|
||||
raw["location.wait"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup2 = (raw: RawClient["server.location"]) => ({ get: Endpoint2_0(raw), wait: Endpoint2_1(raw) })
|
||||
|
||||
type Endpoint3_0Request = Parameters<RawClient["server.agent"]["agent.list"]>[0]
|
||||
type Endpoint3_0Input = { readonly location?: Endpoint3_0Request["query"]["location"] }
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
ServerGetOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationWaitInput,
|
||||
LocationWaitOutput,
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
PluginListInput,
|
||||
@@ -385,6 +387,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
wait: (input?: LocationWaitInput, requestOptions?: RequestOptions) =>
|
||||
request<LocationWaitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/location/wait`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [503, 401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
agent: {
|
||||
list: (input?: AgentListInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2390,6 +2390,14 @@ export type InvalidRequestError = {
|
||||
export const isInvalidRequestError = (value: unknown): value is InvalidRequestError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidRequestError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type InvalidCursorError = { readonly _tag: "InvalidCursorError"; readonly message: string }
|
||||
export const isInvalidCursorError = (value: unknown): value is InvalidCursorError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "InvalidCursorError"
|
||||
@@ -2443,14 +2451,6 @@ export type SkillNotFoundError = {
|
||||
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
@@ -2562,6 +2562,14 @@ export type LocationGetInput = {
|
||||
|
||||
export type LocationGetOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type LocationWaitInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type LocationWaitOutput = { directory: string; workspaceID?: string; project: { id: string; directory: string } }
|
||||
|
||||
export type AgentListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -48,6 +48,29 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.project)).toEqual(["list", "current", "directories"])
|
||||
})
|
||||
|
||||
test("location.wait uses the explicit initial-settlement barrier", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
request = input instanceof Request ? input : new Request(input, init)
|
||||
return Response.json({
|
||||
directory: "/tmp/project",
|
||||
workspaceID: "wrk_test",
|
||||
project: { id: "proj_test", directory: "/tmp/project" },
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const result = await client.location.wait({ location: { directory: "/tmp/project", workspace: "wrk_test" } })
|
||||
|
||||
expect(result.workspaceID).toBe("wrk_test")
|
||||
expect(request?.method).toBe("POST")
|
||||
expect(request?.url).toBe(
|
||||
"http://localhost:3000/api/location/wait?location%5Bdirectory%5D=%2Ftmp%2Fproject&location%5Bworkspace%5D=wrk_test",
|
||||
)
|
||||
})
|
||||
|
||||
test("server.get uses the public HTTP contract", async () => {
|
||||
let request: Request | undefined
|
||||
const client = OpenCode.make({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ServiceUnavailableError } from "../errors.js"
|
||||
|
||||
export const LocationQuery = Schema.Struct({
|
||||
location: Schema.optional(
|
||||
@@ -41,4 +42,19 @@ export const LocationGroup = HttpApiGroup.make("server.location")
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("location.wait", "/api/location/wait", {
|
||||
query: LocationQuery,
|
||||
success: Location.Info,
|
||||
error: ServiceUnavailableError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.location.wait",
|
||||
summary: "Wait for location",
|
||||
description: "Wait for the location's initial plugin generation to settle.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "location" }))
|
||||
|
||||
@@ -17,7 +17,8 @@ export const ModelGroup = HttpApiGroup.make("server.model")
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.model.list",
|
||||
summary: "List models",
|
||||
description: "Retrieve available models ordered by release date.",
|
||||
description:
|
||||
"Retrieve the current snapshot of available models ordered by release date. The snapshot may precede initial plugin settlement.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor"
|
||||
import { ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
|
||||
export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (handlers) =>
|
||||
handlers.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
const location = yield* Location.Service
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
}),
|
||||
),
|
||||
handlers
|
||||
.handle(
|
||||
"location.get",
|
||||
Effect.fn(function* () {
|
||||
return info(yield* Location.Service)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"location.wait",
|
||||
Effect.fn(function* () {
|
||||
const plugins = yield* PluginSupervisor.Service
|
||||
yield* plugins.flush.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: "Location catalog initialization timed out",
|
||||
service: "location.catalog",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return info(yield* Location.Service)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
function info(location: Location.Interface) {
|
||||
return new Location.Info({
|
||||
directory: location.directory,
|
||||
workspaceID: location.workspaceID,
|
||||
project: location.project,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
id: "slow-location-wait-test",
|
||||
async setup() {
|
||||
await Bun.sleep(750)
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import path from "node:path"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("keeps catalog snapshots non-blocking and waits for initial plugin settlement explicitly", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
models: { fetch: false },
|
||||
config: {
|
||||
content: JSON.stringify({ plugins: [path.join(import.meta.dir, "fixture/slow-plugin.ts")] }),
|
||||
},
|
||||
fs: { filewatcher: false },
|
||||
})
|
||||
const base = HttpServer.formatAddress(server.address)
|
||||
const request = (pathname: string, method = "GET") =>
|
||||
fetch(new URL(pathname, base), {
|
||||
method,
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
|
||||
const snapshot = yield* Effect.promise(() => request("/api/model"))
|
||||
expect(snapshot.status).toBe(200)
|
||||
|
||||
let settled = false
|
||||
const waiting = request("/api/location/wait", "POST").then((response) => {
|
||||
settled = true
|
||||
return response
|
||||
})
|
||||
yield* Effect.sleep("50 millis")
|
||||
expect(settled).toBe(false)
|
||||
|
||||
const response = yield* Effect.promise(() => waiting)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: process.cwd() })
|
||||
}),
|
||||
)
|
||||
@@ -30,7 +30,7 @@
|
||||
"./editor": "./src/editor.ts",
|
||||
"./editor-zed": "./src/editor-zed.ts",
|
||||
"./mini": "./src/mini/index.ts",
|
||||
"./mini/tool": "./src/mini/tool.ts",
|
||||
"./mini/tool": "./src/mini/tool.public.ts",
|
||||
"./model-preference": "./src/model-preference.ts",
|
||||
"./runtime": "./src/runtime.tsx",
|
||||
"./terminal-win32": "./src/terminal-win32.ts",
|
||||
|
||||
@@ -525,6 +525,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
})
|
||||
|
||||
const args = useArgs()
|
||||
const startupPrompt = args.prompt ? { text: args.prompt, files: [], agents: [], pasted: [] } : undefined
|
||||
onMount(() => {
|
||||
batch(() => {
|
||||
if (args.agent) local.agent.set(args.agent)
|
||||
@@ -542,6 +543,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: args.sessionID,
|
||||
prompt: startupPrompt,
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -564,12 +566,12 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
const match = response.data[0]?.id
|
||||
if (!match) return
|
||||
if (!args.fork) {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
route.navigate({ type: "session", sessionID: match, prompt: startupPrompt })
|
||||
return
|
||||
}
|
||||
void client.api.session
|
||||
.fork({ sessionID: match })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
|
||||
.catch(toast.error)
|
||||
})
|
||||
.catch(toast.error)
|
||||
@@ -582,7 +584,7 @@ function App(props: { pair?: DialogPairCredentials; started: number }) {
|
||||
forked = true
|
||||
void client.api.session
|
||||
.fork({ sessionID: args.sessionID })
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id }))
|
||||
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
|
||||
.catch(toast.error)
|
||||
})
|
||||
|
||||
|
||||
@@ -85,67 +85,6 @@ export function runProviders(providers: CurrentProvider[], models: CurrentModel[
|
||||
return [...grouped.values()]
|
||||
}
|
||||
|
||||
export async function waitForDefaultModel(input: {
|
||||
sdk: OpenCodeClient
|
||||
location: LocationRef
|
||||
timeoutMs?: number
|
||||
requestTimeoutMs?: number
|
||||
active?: () => boolean
|
||||
signal?: AbortSignal
|
||||
}): Promise<{ providerID: string; modelID: string } | undefined> {
|
||||
const deadline = Date.now() + (input.timeoutMs ?? 5_000)
|
||||
while (Date.now() < deadline && !input.signal?.aborted && (input.active?.() ?? true)) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Math.min(input.requestTimeoutMs ?? 1_000, Math.max(1, deadline - Date.now())),
|
||||
)
|
||||
const abort = () => controller.abort()
|
||||
input.signal?.addEventListener("abort", abort, { once: true })
|
||||
const model = await abortable(
|
||||
input.sdk.model
|
||||
.default(location(input.location), { signal: controller.signal })
|
||||
.then((result) => result.data)
|
||||
.catch(() => undefined),
|
||||
controller.signal,
|
||||
).finally(() => {
|
||||
clearTimeout(timeout)
|
||||
input.signal?.removeEventListener("abort", abort)
|
||||
})
|
||||
if (model) return { providerID: model.providerID, modelID: model.id }
|
||||
await wait(25, input.signal)
|
||||
}
|
||||
}
|
||||
|
||||
function abortable<A>(task: Promise<A>, signal: AbortSignal): Promise<A | undefined> {
|
||||
if (signal.aborted) return Promise.resolve(undefined)
|
||||
return new Promise((resolve) => {
|
||||
const abort = () => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
resolve(undefined)
|
||||
}
|
||||
signal.addEventListener("abort", abort, { once: true })
|
||||
void task.then((value) => {
|
||||
signal.removeEventListener("abort", abort)
|
||||
resolve(value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function wait(delay: number, signal?: AbortSignal) {
|
||||
if (!signal) return new Promise<void>((resolve) => setTimeout(resolve, delay))
|
||||
if (signal.aborted) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal?.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadRunAgents(sdk: OpenCodeClient, ref: LocationRef, signal?: AbortSignal): Promise<RunAgent[]> {
|
||||
const result = await sdk.agent.list(location(ref), ...requestOptions(signal))
|
||||
return result.data.map(runAgent)
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
// the synthetic tool parts through the same callbacks used by the live footer.
|
||||
import path from "path"
|
||||
import type { JsonValue, SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import { parseSlashHead } from "../prompt/parse"
|
||||
import { writeSessionOutput } from "./stream"
|
||||
import { toolCommit } from "./stream-v2.subagent"
|
||||
import { toolCommit, toolFinalPhase } from "./stream-v2.subagent"
|
||||
import type {
|
||||
FooterApi,
|
||||
FooterView,
|
||||
@@ -110,7 +111,6 @@ const SAMPLE_TABLE = [
|
||||
|
||||
type Ref = {
|
||||
msg: string
|
||||
part: string
|
||||
call: string
|
||||
tool: string
|
||||
input: Record<string, JsonValue>
|
||||
@@ -126,7 +126,6 @@ type FormRequest = {
|
||||
type Perm = {
|
||||
ref: Ref
|
||||
done: {
|
||||
title: string
|
||||
output: string
|
||||
metadata?: Record<string, JsonValue>
|
||||
}
|
||||
@@ -203,7 +202,6 @@ function showSubagent(
|
||||
description: input.description,
|
||||
status: input.status,
|
||||
title: input.title,
|
||||
lastUpdatedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
details: {
|
||||
@@ -337,7 +335,6 @@ async function emitReasoning(state: State, body: string, signal?: AbortSignal):
|
||||
function make(state: State, tool: string, input: Record<string, JsonValue>): Ref {
|
||||
return {
|
||||
msg: open(state),
|
||||
part: take(state, "part", "part"),
|
||||
call: take(state, "call", "call"),
|
||||
tool,
|
||||
input,
|
||||
@@ -346,7 +343,7 @@ function make(state: State, tool: string, input: Record<string, JsonValue>): Ref
|
||||
}
|
||||
|
||||
function startTool(state: State, ref: Ref, structured: Record<string, JsonValue> = {}): SessionMessageAssistantTool {
|
||||
state.started.add(ref.part)
|
||||
state.started.add(ref.call)
|
||||
const part = {
|
||||
type: "tool" as const,
|
||||
id: ref.call,
|
||||
@@ -386,12 +383,11 @@ function doneTool(
|
||||
state: State,
|
||||
ref: Ref,
|
||||
output: {
|
||||
title: string
|
||||
output: string
|
||||
metadata?: Record<string, JsonValue>
|
||||
},
|
||||
): void {
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
if (!state.started.has(ref.call)) startTool(state, ref)
|
||||
const part: SessionMessageAssistantTool = {
|
||||
type: "tool",
|
||||
id: ref.call,
|
||||
@@ -404,11 +400,11 @@ function doneTool(
|
||||
},
|
||||
time: { created: ref.start, ran: ref.start, completed: Date.now() },
|
||||
}
|
||||
present(state, [toolCommit(part, ref.msg, output.output ? "progress" : "final")])
|
||||
present(state, [toolCommit(part, ref.msg, toolFinalPhase(part))])
|
||||
}
|
||||
|
||||
function failTool(state: State, ref: Ref, error: string): void {
|
||||
if (!state.started.has(ref.part)) startTool(state, ref)
|
||||
if (!state.started.has(ref.call)) startTool(state, ref)
|
||||
present(state, [
|
||||
toolCommit(
|
||||
{
|
||||
@@ -443,7 +439,6 @@ async function emitBash(state: State, signal?: AbortSignal): Promise<void> {
|
||||
startTool(state, ref)
|
||||
await wait(70, signal)
|
||||
doneTool(state, ref, {
|
||||
title: "git status",
|
||||
output: `${process.cwd()}\ngit status\nOn branch demo\nnothing to commit, working tree clean\n`,
|
||||
metadata: {
|
||||
exit: 0,
|
||||
@@ -458,7 +453,6 @@ function emitWrite(state: State): void {
|
||||
content: "export const demo = 42\n",
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "write",
|
||||
output: "",
|
||||
metadata: {},
|
||||
})
|
||||
@@ -470,7 +464,6 @@ function emitEdit(state: State): void {
|
||||
path: file,
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "edit",
|
||||
output: "",
|
||||
metadata: {
|
||||
files: [
|
||||
@@ -490,7 +483,6 @@ function emitPatch(state: State): void {
|
||||
patchText: "*** Begin Patch\n*** End Patch",
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "patch",
|
||||
output: "",
|
||||
metadata: {
|
||||
files: [
|
||||
@@ -517,10 +509,9 @@ function emitTask(state: State): void {
|
||||
agent: "explore",
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "Reducer touchpoints found",
|
||||
output: "",
|
||||
metadata: {
|
||||
sessionID: "sub_demo_1",
|
||||
sessionID: "ses_demo_child",
|
||||
status: "completed",
|
||||
output: "",
|
||||
},
|
||||
@@ -542,7 +533,7 @@ function emitTask(state: State): void {
|
||||
time: { created: Date.now(), ran: Date.now() },
|
||||
} satisfies SessionMessageAssistantTool
|
||||
showSubagent(state, {
|
||||
sessionID: "sub_demo_1",
|
||||
sessionID: "ses_demo_child",
|
||||
label: "Explore",
|
||||
description: "Scan run/* for reducer touchpoints",
|
||||
status: "completed",
|
||||
@@ -562,16 +553,7 @@ function emitTask(state: State): void {
|
||||
messageID: "sub_demo_msg_reasoning",
|
||||
partID: "sub_demo_reasoning_1",
|
||||
},
|
||||
{
|
||||
kind: "tool",
|
||||
text: "running read",
|
||||
phase: "start",
|
||||
source: "tool",
|
||||
messageID: "sub_demo_msg_tool",
|
||||
partID: "sub_demo_tool_1",
|
||||
tool: "read",
|
||||
part,
|
||||
},
|
||||
toolCommit(part, "sub_demo_msg_tool", "start"),
|
||||
{
|
||||
kind: "assistant",
|
||||
text: "Footer updates flow through stream.ts into RunFooter",
|
||||
@@ -610,7 +592,6 @@ function emitQuestionTool(state: State): void {
|
||||
],
|
||||
})
|
||||
doneTool(state, ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
metadata: {
|
||||
answers: [["Diff"], ["Usage", "custom-note"]],
|
||||
@@ -635,7 +616,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
patterns: [command],
|
||||
always: ["*"],
|
||||
done: {
|
||||
title: "git status --short",
|
||||
output: `${root}\ngit status --short\n M src/demo-format.ts\n?? src/demo-permission.ts\n`,
|
||||
metadata: {
|
||||
exit: 0,
|
||||
@@ -658,7 +638,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
patterns: [target],
|
||||
always: [target],
|
||||
done: {
|
||||
title: "read",
|
||||
output: ["1: {", '2: "name": "opencode",', '3: "private": true', "4: }"].join("\n"),
|
||||
metadata: {},
|
||||
},
|
||||
@@ -677,7 +656,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
patterns: ["explore"],
|
||||
always: ["*"],
|
||||
done: {
|
||||
title: "Footer spacing checked",
|
||||
output: "",
|
||||
metadata: {
|
||||
sessionID: "sub_demo_perm_1",
|
||||
@@ -707,7 +685,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
},
|
||||
always: [`${dir}/**`],
|
||||
done: {
|
||||
title: "read",
|
||||
output: `1: # External demo\n2: Shared preview file\nPath: ${target}`,
|
||||
metadata: {},
|
||||
},
|
||||
@@ -726,7 +703,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
patterns: ["*"],
|
||||
always: ["*"],
|
||||
done: {
|
||||
title: "Retry allowed",
|
||||
output: "Continuing after repeated failures.\n",
|
||||
metadata: {},
|
||||
},
|
||||
@@ -744,7 +720,6 @@ function emitPermission(state: State, kind: PermissionKind = "edit"): void {
|
||||
patterns: [file],
|
||||
always: [file],
|
||||
done: {
|
||||
title: "edit",
|
||||
output: "",
|
||||
metadata: {
|
||||
files: [{ file, status: "modified", patch: diff }],
|
||||
@@ -955,8 +930,9 @@ export function createRunDemo(input: Input) {
|
||||
|
||||
const prompt = async (line: RunPrompt, signal?: AbortSignal): Promise<boolean> => {
|
||||
const text = line.text.trim()
|
||||
const list = text.split(/\s+/)
|
||||
const cmd = list[0] || ""
|
||||
const head = parseSlashHead(text)
|
||||
const list = head?.arguments.split(/\s+/).filter(Boolean) ?? []
|
||||
const cmd = head ? `/${head.name}` : ""
|
||||
|
||||
clearSubagent(state.footer)
|
||||
|
||||
@@ -966,7 +942,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
if (cmd === "/permission") {
|
||||
const kind = permissionKind(list[1])
|
||||
const kind = permissionKind(list[0])
|
||||
if (!kind) {
|
||||
note(state.footer, `Pick a permission kind: ${PERMISSIONS.join(", ")}`)
|
||||
return true
|
||||
@@ -977,7 +953,7 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
if (cmd === "/form") {
|
||||
const kind = formKind(list[1])
|
||||
const kind = formKind(list[0])
|
||||
if (!kind) {
|
||||
note(state.footer, `Pick a form kind: ${FORMS.join(", ")}`)
|
||||
return true
|
||||
@@ -988,8 +964,8 @@ export function createRunDemo(input: Input) {
|
||||
}
|
||||
|
||||
if (cmd === "/fmt") {
|
||||
const kind = (list[1] || "").toLowerCase()
|
||||
const body = list.slice(2).join(" ")
|
||||
const kind = (list[0] || "").toLowerCase()
|
||||
const body = list.slice(1).join(" ")
|
||||
if (!kind) {
|
||||
note(state.footer, `Pick a kind: ${KINDS.join(", ")}`)
|
||||
return true
|
||||
@@ -1032,7 +1008,6 @@ export function createRunDemo(input: Input) {
|
||||
clearBlocker(state)
|
||||
if (form.kind === "question") {
|
||||
doneTool(state, form.ref, {
|
||||
title: "question",
|
||||
output: "",
|
||||
metadata: {
|
||||
answers: form.request.fields.map((field) => {
|
||||
@@ -1045,7 +1020,6 @@ export function createRunDemo(input: Input) {
|
||||
return true
|
||||
}
|
||||
doneTool(state, form.ref, {
|
||||
title: form.request.title,
|
||||
output: `Form submitted: ${Object.entries(input.answer)
|
||||
.map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(", ") : String(value)}`)
|
||||
.join("; ")}\n`,
|
||||
|
||||
@@ -54,10 +54,6 @@ type SubagentEntry = PanelEntry & {
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type QueuedEntry = PanelEntry & {
|
||||
prompt: FooterQueuedPrompt
|
||||
}
|
||||
|
||||
type SettingEntry = PanelEntry & {
|
||||
key: keyof MiniSettings
|
||||
}
|
||||
@@ -92,18 +88,6 @@ function countLabel(count: number, total: number, query: string) {
|
||||
return `${count}/${total}`
|
||||
}
|
||||
|
||||
function categoryRank(category: string) {
|
||||
if (category === "Project Commands") {
|
||||
return 0
|
||||
}
|
||||
|
||||
if (category === "MCP Commands") {
|
||||
return 1
|
||||
}
|
||||
|
||||
return 2
|
||||
}
|
||||
|
||||
function subagentStatusLabel(status: FooterSubagentTab["status"]) {
|
||||
if (status === "completed") {
|
||||
return "done"
|
||||
@@ -374,7 +358,6 @@ export function RunCommandMenuBody(props: {
|
||||
const skills = createMemo(() => (props.commands() ?? []).filter((item) => item.source === "skill"))
|
||||
const activeSubagentCount = createMemo(() => props.subagents().filter((item) => item.status === "running").length)
|
||||
const entries = createMemo<CommandEntry[]>(() => {
|
||||
const builtins = ["compact", "editor", "new", "settings"]
|
||||
const session: CommandEntry[] = [
|
||||
{
|
||||
action: "editor",
|
||||
@@ -473,29 +456,10 @@ export function RunCommandMenuBody(props: {
|
||||
]
|
||||
: []),
|
||||
]
|
||||
const commands = (props.commands() ?? [])
|
||||
.filter((item) => item.source !== "skill" && !builtins.includes(item.name))
|
||||
.map(
|
||||
(item) =>
|
||||
({
|
||||
action: "slash",
|
||||
category: item.source === "mcp" ? "MCP Commands" : "Project Commands",
|
||||
name: item.name,
|
||||
display: item.name,
|
||||
footer: `/${item.name}`,
|
||||
keywords:
|
||||
item.source === "mcp"
|
||||
? `/${item.name} ${item.name} mcp ${item.description ?? ""}`
|
||||
: `/${item.name} ${item.name} ${item.description ?? ""}`,
|
||||
}) satisfies CommandEntry,
|
||||
)
|
||||
.sort((a, b) => categoryRank(a.category) - categoryRank(b.category) || a.display.localeCompare(b.display))
|
||||
|
||||
return [
|
||||
...session,
|
||||
...prompt,
|
||||
...agent,
|
||||
...commands,
|
||||
{
|
||||
action: "settings",
|
||||
category: "System",
|
||||
@@ -779,13 +743,12 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
}) {
|
||||
const entries = createMemo<QueuedEntry[]>(() =>
|
||||
const entries = createMemo(() =>
|
||||
props.prompts().map((prompt) => ({
|
||||
category: "",
|
||||
display: prompt.prompt.text.replaceAll("\n", " "),
|
||||
footer: prompt.delivery,
|
||||
keywords: prompt.prompt.text,
|
||||
prompt,
|
||||
})),
|
||||
)
|
||||
const controller = createSearchablePanelController({
|
||||
|
||||
@@ -31,13 +31,13 @@ import { realignEditorPromptParts, resolveEditorSlashValue } from "./prompt.edit
|
||||
import { monoTruncateMiddle } from "./mono"
|
||||
import { FOOTER_MENU_ROWS, createFooterMenuState, type RunFooterMenuItem } from "./footer.menu"
|
||||
import type { RunFooterTheme } from "./theme"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference, RunTuiConfig } from "./types"
|
||||
import type { FooterState, RunAgent, RunCommand, RunPrompt, RunPromptPart, RunReference } from "./types"
|
||||
|
||||
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
|
||||
const AUTOCOMPLETE_BOTTOM_ROWS = 1
|
||||
|
||||
export const TEXTAREA_MIN_ROWS = 1
|
||||
export const TEXTAREA_MAX_ROWS = 6
|
||||
const TEXTAREA_MAX_ROWS = 6
|
||||
export const PROMPT_MAX_ROWS = TEXTAREA_MAX_ROWS + AUTOCOMPLETE_ROWS - 1 + AUTOCOMPLETE_BOTTOM_ROWS
|
||||
|
||||
type Mention = Extract<RunPromptPart, { type: "file" | "agent" }>
|
||||
@@ -65,7 +65,6 @@ type PromptInput = {
|
||||
agents: Accessor<RunAgent[]>
|
||||
references: Accessor<RunReference[]>
|
||||
commands: Accessor<RunCommand[] | undefined>
|
||||
tuiConfig: RunTuiConfig
|
||||
state: Accessor<FooterState>
|
||||
view: Accessor<string>
|
||||
prompt: Accessor<boolean>
|
||||
@@ -142,7 +141,7 @@ function parseSlashCommand(text: string, commands: RunCommand[] | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export function selectedCommand(text: string, command: RunPrompt["command"], commands?: RunCommand[]) {
|
||||
export function selectedCommand(text: string, command: RunPrompt["command"]) {
|
||||
if (!command) {
|
||||
return
|
||||
}
|
||||
@@ -152,14 +151,10 @@ export function selectedCommand(text: string, command: RunPrompt["command"], com
|
||||
return
|
||||
}
|
||||
|
||||
// Bound drafts (e.g. the skill picker) may predate or omit the catalog
|
||||
// source; resolve it at submit time so routing never degrades to a plain
|
||||
// command for a skill entry.
|
||||
const source = command.source ?? commands?.find((item) => item.name === command.name)?.source
|
||||
return {
|
||||
name: command.name,
|
||||
arguments: head.arguments,
|
||||
...(source ? { source } : {}),
|
||||
...(command.source ? { source: command.source } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1140,7 +1135,7 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
return
|
||||
}
|
||||
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command, input.commands())
|
||||
const command = next.mode === "shell" ? undefined : selectedCommand(next.text, next.command)
|
||||
if (!command && next.mode !== "shell" && isExitCommand(next.text)) {
|
||||
input.onExit()
|
||||
return
|
||||
|
||||
@@ -74,7 +74,6 @@ type RunFooterOptions = {
|
||||
agents: RunAgent[]
|
||||
references: RunReference[]
|
||||
wrote?: boolean
|
||||
sessionID: () => string | undefined
|
||||
agentLabel: string
|
||||
modelLabel: string
|
||||
model: RunInput["model"]
|
||||
@@ -312,7 +311,6 @@ export class RunFooter implements FooterApi {
|
||||
currentVariant: footer.currentVariant,
|
||||
theme: footer.theme,
|
||||
mono: options.mono,
|
||||
tuiConfig: options.tuiConfig,
|
||||
miniSettings: footer.miniSettings,
|
||||
history: footer.history,
|
||||
onSubmit: footer.handlePrompt,
|
||||
|
||||
@@ -50,7 +50,6 @@ import type {
|
||||
RunPrompt,
|
||||
RunProvider,
|
||||
RunReference,
|
||||
RunTuiConfig,
|
||||
} from "./types"
|
||||
import type { RunTheme } from "./theme"
|
||||
|
||||
@@ -88,7 +87,6 @@ type RunFooterViewProps = {
|
||||
queuedPrompts?: () => FooterQueuedPrompt[]
|
||||
theme: () => RunTheme
|
||||
mono: boolean
|
||||
tuiConfig: RunTuiConfig
|
||||
miniSettings: () => MiniSettings
|
||||
history?: () => RunPrompt[]
|
||||
onSubmit: (input: RunPrompt) => boolean
|
||||
@@ -359,7 +357,6 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
agents: props.agents,
|
||||
references: props.references,
|
||||
commands: props.commands,
|
||||
tuiConfig: props.tuiConfig,
|
||||
state: props.state,
|
||||
view: promptView,
|
||||
prompt,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { FormAnswerField } from "../util/form"
|
||||
import type { FormReply, MiniFormRequest } from "./types"
|
||||
|
||||
export { formCustom, formLabel, formRows, formSelected, formTextual, formValidateValue }
|
||||
export { formCustom, formLabel, formRows, formTextual, formValidateValue }
|
||||
|
||||
export type FormBodyState = {
|
||||
formID: string
|
||||
@@ -97,7 +97,7 @@ export function formSetSelected(state: FormBodyState, selected: number): FormBod
|
||||
return { ...state, selected, error: "" }
|
||||
}
|
||||
|
||||
export function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
|
||||
function formSetEditing(state: FormBodyState, editing: boolean): FormBodyState {
|
||||
return { ...state, editing, error: "" }
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export function permissionLabel(option: PermissionOption): string {
|
||||
|
||||
export { permissionAlwaysLines }
|
||||
|
||||
export function permissionReply(
|
||||
function permissionReply(
|
||||
sessionID: string,
|
||||
requestID: string,
|
||||
reply: PermissionReply["reply"],
|
||||
|
||||
@@ -82,6 +82,7 @@ export type Lifecycle = {
|
||||
footer: FooterApi
|
||||
onResize(fn: () => void): () => void
|
||||
refreshTheme(): void
|
||||
setTitle(title?: string): void
|
||||
resetForReplay(input: { sessionTitle?: string; sessionID?: string; history: RunPrompt[] }): Promise<void>
|
||||
close(input: { showExit: boolean; sessionTitle?: string; sessionID?: string; history?: RunPrompt[] }): Promise<void>
|
||||
}
|
||||
@@ -184,6 +185,12 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
consoleMode: "disabled",
|
||||
clearOnShutdown: false,
|
||||
})
|
||||
const setTitle = (title?: string) => {
|
||||
if (input.host.platform !== "linux") return
|
||||
if (!title || isDefaultTitle(title)) return renderer.setTerminalTitle("OpenCode")
|
||||
renderer.setTerminalTitle(`OC | ${title.length > 40 ? title.slice(0, 37) + "..." : title}`)
|
||||
}
|
||||
setTitle(input.sessionTitle)
|
||||
const theme = await resolveRunTheme(renderer, tuiConfig.theme, mono)
|
||||
renderer.setBackgroundColor(theme.background)
|
||||
const state: SplashState = {
|
||||
@@ -225,7 +232,6 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
findFiles: input.findFiles,
|
||||
agents: input.agents,
|
||||
references: input.references,
|
||||
sessionID: input.getSessionID ?? (() => input.sessionID),
|
||||
...labels,
|
||||
model: input.model,
|
||||
variant: input.variant,
|
||||
@@ -338,6 +344,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
footer.close()
|
||||
await footer.idle().catch(() => {})
|
||||
footer.destroy()
|
||||
if (input.host.platform === "linux") renderer.setTerminalTitle("")
|
||||
shutdown(renderer)
|
||||
if (!wroteExit) {
|
||||
input.host.stdout.write("\n")
|
||||
@@ -350,6 +357,7 @@ export async function createRuntimeLifecycle(input: LifecycleInput): Promise<Lif
|
||||
refreshTheme() {
|
||||
footer.refreshTheme()
|
||||
},
|
||||
setTitle,
|
||||
onResize(fn) {
|
||||
let width = renderer.terminalWidth
|
||||
let height = renderer.terminalHeight
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import type { LocationRef } from "@opencode-ai/client/promise"
|
||||
import type { Config } from "../config"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences, waitForDefaultModel } from "./catalog.shared"
|
||||
import { loadRunAgents, loadRunCommands, loadRunReferences } from "./catalog.shared"
|
||||
import {
|
||||
resolveMiniSettings,
|
||||
resolveModelInfo,
|
||||
@@ -443,6 +443,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
if (footer.isClosed || runtimeController.signal.aborted) return
|
||||
state.sessionID = next.sessionID
|
||||
state.sessionTitle = next.sessionTitle ?? state.sessionTitle
|
||||
shell.setTitle(state.sessionTitle)
|
||||
state.agent = next.agent
|
||||
state.location = next.location
|
||||
state.model = next.model
|
||||
@@ -505,12 +506,15 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
return
|
||||
}
|
||||
|
||||
const model = await waitForDefaultModel({
|
||||
sdk,
|
||||
location: state.location,
|
||||
active: () => currentModelLoad(generation, sdk),
|
||||
signal,
|
||||
})
|
||||
const model = await sdk.model
|
||||
.default(
|
||||
{ location: { directory: state.location.directory, workspace: state.location.workspaceID } },
|
||||
{ signal },
|
||||
)
|
||||
.then((result) =>
|
||||
result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined,
|
||||
)
|
||||
.catch(() => undefined)
|
||||
if (!currentModelLoad(generation, sdk)) return
|
||||
const [fallbackSavedVariant, info] = await Promise.all([
|
||||
input.host.preferences.resolveVariant(model),
|
||||
@@ -760,6 +764,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
replayLimit: input.replayLimit,
|
||||
footer,
|
||||
onCommit: rememberLocal,
|
||||
onSessionTitle: (title) => {
|
||||
state.sessionTitle = title
|
||||
shell.setTitle(title)
|
||||
},
|
||||
trace: log,
|
||||
onCatalogRefresh: requestCatalogRefresh,
|
||||
contextLimit: (model) =>
|
||||
@@ -906,6 +914,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
||||
state.shown = false
|
||||
state.sessionID = created.sessionID
|
||||
state.sessionTitle = created.sessionTitle
|
||||
shell.setTitle(state.sessionTitle)
|
||||
state.agent = created.agent ?? state.agent
|
||||
state.location = created.location
|
||||
state.model = created.model
|
||||
|
||||
@@ -30,7 +30,7 @@ export function sameEntryGroup(left: StreamCommit | undefined, right: StreamComm
|
||||
return Boolean(current && next && current === next)
|
||||
}
|
||||
|
||||
export function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
function entryLayout(commit: StreamCommit, body: RunEntryBody = entryBody(commit)): EntryLayout {
|
||||
if (commit.kind === "tool") {
|
||||
if (body.type === "structured" || body.type === "markdown") {
|
||||
return "block"
|
||||
|
||||
@@ -194,7 +194,6 @@ function tab(child: ChildState): FooterSubagentTab {
|
||||
status: child.status,
|
||||
background: child.background ? true : undefined,
|
||||
title: child.title,
|
||||
lastUpdatedAt: child.lastUpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1084,11 +1083,11 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
|
||||
input.emit()
|
||||
},
|
||||
snapshot() {
|
||||
const tabs = [...children.values()].map(tab).toSorted((a, b) => {
|
||||
const tabs = [...children.values()].toSorted((a, b) => {
|
||||
const active = Number(b.status === "running") - Number(a.status === "running")
|
||||
if (active !== 0) return active
|
||||
return b.lastUpdatedAt - a.lastUpdatedAt
|
||||
})
|
||||
}).map(tab)
|
||||
const child = selected ? children.get(selected) : undefined
|
||||
const details: Record<string, FooterSubagentDetail> =
|
||||
child && !child.detailStale ? { [child.sessionID]: { commits: child.frames.map((item) => item.commit) } } : {}
|
||||
|
||||
@@ -46,6 +46,7 @@ type StreamInput = {
|
||||
replayLimit?: number
|
||||
footer: FooterApi
|
||||
onCommit?: (commit: StreamCommit) => void
|
||||
onSessionTitle?: (title: string) => void
|
||||
trace?: Trace
|
||||
signal?: AbortSignal
|
||||
onCatalogRefresh?: (signal?: AbortSignal) => unknown | Promise<unknown>
|
||||
@@ -828,6 +829,10 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
||||
}
|
||||
input.trace?.write("recv.event", event)
|
||||
subagents.main(client, event, attempt.signal)
|
||||
if (event.type === "session.renamed") {
|
||||
input.onSessionTitle?.(event.data.title)
|
||||
return
|
||||
}
|
||||
if (event.type === "session.input.admitted") {
|
||||
if (event.data.input.type !== "user") return
|
||||
mergePending({
|
||||
|
||||
@@ -85,7 +85,7 @@ function traceCommit(commit: StreamCommit) {
|
||||
}
|
||||
}
|
||||
|
||||
export function traceSubagentState(state: FooterSubagentState) {
|
||||
function traceSubagentState(state: FooterSubagentState) {
|
||||
return {
|
||||
tabs: state.tabs,
|
||||
details: Object.fromEntries(
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export { toolInlineInfo, toolOutputText } from "./tool"
|
||||
export type { MiniToolPart } from "./types"
|
||||
@@ -27,18 +27,17 @@ import {
|
||||
import { formatPath } from "../util/path-format"
|
||||
import type { RunEntryBody, StreamCommit, ToolSnapshot } from "./types"
|
||||
|
||||
export type { MiniToolPart } from "./types"
|
||||
export { canonicalToolName } from "../util/tool-display"
|
||||
|
||||
export type ToolView = {
|
||||
type ToolView = {
|
||||
output: boolean
|
||||
final: boolean
|
||||
snap?: "code" | "diff" | "structured"
|
||||
}
|
||||
|
||||
export type ToolPhase = "start" | "progress" | "final"
|
||||
type ToolPhase = "start" | "progress" | "final"
|
||||
|
||||
export type ToolDict = Record<string, unknown>
|
||||
type ToolDict = Record<string, unknown>
|
||||
|
||||
type PatchFile = {
|
||||
status?: string
|
||||
@@ -78,7 +77,7 @@ type ToolMetadata = ToolDict & {
|
||||
exit?: number
|
||||
}
|
||||
|
||||
export type ToolFrame = {
|
||||
type ToolFrame = {
|
||||
directory?: string
|
||||
raw: string
|
||||
name: string
|
||||
@@ -94,7 +93,7 @@ export type ToolFrame = {
|
||||
}
|
||||
}
|
||||
|
||||
export type ToolInline = {
|
||||
type ToolInline = {
|
||||
icon: string
|
||||
title: string
|
||||
description?: string
|
||||
@@ -102,7 +101,7 @@ export type ToolInline = {
|
||||
body?: string
|
||||
}
|
||||
|
||||
export type ToolProps = {
|
||||
type ToolProps = {
|
||||
input: ToolInput
|
||||
metadata: ToolMetadata
|
||||
frame: ToolFrame
|
||||
@@ -166,7 +165,7 @@ export function toolOutputText(name: string, content: ReadonlyArray<{ type: stri
|
||||
|
||||
function normalizeInput(name: string, value: unknown) {
|
||||
const input = dict(value)
|
||||
const path = typeof input.path === "string" ? input.path : text(input.filePath) || text(input.filepath)
|
||||
const path = typeof input.path === "string" ? input.path : text(input.filePath)
|
||||
const agent = typeof input.agent === "string" ? input.agent : text(input.subagent_type)
|
||||
return {
|
||||
...input,
|
||||
@@ -214,11 +213,6 @@ function normalizeStructured(name: string, value: unknown) {
|
||||
...structured,
|
||||
...(["edit", "patch"].includes(name) && Array.isArray(structured.files) ? { files } : {}),
|
||||
...(name === "subagent" && sessionID ? { sessionID } : {}),
|
||||
...(name === "shell" &&
|
||||
finiteNumber(structured.exit) === undefined &&
|
||||
finiteNumber(structured.exitCode) !== undefined
|
||||
? { exit: finiteNumber(structured.exitCode) }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -674,7 +668,7 @@ function scrollShellFinal(p: ToolProps): string {
|
||||
return fail(p.frame)
|
||||
}
|
||||
|
||||
const code = p.metadata.exit ?? finiteNumber(p.frame.meta.exitCode) ?? finiteNumber(p.frame.meta.exit_code)
|
||||
const code = p.metadata.exit
|
||||
const time = span(p.frame)
|
||||
if (code === undefined) {
|
||||
if (!time) {
|
||||
@@ -1113,7 +1107,7 @@ function frame(part: SessionMessageAssistantTool, directory?: string): ToolFrame
|
||||
}
|
||||
}
|
||||
|
||||
export function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
||||
function toolFrame(commit: StreamCommit, raw: string): ToolFrame {
|
||||
const current = commit.part ? frame(commit.part, commit.directory) : undefined
|
||||
return {
|
||||
directory: commit.directory,
|
||||
@@ -1198,7 +1192,7 @@ export function toolScroll(phase: ToolPhase, ctx: ToolFrame): string {
|
||||
return fallbackFinal(ctx)
|
||||
}
|
||||
|
||||
export function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
|
||||
function toolSnapshot(commit: StreamCommit, raw: string): ToolSnapshot | undefined {
|
||||
const ctx = toolFrame(commit, raw)
|
||||
const draw = rule(ctx.name)?.snap
|
||||
if (!draw) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export type RunCommand = {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export type RunProviderModel = {
|
||||
type RunProviderModel = {
|
||||
name?: string
|
||||
cost?: {
|
||||
input: number
|
||||
@@ -159,7 +159,7 @@ export type MiniHost = {
|
||||
export type EntryKind = "system" | "user" | "assistant" | "reasoning" | "tool" | "error"
|
||||
|
||||
// Whether the assistant is actively processing a turn.
|
||||
export type FooterPhase = "idle" | "running"
|
||||
type FooterPhase = "idle" | "running"
|
||||
|
||||
// Full snapshot of footer status bar state. Every update replaces the whole
|
||||
// object in the SolidJS signal so the view re-renders atomically.
|
||||
@@ -188,14 +188,14 @@ export type ScrollbackOptions = {
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
export type ToolCodeSnapshot = {
|
||||
type ToolCodeSnapshot = {
|
||||
kind: "code"
|
||||
title: string
|
||||
content: string
|
||||
file?: string
|
||||
}
|
||||
|
||||
export type ToolDiffSnapshot = {
|
||||
type ToolDiffSnapshot = {
|
||||
kind: "diff"
|
||||
items: Array<{
|
||||
title: string
|
||||
@@ -205,14 +205,14 @@ export type ToolDiffSnapshot = {
|
||||
}>
|
||||
}
|
||||
|
||||
export type ToolTaskSnapshot = {
|
||||
type ToolTaskSnapshot = {
|
||||
kind: "task"
|
||||
title: string
|
||||
rows: string[]
|
||||
tail: string
|
||||
}
|
||||
|
||||
export type ToolQuestionSnapshot = {
|
||||
type ToolQuestionSnapshot = {
|
||||
kind: "question"
|
||||
items: Array<{
|
||||
question: string
|
||||
@@ -223,15 +223,7 @@ export type ToolQuestionSnapshot = {
|
||||
|
||||
export type ToolSnapshot = ToolCodeSnapshot | ToolDiffSnapshot | ToolTaskSnapshot | ToolQuestionSnapshot
|
||||
|
||||
export type MiniToolState =
|
||||
| { status: "pending"; input: Record<string, unknown>; raw?: string }
|
||||
| {
|
||||
status: "running"
|
||||
input: Record<string, unknown>
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
time: { start: number }
|
||||
}
|
||||
type MiniToolState =
|
||||
| {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
@@ -304,7 +296,6 @@ export type FooterSubagentTab = {
|
||||
status: "running" | "completed" | "cancelled" | "error"
|
||||
background?: boolean
|
||||
title?: string
|
||||
lastUpdatedAt: number
|
||||
}
|
||||
|
||||
export type FooterSubagentDetail = {
|
||||
@@ -392,7 +383,7 @@ export type FormCancel = {
|
||||
location?: LocationRef
|
||||
}
|
||||
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "session" | "mini">
|
||||
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini">
|
||||
|
||||
export type MiniSettings = {
|
||||
thinking: "show" | "hide"
|
||||
@@ -408,11 +399,11 @@ export type MiniSettingChange = {
|
||||
|
||||
// Lifecycle phase of a scrollback entry. "start" opens the entry, "progress"
|
||||
// appends content (coalesced in the footer queue), "final" closes it.
|
||||
export type StreamPhase = "start" | "progress" | "final"
|
||||
type StreamPhase = "start" | "progress" | "final"
|
||||
|
||||
export type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
type StreamSource = "assistant" | "reasoning" | "tool" | "system"
|
||||
|
||||
export type StreamToolState = "running" | "completed" | "error"
|
||||
type StreamToolState = "running" | "completed" | "error"
|
||||
|
||||
// A single append-only commit to scrollback. The transport produces these from
|
||||
// V2 events, and RunFooter.append() queues them for the next
|
||||
|
||||
@@ -84,6 +84,7 @@ import { createSessionRows, messageBoundaryIDs, resolvePart, type PartRef, type
|
||||
import { switchLabel } from "../../util/model"
|
||||
import { findMessageBoundary, messageNavigationSlack } from "./message-navigation"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
import { useArgs } from "../../context/args"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
@@ -120,6 +121,7 @@ export function Session() {
|
||||
const { navigate } = useRoute()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const args = useArgs()
|
||||
const paths = useTuiPaths()
|
||||
const configState = useConfig()
|
||||
const config = configState.data
|
||||
@@ -216,6 +218,7 @@ export function Session() {
|
||||
const boundaries = createMemo(() => messageBoundaryIDs(rows, messages()))
|
||||
const [navigationMessage, setNavigationMessage] = createSignal<string>()
|
||||
const [navigationSlack, setNavigationSlack] = createSignal(0)
|
||||
const [synced, setSynced] = createSignal(false)
|
||||
|
||||
const clearMessageNavigation = () => {
|
||||
setNavigationSlack(0)
|
||||
@@ -242,6 +245,7 @@ export function Session() {
|
||||
|
||||
createEffect(() => {
|
||||
if (client.connection.status() !== "connected") return
|
||||
setSynced(false)
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
@@ -261,6 +265,7 @@ export function Session() {
|
||||
}
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
setSynced(true)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
@@ -273,15 +278,25 @@ export function Session() {
|
||||
})
|
||||
|
||||
let seeded = false
|
||||
let sent = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef | undefined
|
||||
const [prompt, setPrompt] = createSignal<PromptRef>()
|
||||
const bind = (r: PromptRef | undefined) => {
|
||||
prompt = r
|
||||
setPrompt(r)
|
||||
promptRef.set(r)
|
||||
if (seeded || !route.prompt || !r) return
|
||||
seeded = true
|
||||
r.set(route.prompt)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const current = prompt()
|
||||
if (sent || !current || !synced() || !local.model.ready) return
|
||||
if (!local.agent.current() || !local.model.current()) return
|
||||
if (!args.prompt || route.prompt?.text !== args.prompt || current.current.text !== args.prompt) return
|
||||
sent = true
|
||||
current.submit()
|
||||
})
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const unavailable = (feature: string) => {
|
||||
@@ -526,7 +541,7 @@ export function Session() {
|
||||
void client.api.session.revert
|
||||
.stage({ sessionID: route.sessionID, messageID: message.id })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
prompt?.set({
|
||||
prompt()?.set({
|
||||
...projectedPromptInput(message),
|
||||
pasted: [],
|
||||
})
|
||||
|
||||
@@ -69,6 +69,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
setTitle(title)
|
||||
}
|
||||
const events = createEventStream()
|
||||
let promptRequests = 0
|
||||
const calls = createFetch((url) => {
|
||||
const session = {
|
||||
id: "dummy",
|
||||
@@ -88,6 +89,10 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
promptRequests++
|
||||
return json({ data: {} })
|
||||
}
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
const originalWrite = process.stdout.write.bind(process.stdout)
|
||||
@@ -124,6 +129,7 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
|
||||
expect(stdout).toContain("Renamed session")
|
||||
expect(stdout).toContain("opencode2 -s dummy")
|
||||
expect(promptRequests).toBe(0)
|
||||
} finally {
|
||||
process.stdout.write = originalWrite
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
@@ -131,3 +137,80 @@ test("session lifecycle updates the terminal title and prints the epilogue after
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
test("session startup prompt is submitted exactly once", async () => {
|
||||
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
|
||||
const core = await import("@opentui/core")
|
||||
mock.module("@opentui/core", () => ({ ...core, createCliRenderer: async () => setup.renderer }))
|
||||
const events = createEventStream()
|
||||
const cwd = process.cwd()
|
||||
const location = { directory: cwd, project: { id: "project", directory: cwd } }
|
||||
const session = {
|
||||
id: "dummy",
|
||||
title: "Demo session",
|
||||
projectID: "project",
|
||||
location: { directory: cwd },
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
}
|
||||
const bodies: unknown[] = []
|
||||
const promptSubmitted = Promise.withResolvers<void>()
|
||||
const calls = createFetch(async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/session") return json({ data: [session], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy") return json({ data: session })
|
||||
if (url.pathname === "/api/session/dummy/message") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/dummy/pending") return json({ data: [] })
|
||||
if (url.pathname === "/api/session/dummy/permission") return json({ data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/model")
|
||||
return json({
|
||||
location,
|
||||
data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }],
|
||||
})
|
||||
if (url.pathname === "/api/session/dummy/prompt") {
|
||||
bodies.push(await request.json())
|
||||
promptSubmitted.resolve()
|
||||
return json({ data: {} })
|
||||
}
|
||||
}, events)
|
||||
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
|
||||
|
||||
try {
|
||||
const { run } = await import("../src/app")
|
||||
const task = Effect.runPromise(
|
||||
run({
|
||||
app: { name: "test", version: "test", channel: "test" },
|
||||
server: { endpoint: { url: server.url.toString() } },
|
||||
config: { get: async () => ({}), update: async () => ({}) },
|
||||
packages: { resolve: async () => undefined },
|
||||
args: { sessionID: "dummy", prompt: "RESUME_READY" },
|
||||
log: () => {},
|
||||
}).pipe(Effect.provide(AppNodeBuilder.build(Global.node)), Effect.provide(FileSystem.layerNoop({}))),
|
||||
)
|
||||
|
||||
await Promise.race([
|
||||
promptSubmitted.promise,
|
||||
Bun.sleep(2000).then(() => {
|
||||
throw new Error("startup prompt was not submitted")
|
||||
}),
|
||||
])
|
||||
await Bun.sleep(20)
|
||||
setup.renderer.destroy()
|
||||
await task
|
||||
|
||||
expect(bodies).toHaveLength(1)
|
||||
expect(bodies[0]).toMatchObject({ text: "RESUME_READY" })
|
||||
} finally {
|
||||
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
|
||||
await server.stop()
|
||||
mock.restore()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { loadRunReferences, runProviders, waitForDefaultModel } from "../../src/mini/catalog.shared"
|
||||
import { loadRunReferences, runProviders } from "../../src/mini/catalog.shared"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
|
||||
afterEach(() => {
|
||||
@@ -8,26 +8,6 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("run catalog shared", () => {
|
||||
test("resolves the catalog-selected model for the footer", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const selected = spyOn(client.model, "default").mockImplementation(
|
||||
() =>
|
||||
Promise.resolve({
|
||||
location: { directory: "/tmp", project: { id: "proj_1", directory: "/tmp" } },
|
||||
data: { id: "gpt-5", providerID: "openai" },
|
||||
}) as never,
|
||||
)
|
||||
|
||||
await expect(waitForDefaultModel({ sdk: client, location: { directory: "/tmp" } })).resolves.toEqual({
|
||||
providerID: "openai",
|
||||
modelID: "gpt-5",
|
||||
})
|
||||
expect(selected).toHaveBeenCalledWith(
|
||||
{ location: { directory: "/tmp", workspace: undefined } },
|
||||
{ signal: expect.any(AbortSignal) },
|
||||
)
|
||||
})
|
||||
|
||||
test("loads visible project references from the current reference catalog", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const list = spyOn(client.reference, "list").mockImplementation(
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { resolve, type Info, type Resolved } from "../../../src/config"
|
||||
import { TuiKeybind } from "../../../src/config/keybind"
|
||||
|
||||
type ResolvedInput = Omit<Info, "attention" | "keybinds" | "leader"> & {
|
||||
attention?: Partial<Resolved["attention"]>
|
||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||
leader_timeout?: number
|
||||
}
|
||||
|
||||
export function createTuiResolvedConfig(input: ResolvedInput = {}) {
|
||||
const { leader_timeout, ...current } = input
|
||||
return resolve(
|
||||
{
|
||||
...current,
|
||||
leader: leader_timeout === undefined ? undefined : { timeout: leader_timeout },
|
||||
},
|
||||
{ terminalSuspend: process.platform !== "win32" },
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
label: "Explore",
|
||||
description: "Inspect the keymap",
|
||||
status: "running",
|
||||
lastUpdatedAt: 1,
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
@@ -54,7 +53,6 @@ test("down opens subagents from an empty prompt", async () => {
|
||||
view={view}
|
||||
subagent={subagents}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={config}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
|
||||
@@ -35,7 +35,7 @@ import type {
|
||||
} from "../../src/mini/types"
|
||||
import { selectedCommand } from "../../src/mini/footer.prompt"
|
||||
import { RejectField } from "../../src/mini/footer.permission"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
const tuiConfig = createTuiResolvedConfig()
|
||||
|
||||
@@ -87,7 +87,6 @@ function subagent(input: {
|
||||
label: input.label,
|
||||
description: input.description,
|
||||
status: input.status ?? "running",
|
||||
lastUpdatedAt: 1,
|
||||
} satisfies FooterSubagentTab
|
||||
}
|
||||
|
||||
@@ -152,7 +151,6 @@ async function renderFooter(
|
||||
subagent={subagents}
|
||||
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
|
||||
mono={input.mono ?? false}
|
||||
tuiConfig={config}
|
||||
miniSettings={miniSettings}
|
||||
onSubmit={input.onSubmit ?? (() => true)}
|
||||
onPermissionReply={() => {}}
|
||||
@@ -355,7 +353,7 @@ test("run entry content updates when live commit text changes", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("direct command panel renders grouped command palette", async () => {
|
||||
test("direct command panel renders grouped actions without catalog commands", async () => {
|
||||
const [commands] = createSignal<RunCommand[] | undefined>([
|
||||
command({ name: "review", description: "Review code" }),
|
||||
command({ name: "deploy", description: "Deploy prompt", source: "mcp" }),
|
||||
@@ -423,6 +421,16 @@ test("direct command panel renders grouped command palette", async () => {
|
||||
expect(frame).not.toContain("Review code")
|
||||
expect(frame).not.toContain("Commands 8")
|
||||
|
||||
await app.mockInput.typeText("review")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("No results found")
|
||||
|
||||
app.mockInput.pressKey("u", { ctrl: true })
|
||||
await app.mockInput.typeText("deploy")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("No results found")
|
||||
|
||||
app.mockInput.pressKey("u", { ctrl: true })
|
||||
await app.mockInput.typeText("status")
|
||||
await app.renderOnce()
|
||||
expect(app.captureCharFrame()).toContain("Show status")
|
||||
@@ -976,27 +984,17 @@ test("direct footer closes settings with ctrl-c instead of arming exit", async (
|
||||
}
|
||||
})
|
||||
|
||||
test("selectedCommand backfills the catalog source for bound drafts", () => {
|
||||
const catalog = [command({ name: "opencode-ts", description: "TS skill", source: "skill" })]
|
||||
|
||||
// The skill picker binds `/name ` drafts; older drafts may lack source.
|
||||
expect(selectedCommand("/opencode-ts fix it", { name: "opencode-ts", arguments: "" }, catalog)).toEqual({
|
||||
name: "opencode-ts",
|
||||
arguments: "fix it",
|
||||
source: "skill",
|
||||
})
|
||||
// An explicit source wins without a catalog lookup.
|
||||
test("selectedCommand validates the bound command and refreshes its arguments", () => {
|
||||
expect(selectedCommand("/opencode-ts", { name: "opencode-ts", arguments: "", source: "skill" })).toEqual({
|
||||
name: "opencode-ts",
|
||||
arguments: "",
|
||||
source: "skill",
|
||||
})
|
||||
// Plain commands stay untagged.
|
||||
expect(
|
||||
selectedCommand("/deploy prod", { name: "deploy", arguments: "" }, [
|
||||
command({ name: "deploy", description: "Deploy" }),
|
||||
]),
|
||||
).toEqual({ name: "deploy", arguments: "prod" })
|
||||
expect(selectedCommand("/deploy prod", { name: "deploy", arguments: "" })).toEqual({
|
||||
name: "deploy",
|
||||
arguments: "prod",
|
||||
})
|
||||
expect(selectedCommand("/other", { name: "deploy", arguments: "" })).toBeUndefined()
|
||||
})
|
||||
|
||||
test("direct footer tags skill slash submissions with their catalog source", async () => {
|
||||
@@ -1142,7 +1140,6 @@ test("direct footer shows authoritative pending work while running", async () =>
|
||||
},
|
||||
]}
|
||||
theme={() => RUN_THEME_FALLBACK}
|
||||
tuiConfig={tuiConfig}
|
||||
miniSettings={() => ({ thinking: "hide", shell_output: "hide", turn_summary: "show", footer: "show", mono: false })}
|
||||
mono={false}
|
||||
onSubmit={() => true}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import type { Resolved } from "../../src/config"
|
||||
import { resolveMiniSettings, resolveModelInfo, resolveRunTuiConfig } from "../../src/mini/runtime.boot"
|
||||
import { catalogModel, catalogProvider } from "./fixture/catalog"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
|
||||
|
||||
function config(input?: {
|
||||
leader?: string
|
||||
@@ -21,7 +21,7 @@ function config(input?: {
|
||||
}): Resolved {
|
||||
const bind = input?.bindings
|
||||
return createTuiResolvedConfig({
|
||||
leader_timeout: input?.leaderTimeout,
|
||||
leader: input?.leaderTimeout === undefined ? undefined : { timeout: input.leaderTimeout },
|
||||
keybinds: {
|
||||
...(input?.leader && { leader: input.leader }),
|
||||
...(bind?.commandList && { command_list: bind.commandList }),
|
||||
@@ -95,14 +95,12 @@ describe("run runtime boot", () => {
|
||||
const result = await resolveRunTuiConfig(
|
||||
createTuiResolvedConfig({
|
||||
theme: { mode: "light" },
|
||||
leader_timeout: 450,
|
||||
session: { thinking: "show" },
|
||||
leader: { timeout: 450 },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.theme).toEqual({ mode: "light" })
|
||||
expect(result.leader.timeout).toBe(450)
|
||||
expect(result.session?.thinking).toBe("show")
|
||||
expect(resolveMiniSettings(result)).toEqual({
|
||||
thinking: "hide",
|
||||
shell_output: "hide",
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { LifecycleInput } from "../../src/mini/runtime.lifecycle"
|
||||
import type { FooterEvent, MiniHost } from "../../src/mini/types"
|
||||
import { catalogModel, catalogProvider, stubCatalogLists } from "./fixture/catalog"
|
||||
import { createFooterApiFixture } from "./fixture/footer-api"
|
||||
import { createTuiResolvedConfig } from "./fixture/tui-runtime"
|
||||
|
||||
function defer<T>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void
|
||||
@@ -84,6 +83,7 @@ describe("run interactive runtime", () => {
|
||||
footer: api,
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
setTitle: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}
|
||||
@@ -180,6 +180,7 @@ describe("run interactive runtime", () => {
|
||||
footer: api,
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
setTitle: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}
|
||||
@@ -199,6 +200,7 @@ describe("run interactive runtime", () => {
|
||||
const lifecycleStarted = defer<void>()
|
||||
const painted = defer<void>()
|
||||
const events: FooterEvent[] = []
|
||||
const titles: Array<string | undefined> = []
|
||||
const api = footer(events)
|
||||
api.idle = () => painted.promise
|
||||
const event = api.event
|
||||
@@ -265,6 +267,7 @@ describe("run interactive runtime", () => {
|
||||
footer: api,
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
setTitle: (title) => titles.push(title),
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}
|
||||
@@ -282,6 +285,7 @@ describe("run interactive runtime", () => {
|
||||
history: [{ text: "previous prompt", parts: [] }],
|
||||
})
|
||||
expect(events).toContainEqual({ type: "agent", agent: "review" })
|
||||
expect(titles).toEqual(["Resume"])
|
||||
expect(events).toContainEqual({
|
||||
type: "model",
|
||||
model: "Little Frank · OpenAI · high",
|
||||
@@ -344,6 +348,7 @@ describe("run interactive runtime", () => {
|
||||
footer: api,
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
setTitle: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: async (input) => {
|
||||
closedTitle = input.sessionTitle
|
||||
@@ -423,6 +428,7 @@ describe("run interactive runtime", () => {
|
||||
footer: api,
|
||||
onResize: () => () => {},
|
||||
refreshTheme: () => {},
|
||||
setTitle: () => {},
|
||||
resetForReplay: () => Promise.resolve(),
|
||||
close: () => Promise.resolve(),
|
||||
}
|
||||
|
||||
@@ -200,6 +200,31 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe("V2 mini transport", () => {
|
||||
test("reports session title changes", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
const titles: string[] = []
|
||||
const transport = await createSessionTransport({
|
||||
sdk: sdk({ streams: [events] }),
|
||||
sessionID: "ses_1",
|
||||
thinking: false,
|
||||
footer: footer().api,
|
||||
onSessionTitle: (title) => titles.push(title),
|
||||
})
|
||||
|
||||
events.push({
|
||||
id: "evt_renamed",
|
||||
created: 1,
|
||||
type: "session.renamed",
|
||||
durable: durable("ses_1", 1),
|
||||
data: { sessionID: "ses_1", title: "Greeting" },
|
||||
})
|
||||
|
||||
while (titles.length === 0) await Bun.sleep(0)
|
||||
expect(titles).toEqual(["Greeting"])
|
||||
await transport.close()
|
||||
})
|
||||
|
||||
test("formats footer usage with compact tokens and context percentage", async () => {
|
||||
const events = feed()
|
||||
events.push(connected())
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
Status: **Historical decision record.** Option B, replayable Location-scoped Catalog transforms, was selected and implemented. All interfaces and flows below are historical option sketches, not current API reference; current behavior is owned by Core.
|
||||
|
||||
## Implemented Readiness Contract
|
||||
|
||||
Catalog list endpoints return Location-scoped snapshots and do not wait for
|
||||
plugin activation. Consumers that need an authoritative startup validation call
|
||||
`POST /api/location/wait`, which waits up to five seconds for the initial plugin
|
||||
generation to settle and returns the resolved Location. A timeout returns 503;
|
||||
clients may call the endpoint again. After the barrier settles, absence from a
|
||||
catalog or agent snapshot is authoritative until a later configuration or
|
||||
integration update.
|
||||
|
||||
Ongoing catalog changes remain event-driven: `catalog.updated` tells frontends
|
||||
to fetch a new snapshot. The wait endpoint is only an initial-settlement barrier
|
||||
and does not turn snapshot reads into blocking operations.
|
||||
|
||||
The decision compared where provider/model inputs live and how visible catalog state changes after boot. The designs below preserve that comparison.
|
||||
|
||||
## Scenarios
|
||||
|
||||
Reference in New Issue
Block a user