refactor(server): extract provider turn runner (#668)

Claude, Codex, OpenCode, and ACP each reimplemented the same `run()`
orchestration: pre-start event buffering, turn-id filtering, terminal
event settling, timeline collection, and final-text reduction. Hoist
the loop into `provider-runner.ts` with a small adapter (startTurn,
subscribe, getSessionId) and pluggable final-text reducer.

Net -252 lines across the four providers. Also fixes a latent bug
where a `turn_failed` emitted before startTurn returned was buffered
and silently swallowed instead of rejecting the run.
This commit is contained in:
Mohamed Boudra
2026-05-02 15:45:08 +08:00
committed by GitHub
parent b6181c2d31
commit ce8dc46054
6 changed files with 322 additions and 292 deletions
@@ -86,6 +86,7 @@ import {
type ProviderRuntimeSettings,
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { findExecutable } from "../../../utils/executable.js";
import { spawnProcess } from "../../../utils/spawn.js";
@@ -857,84 +858,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const buffered: AgentStreamEvent[] = [];
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
const result = await runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: () => this.sessionId ?? "",
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
});
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
if (turnId && "turnId" in event && event.turnId && event.turnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text.startsWith(finalText)
? event.item.text
: `${finalText}${event.item.text}`;
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
buffered.push(event);
return;
}
processEvent(event);
});
try {
const started = await this.startTurn(prompt, options);
turnId = started.turnId;
for (const event of buffered) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
if (!this.sessionId) {
throw new Error("ACP session did not expose a session id");
}
return {
sessionId: this.sessionId,
finalText,
usage,
timeline,
};
return result;
}
async startTurn(
@@ -42,6 +42,7 @@ import {
formatProviderDiagnosticError,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import type {
@@ -1543,77 +1544,14 @@ class ClaudeAgentSession implements AgentSession {
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
const eventTurnId = (event as { turnId?: string }).turnId;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
if (!finalText) {
finalText = event.item.text;
} else if (event.item.text.startsWith(finalText)) {
finalText = event.item.text;
} else {
finalText += event.item.text;
}
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
const result = await runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: () => this.claudeSessionId ?? "",
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
});
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await this.startTurn(prompt, options);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
this.cachedRuntimeInfo = {
provider: "claude",
@@ -1626,12 +1564,7 @@ class ClaudeAgentSession implements AgentSession {
throw new Error("Session ID not set after run completed");
}
return {
sessionId: this.claudeSessionId,
finalText,
usage,
timeline,
};
return result;
}
async startTurn(
@@ -60,6 +60,7 @@ import {
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import type { WorkspaceGitService } from "../../workspace-git-service.js";
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
@@ -2962,81 +2963,22 @@ class CodexAppServerAgentSession implements AgentSession {
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
const eventTurnId = (event as { turnId?: string }).turnId;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
} else if (event.item.type === "tool_call" && event.item.detail.type === "plan") {
finalText = event.item.detail.text;
return runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: async () => (await this.getRuntimeInfo()).sessionId ?? "",
reduceFinalText: ({ current, item }) => {
if (item.type === "assistant_message") {
return item.text;
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
if (item.type === "tool_call" && item.detail.type === "plan") {
return item.detail.text;
}
return current;
},
});
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await this.startTurn(prompt, options);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
const info = await this.getRuntimeInfo();
return {
sessionId: info.sessionId ?? "",
finalText,
usage,
timeline,
};
}
async startTurn(
@@ -57,6 +57,7 @@ import {
resolveBinaryVersion,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
import { runProviderTurn } from "./provider-runner.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
const OPENCODE_CAPABILITIES: AgentCapabilityFlags = {
@@ -2271,78 +2272,13 @@ class OpenCodeAgentSession implements AgentSession {
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
const eventTurnId = (event as { turnId?: string }).turnId;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
if (event.item.type === "assistant_message") {
finalText = event.item.text;
}
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
return runProviderTurn({
prompt,
runOptions: options,
startTurn: (p, o) => this.startTurn(p, o),
subscribe: (callback) => this.subscribe(callback),
getSessionId: () => this.sessionId,
});
const unsubscribe = this.subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await this.startTurn(prompt, options);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
if (!settled) {
await completion;
}
} finally {
unsubscribe();
}
return {
sessionId: this.sessionId,
finalText,
usage,
timeline,
};
}
async interrupt(): Promise<void> {
@@ -0,0 +1,154 @@
import { describe, expect, test } from "vitest";
import type { AgentPromptInput, AgentRunOptions, AgentStreamEvent } from "../agent-sdk-types.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
class FakeTurnRunner {
readonly events: AgentStreamEvent[] = [];
private subscriber: ((event: AgentStreamEvent) => void) | null = null;
constructor(
private readonly turnId: string,
private readonly sessionId: string,
private readonly onStart?: () => void,
) {}
async startTurn(
_prompt: AgentPromptInput,
_options?: AgentRunOptions,
): Promise<{ turnId: string }> {
this.onStart?.();
return { turnId: this.turnId };
}
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscriber = callback;
return () => {
this.subscriber = null;
};
}
getSessionId(): string {
return this.sessionId;
}
emit(event: AgentStreamEvent): void {
this.events.push(event);
this.subscriber?.(event);
}
}
describe("runProviderTurn", () => {
test("buffers events emitted before startTurn returns", async () => {
const runner = new FakeTurnRunner("turn-1", "session-1", () => {
runner.emit({
type: "timeline",
provider: "codex",
turnId: "turn-1",
item: { type: "assistant_message", text: "hello" },
});
runner.emit({
type: "turn_completed",
provider: "codex",
turnId: "turn-1",
usage: { inputTokens: 1 },
});
});
const result = await runProviderTurn({
prompt: "hi",
startTurn: (prompt, options) => runner.startTurn(prompt, options),
subscribe: (callback) => runner.subscribe(callback),
getSessionId: () => runner.getSessionId(),
});
expect(result).toEqual({
sessionId: "session-1",
finalText: "hello",
usage: { inputTokens: 1 },
timeline: [{ type: "assistant_message", text: "hello" }],
});
});
test("ignores events from other turns after the turn id is known", async () => {
const runner = new FakeTurnRunner("turn-1", "session-1");
const resultPromise = runProviderTurn({
prompt: "hi",
startTurn: (prompt, options) => runner.startTurn(prompt, options),
subscribe: (callback) => runner.subscribe(callback),
getSessionId: () => runner.getSessionId(),
});
runner.emit({
type: "timeline",
provider: "opencode",
turnId: "other-turn",
item: { type: "assistant_message", text: "wrong" },
});
runner.emit({
type: "timeline",
provider: "opencode",
turnId: "turn-1",
item: { type: "assistant_message", text: "right" },
});
runner.emit({ type: "turn_completed", provider: "opencode", turnId: "turn-1" });
await expect(resultPromise).resolves.toMatchObject({
finalText: "right",
timeline: [{ type: "assistant_message", text: "right" }],
});
});
test("rejects when the turn fails", async () => {
const runner = new FakeTurnRunner("turn-1", "session-1");
const resultPromise = runProviderTurn({
prompt: "hi",
startTurn: (prompt, options) => runner.startTurn(prompt, options),
subscribe: (callback) => runner.subscribe(callback),
getSessionId: () => runner.getSessionId(),
});
runner.emit({
type: "turn_failed",
provider: "claude",
turnId: "turn-1",
error: "provider failed",
});
await expect(resultPromise).rejects.toThrow("provider failed");
});
test("supports growing assistant message reducers", async () => {
const runner = new FakeTurnRunner("turn-1", "session-1");
const resultPromise = runProviderTurn({
prompt: "hi",
startTurn: (prompt, options) => runner.startTurn(prompt, options),
subscribe: (callback) => runner.subscribe(callback),
getSessionId: () => runner.getSessionId(),
reduceFinalText: appendOrReplaceGrowingAssistantMessage,
});
runner.emit({
type: "timeline",
provider: "acp",
turnId: "turn-1",
item: { type: "assistant_message", text: "hello" },
});
runner.emit({
type: "timeline",
provider: "acp",
turnId: "turn-1",
item: { type: "assistant_message", text: "hello world" },
});
runner.emit({
type: "timeline",
provider: "acp",
turnId: "turn-1",
item: { type: "assistant_message", text: "!" },
});
runner.emit({ type: "turn_completed", provider: "acp", turnId: "turn-1" });
await expect(resultPromise).resolves.toMatchObject({
finalText: "hello world!",
});
});
});
@@ -0,0 +1,128 @@
import type {
AgentPromptInput,
AgentRunOptions,
AgentRunResult,
AgentStreamEvent,
AgentTimelineItem,
} from "../agent-sdk-types.js";
export type ProviderFinalTextReducer = (params: {
current: string;
item: AgentTimelineItem;
}) => string;
export interface ProviderTurnRunner {
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
subscribe(callback: (event: AgentStreamEvent) => void): () => void;
getSessionId(): string | Promise<string>;
}
export interface RunProviderTurnOptions extends ProviderTurnRunner {
prompt: AgentPromptInput;
runOptions?: AgentRunOptions;
reduceFinalText?: ProviderFinalTextReducer;
}
export async function runProviderTurn({
prompt,
runOptions,
startTurn,
subscribe,
getSessionId,
reduceFinalText = replaceFinalTextWithAssistantMessage,
}: RunProviderTurnOptions): Promise<AgentRunResult> {
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentRunResult["usage"];
let turnId: string | null = null;
const bufferedEvents: AgentStreamEvent[] = [];
let settled = false;
let resolveCompletion!: () => void;
let rejectCompletion!: (error: Error) => void;
const processEvent = (event: AgentStreamEvent) => {
if (settled) {
return;
}
const eventTurnId = "turnId" in event ? event.turnId : undefined;
if (turnId && eventTurnId && eventTurnId !== turnId) {
return;
}
if (event.type === "timeline") {
timeline.push(event.item);
finalText = reduceFinalText({ current: finalText, item: event.item });
return;
}
if (event.type === "turn_completed") {
usage = event.usage;
settled = true;
resolveCompletion();
return;
}
if (event.type === "turn_failed") {
settled = true;
rejectCompletion(new Error(event.error));
return;
}
if (event.type === "turn_canceled") {
settled = true;
resolveCompletion();
}
};
const completion = new Promise<void>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
const unsubscribe = subscribe((event) => {
if (!turnId) {
bufferedEvents.push(event);
return;
}
processEvent(event);
});
try {
const result = await startTurn(prompt, runOptions);
turnId = result.turnId;
for (const event of bufferedEvents) {
processEvent(event);
}
await completion;
} finally {
unsubscribe();
}
return {
sessionId: await getSessionId(),
finalText,
usage,
timeline,
};
}
export function replaceFinalTextWithAssistantMessage({
current,
item,
}: {
current: string;
item: AgentTimelineItem;
}): string {
return item.type === "assistant_message" ? item.text : current;
}
export function appendOrReplaceGrowingAssistantMessage({
current,
item,
}: {
current: string;
item: AgentTimelineItem;
}): string {
if (item.type !== "assistant_message") {
return current;
}
if (!current) {
return item.text;
}
return item.text.startsWith(current) ? item.text : `${current}${item.text}`;
}