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"]);
|
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 () => {
|
test("permission request notifies once without forcing unread attention state", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-attention-permission-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ type ActiveManagedAgent =
|
|||||||
| ManagedAgentRunning
|
| ManagedAgentRunning
|
||||||
| ManagedAgentError;
|
| ManagedAgentError;
|
||||||
|
|
||||||
|
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||||
|
|
||||||
function attachPersistenceCwd(
|
function attachPersistenceCwd(
|
||||||
handle: AgentPersistenceHandle | null,
|
handle: AgentPersistenceHandle | null,
|
||||||
cwd: string
|
cwd: string
|
||||||
@@ -917,7 +919,7 @@ export class AgentManager {
|
|||||||
} else if (event.type === "turn_completed") {
|
} else if (event.type === "turn_completed") {
|
||||||
usage = event.usage;
|
usage = event.usage;
|
||||||
} else if (event.type === "turn_failed") {
|
} else if (event.type === "turn_failed") {
|
||||||
throw new Error(event.error);
|
throw new Error(this.formatTurnFailedMessage(event));
|
||||||
} else if (event.type === "turn_canceled") {
|
} else if (event.type === "turn_canceled") {
|
||||||
canceled = true;
|
canceled = true;
|
||||||
}
|
}
|
||||||
@@ -1027,8 +1029,9 @@ export class AgentManager {
|
|||||||
|
|
||||||
const mutableAgent = agent as ActiveManagedAgent;
|
const mutableAgent = agent as ActiveManagedAgent;
|
||||||
mutableAgent.pendingRun = null;
|
mutableAgent.pendingRun = null;
|
||||||
mutableAgent.lifecycle = error ? "error" : "idle";
|
const terminalError = error ?? mutableAgent.lastError;
|
||||||
mutableAgent.lastError = error;
|
mutableAgent.lifecycle = terminalError ? "error" : "idle";
|
||||||
|
mutableAgent.lastError = terminalError;
|
||||||
const persistenceHandle =
|
const persistenceHandle =
|
||||||
mutableAgent.session.describePersistence() ??
|
mutableAgent.session.describePersistence() ??
|
||||||
(mutableAgent.runtimeInfo?.sessionId
|
(mutableAgent.runtimeInfo?.sessionId
|
||||||
@@ -1056,6 +1059,11 @@ export class AgentManager {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
finalizeError =
|
finalizeError =
|
||||||
error instanceof Error ? error.message : "Agent stream failed";
|
error instanceof Error ? error.message : "Agent stream failed";
|
||||||
|
self.handleStreamEvent(agent, {
|
||||||
|
type: "turn_failed",
|
||||||
|
provider: agent.provider,
|
||||||
|
error: finalizeError,
|
||||||
|
});
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
await self.refreshRuntimeInfo(agent);
|
await self.refreshRuntimeInfo(agent);
|
||||||
@@ -1744,10 +1752,14 @@ export class AgentManager {
|
|||||||
void this.refreshRuntimeInfo(agent);
|
void this.refreshRuntimeInfo(agent);
|
||||||
break;
|
break;
|
||||||
case "turn_failed":
|
case "turn_failed":
|
||||||
if (!agent.pendingRun) {
|
agent.lifecycle = "error";
|
||||||
agent.lifecycle = "error";
|
|
||||||
}
|
|
||||||
agent.lastError = event.error;
|
agent.lastError = event.error;
|
||||||
|
this.appendSystemErrorTimelineMessage(
|
||||||
|
agent,
|
||||||
|
event.provider,
|
||||||
|
this.formatTurnFailedMessage(event),
|
||||||
|
options
|
||||||
|
);
|
||||||
for (const [requestId] of agent.pendingPermissions) {
|
for (const [requestId] of agent.pendingPermissions) {
|
||||||
agent.pendingPermissions.delete(requestId);
|
agent.pendingPermissions.delete(requestId);
|
||||||
if (!options?.fromHistory) {
|
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(
|
private shouldSuppressLiveUserMessageEcho(
|
||||||
agent: ActiveManagedAgent,
|
agent: ActiveManagedAgent,
|
||||||
event: AgentStreamEvent,
|
event: AgentStreamEvent,
|
||||||
|
|||||||
@@ -240,7 +240,13 @@ export type AgentStreamEvent =
|
|||||||
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
| { type: "thread_started"; sessionId: string; provider: AgentProvider }
|
||||||
| { type: "turn_started"; provider: AgentProvider }
|
| { type: "turn_started"; provider: AgentProvider }
|
||||||
| { type: "turn_completed"; provider: AgentProvider; usage?: AgentUsage }
|
| { 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: "turn_canceled"; provider: AgentProvider; reason: string }
|
||||||
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
|
| { type: "timeline"; item: AgentTimelineItem; provider: AgentProvider }
|
||||||
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
|
| { type: "permission_requested"; provider: AgentProvider; request: AgentPermissionRequest }
|
||||||
|
|||||||
@@ -432,6 +432,8 @@ type ClaudeOptionsLogSummary = {
|
|||||||
hasStderrHandler: boolean;
|
hasStderrHandler: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_RECENT_STDERR_CHARS = 4000;
|
||||||
|
|
||||||
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
|
function summarizeClaudeOptionsForLog(options: ClaudeOptions): ClaudeOptionsLogSummary {
|
||||||
const systemPromptRaw = options.systemPrompt;
|
const systemPromptRaw = options.systemPrompt;
|
||||||
const systemPromptSummary = (() => {
|
const systemPromptSummary = (() => {
|
||||||
@@ -1512,6 +1514,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
private readonly localUserMessageIds = new Set<string>();
|
private readonly localUserMessageIds = new Set<string>();
|
||||||
private suppressLocalReplayActivity = false;
|
private suppressLocalReplayActivity = false;
|
||||||
|
private recentStderr = "";
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@@ -1650,6 +1653,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurn = foregroundTurn;
|
this.activeForegroundTurn = foregroundTurn;
|
||||||
this.preReplayMetadataSeen = false;
|
this.preReplayMetadataSeen = false;
|
||||||
this.transitionTurnState("foreground", "foreground stream started");
|
this.transitionTurnState("foreground", "foreground stream started");
|
||||||
|
this.clearRecentStderr();
|
||||||
|
|
||||||
let finishedNaturally = false;
|
let finishedNaturally = false;
|
||||||
let cancelIssued = false;
|
let cancelIssued = false;
|
||||||
@@ -2281,6 +2285,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
settingSources: CLAUDE_SETTING_SOURCES,
|
settingSources: CLAUDE_SETTING_SOURCES,
|
||||||
stderr: (data: string) => {
|
stderr: (data: string) => {
|
||||||
|
this.captureStderr(data);
|
||||||
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
this.logger.error({ stderr: data.trim() }, "Claude Agent SDK stderr");
|
||||||
},
|
},
|
||||||
env: {
|
env: {
|
||||||
@@ -2413,11 +2418,41 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private failRun(run: RunRecord, errorMessage: string): void {
|
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",
|
type: "turn_failed",
|
||||||
provider: "claude",
|
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(
|
private cancelRun(
|
||||||
@@ -3421,7 +3456,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
|
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
|
||||||
? message.errors.join("\n")
|
? message.errors.join("\n")
|
||||||
: "Claude run failed";
|
: "Claude run failed";
|
||||||
events.push({ type: "turn_failed", provider: "claude", error: errorMessage });
|
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ export const CLAUDE_MODEL_CATALOG: readonly ClaudeCatalogModel[] = [
|
|||||||
name: "Sonnet 4.5",
|
name: "Sonnet 4.5",
|
||||||
description: "Sonnet 4.5 · Best for everyday tasks",
|
description: "Sonnet 4.5 · Best for everyday tasks",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
family: "sonnet",
|
||||||
|
modelId: "claude-sonnet-4-6",
|
||||||
|
name: "Sonnet 4.6",
|
||||||
|
description: "Sonnet 4.6 · Best for everyday tasks",
|
||||||
isLatestInFamily: true,
|
isLatestInFamily: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -319,6 +319,8 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion('type', [
|
|||||||
type: z.literal('turn_failed'),
|
type: z.literal('turn_failed'),
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
|
code: z.string().optional(),
|
||||||
|
diagnostic: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('turn_canceled'),
|
type: z.literal('turn_canceled'),
|
||||||
|
|||||||
Reference in New Issue
Block a user