Fix OpenCode custom command hangs (#1063)

* Fix OpenCode slash command turns

* ci: install opencode in server-tests so real OpenCode e2e runs

Server tests previously only installed the Claude Code CLI, so every
opencode-*.real.e2e.test.ts self-skipped on CI. The custom-command and
turn-lifecycle real tests cover the OpenCode session SSE rewrite, so
add opencode-ai globally and let them run. Big Pickle is free so no
provider secrets needed.

* Fix OpenCode unit tests exposed by enabling opencode in CI

server-tests previously skipped every OpenCodeAgentClient test because
opencode-ai was not installed. Now that CI runs them, four real issues
surface:

- Drop unused mkdirSync import (was used on a code path 1812b1489
  refactored away on main; the rebase merged imports without rechecking
  usage). Fixes the lint job.
- Fix slash-command-timeout test passing four ctor args instead of three,
  which silently dropped the TestOpenCodeRuntime and ran against the real
  opencode runtime.
- Relax the listModels contextWindowMaxTokens assertion. Some providers
  routed through OpenCode (OpenAI in particular) don't expose a numeric
  context window; assert the type only when the field is present.
- Swallow late notifySubscribers calls once close() flips the closed
  flag and clear subscribers up front. The session-lifetime SSE stream
  introduced by the prior commit means a session.error arriving after
  the owning test has moved on can rejectCompletion on a deferred no
  one is awaiting, surfacing as an unhandled rejection in a downstream
  test file.
This commit is contained in:
Mohamed Boudra
2026-05-17 19:51:20 +08:00
committed by GitHub
parent b3c272f720
commit 00e7ac9ec8
13 changed files with 394 additions and 173 deletions

View File

@@ -104,8 +104,8 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Install Claude Code CLI for provider tests
run: npm install -g @anthropic-ai/claude-code
- name: Install agent CLIs for provider tests
run: npm install -g @anthropic-ai/claude-code opencode-ai
- name: Build highlight dependency
run: npm run build --workspace=@getpaseo/highlight

View File

@@ -1,8 +1,12 @@
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../../../utils/executable.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
const BIG_PICKLE_MODEL = "opencode/big-pickle";
const logger = pino({ level: "silent" });
describe("opencode agent commands contract (real)", () => {
let canRun = false;
@@ -17,13 +21,18 @@ describe("opencode agent commands contract (real)", () => {
}
});
afterAll(async () => {
await OpenCodeServerManager.getInstance(logger).shutdown();
});
test("lists slash commands with the expected contract", async () => {
expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
model: BIG_PICKLE_MODEL,
modeId: "plan",
});
@@ -50,10 +59,11 @@ describe("opencode agent commands contract (real)", () => {
test("executes a slash command without arguments", async () => {
expect(await isCommandAvailable("opencode")).toBe(true);
const client = new OpenCodeAgentClient(pino({ level: "silent" }));
const client = new OpenCodeAgentClient(logger);
const session = await client.createSession({
provider: "opencode",
cwd: process.cwd(),
model: BIG_PICKLE_MODEL,
modeId: "plan",
});

View File

@@ -0,0 +1,107 @@
import { existsSync } from "node:fs";
import { mkdir, mkdtemp, rm, rmdir, writeFile } from "node:fs/promises";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { beforeAll, beforeEach, describe, expect, test } from "vitest";
import pino from "pino";
import { isCommandAvailable } from "../../../utils/executable.js";
import { createDaemonTestContext, type DaemonTestContext } from "../../test-utils/index.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
const COMMAND_NAME = "paseo-issue-903-big-pickle";
const COMMAND_FILE_NAME = `${COMMAND_NAME}.md`;
const BIG_PICKLE_MODEL = "opencode/big-pickle";
const EXPECTED_RESPONSE = "PASEO_ISSUE_903_BIG_PICKLE_OK";
describe("opencode custom command Big Pickle E2E (real)", () => {
let canRun = false;
beforeAll(async () => {
canRun = await isCommandAvailable("opencode");
});
beforeEach((context) => {
if (!canRun) {
context.skip();
}
});
test("executes a global custom command through Paseo using Big Pickle", async () => {
const commandDir = path.join(homedir(), ".config", "opencode", "command");
const commandFile = path.join(commandDir, COMMAND_FILE_NAME);
const commandDirExisted = existsSync(commandDir);
if (existsSync(commandFile)) {
throw new Error(`Refusing to overwrite existing OpenCode command file: ${commandFile}`);
}
const projectDir = await mkdtemp(path.join(tmpdir(), "paseo-opencode-big-pickle-"));
const logger = pino({ level: "silent" });
let ctx: DaemonTestContext | undefined;
try {
await mkdir(commandDir, { recursive: true });
await writeFile(
commandFile,
[
"---",
"description: Paseo issue 903 Big Pickle custom command",
"agent: build",
"---",
"",
"Reply exactly with this token and nothing else:",
EXPECTED_RESPONSE,
"",
].join("\n"),
);
ctx = await createDaemonTestContext({
logger,
agentClients: {
opencode: new OpenCodeAgentClient(logger),
},
});
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd: projectDir,
model: BIG_PICKLE_MODEL,
modeId: "plan",
title: "OpenCode issue 903 Big Pickle custom command",
});
expect(agent.provider).toBe("opencode");
expect(agent.model).toBe(BIG_PICKLE_MODEL);
expect(agent.status).toBe("idle");
const commands = await ctx.client.listCommands(agent.id);
expect(commands.error).toBeNull();
expect(commands.commands).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: COMMAND_NAME,
description: "Paseo issue 903 Big Pickle custom command",
}),
]),
);
await ctx.client.sendMessage(agent.id, `/${COMMAND_NAME}`);
const state = await ctx.client.waitForFinish(agent.id, 90_000);
expect(state.status).toBe("idle");
expect(state.error).toBeNull();
expect(state.final?.status).toBe("idle");
expect(state.lastMessage).toContain(EXPECTED_RESPONSE);
} finally {
await ctx?.cleanup();
await OpenCodeServerManager.getInstance(logger).shutdown();
await rm(commandFile, { force: true });
if (!commandDirExisted) {
await rmdir(commandDir).catch(() => undefined);
}
await rm(projectDir, { recursive: true, force: true });
}
}, 120_000);
});

