Skip to content

Commit cf7d4ca

Browse files
authored
Merge branch 'main' into agent/git-session-working-tree
2 parents 695ec74 + 2486412 commit cf7d4ca

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;
@@ -255,8 +256,9 @@ export async function collectEventsUntilTerminal(
255256
} = {},
256257
): Promise<Omit<CollectedSessionEvents, "eventId">> {
257258
const start = Date.now();
258-
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
259+
const maxPollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
259260
const pollTimeoutMs = options.pollTimeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
261+
let currentIntervalMs = Math.min(POLL_INITIAL_INTERVAL_MS, maxPollIntervalMs);
260262
let terminalStatus = "idle";
261263
let result: ProviderSessionEventList | undefined;
262264

@@ -274,7 +276,8 @@ export async function collectEventsUntilTerminal(
274276
terminalStatus = terminalEvent.status;
275277
break;
276278
}
277-
await delay(pollIntervalMs);
279+
await delay(currentIntervalMs);
280+
currentIntervalMs = Math.min(currentIntervalMs * 2, maxPollIntervalMs);
278281
}
279282
} else {
280283
while (true) {
@@ -284,7 +287,8 @@ export async function collectEventsUntilTerminal(
284287
terminalStatus = session.status;
285288
break;
286289
}
287-
await delay(pollIntervalMs);
290+
await delay(currentIntervalMs);
291+
currentIntervalMs = Math.min(currentIntervalMs * 2, maxPollIntervalMs);
288292
}
289293

290294
result = await adapter.listSessionEvents(sessionId, { limit: 100 });
@@ -537,13 +541,40 @@ function buildAgentNameByRemoteId(ctx: ProjectRuntimeContext, provider: string):
537541
}
538542

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

549580
// 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)