diff --git a/packages/opencode/index.ts b/packages/opencode/index.ts index d9c3f8a..03c1818 100644 --- a/packages/opencode/index.ts +++ b/packages/opencode/index.ts @@ -330,8 +330,18 @@ export async function createOpenCodeTools( const shell = createNodeShell(projectRoot); const navigatorEnabled = config.adapters.opencode.navigator.enabled; + let agentNames: string[] | undefined; + if (navigatorEnabled && navigator) { + try { + const response = await navigator.client.v2.agent.list({ location: { directory: projectRoot } }); + agentNames = response.data?.data.map((agent) => agent.id); + } catch { + // Target-workspace validation remains authoritative if discovery is unavailable during registration. + } + } const navigatorTools: Record = navigatorEnabled && navigator ? createNavigatorTools({ + agentNames, client: navigator.client, legacyClient: navigator.legacyClient, projectID: navigator.projectID, diff --git a/packages/opencode/navigator.ts b/packages/opencode/navigator.ts index b4a0c25..8f79a60 100644 --- a/packages/opencode/navigator.ts +++ b/packages/opencode/navigator.ts @@ -6,6 +6,7 @@ import type { Part as LegacyPart, } from "@opencode-ai/sdk/client"; import type { + ModelV2Info, OpencodeClient, SessionMessage, SessionV2Info, @@ -82,6 +83,7 @@ type NavigatorToolName = | "worktree_remove"; type NavigatorContext = { + agentNames?: string[]; checkout: string; projectID: string; config: NavigatorConfig; @@ -121,6 +123,35 @@ async function assertKnownV2Agent(client: NavigatorClient, location: NavigatorLo throw new Error(`Unknown OpenCode agent "${agent}". Available agents: ${agents.map((item) => item.id).join(", ")}`); } +async function assertKnownV2Model( + client: NavigatorClient, + location: NavigatorLocation, + model: { providerID: string; modelID: string; variant?: string }, +) { + const models = envelopeData( + await client.v2.model.list({ location }), + "OpenCode model list", + ); + const match = models.find((item) => item.providerID === model.providerID && item.id === model.modelID && item.enabled); + if (!match) { + const available = models + .filter((item) => item.enabled && item.providerID === model.providerID) + .slice(0, 20) + .map((item) => item.id); + throw new Error( + `Unknown or disabled OpenCode model "${model.providerID}/${model.modelID}"` + + (available.length ? `. Available ${model.providerID} models: ${available.join(", ")}` : ""), + ); + } + if (!model.variant) return; + const variants = match.variants.map((item) => item.id); + if (variants.includes(model.variant)) return; + throw new Error( + `Unknown variant "${model.variant}" for OpenCode model "${model.providerID}/${model.modelID}"` + + (variants.length ? `. Available variants: ${variants.join(", ")}` : ". This model has no variants"), + ); +} + function normalizeDirectory(directory: string) { return path.resolve(directory); } @@ -532,6 +563,10 @@ export function createNavigatorTools( input: NavigatorContext & { client: NavigatorClient }, ): Record { const { client, ...navigator } = input; + const agentNames = [...new Set(navigator.agentNames ?? [])].sort(); + const agentSchema = agentNames.length + ? tool.schema.enum(agentNames as [string, ...string[]]) + : tool.schema.string().min(1); return { worktree_list: tool({ description: `${explicitNavigatorUse} List the current OpenCode project checkout and its managed native worktrees.`, @@ -560,7 +595,7 @@ export function createNavigatorTools( startCommand: tool.schema.string().min(1).optional(), }), ]), - agent: tool.schema.string().min(1).optional(), + agent: agentSchema.describe("Registered OpenCode agent; omit to inherit the caller/default agent").optional(), model: tool.schema.object({ providerID: tool.schema.string().min(1), modelID: tool.schema.string().min(1), @@ -654,9 +689,12 @@ export function createNavigatorTools( let session: SessionV2Info | { id: string; projectID: string }; try { - if (navigator.protocol === "v2" && selectedAgent) { + if (selectedAgent) { await assertKnownV2Agent(client, location, selectedAgent); } + if (selectedModel) { + await assertKnownV2Model(client, location, selectedModel); + } session = navigator.protocol === "v1" ? responseData( await navigator.legacyClient(location.directory).session.create(), @@ -815,15 +853,22 @@ export function createNavigatorTools( args: { sessionID: tool.schema.string().min(1), prompt: tool.schema.string().min(1), - agent: tool.schema.string().min(1).optional(), + agent: agentSchema.describe("Registered OpenCode agent; omit to keep the session's current agent").optional(), model: tool.schema.object({ providerID: tool.schema.string().min(1), modelID: tool.schema.string().min(1), + variant: tool.schema.string().min(1).optional(), }).optional(), }, async execute(args, context) { assertNotCallingSession(args.sessionID, context, "send to"); const session = await getOwnedSession(client, navigator.projectID, args.sessionID); + if (args.agent) { + await assertKnownV2Agent(client, session.location, args.agent); + } + if (args.model) { + await assertKnownV2Model(client, session.location, args.model); + } if (navigator.protocol === "v1") { const response = await navigator.legacyClient(session.location.directory).session.promptAsync({ path: { id: args.sessionID }, @@ -837,7 +882,6 @@ export function createNavigatorTools( return json({ sessionID: args.sessionID, admitted: true }); } if (args.agent) { - await assertKnownV2Agent(client, session.location, args.agent); const response = await client.v2.session.switchAgent({ sessionID: args.sessionID, agent: args.agent, @@ -847,7 +891,11 @@ export function createNavigatorTools( if (args.model) { const response = await client.v2.session.switchModel({ sessionID: args.sessionID, - model: { providerID: args.model.providerID, id: args.model.modelID }, + model: { + providerID: args.model.providerID, + id: args.model.modelID, + ...(args.model.variant ? { variant: args.model.variant } : {}), + }, }); if (response.error !== undefined) failResponse(response.error, `OpenCode model switch for session ${args.sessionID}`); } diff --git a/packages/opencode/test/navigator.test.ts b/packages/opencode/test/navigator.test.ts index 933d7e2..7b5fb14 100644 --- a/packages/opencode/test/navigator.test.ts +++ b/packages/opencode/test/navigator.test.ts @@ -37,6 +37,15 @@ function createClient(overrides: Record = {}) { agent: { list: async () => response({ location: { directory: "/repo" }, data: [{ id: "build" }, { id: "reviewer" }, { id: "worker" }] }), }, + model: { + list: async () => response({ + location: { directory: "/repo" }, + data: [ + { id: "gpt-5.6-sol", providerID: "openai", enabled: true, variants: [{ id: "xhigh" }] }, + { id: "gpt-5.5", providerID: "openai", enabled: true, variants: [] }, + ], + }), + }, session: { active: async () => response({ data: {} }), get: async ({ sessionID }: { sessionID: string }) => response({ @@ -80,6 +89,7 @@ function tools(client = createClient(), config = navigatorConfig, protocol: "v1" projectID: "project-1", checkout: "/repo", protocol, + agentNames: ["build", "reviewer", "worker"], }); } @@ -96,6 +106,12 @@ describe("Kompass Navigator", () => { } }); + test("exposes registered agents as tool enums", () => { + const navigator = tools(); + assert.deepEqual((navigator.session_create as any).args.agent.unwrap().options, ["build", "reviewer", "worker"]); + assert.deepEqual((navigator.session_send as any).args.agent.unwrap().options, ["build", "reviewer", "worker"]); + }); + test("matches Desktop protocol detection", async () => { const legacy = createClient({ global: { health: async () => response({ healthy: true }) }, @@ -327,6 +343,25 @@ describe("Kompass Navigator", () => { assert.equal(sessionCreates, 0); }); + test("validates explicit models and variants before creating a V2 session", async () => { + let sessionCreates = 0; + const client = createClient({ + session: { create: async () => { sessionCreates += 1; return response({ data: session("created") }); } }, + }); + + await assert.rejects((tools(client).session_create as any).execute({ + prompt: "work", + model: { providerID: "openai", modelID: "missing" }, + environment: { type: "checkout" }, + }, context()), /Unknown or disabled OpenCode model.*Available openai models/); + await assert.rejects((tools(client).session_create as any).execute({ + prompt: "work", + model: { providerID: "openai", modelID: "gpt-5.6-sol", variant: "missing" }, + environment: { type: "checkout" }, + }, context()), /Unknown variant.*Available variants: xhigh/); + assert.equal(sessionCreates, 0); + }); + test("uses one legacy transcript path when Desktop selects V1", async () => { const prompts: any[] = []; const client = createClient({ @@ -360,6 +395,30 @@ describe("Kompass Navigator", () => { assert.equal(output.messages[0].items[0].text, "visible"); }); + test("validates explicit options before sending a legacy prompt", async () => { + let prompts = 0; + const client = createClient({ + legacySession: { + promptAsync: async () => { prompts += 1; return response(undefined); }, + }, + }); + const navigator = tools(client, navigatorConfig, "v1"); + + await assert.rejects( + (navigator.session_send as any).execute({ sessionID: "session-1", prompt: "work", agent: "missing" }, context()), + /Unknown OpenCode agent "missing"/, + ); + await assert.rejects( + (navigator.session_send as any).execute({ + sessionID: "session-1", + prompt: "work", + model: { providerID: "openai", modelID: "missing" }, + }, context()), + /Unknown or disabled OpenCode model/, + ); + assert.equal(prompts, 0); + }); + test("preserves legacy errored tool output text", async () => { const client = createClient({ legacySession: { diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index 034224c..d9f8562 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -287,9 +287,12 @@ describe("createOpenCodeTools", () => { await withTempHome(async () => { const navigatorClient = { worktree: { list() {}, create() {}, remove() {} }, - v2: { session: { - create() {}, list() {}, get() {}, messages() {}, prompt() {}, active() {}, wait() {}, interrupt() {}, - } }, + v2: { + agent: { list: async () => ({ data: { data: [{ id: "build" }, { id: "reviewer" }] } }) }, + session: { + create() {}, list() {}, get() {}, messages() {}, prompt() {}, active() {}, wait() {}, interrupt() {}, + }, + }, }; const tools = await createOpenCodeTools(createMockClient() as never, process.cwd(), { client: navigatorClient as never, @@ -299,6 +302,10 @@ describe("createOpenCodeTools", () => { }); assert.ok(tools.kompass_session_create); assert.ok(tools.kompass_worktree_list); + assert.deepEqual( + (tools.kompass_session_create as any).args.agent.unwrap().options, + ["build", "reviewer"], + ); }); });