View File

@@ -4,7 +4,6 @@ import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
createEventStream,
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
@@ -19,7 +18,7 @@ function mockOpenCodeClient(options: MockOpenCodeClientOptions = {}) {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = new TestOpenCodeClient();
openCodeClient.appAgentsResponse = { data: options.agents ?? [] };
openCodeClient.eventStream = createEventStream(options.events ?? [idleEvent()]);
openCodeClient.sessionPromptAsyncEvents = options.events ?? [idleEvent()];
runtime.enqueueClient(openCodeClient);
return { openCodeClient, runtime };

View File

@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import {
idleEvent,
TestOpenCodeClient,
TestOpenCodeRuntime,
} from "./opencode/test-utils/test-opencode-runtime.js";
@@ -93,6 +94,41 @@ describe("OpenCodeAgentSession slash command timeout handling", () => {
usage: undefined,
});
});
test("leaves successful slash command turns open until OpenCode emits idle", async () => {
const runtime = new TestOpenCodeRuntime();
const openCodeClient = createOpenCodeClientWithConnectedProvider();
openCodeClient.sessionCommandEvents = [];
openCodeClient.commandListResponse = {
data: [{ name: "help", description: "Show help", hints: [] }],
};
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { runtime });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp" });
const runPromise = session.run("/help");
await nextTick();
await nextTick();
expect(openCodeClient.calls.sessionCommand).toHaveLength(1);
let settled = false;
void runPromise.then(() => {
settled = true;
return undefined;
});
await nextTick();
expect(settled).toBe(false);
openCodeClient.emitEvent(idleEvent());
await expect(runPromise).resolves.toMatchObject({
sessionId: "session-1",
finalText: "",
timeline: [],
usage: undefined,
});
});
});
function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
@@ -105,3 +141,7 @@ function createOpenCodeClientWithConnectedProvider(): TestOpenCodeClient {
};
return openCodeClient;
}
function nextTick(): Promise<void> {
return new Promise((resolve) => setImmediate(resolve));
}

View File

