Improve provider error surfacing and add Sonnet 4.6
This commit is contained in:
@@ -1853,6 +1853,137 @@ describe("AgentManager", () => {
|
||||
expect(attentionReasons).toEqual(["error", "error"]);
|
||||
});
|
||||
|
||||
test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class TurnFailedSession extends TestAgentSession {
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
yield { type: "turn_failed", provider: this.provider, error: "invalid model id" };
|
||||
}
|
||||
}
|
||||
|
||||
class TurnFailedClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new TurnFailedSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||
return new TurnFailedSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TurnFailedClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000131",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Turn failed test",
|
||||
});
|
||||
|
||||
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow("invalid model id");
|
||||
|
||||
const snapshot = manager.getAgent(agent.id);
|
||||
expect(snapshot?.lifecycle).toBe("error");
|
||||
expect(snapshot?.lastError).toBe("invalid model id");
|
||||
|
||||
const systemErrors = manager
|
||||
.getTimeline(agent.id)
|
||||
.filter(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]")
|
||||
);
|
||||
expect(systemErrors).toHaveLength(1);
|
||||
expect(systemErrors[0]?.text).toContain("invalid model id");
|
||||
});
|
||||
|
||||
test("turn_failed surfaces provider code and diagnostic in system error message", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-detail-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class DetailedFailureSession extends TestAgentSession {
|
||||
async *stream(): AsyncGenerator<AgentStreamEvent> {
|
||||
yield { type: "turn_started", provider: this.provider };
|
||||
yield {
|
||||
type: "turn_failed",
|
||||
provider: this.provider,
|
||||
error: "Provider execution failed",
|
||||
code: "126",
|
||||
diagnostic: "No preset version installed for command claude",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class DetailedFailureClient implements AgentClient {
|
||||
readonly provider = "codex" as const;
|
||||
readonly capabilities = TEST_CAPABILITIES;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new DetailedFailureSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(config?: Partial<AgentSessionConfig>): Promise<AgentSession> {
|
||||
return new DetailedFailureSession({
|
||||
provider: "codex",
|
||||
cwd: config?.cwd ?? process.cwd(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new DetailedFailureClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000132",
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: "Detailed failure test",
|
||||
});
|
||||
|
||||
await expect(manager.runAgent(agent.id, "hello")).rejects.toThrow(
|
||||
"Provider execution failed"
|
||||
);
|
||||
|
||||
const systemError = manager
|
||||
.getTimeline(agent.id)
|
||||
.find(
|
||||
(item): item is Extract<AgentTimelineItem, { type: "assistant_message" }> =>
|
||||
item.type === "assistant_message" && item.text.includes("[System Error]")
|
||||
);
|
||||
expect(systemError?.text).toContain("Provider execution failed");
|
||||
expect(systemError?.text).toContain("code: 126");
|
||||
expect(systemError?.text).toContain("No preset version installed for command claude");
|
||||
});
|
||||
|
||||
test("permission request notifies once without forcing unread attention state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -217,6 +217,8 @@ type ActiveManagedAgent =
|
||||
| ManagedAgentRunning
|
||||
| ManagedAgentError;
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string
|
||||
@@ -917,7 +919,7 @@ export class AgentManager {
|
||||
} else if (event.type === "turn_completed") {
|
||||
usage = event.usage;
|
||||
} else if (event.type === "turn_failed") {
|
||||
throw new Error(event.error);
|
||||
throw new Error(this.formatTurnFailedMessage(event));
|
||||
} else if (event.type === "turn_canceled") {
|
||||
canceled = true;
|
||||
}
|
||||
@@ -1027,8 +1029,9 @@ export class AgentManager {
|
||||
|
||||
const mutableAgent = agent as ActiveManagedAgent;
|
||||
mutableAgent.pendingRun = null;
|
||||
mutableAgent.lifecycle = error ? "error" : "idle";
|
||||
mutableAgent.lastError = error;
|
||||
const terminalError = error ?? mutableAgent.lastError;
|
||||
mutableAgent.lifecycle = terminalError ? "error" : "idle";
|
||||
mutableAgent.lastError = terminalError;
|
||||
const persistenceHandle =
|
||||
mutableAgent.session.describePersistence() ??
|
||||
(mutableAgent.runtimeInfo?.sessionId
|
||||
@@ -1056,6 +1059,11 @@ export class AgentManager {
|
||||
} catch (error) {
|
||||
finalizeError =
|
||||
error instanceof Error ? error.message : "Agent stream failed";
|
||||
self.handleStreamEvent(agent, {
|
||||
type: "turn_failed",
|
||||
provider: agent.provider,
|
||||
error: finalizeError,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
await self.refreshRuntimeInfo(agent);
|
||||
@@ -1744,10 +1752,14 @@ export class AgentManager {
|
||||
void this.refreshRuntimeInfo(agent);
|
||||
break;
|
||||
case "turn_failed":
|
||||
if (!agent.pendingRun) {
|
||||
agent.lifecycle = "error";
|
||||
}
|
||||
agent.lifecycle = "error";
|
||||
agent.lastError = event.error;
|
||||
this.appendSystemErrorTimelineMessage(
|
||||
agent,
|
||||
event.provider,
|
||||
this.formatTurnFailedMessage(event),
|
||||
options
|
||||
);
|
||||
for (const [requestId] of agent.pendingPermissions) {
|
||||
agent.pendingPermissions.delete(requestId);
|
||||
if (!options?.fromHistory) {
|
||||
@@ -1814,6 +1826,62 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private appendSystemErrorTimelineMessage(
|
||||
agent: ActiveManagedAgent,
|
||||
provider: AgentProvider,
|
||||
message: string,
|
||||
options?: {
|
||||
fromHistory?: boolean;
|
||||
canonicalUserMessagesById?: ReadonlyMap<string, string>;
|
||||
}
|
||||
): void {
|
||||
if (options?.fromHistory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = message.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
||||
const lastItem = agent.timelineRows[agent.timelineRows.length - 1]?.item;
|
||||
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: AgentTimelineItem = { type: "assistant_message", text };
|
||||
const row = this.recordTimeline(agent, item);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{
|
||||
type: "timeline",
|
||||
item,
|
||||
provider,
|
||||
},
|
||||
{
|
||||
seq: row.seq,
|
||||
epoch: this.ensureTimelineState(agent).epoch,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private formatTurnFailedMessage(
|
||||
event: Extract<AgentStreamEvent, { type: "turn_failed" }>
|
||||
): string {
|
||||
const base = event.error.trim();
|
||||
const parts = [base.length > 0 ? base : "Provider run failed"];
|
||||
const code = event.code?.trim();
|
||||
if (code) {
|
||||
parts.push(`code: ${code}`);
|
||||
}
|
||||
const diagnostic = event.diagnostic?.trim();
|
||||
if (diagnostic && diagnostic !== base) {
|
||||
parts.push(diagnostic);
|
||||
}
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
private shouldSuppressLiveUserMessageEcho(
|
||||
agent: ActiveManagedAgent,
|
||||
event: AgentStreamEvent,
|
||||
|
||||
@@ -240,7 +240,13 @@ export type AgentStreamEvent =
|
||||
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
||||
| { type: "turn_started"; provider: AgentProvider }
|
||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage }
|
||||
| { type: "turn_failed"; provider: AgentProvider; error: string }
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
error: string;
|
||||
code?: string;
|
||||
diagnostic?: string;
|
||||
}
|
||||
| { type: "turn_canceled"; provider: AgentProvider; reason: string }
|
||||
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
|
||||
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
|
||||
|
||||
@@ -432,6 +432,8 @@ type ClaudeOptionsLogSummary = {
|
||||
hasStderrHandler: boolean;
|
||||
};
|
||||
|
||||
const MAX_RECENT_STDERR_CHARS = 4000;
|
||||
|
||||
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
|
||||
const systemPromptRaw = options.systemPrompt;
|
||||
const systemPromptSummary = (() => {
|
||||
@@ -1512,6 +1514,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private userMessageIds: string[] = [];
|
||||
private readonly localUserMessageIds = new Set<string>();
|
||||
private suppressLocalReplayActivity = false;
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
|
||||
constructor(
|
||||
@@ -1650,6 +1653,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.activeForegroundTurn = foregroundTurn;
|
||||
this.preReplayMetadataSeen = false;
|
||||
this.transitionTurnState("foreground", "foreground stream started");
|
||||
this.clearRecentStderr();
|
||||
|
||||
let finishedNaturally = false;
|
||||
let cancelIssued = false;
|
||||
@@ -2281,6 +2285,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
},
|
||||
settingSources: CLAUDE_SETTING_SOURCES,
|
||||
stderr: (data: string) => {
|
||||
this.captureStderr(data);
|
||||
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
||||
},
|
||||
env: {
|
||||
@@ -2413,11 +2418,41 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private failRun(run: RunRecord, errorMessage: string): void {
|
||||
this.emitRunEvent(run, {
|
||||
this.emitRunEvent(run, this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
|
||||
private buildTurnFailedEvent(
|
||||
errorMessage: string
|
||||
): Extract<AgentStreamEvent, { type: "turn_failed" }> {
|
||||
const normalized = errorMessage.trim() || "Claude run failed";
|
||||
const exitCodeMatch = normalized.match(/\bcode\s+(\d+)\b/i);
|
||||
const code = exitCodeMatch ? exitCodeMatch[1] : undefined;
|
||||
const diagnostic = this.getRecentStderrDiagnostic();
|
||||
return {
|
||||
type: "turn_failed",
|
||||
provider: "claude",
|
||||
error: errorMessage,
|
||||
});
|
||||
error: normalized,
|
||||
...(code ? { code } : {}),
|
||||
...(diagnostic ? { diagnostic } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private captureStderr(data: string): void {
|
||||
const text = data.trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
const combined = this.recentStderr ? `${this.recentStderr}\n${text}` : text;
|
||||
this.recentStderr = combined.slice(-MAX_RECENT_STDERR_CHARS);
|
||||
}
|
||||
|
||||
private clearRecentStderr(): void {
|
||||
this.recentStderr = "";
|
||||
}
|
||||
|
||||
private getRecentStderrDiagnostic(): string | undefined {
|
||||
const text = this.recentStderr.trim();
|
||||
return text.length > 0 ? text : undefined;
|
||||
}
|
||||
|
||||
private cancelRun(
|
||||
@@ -3421,7 +3456,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
|
||||
? message.errors.join("\n")
|
||||
: "Claude run failed";
|
||||
events.push({ type: "turn_failed", provider: "claude", error: errorMessage });
|
||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [
|
||||
name: "Sonnet 4.5",
|
||||
description: "Sonnet 4.5 · Best for everyday tasks",
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
family: "sonnet",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
name: "Sonnet 4.6",
|
||||
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||
isLatestInFamily: true,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -319,6 +319,8 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion('type', [
|
||||
type: z.literal('turn_failed'),
|
||||
provider: AgentProviderSchema,
|
||||
error: z.string(),
|
||||
code: z.string().optional(),
|
||||
diagnostic: z.string().optional(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('turn_canceled'),
|
||||
|
||||
Reference in New Issue
Block a user