fix: lazy agent loading and type safety improvements
1. Lazy agent loading - fix race condition where prompts sent before initialization completes:
- Add ensureAgentLoaded() helper that deduplicates initialization requests
- Update handleSendAgentMessage/Audio to await agent initialization before streaming
- Fix status updates being sent to client after initialization
2. Type safety - remove unsafe 'as any' casts for agent status:
- Create AGENT_LIFECYCLE_STATUSES constant as single source of truth
- Export AgentStatusSchema from messages.ts for reuse
- Update registry schema to validate lastStatus against AgentStatusSchema
- Add .default("closed") to handle missing status values from legacy files
- Remove (record.lastStatus as any) cast in buildStoredAgentPayload
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -22,12 +22,16 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import { resolveAgentModel } from "./model-resolver.js";
|
||||
|
||||
export const AGENT_LIFECYCLE_STATUSES = [
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
] as const;
|
||||
|
||||
export type AgentLifecycleStatus =
|
||||
| "initializing"
|
||||
| "idle"
|
||||
| "running"
|
||||
| "error"
|
||||
| "closed";
|
||||
(typeof AGENT_LIFECYCLE_STATUSES)[number];
|
||||
|
||||
export type AgentSnapshot = {
|
||||
id: string;
|
||||
|
||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AgentStatusSchema } from "../messages.js";
|
||||
import type { AgentSnapshot } from "./agent-manager.js";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
|
||||
@@ -36,7 +37,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
lastActivityAt: z.string().optional(),
|
||||
lastUserMessageAt: z.string().nullable().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
lastStatus: z.string().nullable().optional(),
|
||||
lastStatus: AgentStatusSchema.default("closed"),
|
||||
lastModeId: z.string().nullable().optional(),
|
||||
config: SERIALIZABLE_CONFIG_SCHEMA,
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
@@ -126,7 +127,7 @@ export class AgentRegistry {
|
||||
lastActivityAt: existing?.lastActivityAt ?? existing?.updatedAt ?? now,
|
||||
lastUserMessageAt: existing?.lastUserMessageAt ?? null,
|
||||
title: existing?.title ?? null,
|
||||
lastStatus: existing?.lastStatus ?? null,
|
||||
lastStatus: existing?.lastStatus ?? "closed",
|
||||
lastModeId: nextModeId,
|
||||
config: sanitizedConfig,
|
||||
persistence: existing?.persistence ?? null,
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import type { AgentSnapshot } from "./agent/agent-manager.js";
|
||||
import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentSnapshot,
|
||||
} from "./agent/agent-manager.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentModelDefinition,
|
||||
@@ -26,14 +29,7 @@ export type AgentSnapshotPayload = Omit<
|
||||
const AGENT_PROVIDERS: [AgentProvider, AgentProvider] = ["claude", "codex"];
|
||||
const AgentProviderSchema = z.enum(AGENT_PROVIDERS);
|
||||
|
||||
const AGENT_LIFECYCLE_STATUSES = [
|
||||
"initializing",
|
||||
"idle",
|
||||
"running",
|
||||
"error",
|
||||
"closed",
|
||||
] as const;
|
||||
const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||
|
||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||
id: z.string(),
|
||||
|
||||
@@ -232,6 +232,10 @@ export class Session {
|
||||
private readonly agentMcpConfig: AgentMcpClientConfig;
|
||||
private agentTitleCache: Map<string, string | null> = new Map();
|
||||
private unsubscribeAgentEvents: (() => void) | null = null;
|
||||
private pendingAgentInitializations: Map<
|
||||
string,
|
||||
Promise<AgentSnapshot>
|
||||
> = new Map();
|
||||
|
||||
constructor(
|
||||
clientId: string,
|
||||
@@ -559,7 +563,7 @@ export class Session {
|
||||
createdAt: createdAt.toISOString(),
|
||||
updatedAt: updatedAt.toISOString(),
|
||||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||||
status: (record.lastStatus as any) ?? "closed",
|
||||
status: record.lastStatus,
|
||||
sessionId: null,
|
||||
capabilities: defaultCapabilities,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
@@ -572,6 +576,49 @@ export class Session {
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureAgentLoaded(agentId: string): Promise<AgentSnapshot> {
|
||||
const existing = this.agentManager.getAgent(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const inflight = this.pendingAgentInitializations.get(agentId);
|
||||
if (inflight) {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
const initPromise = (async () => {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(record.persistence);
|
||||
let snapshot: AgentSnapshot;
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId);
|
||||
}
|
||||
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
return this.agentManager.getAgent(agentId) ?? snapshot;
|
||||
})();
|
||||
|
||||
this.pendingAgentInitializations.set(agentId, initPromise);
|
||||
|
||||
try {
|
||||
return await initPromise;
|
||||
} finally {
|
||||
this.pendingAgentInitializations.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
private async forwardAgentState(agent: AgentSnapshot): Promise<void> {
|
||||
try {
|
||||
const payload = await this.buildAgentPayload(agent);
|
||||
@@ -959,6 +1006,17 @@ export class Session {
|
||||
}] Sending text to agent ${agentId}: ${text.substring(0, 50)}...${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
||||
);
|
||||
|
||||
try {
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to initialize agent before sending prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
@@ -1053,6 +1111,17 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.ensureAgentLoaded(agentId);
|
||||
} catch (error) {
|
||||
this.handleAgentRunError(
|
||||
agentId,
|
||||
error,
|
||||
"Failed to initialize agent before sending audio prompt"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.interruptAgentIfRunning(agentId);
|
||||
} catch (error) {
|
||||
@@ -1108,27 +1177,7 @@ export class Session {
|
||||
);
|
||||
|
||||
try {
|
||||
let snapshot = this.agentManager.getAgent(agentId);
|
||||
if (!snapshot) {
|
||||
const record = await this.agentRegistry.get(agentId);
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const handle = toAgentPersistenceHandle(record.persistence);
|
||||
if (handle) {
|
||||
snapshot = await this.agentManager.resumeAgent(
|
||||
handle,
|
||||
buildConfigOverrides(record),
|
||||
agentId
|
||||
);
|
||||
} else {
|
||||
const config = buildSessionConfig(record);
|
||||
snapshot = await this.agentManager.createAgent(config, agentId);
|
||||
}
|
||||
}
|
||||
|
||||
await this.agentManager.primeAgentHistory(agentId);
|
||||
const snapshot = await this.ensureAgentLoaded(agentId);
|
||||
await this.forwardAgentState(snapshot);
|
||||
|
||||
// Send timeline snapshot after hydration (if any)
|
||||
@@ -1140,6 +1189,7 @@ export class Session {
|
||||
payload: {
|
||||
status: "agent_initialized",
|
||||
agentId,
|
||||
agentStatus: snapshot.status,
|
||||
requestId,
|
||||
timelineSize,
|
||||
},
|
||||
@@ -1147,7 +1197,7 @@ export class Session {
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Session ${this.clientId}] Agent ${agentId} initialized with ${timelineSize} timeline item(s)`
|
||||
`[Session ${this.clientId}] Agent ${agentId} initialized with ${timelineSize} timeline item(s); status=${snapshot.status}`
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
|
||||
Reference in New Issue
Block a user