@@ -1,4 +1,4 @@
import { beforeAll, describe, expect, test, vi } from "vitest";
import { afterAll, describe, expect, test, vi } from "vitest";
import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
@@ -11,6 +11,7 @@ import {
OpenCodeAgentClient,
translateOpenCodeEvent,
} from "./opencode-agent.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import { streamSession } from "./test-utils/session-stream-adapter.js";
import {
TestOpenCodeClient,
@@ -33,8 +34,7 @@ function tmpCwd(): string {
}
}
// Dynamic model selection - will be set in beforeAll
let TEST_MODEL: string | undefined;
const TEST_MODEL = "opencode/big-pickle";
interface TurnResult {
events: AgentStreamEvent[];
@@ -82,14 +82,6 @@ async function collectTurnEvents(iterator: AsyncGenerator<AgentStreamEvent>): Pr
return result;
}
function createAsyncIterable<T>(items: T[]): AsyncIterable<T> {
return (async function* () {
for (const item of items) {
yield item;
}
})();
}
function isBinaryInstalled(binary: string): boolean {
try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
@@ -109,45 +101,9 @@ const hasOpenCode = isBinaryInstalled("opencode");
model: TEST_MODEL,
});
beforeAll(async () => {
const startTime = Date.now();
logger.info("beforeAll: Starting model selection");
const client = new OpenCodeAgentClient(logger);
const models = await client.listModels({ cwd: os.homedir(), force: false });
logger.info(
{ modelCount: models.length, elapsed: Date.now() - startTime },
"beforeAll: Retrieved models",
);
// Prefer cheap models that support tool use (required by OpenCode agents).
// Avoid free-tier OpenRouter models — they often lack tool-use support.
const fastModel = models.find(
(m) =>
m.id.includes("gpt-4.1-nano") ||
m.id.includes("gpt-4.1-mini") ||
m.id.includes("gpt-5-nano") ||
m.id.includes("gpt-5.4-mini") ||
m.id.includes("gpt-4o-mini"),
);
if (fastModel) {
TEST_MODEL = fastModel.id;
} else if (models.length > 0) {
// Fallback to any available model
TEST_MODEL = models[0].id;
} else {
throw new Error(
"No OpenCode models available. Please authenticate with a provider (e.g., set OPENAI_API_KEY).",
);
}
logger.info(
{ model: TEST_MODEL, totalElapsed: Date.now() - startTime },
"beforeAll: Selected OpenCode test model",
);
}, 30_000);
afterAll(async () => {
await OpenCodeServerManager.getInstance(logger).shutdown();
});
test("creates a session with valid id and provider", async () => {
const cwd = tmpCwd();
@@ -200,6 +156,7 @@ const hasOpenCode = isBinaryInstalled("opencode");
// HARD ASSERT: At least one model is returned (OpenCode has connected providers)
expect(models.length).toBeGreaterThan(0);
expect(models.some((model) => model.id === TEST_MODEL)).toBe(true);
// HARD ASSERT: Each model has required fields with correct types
for (const model of models) {
@@ -214,8 +171,12 @@ const hasOpenCode = isBinaryInstalled("opencode");
expect(model.metadata).toMatchObject({
providerId: expect.any(String),
modelId: expect.any(String),
contextWindowMaxTokens: expect.any(Number),
});
// contextWindowMaxTokens is upstream-provided and may be absent for some
// OpenCode-routed providers; assert the type only when present.
if (model.metadata?.contextWindowMaxTokens !== undefined) {
expect(typeof model.metadata.contextWindowMaxTokens).toBe("number");
}
}
}, 60_000);
@@ -623,6 +584,7 @@ describe("OpenCode adapter context-window normalization", () => {
describe("OpenCode adapter startTurn error handling", () => {
test("emits turn_started before live OpenCode timeline items", async () => {
const eventsGate = createTestDeferred<void>();
const globalEvents = [
{
payload: {
@@ -669,10 +631,18 @@ describe("OpenCode adapter startTurn error handling", () => {
];
const fakeClient = {
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
event: vi.fn().mockResolvedValue({
stream: (async function* () {
await eventsGate.promise;
yield* globalEvents;
})(),
}),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
promptAsync: vi.fn().mockImplementation(async () => {
eventsGate.resolve();
return { data: {}, error: undefined };
}),
},
} as never;
@@ -698,6 +668,7 @@ describe("OpenCode adapter startTurn error handling", () => {
});
test("unwraps OpenCode global event payloads during a turn", async () => {
const eventsGate = createTestDeferred<void>();
const globalEvents = [
{
payload: {
@@ -760,10 +731,18 @@ describe("OpenCode adapter startTurn error handling", () => {
subscribe: vi.fn(),
},
global: {
event: vi.fn().mockResolvedValue({ stream: createAsyncIterable(globalEvents) }),
event: vi.fn().mockResolvedValue({
stream: (async function* () {
await eventsGate.promise;
yield* globalEvents;
})(),
}),
},
session: {
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
promptAsync: vi.fn().mockImplementation(async () => {
eventsGate.resolve();
return { data: {}, error: undefined };
}),
},
} as never;
@@ -790,11 +769,13 @@ describe("OpenCode adapter startTurn error handling", () => {
test("keeps a turn active while OpenCode is retrying", async () => {
vi.useFakeTimers();
const eventsGate = createTestDeferred<void>();
const retryStream: AsyncIterable<unknown> = {
[Symbol.asyncIterator]: () => {
let emitted = false;
return {
next: async () => {
await eventsGate.promise;
if (!emitted) {
emitted = true;
return {
@@ -826,7 +807,10 @@ describe("OpenCode adapter startTurn error handling", () => {
session: {
abort: vi.fn().mockResolvedValue({ error: null }),
update: vi.fn().mockResolvedValue({ error: null }),
promptAsync: vi.fn().mockResolvedValue({ data: {}, error: undefined }),
promptAsync: vi.fn().mockImplementation(async () => {
eventsGate.resolve();
return { data: {}, error: undefined };
}),
},
} as never;

View File

@@ -2267,6 +2267,9 @@ class OpenCodeAgentSession implements AgentSession {
private pendingChildToolPartsBySessionId = new Map<string, OpenCodeToolPartEventPart[]>();
private selectedModelContextWindowMaxTokens: number | undefined;
private releaseServer: (() => void) | null;
private eventStreamAbortController: AbortController | null = null;
private eventStreamReady: Deferred<void> | null = null;
private closed = false;
private readonly persistSession: boolean;
private deletedFromProvider = false;
constructor(
@@ -2290,6 +2293,7 @@ class OpenCodeAgentSession implements AgentSession {
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
this.startEventStream();
}
get id(): string | null {
@@ -2422,22 +2426,17 @@ class OpenCodeAgentSession implements AgentSession {
const effectiveVariant = thinkingOptionId ?? undefined;
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
try {
await this.ensureEventStreamReady();
} catch (error) {
if (this.abortController === turnAbortController) {
this.abortController = null;
}
throw error;
}
const turnId = this.createTurnId();
this.activeForegroundTurnId = turnId;
// OpenCode's /event SSE endpoint does NOT replay past events. If we send
// the prompt before our reader is connected, terminal events fired early
// by the server (e.g. session.error / session.idle for invalid model or
// mode) are missed and the turn hangs forever. Wait for the subscription
// to be established before sending anything.
const subscriptionReady = createDeferred<void>();
void this.consumeEventStream(turnId, turnAbortController, subscriptionReady);
try {
await subscriptionReady.promise;
} catch {
// consumeEventStream already finished the turn with the subscription error.
return { turnId };
}
this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId);
const slashCommand = await this.resolveSlashCommandInvocation(prompt);
@@ -2480,12 +2479,8 @@ class OpenCodeAgentSession implements AgentSession {
return { turnId };
}
// command() blocks until the server finishes processing. OpenCode's SSE
// endpoint does NOT replay past events, so if the command completes before
// our SSE reader connects, we miss `session.idle` and the turn hangs.
// Handle both success and error in the response handler as a fallback —
// finishForegroundTurn's guard prevents duplicate terminal events if the
// SSE stream already delivered the event.
// command() is only dispatch acknowledgement. OpenCode session events are
// the source of truth for when the command turn becomes idle or fails.
void this.client.session
.command({
sessionID: this.sessionId,
@@ -2514,11 +2509,6 @@ class OpenCodeAgentSession implements AgentSession {
{ type: "turn_failed", provider: "opencode", error: errorMsg },
turnId,
);
} else {
this.finishForegroundTurn(
{ type: "turn_completed", provider: "opencode", usage: undefined },
turnId,
);
}
return;
})
@@ -2608,7 +2598,6 @@ class OpenCodeAgentSession implements AgentSession {
return { turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
return () => {
@@ -2616,109 +2605,115 @@ class OpenCodeAgentSession implements AgentSession {
};
}
private startEventStream(): void {
void this.ensureEventStreamReady().catch((error) => {
this.logger.warn({ err: error, sessionId: this.sessionId }, "OpenCode event stream failed");
});
}
private ensureEventStreamReady(): Promise<void> {
if (this.eventStreamReady) {
return this.eventStreamReady.promise;
}
const eventStreamAbortController = new AbortController();
const eventStreamReady = createDeferred<void>();
this.eventStreamAbortController = eventStreamAbortController;
this.eventStreamReady = eventStreamReady;
void this.consumeEventStream(eventStreamAbortController, eventStreamReady).finally(() => {
if (this.eventStreamAbortController === eventStreamAbortController) {
this.eventStreamAbortController = null;
this.eventStreamReady = null;
}
});
return eventStreamReady.promise;
}
private async consumeEventStream(
turnId: string,
turnAbortController: AbortController,
subscriptionReady: Deferred<void>,
eventStreamAbortController: AbortController,
eventStreamReady: Deferred<void>,
): Promise<void> {
this.traceOpenCode("provider.opencode.subscribe.start", {
turnId,
sessionId: this.sessionId,
cwd: this.config.cwd,
});
let eventStreamReadyResolved = false;
try {
const result = await this.client.global.event({
signal: turnAbortController.signal,
signal: eventStreamAbortController.signal,
sseMaxRetryAttempts: 0,
});
eventStreamReadyResolved = true;
this.traceOpenCode("provider.opencode.subscribe.ready", {
sessionId: this.sessionId,
});
eventStreamReady.resolve();
let eventCount = 0;
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) {
eventCount += 1;
if (!subscriptionReadyResolved) {
subscriptionReadyResolved = true;
this.traceOpenCode("provider.opencode.subscribe.ready", {
turnId,
sessionId: this.sessionId,
});
subscriptionReady.resolve();
}
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent,
eventCount,
turnId,
turnAbortController,
});
if (!shouldContinue) {
return;
}
await this.consumeOpenCodeStreamEvent({ rawEvent, eventCount });
}
this.traceOpenCode("provider.opencode.stream.eof", {
turnId,
eventCount,
aborted: turnAbortController.signal.aborted,
stillActive: this.activeForegroundTurnId === turnId,
aborted: eventStreamAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
this.traceOpenCode("provider.opencode.turn.fail_eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
if (!eventStreamAbortController.signal.aborted) {
if (!eventStreamReadyResolved) {
eventStreamReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
const activeTurnId = this.activeForegroundTurnId;
if (activeTurnId) {
this.traceOpenCode("provider.opencode.turn.fail_eof", {
turnId: activeTurnId,
eventCount,
});
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
activeTurnId,
);
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
turnId,
);
}
} catch (error) {
this.traceOpenCode("provider.opencode.subscribe.error", {
turnId,
turnId: this.activeForegroundTurnId ?? undefined,
error:
error instanceof Error ? { name: error.name, message: error.message } : String(error),
});
subscriptionReady.reject(error);
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
if (!eventStreamReadyResolved) {
eventStreamReady.reject(error);
}
const activeTurnId = this.activeForegroundTurnId;
if (!eventStreamAbortController.signal.aborted && activeTurnId) {
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: toDiagnosticErrorMessage(error),
},
turnId,
activeTurnId,
);
}
} finally {
if (turnAbortController.signal.aborted) {
this.finishForegroundTurn(
{
type: "turn_canceled",
provider: "opencode",
reason: "interrupted",
},
turnId,
);
}
if (this.abortController === turnAbortController && this.activeForegroundTurnId !== turnId) {
this.abortController = null;
}
}
}
private async consumeOpenCodeStreamEvent(params: {
rawEvent: unknown;
eventCount: number;
turnId: string;
turnAbortController: AbortController;
}): Promise<boolean> {
const { rawEvent, eventCount, turnId, turnAbortController } = params;
}): Promise<void> {
const { rawEvent, eventCount } = params;
const turnId = this.activeForegroundTurnId;
const event = unwrapOpenCodeGlobalEvent(rawEvent);
this.traceOpenCode("provider.opencode.raw_event", {
turnId,
turnId: turnId ?? undefined,
n: eventCount,
type: event?.type,
rawType: readOpenCodeRecord(rawEvent)?.type,
@@ -2727,16 +2722,15 @@ class OpenCodeAgentSession implements AgentSession {
properties: event?.properties,
});
if (!event) {
return true;
return;
}
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
if (!turnId) {
this.traceOpenCode("provider.opencode.event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
reason: "no_active_turn",
type: event.type,
});
return false;
return;
}
const translated = await this.translateEvent(event);
this.traceOpenCode("provider.opencode.parsed_event", {
@@ -2750,7 +2744,7 @@ class OpenCodeAgentSession implements AgentSession {
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
this.traceOpenCode("provider.opencode.parsed_event.skip_active", { turnId, type: e.type });
return false;
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
@@ -2762,12 +2756,10 @@ class OpenCodeAgentSession implements AgentSession {
type: terminalEvent.type,
});
this.finishForegroundTurn(terminalEvent, turnId);
return false;
return;
}
this.notifySubscribers(e, turnId);
}
return true;
}
private finishForegroundTurn(
@@ -2790,8 +2782,6 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.clear();
}
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
this.abortController = null;
this.notifySubscribers(event, turnId);
}
@@ -2833,6 +2823,9 @@ class OpenCodeAgentSession implements AgentSession {
}
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
if (this.closed) {
return;
}
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
const tagged = turnId ? { ...event, turnId } : event;
this.traceOpenCode("provider.opencode.event_emit", {
@@ -3003,7 +2996,16 @@ class OpenCodeAgentSession implements AgentSession {
async close(): Promise<void> {
try {
// Flip closed before clearing subscribers so any event the SDK delivers
// after the abort (between here and subscribers.clear) is swallowed by
// notifySubscribers instead of bubbling through provider-runner as an
// unhandled rejection in whichever test the daemon hops to next.
this.closed = true;
this.abortController?.abort();
this.eventStreamAbortController?.abort();
this.eventStreamAbortController = null;
this.eventStreamReady = null;
this.subscribers.clear();
await reconcileOpenCodeSessionClose({
client: this.client,
sessionId: this.sessionId,
@@ -3011,7 +3013,6 @@ class OpenCodeAgentSession implements AgentSession {
logger: this.logger,
});
await this.deleteProviderSessionIfEphemeral();
this.subscribers.clear();
this.activeForegroundTurnId = null;
} finally {
this.releaseServer?.();

View File

@@ -1,15 +1,19 @@
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
const BIG_PICKLE_MODEL = "opencode/big-pickle";
describe("OpenCode assistant message", () => {
let canRun = false;
const logger = pino({ level: "silent" });
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
@@ -21,16 +25,19 @@ describe("OpenCode assistant message", () => {
}
});
afterAll(async () => {
await OpenCodeServerManager.getInstance(logger).shutdown();
});
test("assistant_message appears in live stream with opencode/big-pickle", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-msg-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/big-pickle",
model: BIG_PICKLE_MODEL,
modeId: "build",
});
@@ -46,13 +53,14 @@ describe("OpenCode assistant message", () => {
test("streamHistory returns assistant_message after a completed turn", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-history-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: BIG_PICKLE_MODEL,
modeId: "build",
});
const result = await session.run("Say hello back in one sentence.");

View File

@@ -1,15 +1,19 @@
import { beforeAll, beforeEach, describe, test, expect } from "vitest";
import { afterAll, beforeAll, beforeEach, describe, test, expect } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { OpenCodeAgentClient } from "./opencode-agent.js";
import { OpenCodeServerManager } from "./opencode/server-manager.js";
import { isProviderAvailable } from "../../daemon-e2e/agent-configs.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
const BIG_PICKLE_MODEL = "opencode/big-pickle";
describe("OpenCode reasoning dedup", () => {
let canRun = false;
const logger = pino({ level: "silent" });
beforeAll(async () => {
canRun = await isProviderAvailable("opencode");
@@ -21,16 +25,19 @@ describe("OpenCode reasoning dedup", () => {
}
});
afterAll(async () => {
await OpenCodeServerManager.getInstance(logger).shutdown();
});
test("reasoning content is not duplicated as assistant_message", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "opencode-reasoning-dedup-"));
const logger = pino({ level: "silent" });
const client = new OpenCodeAgentClient(logger);
try {
const session = await client.createSession({
provider: "opencode",
cwd,
model: "opencode/gpt-5-nano",
model: BIG_PICKLE_MODEL,
modeId: "build",
});

View File

@@ -106,6 +106,19 @@ describe("OpenCodeServerManager generations", () => {
expect(second.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("shutdown still signals a process after an earlier kill signal if it has not exited", async () => {
const manager = createTestManager();
const first = createGeneration(4451);
stubGenerations(manager, [first]);
await manager.acquire({ force: false });
first.process.killed = true;
await manager.shutdown();
expect(first.process.kill).toHaveBeenCalledWith("SIGTERM");
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const manager = createTestManager();
const first = createGeneration(4501);

View File

@@ -277,7 +277,10 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
private async killServer(server: OpenCodeServerGeneration): Promise<void> {
if (server.process.killed) {
if (
(server.process.exitCode !== null && server.process.exitCode !== undefined) ||
(server.process.signalCode !== null && server.process.signalCode !== undefined)
) {
return;
}
const result = await terminateWithTreeKill(server.process, {

View File

@@ -65,7 +65,7 @@ export class TestOpenCodeClient {
appAgentsResponse: OpenCodeResponse = { data: [] };
commandListResponse: OpenCodeResponse = { data: [] };
eventStream: AsyncIterable<unknown> = createEventStream([idleEvent()]);
eventStream: AsyncIterable<unknown>;
experimentalSessionListResponse: OpenCodeResponse = { data: [] };
permissionReplyResponse: OpenCodeResponse = {};
providerListResponse: OpenCodeResponse = { data: { connected: [], all: [] } };
@@ -74,13 +74,24 @@ export class TestOpenCodeClient {
questionReplyResponse: OpenCodeResponse = {};
sessionAbortResponse: OpenCodeResponse = {};
sessionCommandError: unknown = null;
sessionCommandEvents: unknown[] = [idleEvent()];
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {};
sessionMessagesResponse: OpenCodeResponse = { data: [] };
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
sessionPromptAsyncResponse: OpenCodeResponse = {};
sessionSummarizeResponse: OpenCodeResponse = { data: {} };
sessionUpdateResponse: OpenCodeResponse = {};
private readonly queuedEventStream = createQueuedEventStream();
constructor() {
this.eventStream = this.queuedEventStream.stream;
}
emitEvent(event: unknown): void {
this.queuedEventStream.emit(event);
}
asSdkClient(): OpencodeClient {
return {
@@ -154,6 +165,9 @@ export class TestOpenCodeClient {
if (this.sessionCommandError) {
throw this.sessionCommandError;
}
for (const event of this.sessionCommandEvents) {
this.emitEvent(event);
}
return this.sessionCommandResponse;
},
create: async (parameters: unknown) => {
@@ -170,6 +184,9 @@ export class TestOpenCodeClient {
},
promptAsync: async (parameters: unknown) => {
this.calls.sessionPromptAsync.push(parameters);
for (const event of this.sessionPromptAsyncEvents) {
this.emitEvent(event);
}
return this.sessionPromptAsyncResponse;
},
summarize: async (parameters: unknown) => {
@@ -193,6 +210,38 @@ export function createEventStream(events: unknown[]): AsyncGenerator<unknown> {
})();
}
function createQueuedEventStream(): {
stream: AsyncIterable<unknown>;
emit: (event: unknown) => void;
} {
const queue: unknown[] = [];
const waiters: Array<(result: IteratorResult<unknown>) => void> = [];
return {
stream: {
[Symbol.asyncIterator]: () => ({
next: () => {
const event = queue.shift();
if (event !== undefined) {
return Promise.resolve({ done: false, value: event });
}
return new Promise<IteratorResult<unknown>>((resolve) => {
waiters.push(resolve);
});
},
}),
},
emit: (event: unknown) => {
const waiter = waiters.shift();
if (waiter) {
waiter({ done: false, value: event });
return;
}
queue.push(event);
},
};
}
export function idleEvent(): unknown {
return {
type: "session.idle",

View File

@@ -50,7 +50,7 @@ export const agentConfigs = {
},
opencode: {
provider: "opencode",
model: "opencode/glm-5-free",
model: "opencode/big-pickle",
modes: {
full: "default",
ask: "default",