Compare commits

..

3 Commits

Author SHA1 Message Date
Mohamed Boudra
33262843a5 chore(release): cut 0.1.74 2026-05-11 18:17:50 +07:00
Mohamed Boudra
1cd02a0e1a chore: changelog for 0.1.74 2026-05-11 18:16:46 +07:00
Mohamed Boudra
40ab9e3f20 Use OpenCode global event stream (#916)
* Use OpenCode global event stream

* Use stable OpenCode model in initial prompt e2e

* Clean up OpenCode verification notes
2026-05-11 09:17:00 +00:00
16 changed files with 383 additions and 2325 deletions

View File

@@ -1,5 +1,11 @@
# Changelog
## 0.1.74 - 2026-05-11
### Fixed
- **OpenCode agent turns no longer stall.** Paseo now follows OpenCode's global event stream, so turns stream reliably without falling back to fragile recovery paths. ([#916](https://github.com/getpaseo/paseo/pull/916))
## 0.1.73 - 2026-05-10
### Fixed

View File

@@ -0,0 +1,49 @@
# OpenCode Global Event Verification
Date: 2026-05-11
## Objective
Replace the OpenCode provider's per-directory `/event` stream with OpenCode's `/global/event` stream and remove the EOF polling recovery path that was added for the `/event` regression.
## Environment
- `opencode --version`: `1.14.46`
- `which opencode`: `/Users/moboudra/.asdf/installs/nodejs/22.20.0/bin/opencode`
- `node --version`: `v22.20.0`
- `npm --version`: `10.9.3`
Each OpenCode test file was run independently with:
```bash
/opt/homebrew/bin/timeout 420s npx vitest run <file> --maxWorkers=1 --minWorkers=1
```
## Baseline
Before the provider change, the OpenCode matrix had 16 passing files and 4 failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: Vitest reports "No test suite found in file".
- `packages/server/src/server/agent/providers/opencode-agent.test.ts`: `plan mode blocks edits while build mode can write files` did not observe a completed tool call.
- `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts`: brittle unavailable-model assertion received an auth failure from the upstream API.
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: timed out waiting for an interrupted sleep tool call, even though the recent bash tool call status was `failed`.
## Post-Change Result
After switching to `/global/event`, removing polling recovery, and replacing the brittle initial-prompt model case with `opencode/big-pickle`, the OpenCode matrix had 18 passing files and 2 baseline-equivalent failing files:
- `packages/cli/tests/e2e/opencode-invalid-model.test.ts`: unchanged; Vitest still reports "No test suite found in file".
- `packages/server/src/server/daemon-e2e/opencode-send-interrupt.real.e2e.test.ts`: unchanged; still times out after the interrupted sleep tool call is already marked `failed`.
The previously failing provider unit file now passes, and `packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts` passes with `opencode/big-pickle`.
One live reasoning-dedup matrix run returned no reasoning content; an immediate targeted rerun passed. This appears model-output dependent rather than related to the event-stream change.
## Focused Verification
- `npm run typecheck`
- `npm run lint`
- `git diff --check`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/agent/providers/opencode-agent.error-handling.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`
- `npx vitest run packages/server/src/server/daemon-e2e/opencode-initial-prompt-wait.real.e2e.test.ts --maxWorkers=1 --minWorkers=1`

26
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "paseo",
"version": "0.1.73",
"version": "0.1.74",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paseo",
"version": "0.1.73",
"version": "0.1.74",
"hasInstallScript": true,
"license": "AGPL-3.0-or-later",
"workspaces": [
@@ -38848,7 +38848,7 @@
},
"packages/app": {
"name": "@getpaseo/app",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -38974,10 +38974,10 @@
},
"packages/cli": {
"name": "@getpaseo/cli",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.73",
"@getpaseo/server": "0.1.74",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",
@@ -39020,7 +39020,7 @@
},
"packages/desktop": {
"name": "@getpaseo/desktop",
"version": "0.1.73",
"version": "0.1.74",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@getpaseo/cli": "*",
@@ -39069,7 +39069,7 @@
},
"packages/expo-two-way-audio": {
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.73",
"version": "0.1.74",
"license": "MIT",
"devDependencies": {
"@types/react": "^18.0.25",
@@ -39105,7 +39105,7 @@
},
"packages/highlight": {
"name": "@getpaseo/highlight",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/cpp": "^1.1.5",
@@ -39131,7 +39131,7 @@
},
"packages/relay": {
"name": "@getpaseo/relay",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"base64-js": "^1.5.1",
"tweetnacl": "^1.0.3",
@@ -39146,12 +39146,12 @@
},
"packages/server": {
"name": "@getpaseo/server",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@getpaseo/highlight": "0.1.74",
"@getpaseo/relay": "0.1.74",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",
@@ -39694,7 +39694,7 @@
},
"packages/website": {
"name": "@getpaseo/website",
"version": "0.1.73",
"version": "0.1.74",
"dependencies": {
"@cloudflare/vite-plugin": "^1.29.1",
"@cloudflare/workers-types": "^4.20260317.1",

View File

@@ -1,6 +1,6 @@
{
"name": "paseo",
"version": "0.1.73",
"version": "0.1.74",
"private": true,
"description": "Paseo: voice-controlled development environment with OpenAI Realtime API",
"keywords": [

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/app",
"version": "0.1.73",
"version": "0.1.74",
"private": true,
"main": "index.ts",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/cli",
"version": "0.1.73",
"version": "0.1.74",
"description": "Paseo CLI - control your AI coding agents from the command line",
"bin": {
"paseo": "bin/paseo"
@@ -24,7 +24,7 @@
},
"dependencies": {
"@clack/prompts": "^1.0.0",
"@getpaseo/server": "0.1.73",
"@getpaseo/server": "0.1.74",
"chalk": "^5.3.0",
"commander": "^12.0.0",
"mime-types": "^2.1.35",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/desktop",
"version": "0.1.73",
"version": "0.1.74",
"private": true,
"description": "Paseo desktop app (Electron wrapper)",
"homepage": "https://paseo.sh",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/expo-two-way-audio",
"version": "0.1.73",
"version": "0.1.74",
"description": "Native module for two way audio streaming",
"keywords": [
"ExpoTwoWayAudio",

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/highlight",
"version": "0.1.73",
"version": "0.1.74",
"files": [
"dist"
],

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/relay",
"version": "0.1.73",
"version": "0.1.74",
"description": "Paseo relay for bridging daemon and client connections",
"files": [
"dist"

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/server",
"version": "0.1.73",
"version": "0.1.74",
"description": "Paseo backend server",
"files": [
"dist/server",
@@ -58,8 +58,8 @@
"dependencies": {
"@agentclientprotocol/sdk": "^0.17.1",
"@anthropic-ai/claude-agent-sdk": "^0.2.133",
"@getpaseo/highlight": "0.1.73",
"@getpaseo/relay": "0.1.73",
"@getpaseo/highlight": "0.1.74",
"@getpaseo/relay": "0.1.74",
"@isaacs/ttlcache": "^2.1.4",
"@mariozechner/pi-agent-core": "^0.70.2",
"@mariozechner/pi-ai": "^0.70.2",

View File

@@ -74,22 +74,8 @@ const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
const OPENCODE_BUILD_MODE_ID = "build";
const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
const OPENCODE_STORAGE_SESSION_LIMIT = 200;
// COMPAT(opencodeEofRecovery): added in v0.1.73 to compensate for OpenCode 1.14.42+
// closing the /event SSE stream cleanly after `server.connected`. Drop this whole
// recovery path once OpenCode upstream restores live event delivery and the floor
// version reflects that.
// Upstream: anomalyco/opencode#26697 (SSE /event closes immediately after
// server.connected) and anomalyco/opencode#26635 (prompt_async silently discards
// requests; SSE path broken).
const OPENCODE_EOF_RECOVERY_TIMEOUT_MS = 5 * 60 * 1000;
const OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS = 1_000;
const OPENCODE_RECOVERY_ABORT_TIMEOUT_MS = 2_000;
const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000;
// If OpenCode silently rejects the prompt (invalid model/mode/auth), no assistant
// message is ever persisted. Bound the wait so the turn fails in seconds instead
// of hanging until the completion cap. Valid models normally persist their first
// message within a second of LLM start, so 10s leaves comfortable headroom.
const OPENCODE_EOF_RECOVERY_LIVENESS_MS = 10_000;
const OPENCODE_RETRY_STATUS_FAILURE_MS = 10_000;
const DEFAULT_MODES: AgentMode[] = [
{
@@ -667,19 +653,6 @@ function mergeOpenCodeStepFinishUsage(
}
}
function formatOpenCodeAssistantErrorMessage(
error: NonNullable<OpenCodeAssistantMessage["error"]>,
): string {
const data = (error as { data?: unknown }).data;
if (data && typeof data === "object" && "message" in data) {
const message = (data as { message?: unknown }).message;
if (typeof message === "string" && message.trim().length > 0) {
return message.trim();
}
}
return error.name;
}
function hasNormalizedOpenCodeUsage(usage: AgentUsage): boolean {
return [
usage.inputTokens,
@@ -941,15 +914,8 @@ export const __openCodeInternals = {
},
};
interface OpenCodeRecoveryOptions {
timeoutMs: number;
pollIntervalMs: number;
livenessMs: number;
}
interface OpenCodeAgentClientDeps {
runtime?: OpenCodeRuntime;
recovery?: OpenCodeRecoveryOptions;
}
class ProductionOpenCodeRuntime implements OpenCodeRuntime {
@@ -981,7 +947,6 @@ export class OpenCodeAgentClient implements AgentClient {
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly modelContextWindows = new Map<string, number>();
private readonly storageRoot: string;
private readonly recovery: OpenCodeRecoveryOptions;
constructor(
logger: Logger,
@@ -992,11 +957,6 @@ export class OpenCodeAgentClient implements AgentClient {
this.logger = logger.child({ module: "agent", provider: "opencode" });
this.runtimeSettings = runtimeSettings;
this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
this.recovery = deps.recovery ?? {
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
};
this.runtime =
deps.runtime ??
new ProductionOpenCodeRuntime(
@@ -1044,7 +1004,6 @@ export class OpenCodeAgentClient implements AgentClient {
new Map(this.modelContextWindows),
acquisition.release,
options?.persistSession,
this.recovery,
);
} catch (error) {
acquisition.release();
@@ -1087,7 +1046,6 @@ export class OpenCodeAgentClient implements AgentClient {
new Map(this.modelContextWindows),
acquisition.release,
undefined,
this.recovery,
);
} catch (error) {
acquisition.release();
@@ -2195,6 +2153,24 @@ function traceOpenCode(tag: string, data: Record<string, unknown> = {}): void {
process.stderr.write(`[opencode-trace] ${line}\n`);
}
function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null {
const record = readOpenCodeRecord(event);
if (!record) {
return null;
}
const payload = readOpenCodeRecord(record.payload);
if (typeof payload?.type === "string") {
return payload as unknown as OpenCodeEvent;
}
if (typeof record.type === "string") {
return record as unknown as OpenCodeEvent;
}
return null;
}
class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
@@ -2203,7 +2179,6 @@ class OpenCodeAgentSession implements AgentSession {
private readonly client: OpencodeClient;
private readonly sessionId: string;
private readonly logger: Logger;
private readonly storageRoot: string;
private readonly modelContextWindowsByModelKey: ReadonlyMap<string, number>;
private currentMode: string = "default";
private pendingPermissions = new Map<string, AgentPermissionRequest>();
@@ -2232,41 +2207,25 @@ class OpenCodeAgentSession implements AgentSession {
private releaseServer: (() => void) | null;
private readonly persistSession: boolean;
private deletedFromProvider = false;
private foregroundAssistantMessageEmitted = false;
private foregroundAssistantText = "";
private foregroundUsageUpdated = false;
private foregroundKnownMessageIds = new Set<string>();
private foregroundEmittedQuestionIds = new Set<string>();
private foregroundEmittedPermissionIds = new Set<string>();
private foregroundEmittedReasoningTextLengthByPartId = new Map<string, number>();
private foregroundEmittedToolCallSignatureByCallId = new Map<string, string>();
private foregroundTurnStartedAt: number | null = null;
private readonly recovery: OpenCodeRecoveryOptions;
private retryFailureTimer: ReturnType<typeof setTimeout> | null = null;
constructor(
config: OpenCodeAgentConfig,
client: OpencodeClient,
sessionId: string,
logger: Logger,
storageRoot: string,
_storageRoot: string,
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
releaseServer?: () => void,
persistSession = true,
recovery: OpenCodeRecoveryOptions = {
timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
},
) {
this.config = config;
this.client = client;
this.sessionId = sessionId;
this.logger = logger;
this.storageRoot = storageRoot;
this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
this.currentMode = normalizeOpenCodeModeId(config.modeId);
this.releaseServer = releaseServer ?? null;
this.persistSession = persistSession;
this.recovery = recovery;
this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(
config.model,
);
@@ -2386,19 +2345,11 @@ class OpenCodeAgentSession implements AgentSession {
}
await this.awaitPendingAbortBeforeStartingTurn();
this.foregroundTurnStartedAt = Date.now();
this.runningToolCalls.clear();
this.subAgentsByCallId.clear();
this.subAgentCallIdByChildSessionId.clear();
this.pendingChildToolPartsBySessionId.clear();
this.foregroundAssistantMessageEmitted = false;
this.foregroundAssistantText = "";
this.foregroundUsageUpdated = false;
this.foregroundEmittedQuestionIds.clear();
this.foregroundEmittedPermissionIds.clear();
this.foregroundEmittedReasoningTextLengthByPartId.clear();
this.foregroundEmittedToolCallSignatureByCallId.clear();
this.foregroundKnownMessageIds = await this.readPersistedSessionMessageIds();
this.clearRetryFailureTimer();
const turnAbortController = new AbortController();
this.abortController = turnAbortController;
await this.ensureMcpServersConfigured();
@@ -2611,54 +2562,27 @@ class OpenCodeAgentSession implements AgentSession {
): Promise<void> {
traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
try {
const result = await this.client.event.subscribe(
{ directory: this.config.cwd },
{ signal: turnAbortController.signal },
);
traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
subscriptionReady.resolve();
const result = await this.client.global.event({
signal: turnAbortController.signal,
sseMaxRetryAttempts: 0,
});
let eventCount = 0;
for await (const event of result.stream) {
let subscriptionReadyResolved = false;
for await (const rawEvent of result.stream) {
eventCount += 1;
traceOpenCode("event.raw", {
turnId,
n: eventCount,
type: (event as { type?: string }).type,
properties: (event as { properties?: unknown }).properties,
});
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
break;
if (!subscriptionReadyResolved) {
subscriptionReadyResolved = true;
traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
subscriptionReady.resolve();
}
const translated = await this.translateEvent(event);
traceOpenCode("event.translated", {
const shouldContinue = await this.consumeOpenCodeStreamEvent({
rawEvent,
eventCount,
turnId,
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
turnAbortController,
});
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
return;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
this.finishForegroundTurn(terminalEvent, turnId);
return;
}
this.notifySubscribers(e, turnId);
if (!shouldContinue) {
return;
}
}
@@ -2670,12 +2594,10 @@ class OpenCodeAgentSession implements AgentSession {
});
if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
const recovered = await this.recoverTurnFromPersistedCompletion(turnId);
traceOpenCode("recovery.result", { turnId, recovered });
if (recovered) {
return;
}
traceOpenCode("turn.fail.eof", { turnId, eventCount });
if (!subscriptionReadyResolved) {
subscriptionReady.reject(new Error("OpenCode event stream ended before it became ready"));
}
this.finishForegroundTurn(
{
type: "turn_failed",
@@ -2719,435 +2641,62 @@ class OpenCodeAgentSession implements AgentSession {
}
}
private async recoverTurnFromPersistedCompletion(turnId: string): Promise<boolean> {
traceOpenCode("recovery.start", {
private async consumeOpenCodeStreamEvent(params: {
rawEvent: unknown;
eventCount: number;
turnId: string;
turnAbortController: AbortController;
}): Promise<boolean> {
const { rawEvent, eventCount, turnId, turnAbortController } = params;
const event = unwrapOpenCodeGlobalEvent(rawEvent);
traceOpenCode("event.raw", {
turnId,
foregroundTurnStartedAt: this.foregroundTurnStartedAt,
knownMessageIds: Array.from(this.foregroundKnownMessageIds),
sessionId: this.sessionId,
timeoutMs: this.recovery.timeoutMs,
pollIntervalMs: this.recovery.pollIntervalMs,
n: eventCount,
type: event?.type,
rawType: readOpenCodeRecord(rawEvent)?.type,
directory: readOpenCodeRecord(rawEvent)?.directory,
properties: event ? (event as { properties?: unknown }).properties : undefined,
});
const startedAt = this.foregroundTurnStartedAt;
if (startedAt === null) {
traceOpenCode("recovery.no-start-time", { turnId });
return false;
}
const completionDeadline = Date.now() + this.recovery.timeoutMs;
const livenessDeadline = Date.now() + this.recovery.livenessMs;
let attempt = 0;
let observedActivity = false;
while (true) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("recovery.cancelled", { turnId, attempt });
return true;
}
attempt += 1;
const emittedPromptIds = await this.pollPendingQuestionsAndPermissions(turnId);
if (emittedPromptIds > 0) {
observedActivity = true;
}
const outcome = await this.fetchAssistantOutcomeFromMessagesApi(startedAt);
traceOpenCode("recovery.poll", {
turnId,
attempt,
kind: outcome?.kind ?? "none",
messageId: outcome?.messageId,
emittedPromptIds,
});
if (outcome?.kind === "failure") {
this.foregroundKnownMessageIds.add(outcome.messageId);
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: outcome.error,
},
turnId,
);
return true;
}
if (outcome?.kind === "completion") {
this.emitIncrementalAssistantParts(outcome.parts, turnId);
return this.applyRecoveredAssistantCompletion(outcome, turnId);
}
if (outcome?.kind === "in-progress") {
observedActivity = true;
this.emitIncrementalAssistantParts(outcome.parts, turnId);
}
const now = Date.now();
if (!observedActivity && now >= livenessDeadline) {
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId,
attempt,
"liveness",
);
if (deferred) {
continue;
}
return true;
}
if (now >= completionDeadline) {
const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId,
attempt,
"completion",
);
if (deferred) {
continue;
}
return true;
}
const waitMs = Math.min(this.recovery.pollIntervalMs, completionDeadline - now);
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
}
}
private async deferForPendingPermissionOrFailRecoveredTurnAfterCap(
turnId: string,
attempt: number,
cap: "liveness" | "completion",
): Promise<boolean> {
if (this.pendingPermissions.size > 0) {
// A pending OpenCode question/permission means the turn is blocked on
// user input, not dead. Keep polling until the user response lets the
// assistant finish or the turn is canceled.
traceOpenCode(`recovery.${cap}-deferred-for-permission`, {
turnId,
attempt,
pendingPermissionIds: Array.from(this.pendingPermissions.keys()),
});
await new Promise<void>((resolve) => setTimeout(resolve, this.recovery.pollIntervalMs));
if (!event) {
return true;
}
traceOpenCode(cap === "liveness" ? "recovery.liveness-exhausted" : "recovery.exhausted", {
turnId,
attempt,
});
await this.failRecoveredTurnAfterCap(turnId, cap);
return false;
}
private async failRecoveredTurnAfterCap(
turnId: string,
cap: "liveness" | "completion",
): Promise<void> {
await this.abortOpenCodeSessionAfterRecoveryCap(turnId, cap);
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error: "OpenCode event stream ended before the turn reached a terminal state",
},
turnId,
);
}
private async abortOpenCodeSessionAfterRecoveryCap(
turnId: string,
cap: "liveness" | "completion",
): Promise<void> {
const abortPromise = this.beginSessionAbort(turnId, `recovery-${cap}`);
await withTimeout(
abortPromise,
OPENCODE_RECOVERY_ABORT_TIMEOUT_MS,
"OpenCode session.abort",
).catch((error) => {
this.logger.warn(
{ err: error, sessionId: this.sessionId, turnId, cap },
"OpenCode session.abort exceeded the EOF recovery cap",
);
});
}
private async pollPendingQuestionsAndPermissions(turnId: string): Promise<number> {
const [questionsResponse, permissionsResponse] = await Promise.all([
Promise.resolve()
.then(() => this.client.question.list({ directory: this.config.cwd }))
.catch((error) => {
traceOpenCode("recovery.question-list.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
}),
Promise.resolve()
.then(() => this.client.permission.list({ directory: this.config.cwd }))
.catch((error) => {
traceOpenCode("recovery.permission-list.throw", {
turnId,
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
}),
]);
if (this.activeForegroundTurnId !== turnId) return 0;
let emitted = 0;
for (const question of questionsResponse?.data ?? []) {
if (question.sessionID !== this.sessionId) continue;
if (this.foregroundEmittedQuestionIds.has(question.id)) continue;
this.foregroundEmittedQuestionIds.add(question.id);
emitted += 1;
const synthetic = {
id: question.id,
type: "question.asked",
properties: question,
} as unknown as OpenCodeEvent;
const events = await this.translateEvent(synthetic);
for (const event of events) {
this.notifySubscribers(event, turnId);
}
}
for (const permission of permissionsResponse?.data ?? []) {
if (permission.sessionID !== this.sessionId) continue;
if (this.foregroundEmittedPermissionIds.has(permission.id)) continue;
this.foregroundEmittedPermissionIds.add(permission.id);
emitted += 1;
const synthetic = {
id: permission.id,
type: "permission.asked",
properties: permission,
} as unknown as OpenCodeEvent;
const events = await this.translateEvent(synthetic);
for (const event of events) {
this.notifySubscribers(event, turnId);
}
}
return emitted;
}
private async fetchAssistantOutcomeFromMessagesApi(startedAt: number): Promise<
| {
kind: "completion";
messageId: string;
text: string;
parts: readonly OpenCodePart[];
usage: AgentUsage;
}
| { kind: "failure"; messageId: string; error: string }
| { kind: "in-progress"; messageId: string; parts: readonly OpenCodePart[] }
| null
> {
const response = await Promise.resolve()
.then(() =>
this.client.session.messages({
sessionID: this.sessionId,
directory: this.config.cwd,
}),
)
.catch((error) => {
traceOpenCode("recovery.messages.throw", {
error:
error instanceof Error
? { name: error.name, message: error.message, stack: error.stack }
: String(error),
});
return null;
if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.skip", {
turnId,
n: eventCount,
aborted: turnAbortController.signal.aborted,
activeTurnId: this.activeForegroundTurnId,
});
if (response === null) {
return null;
}
if (response.error || !response.data) {
return null;
}
for (let index = response.data.length - 1; index >= 0; index -= 1) {
const item = response.data[index];
if (!item) continue;
const info = item.info;
if (info.role !== "assistant") continue;
if (this.foregroundKnownMessageIds.has(info.id)) continue;
if (typeof info.time?.created === "number" && info.time.created < startedAt) continue;
if (info.error) {
return {
kind: "failure",
messageId: info.id,
error: formatOpenCodeAssistantErrorMessage(info.error),
};
}
if (typeof info.time?.completed !== "number") {
return { kind: "in-progress", messageId: info.id, parts: item.parts };
}
let text = item.parts
.filter((part): part is Extract<OpenCodePart, { type: "text" }> => part.type === "text")
.map((part) => (part.text ?? "").trim())
.filter((part) => part.length > 0)
.join("\n\n");
if (!text) {
text = stringifyStructuredAssistantMessage(info.structured) ?? "";
}
if (!text) continue;
const usage: AgentUsage = {};
mergeOpenCodeStepFinishUsage(usage, { cost: info.cost, tokens: info.tokens });
return { kind: "completion", messageId: info.id, text, parts: item.parts, usage };
}
return null;
}
private emitIncrementalAssistantParts(parts: readonly OpenCodePart[], turnId: string): void {
for (const part of parts) {
if (part.type === "reasoning" && part.text) {
const emittedTextLength =
this.foregroundEmittedReasoningTextLengthByPartId.get(part.id) ?? 0;
if (part.text.length <= emittedTextLength) continue;
const text = part.text.slice(emittedTextLength);
this.foregroundEmittedReasoningTextLengthByPartId.set(part.id, part.text.length);
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: { type: "reasoning", text },
},
turnId,
);
continue;
}
if (part.type !== "tool") continue;
const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
if (!parsedToolPart.success || !parsedToolPart.data) continue;
const callId = parsedToolPart.data.callId;
const signature = this.createRecoveredToolCallSignature(part, parsedToolPart.data);
const lastSignature = this.foregroundEmittedToolCallSignatureByCallId.get(callId);
if (lastSignature === signature) continue;
this.foregroundEmittedToolCallSignatureByCallId.set(callId, signature);
this.trackToolCall(parsedToolPart.data);
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: parsedToolPart.data,
},
turnId,
);
}
}
private createRecoveredToolCallSignature(
part: Extract<OpenCodePart, { type: "tool" }>,
item: ToolCallTimelineItem,
): string {
const state = (part as { state?: { input?: unknown; output?: unknown; error?: unknown } })
.state;
return JSON.stringify([
item.callId,
item.status,
state?.input ?? null,
state?.output ?? null,
state?.error ?? null,
]);
}
private applyRecoveredAssistantCompletion(
completion: {
messageId: string;
text: string;
parts: readonly OpenCodePart[];
usage: AgentUsage;
},
turnId: string,
): boolean {
if (this.activeForegroundTurnId !== turnId) {
return false;
}
this.foregroundKnownMessageIds.add(completion.messageId);
this.logger.warn(
{ sessionId: this.sessionId, turnId },
"Recovered OpenCode turn completion via messages API after SSE EOF",
);
const recoveryText = this.resolvePersistedAssistantRecoveryText(completion.text);
if (recoveryText === null) {
return false;
}
if (recoveryText.length > 0) {
this.notifySubscribers(
{
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: recoveryText },
},
turnId,
);
this.foregroundAssistantMessageEmitted = true;
}
if (hasNormalizedOpenCodeUsage(completion.usage) && !this.foregroundUsageUpdated) {
this.accumulatedUsage = {
...this.accumulatedUsage,
...completion.usage,
};
this.notifySubscribers(
{
type: "usage_updated",
provider: "opencode",
usage: { ...this.accumulatedUsage },
},
turnId,
);
this.foregroundUsageUpdated = true;
}
this.finishForegroundTurn(
{
type: "turn_completed",
provider: "opencode",
usage: hasNormalizedOpenCodeUsage(this.accumulatedUsage)
? { ...this.accumulatedUsage }
: undefined,
},
this.armRetryFailureTimerForStatus(event, turnId);
const translated = await this.translateEvent(event);
traceOpenCode("event.translated", {
turnId,
);
return true;
}
n: eventCount,
count: translated.length,
types: translated.map((t) => t.type),
});
private resolvePersistedAssistantRecoveryText(completedText: string): string | null {
if (!this.foregroundAssistantMessageEmitted) {
return completedText;
}
if (completedText === this.foregroundAssistantText) {
return "";
}
return completedText.startsWith(this.foregroundAssistantText)
? completedText.slice(this.foregroundAssistantText.length)
: null;
}
private async readPersistedSessionMessageIds(): Promise<Set<string>> {
const messageRoot = path.join(this.storageRoot, "message", this.sessionId);
const messageFiles = await findJsonFiles(messageRoot);
const messageIds = new Set<string>();
for (const file of messageFiles) {
const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
if (parsed?.sessionID === this.sessionId) {
messageIds.add(parsed.id);
for (const e of translated) {
if (this.activeForegroundTurnId !== turnId) {
traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
return false;
}
if (e.type === "timeline" && e.item.type === "tool_call") {
this.trackToolCall(e.item);
}
const terminalEvent = toTerminalTurnEvent(e);
if (terminalEvent) {
traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
this.finishForegroundTurn(terminalEvent, turnId);
return false;
}
this.notifySubscribers(e, turnId);
}
return messageIds;
return true;
}
private finishForegroundTurn(
@@ -3169,7 +2718,7 @@ class OpenCodeAgentSession implements AgentSession {
} else {
this.runningToolCalls.clear();
}
this.foregroundTurnStartedAt = null;
this.clearRetryFailureTimer();
this.activeForegroundTurnId = null;
// Abort the SSE connection so the SDK tears down the underlying fetch.
this.abortController?.abort();
@@ -3185,6 +2734,44 @@ class OpenCodeAgentSession implements AgentSession {
this.runningToolCalls.delete(item.callId);
}
private armRetryFailureTimerForStatus(event: OpenCodeEvent, turnId: string): void {
if (this.retryFailureTimer || event.type !== "session.status") {
return;
}
if (event.properties.sessionID !== this.sessionId || event.properties.status.type !== "retry") {
return;
}
const retry = event.properties.status;
const message = typeof retry.message === "string" ? retry.message.trim() : "";
const error = message
? `OpenCode provider retry did not recover: ${message}`
: "OpenCode provider retry did not recover";
this.retryFailureTimer = setTimeout(() => {
this.retryFailureTimer = null;
if (this.activeForegroundTurnId !== turnId) {
return;
}
this.finishForegroundTurn(
{
type: "turn_failed",
provider: "opencode",
error,
},
turnId,
);
}, OPENCODE_RETRY_STATUS_FAILURE_MS);
}
private clearRetryFailureTimer(): void {
if (!this.retryFailureTimer) {
return;
}
clearTimeout(this.retryFailureTimer);
this.retryFailureTimer = null;
}
private synthesizeInterruptedToolCalls(turnId: string): void {
for (const item of this.runningToolCalls.values()) {
const error = { message: "Tool execution aborted" };
@@ -3215,13 +2802,6 @@ class OpenCodeAgentSession implements AgentSession {
private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void {
const turnId = turnIdOverride ?? this.activeForegroundTurnId;
if (event.type === "timeline" && event.item.type === "assistant_message") {
this.foregroundAssistantMessageEmitted = true;
this.foregroundAssistantText += event.item.text;
}
if (event.type === "usage_updated") {
this.foregroundUsageUpdated = true;
}
const tagged = turnId ? { ...event, turnId } : event;
for (const callback of this.subscribers) {
try {

View File

@@ -47,6 +47,7 @@ export class TestOpenCodeClient {
appAgents: [] as unknown[],
commandList: [] as unknown[],
eventSubscribe: [] as unknown[],
globalEvent: [] as unknown[],
permissionReply: [] as unknown[],
providerList: [] as unknown[],
questionReject: [] as unknown[],
@@ -99,6 +100,12 @@ export class TestOpenCodeClient {
return { stream: this.eventStream };
},
},
global: {
event: async (options: unknown) => {
this.calls.globalEvent.push(options);
return { stream: this.eventStream };
},
},
mcp: {
add: async () => ({}),
connect: async () => ({}),

View File

@@ -9,6 +9,8 @@ import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { isProviderAvailable } from "./agent-configs.js";
const OPENCODE_REAL_TEST_MODEL = "opencode/big-pickle";
function tmpCwd(): string {
return mkdtempSync(path.join(tmpdir(), "daemon-real-opencode-init-prompt-"));
}
@@ -47,13 +49,13 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
expect(models.models.some((model) => model.id === OPENCODE_REAL_TEST_MODEL)).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode initial prompt wait regression",
model: "opencode/big-pickle",
model: OPENCODE_REAL_TEST_MODEL,
initialPrompt: "Reply with exactly: BIG_PICKLE_OK",
});
@@ -83,35 +85,4 @@ describe("daemon E2E (real opencode) - initial prompt wait", () => {
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
test("waitForFinish surfaces a terminal error when zai/glm-5.1 enters a fatal retry loop", async () => {
const cwd = tmpCwd();
const { client, daemon } = await createHarness();
try {
const models = await client.listProviderModels("opencode");
expect(models.models.some((model) => model.id === "zai/glm-5.1")).toBe(true);
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode zai fatal retry regression",
model: "zai/glm-5.1",
initialPrompt: "Reply with exactly: GLM_51_OK",
});
const finish = await client.waitForFinish(agent.id, 60_000);
expect(finish.status).toBe("error");
expect((finish.error ?? "").toLowerCase()).toMatch(
/insufficient balance|resource package|recharge/,
);
const snapshot = await client.fetchAgent(agent.id);
expect(snapshot.agent?.status).toBe("error");
} finally {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(cwd, { recursive: true, force: true });
}
}, 90_000);
});

View File

@@ -1,6 +1,6 @@
{
"name": "@getpaseo/website",
"version": "0.1.73",
"version": "0.1.74",
"private": true,
"type": "module",
"scripts": {