mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33262843a5 | ||
|
|
1cd02a0e1a | ||
|
|
40ab9e3f20 |
@@ -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
|
||||
|
||||
49
docs/opencode-global-event-baseline.md
Normal file
49
docs/opencode-global-event-baseline.md
Normal 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
26
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/app",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.74",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/highlight",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.74",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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 () => ({}),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/website",
|
||||
"version": "0.1.73",
|
||||
"version": "0.1.74",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user