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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- Event mapping (#1624): every schema-valid upstream event classifies as mapped, state-only, diagnostic, or ignored-with-reason across wrapped and flat envelopes. Unknown valid types become one bounded provider-neutral diagnostic; oversized envelopes are rejected at the adapter boundary. Raw upstream payload never reaches ingress, persistence, transport, or UI.
- Supervised permissions (#1624): permission and question asks surface as inline cards through the existing permission flow (`permission.request` → `permission.respond` → upstream reply). A shell approval relays once. A question reply carries the user’s ordered selected labels; questions do not offer session approval. Reject relays once and the turn settles cleanly. Duplicate replies and terminal outcomes are blocked.
- Provider notices (#1624): reroute (`session.next.model.switched`) uses the canonical model-fallback event. Warning/error toasts, configuration signals, and authentication failures use bounded provider-neutral notices with replay deduplication. The screen and thread state stay intact.
- Resolved dispatch modes (#1626): every turn carries the resolved permission and review modes. Full Access auto-answers upstream permission asks with `always` and shows no card; questions still card; supervised behavior is unchanged. `getApprovalReviewSupport` honestly reports `unavailable` — OpenCode has no native turn-review verdict — so the resolved review mode is always manual and the turn footer reflects it. The approval-review capability stays undeclared, so the Composer never offers Auto for OpenCode. The adapter is registered in the shared conformance suite (`opencode-core.synthetic.json`).

## How to get to it (user POV)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import "reflect-metadata";
import { describe, expect, it, vi } from "vitest";
import { OpenCodeProvider } from "../opencode-provider.js";
import { OpenCodeServerPool } from "../opencode-server-pool.js";
import type { PermissionRequest, TurnRequest } from "@mcode/contracts";

function testPool(): OpenCodeServerPool {
return new OpenCodeServerPool({
spawn: () => ({ pid: 1, on: () => {}, off: () => {}, kill: () => true }) as never,
waitForHealth: async () => {},
terminateTree: async () => {},
findFreePort: async () => 4096,
now: () => Date.now(),
env: () => ({}),
});
}

interface FakeHttp {
createSession: ReturnType<typeof vi.fn>;
promptAsync: ReturnType<typeof vi.fn>;
abortSession: ReturnType<typeof vi.fn>;
listModels: ReturnType<typeof vi.fn>;
listSessionMessages: ReturnType<typeof vi.fn>;
replyPermission: ReturnType<typeof vi.fn>;
replyQuestion: ReturnType<typeof vi.fn>;
rejectQuestion: ReturnType<typeof vi.fn>;
subscribeEvents: ReturnType<typeof vi.fn>;
getSessionStatus: ReturnType<typeof vi.fn>;
}

function fakeHttp(envelopes: unknown[]): FakeHttp {
let emit: ((e: unknown) => void) | null = null;
const idle = { type: "session.idle", properties: { sessionID: "ses_1" } };
const fake = {
createSession: vi.fn(async () => ({ id: "ses_1" })),
promptAsync: vi.fn(async () => {}),
abortSession: vi.fn(async () => {}),
listModels: vi.fn(async () => []),
listSessionMessages: vi.fn(async () => []),
// A relayed decision unblocks the fake upstream: the next step ends idle,
// which is what lets the turn settle.
replyPermission: vi.fn(async () => {
setTimeout(() => emit?.(idle), 0);
}),
replyQuestion: vi.fn(async () => {
setTimeout(() => emit?.(idle), 0);
}),
rejectQuestion: vi.fn(async () => {
setTimeout(() => emit?.(idle), 0);
}),
getSessionStatus: vi.fn(async () => new Proxy({}, { get: () => ({ type: "idle" }) })),
subscribeEvents: vi.fn(async (_url: string, signal: AbortSignal, onEnvelope: (e: unknown) => void) => {
emit = onEnvelope;
for (const envelope of envelopes) onEnvelope(envelope);
await new Promise<void>((resolve) => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
}),
};
return fake;
}

function testProvider(http: FakeHttp) {
const settingsService = { get: () => ({ provider: { cli: { opencode: "opencode" } } }) };
const envService = { getEnv: () => ({}) };
const host = {
events: { submit: async () => ({ commit: {}, delivery: { ingress: "queued" } }) },
processes: { attach: () => {}, terminateTree: async () => {} },
runtime: { platform: "win32" },
environment: { snapshot: () => ({}) },
browser: {},
threadControl: {},
grants: {},
};
const provider = new OpenCodeProvider(settingsService as never, envService as never, host as never);
provider.configureTestSeams({
pool: testPool(),
http: http as never,
probeCli: async () => ({ binaryPath: "opencode", version: "test" }),
idleConfirm: { intervalMs: 5, requiredPolls: 2, timeoutMs: 500, maxPollErrors: 2 },
});
return provider;
}

function turnRequest(permissionMode: "full" | "supervised" = "supervised"): TurnRequest<"opencode"> {
return {
turnId: "turn-1",
turnExecutionId: "55555555-5555-4555-8555-555555555555",
sessionId: "mcode-thread-1",
workspaceId: "ws-1",
threadId: "thread-1",
message: "run the thing",
cwd: "/w/a",
model: "anthropic/claude-sonnet-4-6",
permissionMode,
approvalReviewMode: "manual",
interactionMode: "build",
providerOptions: {},
} as TurnRequest<"opencode">;
}

function shellAsk(id = "per_1") {
return {
type: "permission.v2.asked",
properties: { id, sessionID: "ses_1", action: "bash", resources: ["echo hi"] },
};
}

function questionAsk(id = "que_1") {
return {
type: "question.v2.asked",
properties: {
id,
sessionID: "ses_1",
questions: [
{ header: "Deploy", question: "Deploy now?", options: [{ label: "Yes" }, { label: "No" }] },
],
},
};
}

describe("OpenCodeProvider approval-review support", () => {
it("honestly reports unavailable for every input without touching upstream", async () => {
const http = fakeHttp([]);
const provider = testProvider(http);

const expected = {
status: "unavailable",
supportedModes: ["manual"],
reason: "opencode-lacks-native-approval-review",
liveChangeScope: "none",
};
await expect(provider.getApprovalReviewSupport({
permissionMode: "supervised",
interactionMode: "build",
requestedMode: "automatic",
model: "anthropic/claude-sonnet-4-6",
})).resolves.toEqual(expected);
await expect(provider.getApprovalReviewSupport({
permissionMode: "full",
interactionMode: "build",
requestedMode: "automatic",
model: "anthropic/claude-sonnet-4-6",
})).resolves.toEqual(expected);
await expect(provider.getApprovalReviewSupport({
permissionMode: "supervised",
interactionMode: "plan",
requestedMode: "manual",
model: "anthropic/claude-sonnet-4-6",
})).resolves.toEqual(expected);

expect(http.subscribeEvents).not.toHaveBeenCalled();
expect(http.createSession).not.toHaveBeenCalled();
expect(http.promptAsync).not.toHaveBeenCalled();
expect(http.listModels).not.toHaveBeenCalled();
await provider.shutdown();
});
});

describe("OpenCodeProvider full-access permission bypass", () => {
it("auto-replies always to a permission ask in full mode without carding", async () => {
const http = fakeHttp([shellAsk()]);
const provider = testProvider(http);
const cards: PermissionRequest[] = [];
const resolved: unknown[] = [];
provider.on("permission_request", (request) => cards.push(request as PermissionRequest));
provider.on("permission_resolved", (payload) => resolved.push(payload));

await provider.sendTurn(turnRequest("full"));

expect(cards).toHaveLength(0);
expect(http.replyPermission).toHaveBeenCalledTimes(1);
expect(http.replyPermission).toHaveBeenCalledWith(
"http://127.0.0.1:4096", "ses_1", "per_1", "always", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(resolved).toEqual([{ requestId: "per_1", decision: "allow-session" }]);
expect(provider.listPendingPermissions("thread-1")).toHaveLength(0);
await provider.shutdown();
});

it("still cards a question ask in full mode", async () => {
const http = fakeHttp([questionAsk()]);
const provider = testProvider(http);
const cards: PermissionRequest[] = [];
provider.on("permission_request", (request) => cards.push(request as PermissionRequest));

const sending = provider.sendTurn(turnRequest("full"));
await vi.waitFor(() => expect(cards).toHaveLength(1));
expect(cards[0]?.toolName).toBe("Question");
expect(http.replyPermission).not.toHaveBeenCalled();
expect(http.rejectQuestion).not.toHaveBeenCalled();

expect(provider.resolvePermission("que_1", "deny")).toBe(true);
await sending;
expect(http.rejectQuestion).toHaveBeenCalledTimes(1);
await provider.shutdown();
});

it("cards a permission ask without auto-replying in supervised mode", async () => {
const http = fakeHttp([shellAsk()]);
const provider = testProvider(http);
const cards: PermissionRequest[] = [];
provider.on("permission_request", (request) => cards.push(request as PermissionRequest));

const sending = provider.sendTurn(turnRequest("supervised"));
await vi.waitFor(() => expect(cards).toHaveLength(1));
expect(http.replyPermission).not.toHaveBeenCalled();

expect(provider.resolvePermission("per_1", "allow")).toBe(true);
await sending;
expect(http.replyPermission).toHaveBeenCalledTimes(1);
expect(http.replyPermission).toHaveBeenCalledWith(
"http://127.0.0.1:4096", "ses_1", "per_1", "once", "v2", expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
await provider.shutdown();
});

it("replies once to a replayed permission ask in full mode", async () => {
const http = fakeHttp([shellAsk(), shellAsk()]);
const provider = testProvider(http);
const resolved: unknown[] = [];
provider.on("permission_resolved", (payload) => resolved.push(payload));

await provider.sendTurn(turnRequest("full"));

expect(http.replyPermission).toHaveBeenCalledTimes(1);
expect(resolved).toEqual([{ requestId: "per_1", decision: "allow-session" }]);
await provider.shutdown();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,8 @@ describe("OpenCodeProvider idle confirmation", () => {
}),
});
const { provider, submitted } = testProvider(http as never);
const sending = provider.sendTurn(turnRequest());
// Full access auto-answers permission asks; a pending card requires supervised.
const sending = provider.sendTurn({ ...turnRequest(), permissionMode: "supervised" });
await vi.waitFor(() => expect(provider.listPendingPermissions("thread-1")).toHaveLength(1));
await new Promise((resolve) => setTimeout(resolve, 30));
expect(endedOutcomes(submitted)).toEqual([]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { inject, injectable } from "tsyringe";
import { logger } from "@mcode/shared";
import type {
AgentEvent,
ApprovalReviewSupport,
IAgentProvider,
IApprovalReviewCapable,
ISessionEvictable,
PermissionDecision,
PermissionQuestion,
Expand Down Expand Up @@ -243,7 +245,7 @@ function partRoleOf(
* events; stop aborts the upstream session while the server stays warm.
*/
@injectable()
export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentProvider, ISessionEvictable {
export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentProvider, ISessionEvictable, IApprovalReviewCapable {
readonly id: ProviderId = "opencode";
readonly descriptor = Object.freeze({
id: "opencode" as const,
Expand Down Expand Up @@ -300,6 +302,25 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP
throw Object.assign(new Error("OpenCode side-channel query is not available in the minimal turn slice"), { code: "ETIMEDOUT" });
}

/**
* Upstream OpenCode exposes only per-tool permission asks; no native
* turn-review verdict exists, so automatic approval review is honestly
* unavailable for every input. Side-effect-free: no probing, no I/O.
*/
async getApprovalReviewSupport(_input: {
permissionMode: "full" | "supervised";
interactionMode: "plan" | "build";
requestedMode: "manual" | "automatic";
model: string;
}): Promise<ApprovalReviewSupport> {
return {
status: "unavailable",
supportedModes: ["manual"],
reason: "opencode-lacks-native-approval-review",
liveChangeScope: "none",
};
}

async listModels(): Promise<ProviderModelInfo[]> {
const cliPath = this.cliPath();
const entry = await this.pool.acquire({ binaryPath: cliPath, cwd: process.cwd(), hostname: OPENCODE_SERVE_HOSTNAME }).catch((error: unknown) => {
Expand Down Expand Up @@ -964,15 +985,7 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP
deliveryAttempt: req.deliveryAttempt ?? 1,
};
if (reason === "permission-request") {
const request = synthesizeOpenCodePermissionRequest({ threadId, properties: normalized.properties });
if (!request) return this.emitAskDiagnostic(req, threadId);
if (this.pendingPermissions.has(request.requestId)) return;
this.pendingPermissions.set(request.requestId, {
request, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl,
kind: "permission", version: askVersion(normalized.type),
signal: state.abortController.signal, routing, replying: false,
});
this.emit("permission_request", request);
this.maybeEmitPermissionAsk(req, baseUrl, upstreamId, normalized, state, routing);
return;
}
const synthesized = synthesizeOpenCodeQuestionRequest({ threadId, properties: normalized.properties });
Expand All @@ -986,6 +999,39 @@ export class OpenCodeProvider extends NodeEvents.EventEmitter implements IAgentP
this.emit("permission_request", synthesized);
}

/**
* Register one upstream permission ask, then either card it for the user
* (supervised) or auto-answer it "always" (full access — the serve API has
* no session-wide bypass, so the gate is adapter-side). The entry stays in
* the pending map either way so dedupe, draining, and session-invalidation
* bookkeeping stay identical.
*/
private maybeEmitPermissionAsk(
req: TurnRequest<"opencode">,
baseUrl: string,
upstreamId: string,
normalized: { type: string; properties: Record<string, unknown> },
state: OpenCodeTurnState,
routing: CanonicalLiveEventRouting,
): void {
const threadId = this.threadIdFor(req.sessionId);
const request = synthesizeOpenCodePermissionRequest({ threadId, properties: normalized.properties });
if (!request) return this.emitAskDiagnostic(req, threadId);
if (this.pendingPermissions.has(request.requestId)) return;
const entry: OpenCodePendingAsk = {
request, sessionId: req.sessionId, upstreamSessionId: upstreamId, baseUrl,
kind: "permission", version: askVersion(normalized.type),
signal: state.abortController.signal, routing, replying: false,
};
this.pendingPermissions.set(request.requestId, entry);
if (req.permissionMode === "full") {
entry.replying = true;
void this.relayDecision(entry, "allow-session");
return;
}
this.emit("permission_request", request);
}

private emitAskDiagnostic(
req: TurnRequest<"opencode">,
threadId: string,
Expand Down
2 changes: 1 addition & 1 deletion packages/contracts/src/providers/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { Provider } from "../compat/agent-model.js";

/**
* Identifier for a supported AI provider.
* "opencode" remains catalog-only until a server adapter ships.
* "gemini" remains catalog-only until a server adapter ships.
*/
export type ProviderId = "claude" | "codex" | "gemini" | "copilot" | "cursor" | "opencode" | "devin";

Expand Down
10 changes: 10 additions & 0 deletions packages/providers/src/__tests__/factories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createCodexProvider,
createCopilotProvider,
createCursorProvider,
createOpencodeProvider,
type ProviderFactoryInput,
type ProviderHostPorts,
} from "../index.js";
Expand Down Expand Up @@ -67,6 +68,7 @@ describe("Provider factories", () => {
["codex", createCodexProvider],
["copilot", createCopilotProvider],
["cursor", createCursorProvider],
["opencode", createOpencodeProvider],
] as const)("creates an inert %s Provider boundary", (id, createProvider) => {
const input = createInput();

Expand Down Expand Up @@ -104,6 +106,14 @@ describe("Provider factories", () => {
{ name: "child-cancellation", support: "unsupported" },
);
}
if (id === "opencode") {
expect(provider.descriptor.capabilities).toEqual([
{ name: "build", support: "supported" },
{ name: "plan", support: "supported" },
{ name: "permissions", support: "supported" },
{ name: "session-eviction", support: "supported" },
]);
}
const hostPortMethods = Object.entries(input.host)
.filter(([name]) => name !== "runtime")
.flatMap(([, port]) => Object.values(port));
Expand Down
Loading
Loading