Skip to content

Commit 40c294a

Browse files
committed
fix(session): handle Qoder requires_action idle state and SSE reconnection
When Qoder agents execute tools, the session transitions through idle(requires_action) before resuming. Three issues caused session run to produce no output or hang: 1. Qoder mapper treated session.status_idle with stop_reason requires_action as terminal idle, causing poll/stream to exit before tool results and the final reply arrived. Fixed by mapping it to running. 2. streamWithResume did a single yield* of the SSE stream. When Qoder closes the SSE connection after requires_action, the generator exited without reconnecting. Added a reconnect loop with exponential backoff that continues until a true terminal status event arrives. 3. SSE parser in base-client discarded the id: field from SSE frames, so event.id was always undefined and stream reconnection could not skip already-consumed events. Now extracts id: and injects it into the parsed payload. Additionally, polling in collectEventsUntilTerminal now uses exponential backoff (300ms initial, doubling to 2s cap) instead of a fixed 2s interval, reducing first-response latency. Change-Id: Ieefe7ad827516f92fcfff3023600564d9eff0eec Co-developed-by: OpenCode <noreply@opencode.ai>
1 parent a7ea745 commit 40c294a

4 files changed

Lines changed: 56 additions & 6 deletions

File tree

packages/sdk/src/internal/core/session-runtime.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ const TERMINAL_SESSION_STATUSES = new Set(["idle", "completed", "failed", "termi
110110

111111
const DEFAULT_POLL_INTERVAL_MS = 2000;
112112
const DEFAULT_POLL_TIMEOUT_MS = 10 * 60 * 1000;
113+
const POLL_INITIAL_INTERVAL_MS = 300;
113114

114115
export function resolveAgentName(agents: Record<string, unknown> | undefined, agentName?: string): string {
115116
if (agentName) return agentName;
@@ -258,8 +259,9 @@ export async function collectEventsUntilTerminal(
258259
} = {},
259260
): Promise<Omit<CollectedSessionEvents, "eventId">> {
260261
const start = Date.now();
261-
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
262+
const maxPollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
262263
const pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
264+
let currentIntervalMs = Math.min(POLL_INITIAL_INTERVAL_MS, maxPollIntervalMs);
263265
let terminalStatus = "idle";
264266
let result: ProviderSessionEventList | undefined;
265267

@@ -277,7 +279,8 @@ export async function collectEventsUntilTerminal(
277279
terminalStatus = terminalEvent.status;
278280
break;
279281
}
280-
await delay(pollIntervalMs);
282+
await delay(currentIntervalMs);
283+
currentIntervalMs = Math.min(currentIntervalMs * 2, maxPollIntervalMs);
281284
}
282285
} else {
283286
while (true) {
@@ -287,7 +290,8 @@ export async function collectEventsUntilTerminal(
287290
terminalStatus = session.status;
288291
break;
289292
}
290-
await delay(pollIntervalMs);
293+
await delay(currentIntervalMs);
294+
currentIntervalMs = Math.min(currentIntervalMs * 2, maxPollIntervalMs);
291295
}
292296

293297
result = await adapter.listSessionEvents(sessionId, { limit: 100 });
@@ -540,13 +544,40 @@ function buildAgentNameByRemoteId(ctx: ProjectRuntimeContext, provider: string):
540544
}
541545

542546
// Qoder: send first (returns event ID), then stream from that ID to avoid missing events.
547+
// When the provider closes the SSE connection mid-turn (e.g. after emitting a
548+
// `session.status_idle` with `stop_reason=requires_action` for tool execution),
549+
// we reconnect automatically until the stream delivers a true terminal status.
543550
async function* streamWithResume(
544551
adapter: SessionWorkflowAdapter,
545552
sessionId: string,
546553
message: string,
547554
): AsyncIterable<ProviderSessionEvent> {
548555
const eventId = await adapter.sendSessionMessage(sessionId, message);
549-
yield* adapter.streamSessionEvents(sessionId, eventId ? { after_id: eventId } : undefined);
556+
let lastEventId: string | undefined = eventId;
557+
let reachedTerminal = false;
558+
let reconnectIntervalMs = POLL_INITIAL_INTERVAL_MS;
559+
const start = Date.now();
560+
561+
while (!reachedTerminal) {
562+
assertNotTimedOut(start, DEFAULT_POLL_TIMEOUT_MS);
563+
for await (const event of adapter.streamSessionEvents(
564+
sessionId,
565+
lastEventId ? { after_id: lastEventId } : undefined,
566+
)) {
567+
if (event.id) lastEventId = event.id;
568+
yield event;
569+
if (event.type === "status" && isTerminalSessionStatus(event.status)) {
570+
reachedTerminal = true;
571+
break;
572+
}
573+
}
574+
if (!reachedTerminal) {
575+
// SSE closed without a terminal event — back off before reconnecting
576+
// using exponential backoff capped at DEFAULT_POLL_INTERVAL_MS.
577+
await delay(reconnectIntervalMs);
578+
reconnectIntervalMs = Math.min(reconnectIntervalMs * 2, DEFAULT_POLL_INTERVAL_MS);
579+
}
580+
}
550581
}
551582

552583
// Claude/Bailian: connect stream first, then send — provider pushes events immediately on send.

packages/sdk/src/internal/providers/base-client.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export abstract class BaseApiClient {
104104
boundary = buffer.indexOf("\n\n");
105105

106106
const dataLines: string[] = [];
107+
let sseId: string | undefined;
107108
for (const line of frame.split("\n")) {
108109
if (line.startsWith(":")) continue;
109110
if (line.startsWith("event:") && line.slice(6).trim() === "heartbeat") {
@@ -113,12 +114,19 @@ export abstract class BaseApiClient {
113114
if (line.startsWith("data:")) {
114115
dataLines.push(line.slice(5).trimStart());
115116
}
117+
if (line.startsWith("id:")) {
118+
sseId = line.slice(3).trimStart();
119+
}
116120
}
117121
if (dataLines.length === 0) continue;
118122

119123
const json = dataLines.join("\n");
120124
try {
121-
yield JSON.parse(json) as Record<string, unknown>;
125+
const parsed = JSON.parse(json) as Record<string, unknown>;
126+
if (sseId !== undefined && parsed.id === undefined) {
127+
parsed.id = sseId;
128+
}
129+
yield parsed;
122130
} catch {
123131
// skip unparseable frames
124132
}

packages/sdk/src/internal/providers/qoder/mapper.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ export function toSessionEvent(raw: Record<string, unknown>): ProviderSessionEve
551551
const type: SessionEventType = QODER_EVENT_MAP[rawType] ?? "unknown";
552552

553553
const event: ProviderSessionEvent = { type, raw_type: rawType, raw };
554+
if (typeof raw.id === "string") event.id = raw.id;
554555
if (typeof raw.role === "string") event.role = raw.role;
555556

556557
if (type === "message") {
@@ -567,12 +568,17 @@ export function toSessionEvent(raw: Record<string, unknown>): ProviderSessionEve
567568
} else if (type === "status") {
568569
// Only session-level idle/terminated are terminal; thread-level idle
569570
// (session.thread_status_idle) is non-terminal and should not stop the stream.
571+
// Additionally, session.status_idle with stop_reason "requires_action" means
572+
// the agent paused for tool execution and will resume — treat it as non-terminal.
573+
const stopReason = extractStopReason(raw.stop_reason);
570574
if (rawType === "session.thread_status_idle") {
571575
event.status = "running";
576+
} else if (rawType === "session.status_idle" && stopReason === "requires_action") {
577+
event.status = "running";
572578
} else {
573579
event.status = rawType.includes("idle") ? "idle" : rawType.includes("terminated") ? "terminated" : "running";
574580
}
575-
event.stop_reason = extractStopReason(raw.stop_reason);
581+
event.stop_reason = stopReason;
576582
} else if (type === "error") {
577583
event.content = extractErrorMessage(raw);
578584
}

packages/sdk/tests/unit/session-event-mappers.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,11 @@ describe("Qoder mapper", () => {
205205
expect(qoderToEvent({ type: "agent.tool_result", content: "result text" }).type).toBe("tool_result");
206206
expect(qoderToEvent({ type: "session.status_idle", stop_reason: "end_turn" }).status).toBe("idle");
207207
expect(qoderToEvent({ type: "session.status_running" }).status).toBe("running");
208+
// requires_action means the agent paused for tool execution; not truly terminal
209+
expect(qoderToEvent({ type: "session.status_idle", stop_reason: "requires_action" }).status).toBe("running");
210+
expect(qoderToEvent({ type: "session.status_idle", stop_reason: { type: "requires_action" } }).status).toBe(
211+
"running",
212+
);
208213
expect(qoderToEvent({ type: "agent.thinking" }).type).toBe("thinking");
209214
const error = qoderToEvent({ type: "session.error", error: "timeout" });
210215
expect(error.type).toBe("error");

0 commit comments

Comments
 (0)