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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/opencode/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolDefinition> = navigatorEnabled && navigator
? createNavigatorTools({
agentNames,
client: navigator.client,
legacyClient: navigator.legacyClient,
projectID: navigator.projectID,
Expand Down
58 changes: 53 additions & 5 deletions packages/opencode/navigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
Part as LegacyPart,
} from "@opencode-ai/sdk/client";
import type {
ModelV2Info,
OpencodeClient,
SessionMessage,
SessionV2Info,
Expand Down Expand Up @@ -82,6 +83,7 @@ type NavigatorToolName =
| "worktree_remove";

type NavigatorContext = {
agentNames?: string[];
checkout: string;
projectID: string;
config: NavigatorConfig;
Expand Down Expand Up @@ -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<ModelV2Info[]>(
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);
}
Expand Down Expand Up @@ -532,6 +563,10 @@ export function createNavigatorTools(
input: NavigatorContext & { client: NavigatorClient },
): Record<NavigatorToolName, ToolDefinition> {
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.`,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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 },
Expand All @@ -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,
Expand All @@ -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}`);
}
Expand Down
59 changes: 59 additions & 0 deletions packages/opencode/test/navigator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ function createClient(overrides: Record<string, any> = {}) {
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({
Expand Down Expand Up @@ -80,6 +89,7 @@ function tools(client = createClient(), config = navigatorConfig, protocol: "v1"
projectID: "project-1",
checkout: "/repo",
protocol,
agentNames: ["build", "reviewer", "worker"],
});
}

Expand All @@ -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 }) },
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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: {
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/test/tool-registration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"],
);
});
});

Expand Down
Loading