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:
Mohamed Boudra
2025-11-29 22:44:03 +00:00
co-authored by Claude
parent 5660935a4b
commit 898b8ce5a1
87 changed files with 157670 additions and 39 deletions
@@ -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,
+5 -9
View File
@@ -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(),
+73 -23
View File
@@ -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(