diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 3c77ce18bb..37ce39b43e 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -3777,31 +3777,18 @@ export class DaemonClient { } const rawBytes = asUint8Array(rawData); - if (rawBytes) { - const frame = decodeTerminalStreamFrame(rawBytes); - if (frame) { - const binaryStartMs = perfNow(); - this.handleBinaryFrame(frame); - let frameKind: "output" | "snapshot" | "other" = "other"; - if (frame.opcode === TerminalStreamOpcode.Output) { - frameKind = "output"; - } else if (frame.opcode === TerminalStreamOpcode.Snapshot) { - frameKind = "snapshot"; - } - this.runtimeMetrics?.recordBinaryFrame( - frameKind, - rawBytes.byteLength, - perfNow() - binaryStartMs, - ); - return; - } + if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) { + return; } const payload = decodeMessageData(rawData); if (!payload) { return; } + this.handleJsonPayload(payload, rawBytes?.byteLength); + } - const bytes = rawBytes?.byteLength ?? payload.length; + private handleJsonPayload(payload: string, rawBytesLength: number | undefined): void { + const bytes = rawBytesLength ?? payload.length; const startMs = perfNow(); let parsedJson: unknown; try { @@ -3830,6 +3817,27 @@ export class DaemonClient { } } + private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean { + const frame = decodeTerminalStreamFrame(rawBytes); + if (!frame) { + return false; + } + const binaryStartMs = perfNow(); + this.handleBinaryFrame(frame); + let frameKind: "output" | "snapshot" | "other" = "other"; + if (frame.opcode === TerminalStreamOpcode.Output) { + frameKind = "output"; + } else if (frame.opcode === TerminalStreamOpcode.Snapshot) { + frameKind = "snapshot"; + } + this.runtimeMetrics?.recordBinaryFrame( + frameKind, + rawBytes.byteLength, + perfNow() - binaryStartMs, + ); + return true; + } + private handleBinaryFrame(frame: TerminalStreamFrame): void { const terminalId = this.slotTerminals.get(frame.slot); if (!terminalId) { @@ -3932,6 +3940,19 @@ export class DaemonClient { this.rejectConnect(new Error(reason ?? "Daemon client is disposed")); return; } + this.emitDisconnectedStateForReconnect(reason, input); + if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { + this.rejectConnect(new Error(reason ?? "Transport disconnected before connect")); + return; + } + + this.armReconnectTimer(); + } + + private emitDisconnectedStateForReconnect( + reason: string | undefined, + input: { reason?: string; event?: string; reasonCode?: string } | undefined, + ): void { this.updateConnectionState( { status: "disconnected", @@ -3943,11 +3964,9 @@ export class DaemonClient { ...(input?.reasonCode ? { reasonCode: input.reasonCode } : {}), }, ); - if (!this.shouldReconnect || this.config.reconnect?.enabled === false) { - this.rejectConnect(new Error(reason ?? "Transport disconnected before connect")); - return; - } + } + private armReconnectTimer(): void { const attempt = this.reconnectAttempt; const baseDelay = this.config.reconnect?.baseDelayMs ?? DEFAULT_RECONNECT_BASE_DELAY_MS; const maxDelay = this.config.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT_MAX_DELAY_MS; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d8753b3a95..a383bb7ed1 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -156,6 +156,16 @@ function resolveInitialAttention(input: AttentionState | undefined): AttentionSt }; } +interface StreamEventFlags { + shouldDispatchEvent: boolean; + shouldNotifyWaiters: boolean; +} + +interface HandleStreamEventOptions { + fromHistory?: boolean; + canonicalUserMessagesById?: ReadonlyMap; +} + interface ForegroundTurnWaiter { turnId: string; callback: (event: AgentStreamEvent) => void; @@ -331,6 +341,32 @@ function normalizeMessageId(messageId: string | undefined): string | undefined { return trimmed.length > 0 ? trimmed : undefined; } +function buildExplicitTimelineSeedForRegister( + now: Date, + options: + | { + timeline?: AgentTimelineItem[]; + timelineRows?: AgentTimelineRow[]; + timelineNextSeq?: number; + createdAt?: Date; + updatedAt?: Date; + } + | undefined, +): SeedAgentTimelineOptions | null { + const hasTimeline = Boolean(options?.timeline?.length); + const hasTimelineRows = Boolean(options?.timelineRows?.length); + const hasTimelineNextSeq = options?.timelineNextSeq !== undefined; + if (!hasTimeline && !hasTimelineRows && !hasTimelineNextSeq) { + return null; + } + return { + items: options?.timeline, + rows: options?.timelineRows, + nextSeq: options?.timelineNextSeq, + timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(), + }; +} + export class AgentManager { private readonly clients = new Map(); private readonly agents = new Map(); @@ -1962,38 +1998,94 @@ export class AgentManager { const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config); const now = new Date(); - const explicitTimelineSeed: SeedAgentTimelineOptions | null = - options?.timeline?.length || - options?.timelineRows?.length || - options?.timelineNextSeq !== undefined - ? { - items: options?.timeline, - rows: options?.timelineRows, - nextSeq: options?.timelineNextSeq, - timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(), - } - : null; + const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({ + agentId: resolvedAgentId, + now, + options, + }); + + const managed = this.buildManagedAgentForRegister({ + resolvedAgentId, + session, + config, + now, + durableTimelineHasRows, + options, + }); + + this.agents.set(resolvedAgentId, managed); + // Initialize previousStatus to track transitions + this.previousStatuses.set(resolvedAgentId, managed.lifecycle); + await this.refreshRuntimeInfo(managed); + await this.persistSnapshot(managed, { + workspaceId: options?.workspaceId, + title: initialPersistedTitle, + }); + this.emitState(managed, { persist: false }); + + await this.refreshSessionState(managed); + managed.lifecycle = "idle"; + await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); + this.emitState(managed, { persist: false }); + this.subscribeToSession(managed); + return { ...managed }; + } + + private async initializeAgentTimelineForRegister(params: { + agentId: string; + now: Date; + options: + | { + timeline?: AgentTimelineItem[]; + timelineRows?: AgentTimelineRow[]; + timelineNextSeq?: number; + createdAt?: Date; + updatedAt?: Date; + } + | undefined; + }): Promise<{ durableTimelineHasRows: boolean }> { + const { agentId, now, options } = params; + const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options); const shouldSeedFromDurable = !explicitTimelineSeed && - !this.timelineStore.has(resolvedAgentId) && + !this.timelineStore.has(agentId) && this.durableTimelineStore !== undefined; const durableTimelineSeed = shouldSeedFromDurable - ? await this.loadCommittedTimelineSeed(resolvedAgentId, now) + ? await this.loadCommittedTimelineSeed(agentId, now) : null; const durableTimelineHasRows = durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1; const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed; - if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) { - this.timelineStore.initialize( - resolvedAgentId, - timelineSeed ?? { timestamp: now.toISOString() }, - ); + if (timelineSeed || !this.timelineStore.has(agentId)) { + this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() }); } if (options?.timelineRows?.length) { - this.enqueueDurableTimelineBulkInsert(resolvedAgentId, options.timelineRows); + this.enqueueDurableTimelineBulkInsert(agentId, options.timelineRows); } + return { durableTimelineHasRows }; + } - const managed = { + private buildManagedAgentForRegister(params: { + resolvedAgentId: string; + session: AgentSession; + config: AgentSessionConfig; + now: Date; + durableTimelineHasRows: boolean; + options: + | { + createdAt?: Date; + updatedAt?: Date; + lastUserMessageAt?: Date | null; + labels?: Record; + historyPrimed?: boolean; + lastUsage?: AgentUsage; + lastError?: string; + attention?: AttentionState; + } + | undefined; + }): ActiveManagedAgent { + const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params; + return { id: resolvedAgentId, provider: config.provider, cwd: config.cwd, @@ -2023,23 +2115,6 @@ export class AgentManager { internal: config.internal ?? false, labels: options?.labels ?? {}, } as ActiveManagedAgent; - - this.agents.set(resolvedAgentId, managed); - // Initialize previousStatus to track transitions - this.previousStatuses.set(resolvedAgentId, managed.lifecycle); - await this.refreshRuntimeInfo(managed); - await this.persistSnapshot(managed, { - workspaceId: options?.workspaceId, - title: initialPersistedTitle, - }); - this.emitState(managed, { persist: false }); - - await this.refreshSessionState(managed); - managed.lifecycle = "idle"; - await this.persistSnapshot(managed, { workspaceId: options?.workspaceId }); - this.emitState(managed, { persist: false }); - this.subscribeToSession(managed); - return { ...managed }; } private async loadCommittedTimelineSeed( @@ -2352,10 +2427,7 @@ export class AgentManager { private async handleStreamEvent( agent: ActiveManagedAgent, event: AgentStreamEvent, - options?: { - fromHistory?: boolean; - canonicalUserMessagesById?: ReadonlyMap; - }, + options?: HandleStreamEventOptions, ): Promise { const eventTurnId = (event as { turnId?: string }).turnId; const isForegroundEvent = Boolean(eventTurnId && agent.activeForegroundTurnId === eventTurnId); @@ -2376,211 +2448,302 @@ export class AgentManager { this.agentStreamCoalescer.flushFor(agent.id); } - let shouldDispatchEvent = true; - let shouldNotifyWaiters = true; + const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true }; + await this.dispatchStreamEventByType({ + agent, + event, + options, + isForegroundEvent, + eventTurnId, + flags, + }); + + if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) { + this.finalizeForegroundTurn(agent, eventTurnId); + } + + if (!options?.fromHistory && flags.shouldDispatchEvent) { + this.dispatchStream(agent.id, event); + } + + return flags.shouldNotifyWaiters; + } + + private async dispatchStreamEventByType(params: { + agent: ActiveManagedAgent; + event: AgentStreamEvent; + options: HandleStreamEventOptions | undefined; + isForegroundEvent: boolean; + eventTurnId: string | undefined; + flags: StreamEventFlags; + }): Promise { + const { agent, event, options, isForegroundEvent, eventTurnId, flags } = params; switch (event.type) { case "thread_started": - { - const previousSessionId = agent.persistence?.sessionId ?? null; - const handle = agent.session.describePersistence(); - if (handle) { - agent.persistence = attachPersistenceCwd(handle, agent.cwd); - if (agent.persistence?.sessionId !== previousSessionId) { - this.emitState(agent); - } - } - void this.refreshRuntimeInfo(agent); - } + this.onStreamThreadStarted(agent); break; case "usage_updated": agent.lastUsage = event.usage; this.emitState(agent); break; case "timeline": - { - // Skip provider-replayed user_message items during history hydration. - if (options?.fromHistory && event.item.type === "user_message") { - const eventMessageId = normalizeMessageId(event.item.messageId); - if (eventMessageId) { - const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId); - if (canonicalText === event.item.text) { - shouldDispatchEvent = false; - shouldNotifyWaiters = false; - break; - } - } - } - - // Suppress user_message echoes for the active foreground turn. - if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) { - const eventMessageId = normalizeMessageId(event.item.messageId); - if ( - eventMessageId && - (await this.hasCommittedUserMessageFromStores(agent.id, { - messageId: eventMessageId, - text: event.item.text, - })) - ) { - break; - } - } - - if (options?.fromHistory) { - this.recordTimeline(agent.id, event.item); - shouldDispatchEvent = false; - shouldNotifyWaiters = false; - break; - } - - this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId); - if (event.item.type === "user_message") { - agent.lastUserMessageAt = new Date(); - this.emitState(agent); - } - shouldDispatchEvent = false; - shouldNotifyWaiters = true; - } + await this.onStreamTimelineEvent({ agent, event, options, isForegroundEvent, flags }); break; case "turn_completed": - this.logger.trace( - { - agentId: agent.id, - lifecycle: agent.lifecycle, - activeForegroundTurnId: agent.activeForegroundTurnId, - eventTurnId, - }, - "handleStreamEvent: turn_completed", - ); - agent.lastUsage = event.usage; - agent.lastError = undefined; - // For autonomous turns (not foreground), transition to idle - // unless a replacement is pending (avoid idle flash during replace) - if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) { - (agent as ActiveManagedAgent).lifecycle = "idle"; - this.emitState(agent); - } - void this.refreshRuntimeInfo(agent); + this.onStreamTurnCompleted({ agent, event, eventTurnId, isForegroundEvent }); break; case "turn_failed": - this.logger.warn( - { - agentId: agent.id, - lifecycle: agent.lifecycle, - activeForegroundTurnId: agent.activeForegroundTurnId, - eventTurnId, - error: event.error, - code: event.code, - diagnostic: event.diagnostic, - }, - "handleStreamEvent: turn_failed", - ); - // For autonomous turns, set error state directly - if (!isForegroundEvent) { - agent.lifecycle = "error"; - } - agent.lastError = event.error; - await this.appendSystemErrorTimelineMessage( - agent, - event.provider, - this.formatTurnFailedMessage(event), - options, - ); - for (const [requestId] of agent.pendingPermissions) { - agent.pendingPermissions.delete(requestId); - if (!options?.fromHistory) { - this.dispatchStream(agent.id, { - type: "permission_resolved", - provider: event.provider, - requestId, - resolution: { behavior: "deny", message: "Turn failed" }, - }); - } - } - if (!isForegroundEvent) { - this.emitState(agent); - } + await this.onStreamTurnFailed({ agent, event, eventTurnId, isForegroundEvent, options }); break; case "turn_canceled": - this.logger.trace( - { - agentId: agent.id, - lifecycle: agent.lifecycle, - activeForegroundTurnId: agent.activeForegroundTurnId, - eventTurnId, - }, - "handleStreamEvent: turn_canceled", - ); - // For autonomous turns, transition to idle - // unless a replacement is pending (avoid idle flash during replace) - if (!isForegroundEvent && !agent.pendingReplacement) { - (agent as ActiveManagedAgent).lifecycle = "idle"; - } - agent.lastError = undefined; - for (const [requestId] of agent.pendingPermissions) { - agent.pendingPermissions.delete(requestId); - if (!options?.fromHistory) { - this.dispatchStream(agent.id, { - type: "permission_resolved", - provider: event.provider, - requestId, - resolution: { behavior: "deny", message: "Interrupted" }, - }); - } - } - if (!isForegroundEvent) { - this.emitState(agent); - } + this.onStreamTurnCanceled({ agent, event, eventTurnId, isForegroundEvent, options }); break; case "turn_started": - this.logger.trace( - { - agentId: agent.id, - lifecycle: agent.lifecycle, - activeForegroundTurnId: agent.activeForegroundTurnId, - eventTurnId, - }, - "handleStreamEvent: turn_started", - ); - // For autonomous turn_started (no foreground match), set running - if (!isForegroundEvent) { - (agent as ActiveManagedAgent).lifecycle = "running"; - this.emitState(agent); - } + this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent }); break; case "permission_requested": - { - const hadPendingPermissions = agent.pendingPermissions.size > 0; - agent.pendingPermissions.set(event.request.id, event.request); - if (!hadPendingPermissions && !agent.internal) { - this.broadcastAgentAttention(agent, "permission"); - } - } - this.emitState(agent); + this.onStreamPermissionRequested(agent, event); break; case "permission_resolved": - agent.pendingPermissions.delete(event.requestId); - if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { - agent.bufferedPermissionResolutions.set(event.requestId, event); - shouldDispatchEvent = false; - break; - } - this.emitState(agent); + this.onStreamPermissionResolved({ agent, event, options, flags }); break; default: break; } + } - if (!options?.fromHistory && isForegroundEvent && isTurnTerminalEvent(event)) { - this.finalizeForegroundTurn(agent, eventTurnId); + private onStreamThreadStarted(agent: ActiveManagedAgent): void { + const previousSessionId = agent.persistence?.sessionId ?? null; + const handle = agent.session.describePersistence(); + if (handle) { + agent.persistence = attachPersistenceCwd(handle, agent.cwd); + if (agent.persistence?.sessionId !== previousSessionId) { + this.emitState(agent); + } + } + void this.refreshRuntimeInfo(agent); + } + + private async onStreamTimelineEvent(params: { + agent: ActiveManagedAgent; + event: Extract; + options: + | { + fromHistory?: boolean; + canonicalUserMessagesById?: ReadonlyMap; + } + | undefined; + isForegroundEvent: boolean; + flags: StreamEventFlags; + }): Promise { + const { agent, event, options, isForegroundEvent, flags } = params; + // Skip provider-replayed user_message items during history hydration. + if (options?.fromHistory && event.item.type === "user_message") { + const eventMessageId = normalizeMessageId(event.item.messageId); + if (eventMessageId) { + const canonicalText = options?.canonicalUserMessagesById?.get(eventMessageId); + if (canonicalText === event.item.text) { + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + } } - // Skip dispatching individual stream events during history replay. - if (!options?.fromHistory && shouldDispatchEvent) { - this.dispatchStream(agent.id, event); + // Suppress user_message echoes for the active foreground turn. + if (!options?.fromHistory && event.item.type === "user_message" && isForegroundEvent) { + const eventMessageId = normalizeMessageId(event.item.messageId); + if ( + eventMessageId && + (await this.hasCommittedUserMessageFromStores(agent.id, { + messageId: eventMessageId, + text: event.item.text, + })) + ) { + return; + } } - return shouldNotifyWaiters; + if (options?.fromHistory) { + this.recordTimeline(agent.id, event.item); + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = false; + return; + } + + this.recordAndDispatchTimelineItem(agent.id, event.item, event.provider, event.turnId); + if (event.item.type === "user_message") { + agent.lastUserMessageAt = new Date(); + this.emitState(agent); + } + flags.shouldDispatchEvent = false; + flags.shouldNotifyWaiters = true; + } + + private onStreamTurnCompleted(params: { + agent: ActiveManagedAgent; + event: Extract; + eventTurnId: string | undefined; + isForegroundEvent: boolean; + }): void { + const { agent, event, eventTurnId, isForegroundEvent } = params; + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, + }, + "handleStreamEvent: turn_completed", + ); + agent.lastUsage = event.usage; + agent.lastError = undefined; + if (!isForegroundEvent && agent.lifecycle !== "idle" && !agent.pendingReplacement) { + (agent as ActiveManagedAgent).lifecycle = "idle"; + this.emitState(agent); + } + void this.refreshRuntimeInfo(agent); + } + + private async onStreamTurnFailed(params: { + agent: ActiveManagedAgent; + event: Extract; + eventTurnId: string | undefined; + isForegroundEvent: boolean; + options: + | { + fromHistory?: boolean; + canonicalUserMessagesById?: ReadonlyMap; + } + | undefined; + }): Promise { + const { agent, event, eventTurnId, isForegroundEvent, options } = params; + this.logger.warn( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, + error: event.error, + code: event.code, + diagnostic: event.diagnostic, + }, + "handleStreamEvent: turn_failed", + ); + if (!isForegroundEvent) { + agent.lifecycle = "error"; + } + agent.lastError = event.error; + await this.appendSystemErrorTimelineMessage( + agent, + event.provider, + this.formatTurnFailedMessage(event), + options, + ); + this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed"); + if (!isForegroundEvent) { + this.emitState(agent); + } + } + + private onStreamTurnCanceled(params: { + agent: ActiveManagedAgent; + event: Extract; + eventTurnId: string | undefined; + isForegroundEvent: boolean; + options: + | { + fromHistory?: boolean; + } + | undefined; + }): void { + const { agent, event, eventTurnId, isForegroundEvent, options } = params; + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, + }, + "handleStreamEvent: turn_canceled", + ); + if (!isForegroundEvent && !agent.pendingReplacement) { + (agent as ActiveManagedAgent).lifecycle = "idle"; + } + agent.lastError = undefined; + this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted"); + if (!isForegroundEvent) { + this.emitState(agent); + } + } + + private onStreamTurnStarted(params: { + agent: ActiveManagedAgent; + eventTurnId: string | undefined; + isForegroundEvent: boolean; + }): void { + const { agent, eventTurnId, isForegroundEvent } = params; + this.logger.trace( + { + agentId: agent.id, + lifecycle: agent.lifecycle, + activeForegroundTurnId: agent.activeForegroundTurnId, + eventTurnId, + }, + "handleStreamEvent: turn_started", + ); + if (!isForegroundEvent) { + (agent as ActiveManagedAgent).lifecycle = "running"; + this.emitState(agent); + } + } + + private onStreamPermissionRequested( + agent: ActiveManagedAgent, + event: Extract, + ): void { + const hadPendingPermissions = agent.pendingPermissions.size > 0; + agent.pendingPermissions.set(event.request.id, event.request); + if (!hadPendingPermissions && !agent.internal) { + this.broadcastAgentAttention(agent, "permission"); + } + this.emitState(agent); + } + + private onStreamPermissionResolved(params: { + agent: ActiveManagedAgent; + event: Extract; + options: { fromHistory?: boolean } | undefined; + flags: StreamEventFlags; + }): void { + const { agent, event, options, flags } = params; + agent.pendingPermissions.delete(event.requestId); + if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { + agent.bufferedPermissionResolutions.set(event.requestId, event); + flags.shouldDispatchEvent = false; + return; + } + this.emitState(agent); + } + + private resolvePendingPermissionsForAgent( + agent: ActiveManagedAgent, + provider: AgentProvider, + options: { fromHistory?: boolean } | undefined, + message: string, + ): void { + for (const [requestId] of agent.pendingPermissions) { + agent.pendingPermissions.delete(requestId); + if (!options?.fromHistory) { + this.dispatchStream(agent.id, { + type: "permission_resolved", + provider, + requestId, + resolution: { behavior: "deny", message }, + }); + } + } } private recordAndDispatchTimelineItem( diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 595da51ffe..a2514cabf0 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -198,62 +198,75 @@ export async function generateAndApplyAgentMetadata( } if (needs.needsBranch && typeof result.branch === "string") { - const normalizedBranch = result.branch.trim(); - const validation = validateBranchSlug(normalizedBranch); - if (!validation.valid) { - options.logger.warn( - { agentId: options.agentId, branch: normalizedBranch, error: validation.error }, - "Generated branch name is invalid", - ); - return; - } + await applyGeneratedBranchRename({ + options, + branch: result.branch, + renameCurrentBranchImpl, + }); + } +} - const workspaceGitService = options.deps?.workspaceGitService; - if (!workspaceGitService) { - return; - } +async function applyGeneratedBranchRename(params: { + options: AgentMetadataGenerationOptions; + branch: string; + renameCurrentBranchImpl: typeof renameCurrentBranch; +}): Promise { + const { options, branch, renameCurrentBranchImpl } = params; + const normalizedBranch = branch.trim(); + const validation = validateBranchSlug(normalizedBranch); + if (!validation.valid) { + options.logger.warn( + { agentId: options.agentId, branch: normalizedBranch, error: validation.error }, + "Generated branch name is invalid", + ); + return; + } - let snapshot: WorkspaceGitRuntimeSnapshot; + const workspaceGitService = options.deps?.workspaceGitService; + if (!workspaceGitService) { + return; + } + + let snapshot: WorkspaceGitRuntimeSnapshot; + try { + snapshot = await workspaceGitService.getSnapshot(options.cwd); + } catch (error) { + options.logger.warn( + { err: error, agentId: options.agentId }, + "Failed to re-check branch eligibility", + ); + return; + } + + if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) { + return; + } + + const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null; + if (snapshot.git.currentBranch !== worktreeDirName) { + return; + } + + try { + await renameCurrentBranchImpl(options.cwd, normalizedBranch); try { - snapshot = await workspaceGitService.getSnapshot(options.cwd); + await workspaceGitService.getSnapshot(options.cwd, { + force: true, + reason: "rename-branch", + }); } catch (error) { options.logger.warn( - { err: error, agentId: options.agentId }, - "Failed to re-check branch eligibility", - ); - return; - } - - if (!snapshot.git.isGit || !snapshot.git.isPaseoOwnedWorktree || !snapshot.git.currentBranch) { - return; - } - - const worktreeDirName = snapshot.git.repoRoot ? basename(snapshot.git.repoRoot) : null; - if (snapshot.git.currentBranch !== worktreeDirName) { - return; - } - - try { - await renameCurrentBranchImpl(options.cwd, normalizedBranch); - try { - await workspaceGitService.getSnapshot(options.cwd, { - force: true, - reason: "rename-branch", - }); - } catch (error) { - options.logger.warn( - { err: error, agentId: options.agentId, cwd: options.cwd }, - "Failed to force-refresh workspace git snapshot after branch rename", - ); - } - options.agentManager.notifyAgentState(options.agentId); - await options.agentManager.flush(); - } catch (error) { - options.logger.warn( - { err: error, agentId: options.agentId, branch: normalizedBranch }, - "Failed to rename branch", + { err: error, agentId: options.agentId, cwd: options.cwd }, + "Failed to force-refresh workspace git snapshot after branch rename", ); } + options.agentManager.notifyAgentState(options.agentId); + await options.agentManager.flush(); + } catch (error) { + options.logger.warn( + { err: error, agentId: options.agentId, branch: normalizedBranch }, + "Failed to rename branch", + ); } } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index ab63bc5d28..66a4eae15c 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -143,6 +143,32 @@ export function toAgentPayload( return payload; } +function buildStoredRuntimeInfo( + record: StoredAgentRecord, + providerRegistry: ReturnType, + logger: Logger, +): AgentRuntimeInfo | undefined { + if (!record.runtimeInfo) return undefined; + const ri = record.runtimeInfo; + const runtimeInfo: AgentRuntimeInfo = { + provider: coerceAgentProvider(logger, providerRegistry, ri.provider, record.id), + sessionId: ri.sessionId, + }; + if (Object.prototype.hasOwnProperty.call(ri, "model")) { + runtimeInfo.model = ri.model ?? null; + } + if (Object.prototype.hasOwnProperty.call(ri, "thinkingOptionId")) { + runtimeInfo.thinkingOptionId = ri.thinkingOptionId ?? null; + } + if (Object.prototype.hasOwnProperty.call(ri, "modeId")) { + runtimeInfo.modeId = ri.modeId ?? null; + } + if (ri.extra) { + runtimeInfo.extra = ri.extra; + } + return runtimeInfo; +} + export function buildStoredAgentPayload( record: StoredAgentRecord, providerRegistry: ReturnType, @@ -162,27 +188,7 @@ export function buildStoredAgentPayload( const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const provider = coerceAgentProvider(logger, providerRegistry, record.provider, record.id); - const runtimeInfo = record.runtimeInfo - ? { - provider: coerceAgentProvider( - logger, - providerRegistry, - record.runtimeInfo.provider, - record.id, - ), - sessionId: record.runtimeInfo.sessionId, - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "model") - ? { model: record.runtimeInfo.model ?? null } - : {}), - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "thinkingOptionId") - ? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null } - : {}), - ...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, "modeId") - ? { modeId: record.runtimeInfo.modeId ?? null } - : {}), - ...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}), - } - : undefined; + const runtimeInfo = buildStoredRuntimeInfo(record, providerRegistry, logger); return { id: record.id, @@ -383,47 +389,39 @@ function sanitizeMetadataArray(value: unknown): AgentMetadata[] | undefined { return sanitized.length > 0 ? sanitized : undefined; } +type UsageNumericField = Exclude; + +function assignFiniteNumber( + source: { [key: string]: JsonValue }, + target: AgentUsage, + field: UsageNumericField, +): boolean { + const raw = source[field]; + if (typeof raw === "number" && Number.isFinite(raw)) { + target[field] = raw; + return true; + } + return raw === undefined || raw === null; +} + function sanitizeUsage(value: unknown): AgentUsage | undefined { const sanitized = sanitizeOptionalJson(value); if (!sanitized || !isJsonObject(sanitized)) { return undefined; } const result: AgentUsage = {}; - const inputTokens = sanitized.inputTokens; - if (typeof inputTokens === "number" && Number.isFinite(inputTokens)) { - result.inputTokens = inputTokens; - } else if (inputTokens !== undefined && inputTokens !== null) { - return undefined; - } - const cachedInputTokens = sanitized.cachedInputTokens; - if (typeof cachedInputTokens === "number" && Number.isFinite(cachedInputTokens)) { - result.cachedInputTokens = cachedInputTokens; - } else if (cachedInputTokens !== undefined && cachedInputTokens !== null) { - return undefined; - } - const outputTokens = sanitized.outputTokens; - if (typeof outputTokens === "number" && Number.isFinite(outputTokens)) { - result.outputTokens = outputTokens; - } else if (outputTokens !== undefined && outputTokens !== null) { - return undefined; - } - const totalCostUsd = sanitized.totalCostUsd; - if (typeof totalCostUsd === "number" && Number.isFinite(totalCostUsd)) { - result.totalCostUsd = totalCostUsd; - } else if (totalCostUsd !== undefined && totalCostUsd !== null) { - return undefined; - } - const contextWindowMaxTokens = sanitized.contextWindowMaxTokens; - if (typeof contextWindowMaxTokens === "number" && Number.isFinite(contextWindowMaxTokens)) { - result.contextWindowMaxTokens = contextWindowMaxTokens; - } else if (contextWindowMaxTokens !== undefined && contextWindowMaxTokens !== null) { - return undefined; - } - const contextWindowUsedTokens = sanitized.contextWindowUsedTokens; - if (typeof contextWindowUsedTokens === "number" && Number.isFinite(contextWindowUsedTokens)) { - result.contextWindowUsedTokens = contextWindowUsedTokens; - } else if (contextWindowUsedTokens !== undefined && contextWindowUsedTokens !== null) { - return undefined; + const fields: UsageNumericField[] = [ + "inputTokens", + "cachedInputTokens", + "outputTokens", + "totalCostUsd", + "contextWindowMaxTokens", + "contextWindowUsedTokens", + ]; + for (const field of fields) { + if (!assignFiniteNumber(sanitized, result, field)) { + return undefined; + } } return Object.keys(result).length ? result : undefined; } diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index e5aeb91786..6ccd234c9c 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -10,6 +10,7 @@ import { buildConfigOverrides, buildSessionConfig } from "../persistence-hooks.j import type { ManagedAgent } from "./agent-manager.js"; import type { AgentPermissionRequest, + AgentProvider, AgentSession, AgentSessionConfig, } from "./agent-sdk-types.js"; @@ -26,12 +27,11 @@ type ManagedAgentOverrides = Omit< attention?: ManagedAgent["attention"]; }; -function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { - const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z"); - const provider = overrides.provider ?? "claude"; - const cwd = overrides.cwd ?? "/tmp/project"; - const lifecycle = overrides.lifecycle ?? "idle"; - const configOverrides = overrides.config ?? {}; +function buildManagedAgentConfig( + provider: AgentProvider, + cwd: string, + configOverrides: Partial, +): AgentSessionConfig { const config: AgentSessionConfig = { provider, cwd, @@ -45,49 +45,88 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) { config.featureValues = configOverrides.featureValues; } + return config; +} + +function buildDefaultCapabilities() { + return { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }; +} + +function buildDefaultRuntimeInfo(params: { + provider: AgentProvider; + config: AgentSessionConfig; + sessionId: string; +}) { + return { + provider: params.provider, + sessionId: params.sessionId, + model: params.config.model ?? null, + modeId: params.config.modeId ?? null, + }; +} + +interface ManagedAgentCore { + provider: AgentProvider; + cwd: string; + lifecycle: ManagedAgent["lifecycle"]; + config: AgentSessionConfig; + session: AgentSession | null; + activeForegroundTurnId: string | null; + now: Date; +} + +function resolveManagedAgentCore(overrides: ManagedAgentOverrides): ManagedAgentCore { + const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z"); + const provider = overrides.provider ?? "claude"; + const cwd = overrides.cwd ?? "/tmp/project"; + const lifecycle = overrides.lifecycle ?? "idle"; + const config = buildManagedAgentConfig(provider, cwd, overrides.config ?? {}); const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); const activeForegroundTurnId = overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); + return { provider, cwd, lifecycle, config, session, activeForegroundTurnId, now }; +} - const agent: ManagedAgent = { +function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { + const core = resolveManagedAgentCore(overrides); + return { id: overrides.id ?? "agent-test", - provider, - cwd, - session, - capabilities: overrides.capabilities ?? { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - config, - lifecycle, - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, + provider: core.provider, + cwd: core.cwd, + session: core.session, + capabilities: overrides.capabilities ?? buildDefaultCapabilities(), + config: core.config, + lifecycle: core.lifecycle, + createdAt: overrides.createdAt ?? core.now, + updatedAt: overrides.updatedAt ?? core.now, availableModes: overrides.availableModes ?? [], - currentModeId: overrides.currentModeId ?? config.modeId ?? null, + currentModeId: overrides.currentModeId ?? core.config.modeId ?? null, pendingPermissions: overrides.pendingPermissions ?? new Map(), - activeForegroundTurnId, + activeForegroundTurnId: core.activeForegroundTurnId, foregroundTurnWaiters: new Set(), unsubscribeSession: null, timeline: overrides.timeline ?? [], attention: overrides.attention ?? { requiresAttention: false }, - runtimeInfo: overrides.runtimeInfo ?? { - provider, - sessionId: overrides.sessionId ?? "session-123", - model: config.model ?? null, - modeId: config.modeId ?? null, - }, + runtimeInfo: + overrides.runtimeInfo ?? + buildDefaultRuntimeInfo({ + provider: core.provider, + config: core.config, + sessionId: overrides.sessionId ?? "session-123", + }), persistence: overrides.persistence ?? null, historyPrimed: overrides.historyPrimed ?? true, - lastUserMessageAt: overrides.lastUserMessageAt ?? now, + lastUserMessageAt: overrides.lastUserMessageAt ?? core.now, lastUsage: overrides.lastUsage, lastError: overrides.lastError, }; - - return agent; } describe("AgentStorage", () => { diff --git a/packages/server/src/server/agent/agent-timeline-store.ts b/packages/server/src/server/agent/agent-timeline-store.ts index 9e6a1be654..d015dc82da 100644 --- a/packages/server/src/server/agent/agent-timeline-store.ts +++ b/packages/server/src/server/agent/agent-timeline-store.ts @@ -34,6 +34,93 @@ function normalizeTimelineMessageId(messageId: string | undefined): string | und return normalized.length > 0 ? normalized : undefined; } +interface FetchContext { + state: AgentTimelineState; + direction: NonNullable; + limit: number; + selectAll: boolean; + cursor: AgentTimelineFetchOptions["cursor"]; + minSeq: number; + maxSeq: number; + window: { minSeq: number; maxSeq: number; nextSeq: number }; +} + +function fetchTail(ctx: FetchContext): AgentTimelineFetchResult { + const { state, direction, limit, selectAll, minSeq, window } = ctx; + const selected = + selectAll || limit >= state.rows.length + ? state.rows + : state.rows.slice(state.rows.length - limit); + return { + epoch: state.epoch, + direction, + reset: false, + staleCursor: false, + gap: false, + window, + hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, + hasNewer: false, + rows: selected.map(cloneRow), + }; +} + +function fetchAfter(ctx: FetchContext): AgentTimelineFetchResult { + const { state, direction, limit, selectAll, cursor, minSeq, maxSeq, window } = ctx; + const baseSeq = cursor?.seq ?? 0; + const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); + if (startIdx < 0) { + return { + epoch: state.epoch, + direction, + reset: false, + staleCursor: false, + gap: false, + window, + hasOlder: baseSeq >= minSeq, + hasNewer: false, + rows: [], + }; + } + + const selected = selectAll + ? state.rows.slice(startIdx) + : state.rows.slice(startIdx, startIdx + limit); + const lastSelected = selected[selected.length - 1]; + return { + epoch: state.epoch, + direction, + reset: false, + staleCursor: false, + gap: false, + window, + hasOlder: selected[0]!.seq > minSeq, + hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq), + rows: selected.map(cloneRow), + }; +} + +function fetchBefore(ctx: FetchContext): AgentTimelineFetchResult { + const { state, direction, limit, selectAll, cursor, minSeq, window } = ctx; + const beforeSeq = cursor?.seq ?? state.nextSeq; + const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); + const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); + const selected = + selectAll || limit >= boundedRows.length + ? boundedRows + : boundedRows.slice(boundedRows.length - limit); + return { + epoch: state.epoch, + direction, + reset: false, + staleCursor: false, + gap: false, + window, + hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, + hasNewer: endExclusive >= 0, + rows: selected.map(cloneRow), + }; +} + export class InMemoryAgentTimelineStore { private readonly states = new Map(); @@ -103,8 +190,6 @@ export class InMemoryAgentTimelineStore { }; } - const cloneRows = (items: AgentTimelineRow[]) => items.map(cloneRow); - if (direction === "after" && cursor && state.rows.length > 0 && cursor.seq < minSeq - 1) { return { epoch: state.epoch, @@ -115,7 +200,7 @@ export class InMemoryAgentTimelineStore { window, hasOlder: false, hasNewer: false, - rows: cloneRows(state.rows), + rows: state.rows.map(cloneRow), }; } @@ -133,76 +218,24 @@ export class InMemoryAgentTimelineStore { }; } - if (direction === "tail") { - const selected = - selectAll || limit >= state.rows.length - ? state.rows - : state.rows.slice(state.rows.length - limit); - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, - hasNewer: false, - rows: cloneRows(selected), - }; - } - - if (direction === "after") { - const baseSeq = cursor?.seq ?? 0; - const startIdx = state.rows.findIndex((row) => row.seq > baseSeq); - if (startIdx < 0) { - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: baseSeq >= minSeq, - hasNewer: false, - rows: [], - }; - } - - const selected = selectAll - ? state.rows.slice(startIdx) - : state.rows.slice(startIdx, startIdx + limit); - const lastSelected = selected[selected.length - 1]; - return { - epoch: state.epoch, - direction, - reset: false, - staleCursor: false, - gap: false, - window, - hasOlder: selected[0]!.seq > minSeq, - hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq), - rows: cloneRows(selected), - }; - } - - const beforeSeq = cursor?.seq ?? state.nextSeq; - const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq); - const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive); - const selected = - selectAll || limit >= boundedRows.length - ? boundedRows - : boundedRows.slice(boundedRows.length - limit); - return { - epoch: state.epoch, + const ctx: FetchContext = { + state, direction, - reset: false, - staleCursor: false, - gap: false, + limit, + selectAll, + cursor, + minSeq, + maxSeq, window, - hasOlder: selected.length > 0 && selected[0]!.seq > minSeq, - hasNewer: endExclusive >= 0, - rows: cloneRows(selected), }; + + if (direction === "tail") { + return fetchTail(ctx); + } + if (direction === "after") { + return fetchAfter(ctx); + } + return fetchBefore(ctx); } append( diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 66acefd335..0283eba2bc 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -329,60 +329,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return expandUserPath(trimmedCwd); }; + const buildCallerAgentScheduleConfigExtras = ( + callerAgent: NonNullable>, + ): Record => { + return { + ...(callerAgent.config.thinkingOptionId + ? { thinkingOptionId: callerAgent.config.thinkingOptionId } + : {}), + ...(callerAgent.config.approvalPolicy + ? { approvalPolicy: callerAgent.config.approvalPolicy } + : {}), + ...(callerAgent.config.sandboxMode ? { sandboxMode: callerAgent.config.sandboxMode } : {}), + ...(typeof callerAgent.config.networkAccess === "boolean" + ? { networkAccess: callerAgent.config.networkAccess } + : {}), + ...(typeof callerAgent.config.webSearch === "boolean" + ? { webSearch: callerAgent.config.webSearch } + : {}), + ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), + ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), + ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), + ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), + }; + }; + + const buildCallerAgentScheduleConfig = ( + callerAgent: NonNullable>, + params?: { provider?: string; cwd?: string }, + ) => { + const hasProviderOverride = params?.provider !== undefined; + const resolvedProviderModel = hasProviderOverride + ? resolveScheduleProviderAndModel({ + provider: params?.provider, + defaultProvider: callerAgent.provider, + }) + : null; + const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider; + let resolvedModel: string | undefined; + if (resolvedProviderModel?.model) { + resolvedModel = resolvedProviderModel.model; + } else if (!hasProviderOverride && callerAgent.config.model) { + resolvedModel = callerAgent.config.model; + } + return { + provider: resolvedProvider, + cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd, + ...(callerAgent.currentModeId + ? { + modeId: mapModeAcrossProviders( + callerAgent.currentModeId, + callerAgent.provider, + resolvedProvider, + ), + } + : {}), + ...(resolvedModel ? { model: resolvedModel } : {}), + ...buildCallerAgentScheduleConfigExtras(callerAgent), + }; + }; + const resolveNewAgentScheduleTarget = (params?: { provider?: string; cwd?: string }) => { const callerAgent = resolveCallerAgent(); if (callerAgent) { - const hasProviderOverride = params?.provider !== undefined; - const resolvedProviderModel = hasProviderOverride - ? resolveScheduleProviderAndModel({ - provider: params?.provider, - defaultProvider: callerAgent.provider, - }) - : null; - const resolvedProvider = resolvedProviderModel?.provider ?? callerAgent.provider; - let resolvedModel: string | undefined; - if (resolvedProviderModel?.model) { - resolvedModel = resolvedProviderModel.model; - } else if (!hasProviderOverride && callerAgent.config.model) { - resolvedModel = callerAgent.config.model; - } return { type: "new-agent" as const, - config: { - provider: resolvedProvider, - cwd: params?.cwd?.trim() ? expandUserPath(params.cwd) : callerAgent.cwd, - ...(callerAgent.currentModeId - ? { - modeId: mapModeAcrossProviders( - callerAgent.currentModeId, - callerAgent.provider, - resolvedProvider, - ), - } - : {}), - ...(resolvedModel ? { model: resolvedModel } : {}), - ...(callerAgent.config.thinkingOptionId - ? { thinkingOptionId: callerAgent.config.thinkingOptionId } - : {}), - ...(callerAgent.config.approvalPolicy - ? { approvalPolicy: callerAgent.config.approvalPolicy } - : {}), - ...(callerAgent.config.sandboxMode - ? { sandboxMode: callerAgent.config.sandboxMode } - : {}), - ...(typeof callerAgent.config.networkAccess === "boolean" - ? { networkAccess: callerAgent.config.networkAccess } - : {}), - ...(typeof callerAgent.config.webSearch === "boolean" - ? { webSearch: callerAgent.config.webSearch } - : {}), - ...(callerAgent.config.title ? { title: callerAgent.config.title } : {}), - ...(callerAgent.config.extra ? { extra: callerAgent.config.extra } : {}), - ...(callerAgent.config.systemPrompt - ? { systemPrompt: callerAgent.config.systemPrompt } - : {}), - ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), - }, + config: buildCallerAgentScheduleConfig(callerAgent, params), }; } @@ -557,6 +568,108 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom return server; } + interface ResolvedCreateAgentArgs { + provider: AgentProvider; + initialPrompt: string; + background: boolean; + normalizedTitle: string | null; + model: string | undefined; + thinking: string | undefined; + labels: Record | undefined; + notifyOnFinish: boolean; + resolvedCwd: string; + resolvedMode: string | undefined; + worktreeConfig: WorktreeConfig | undefined; + shouldBootstrapWorktree: boolean | undefined; + } + + const resolveCallerCreateAgentArgs = ( + args: unknown, + parentAgentId: string, + ): ResolvedCreateAgentArgs => { + const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); + const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider); + const parentAgent = agentManager.getAgent(parentAgentId); + if (!parentAgent) { + throw new Error(`Parent agent ${parentAgentId} not found`); + } + const provider = resolvedProviderModel.provider; + const resolvedCwd = resolveChildAgentCwd({ + parentCwd: parentAgent.cwd, + requestedCwd: callerArgs.cwd, + lockedCwd: callerContext?.lockedCwd, + allowCustomCwd: callerContext?.allowCustomCwd ?? true, + }); + const parentMode = parentAgent.currentModeId; + const resolvedMode = parentMode + ? mapModeAcrossProviders(parentMode, parentAgent.provider, provider) + : undefined; + return { + provider, + initialPrompt: callerArgs.initialPrompt, + background: callerArgs.background ?? false, + normalizedTitle: callerArgs.title.trim(), + model: resolvedProviderModel.model, + thinking: callerArgs.thinking, + labels: callerArgs.labels, + notifyOnFinish: callerArgs.notifyOnFinish ?? false, + resolvedCwd, + resolvedMode, + worktreeConfig: undefined, + shouldBootstrapWorktree: undefined, + }; + }; + + const resolveTopLevelCreateAgentArgs = async ( + args: unknown, + ): Promise => { + const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); + const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); + const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = topLevelArgs; + let resolvedCwd = expandUserPath(cwd); + let worktreeConfig: WorktreeConfig | undefined; + let shouldBootstrapWorktree: boolean | undefined; + + if (worktreeName) { + if (!baseBranch && !refName && !action && githubPrNumber === undefined) { + throw new Error("baseBranch is required when creating a worktree"); + } + const createdWorktree = await createMcpWorktree({ + input: { + cwd: resolvedCwd, + worktreeSlug: worktreeName, + refName, + action, + githubPrNumber, + runSetup: false, + paseoHome: options.paseoHome, + }, + createPaseoWorktree: options.createPaseoWorktree, + resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, + workspaceGitService: options.workspaceGitService, + logger: options.logger, + }); + resolvedCwd = createdWorktree.worktree.worktreePath; + worktreeConfig = createdWorktree.worktree; + shouldBootstrapWorktree = createdWorktree.created; + } + + return { + provider: resolvedProviderModel.provider, + initialPrompt: topLevelArgs.initialPrompt, + background: topLevelArgs.background ?? false, + normalizedTitle: topLevelArgs.title.trim(), + model: resolvedProviderModel.model, + thinking: topLevelArgs.thinking, + labels: topLevelArgs.labels, + notifyOnFinish: topLevelArgs.notifyOnFinish ?? false, + resolvedCwd, + resolvedMode: mode, + worktreeConfig, + shouldBootstrapWorktree, + }; + }; + server.registerTool( "create_agent", { @@ -582,88 +695,23 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom }, }, async (args: unknown) => { - let provider: AgentProvider; - let initialPrompt: string; - let background = false; - let normalizedTitle: string | null; - let model: string | undefined; - let thinking: string | undefined; - let labels: Record | undefined; - let notifyOnFinish = false; - - let resolvedCwd: string; - let resolvedMode: string | undefined; - let worktreeConfig: WorktreeConfig | undefined; - let shouldBootstrapWorktree: boolean | undefined; - - if (callerAgentId) { - const callerArgs = agentToAgentCreateAgentArgsSchema.parse(args); - const resolvedProviderModel = resolveRequiredProviderModel(callerArgs.provider); - provider = resolvedProviderModel.provider; - model = resolvedProviderModel.model; - initialPrompt = callerArgs.initialPrompt; - background = callerArgs.background ?? false; - normalizedTitle = callerArgs.title.trim(); - thinking = callerArgs.thinking; - labels = callerArgs.labels; - notifyOnFinish = callerArgs.notifyOnFinish ?? false; - - const parentAgent = agentManager.getAgent(callerAgentId); - if (!parentAgent) { - throw new Error(`Parent agent ${callerAgentId} not found`); - } - resolvedCwd = resolveChildAgentCwd({ - parentCwd: parentAgent.cwd, - requestedCwd: callerArgs.cwd, - lockedCwd: callerContext?.lockedCwd, - allowCustomCwd: callerContext?.allowCustomCwd ?? true, - }); - const parentMode = parentAgent.currentModeId; - if (parentMode) { - resolvedMode = mapModeAcrossProviders(parentMode, parentAgent.provider, provider); - } - } else { - const topLevelArgs = topLevelCreateAgentArgsSchema.parse(args); - const resolvedProviderModel = resolveRequiredProviderModel(topLevelArgs.provider); - provider = resolvedProviderModel.provider; - model = resolvedProviderModel.model; - initialPrompt = topLevelArgs.initialPrompt; - background = topLevelArgs.background ?? false; - normalizedTitle = topLevelArgs.title.trim(); - thinking = topLevelArgs.thinking; - labels = topLevelArgs.labels; - notifyOnFinish = topLevelArgs.notifyOnFinish ?? false; - const { cwd, mode, worktreeName, baseBranch, refName, action, githubPrNumber } = - topLevelArgs; - - resolvedCwd = expandUserPath(cwd); - - if (worktreeName) { - if (!baseBranch && !refName && !action && githubPrNumber === undefined) { - throw new Error("baseBranch is required when creating a worktree"); - } - const createdWorktree = await createMcpWorktree({ - input: { - cwd: resolvedCwd, - worktreeSlug: worktreeName, - refName, - action, - githubPrNumber, - runSetup: false, - paseoHome: options.paseoHome, - }, - createPaseoWorktree: options.createPaseoWorktree, - resolveDefaultBranch: baseBranch ? async () => baseBranch : undefined, - workspaceGitService: options.workspaceGitService, - logger: options.logger, - }); - resolvedCwd = createdWorktree.worktree.worktreePath; - worktreeConfig = createdWorktree.worktree; - shouldBootstrapWorktree = createdWorktree.created; - } - - resolvedMode = mode; - } + const resolved = callerAgentId + ? resolveCallerCreateAgentArgs(args, callerAgentId) + : await resolveTopLevelCreateAgentArgs(args); + const { + provider, + initialPrompt, + background, + normalizedTitle, + model, + thinking, + labels, + notifyOnFinish, + resolvedCwd, + resolvedMode, + worktreeConfig, + shouldBootstrapWorktree, + } = resolved; const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; const mergedLabels = { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index ccc36f37cb..fc3ebb8074 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1512,23 +1512,14 @@ export class ACPAgentSession implements AgentSession, ACPClient { const item = this.createMessageTimelineItem("reasoning", update); return item ? [this.wrapTimeline(item)] : []; } - case "tool_call": { - let snapshot = mergeToolSnapshot(update.toolCallId, update); - if (this.toolSnapshotTransformer) { - snapshot = this.toolSnapshotTransformer(snapshot); - } - this.toolCalls.set(update.toolCallId, snapshot); - return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; - } - case "tool_call_update": { - const previous = this.toolCalls.get(update.toolCallId); - let snapshot = mergeToolSnapshot(update.toolCallId, update, previous); - if (this.toolSnapshotTransformer) { - snapshot = this.toolSnapshotTransformer(snapshot); - } - this.toolCalls.set(update.toolCallId, snapshot); - return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; - } + case "tool_call": + return this.handleToolCallUpdate(update.toolCallId, update, undefined); + case "tool_call_update": + return this.handleToolCallUpdate( + update.toolCallId, + update, + this.toolCalls.get(update.toolCallId), + ); case "plan": return [this.wrapTimeline(mapPlanToTimeline(update))]; case "current_mode_update": @@ -1556,6 +1547,19 @@ export class ACPAgentSession implements AgentSession, ACPClient { } } + private handleToolCallUpdate( + toolCallId: string, + update: ToolCall | ToolCallUpdate, + previous: ACPToolSnapshot | undefined, + ): AgentStreamEvent[] { + let snapshot = mergeToolSnapshot(toolCallId, update, previous); + if (this.toolSnapshotTransformer) { + snapshot = this.toolSnapshotTransformer(snapshot); + } + this.toolCalls.set(toolCallId, snapshot); + return [this.wrapTimeline(mapToolSnapshotToTimeline(snapshot, this.terminalEntries))]; + } + private createMessageTimelineItem( type: "user_message" | "assistant_message" | "reasoning", update: Extract< @@ -1879,22 +1883,30 @@ function contentBlockToText(content: ContentBlock): string { } } +function coalesceDefined(next: T | undefined, previous: T | undefined, fallback: T): T { + if (next !== undefined) { + return next; + } + if (previous !== undefined) { + return previous; + } + return fallback; +} + function mergeToolSnapshot( toolCallId: string, update: ToolCall | ToolCallUpdate, previous?: ACPToolSnapshot, ): ACPToolSnapshot { - const isFull = "title" in update && typeof update.title === "string"; return { toolCallId, title: (update.title ?? previous?.title ?? toolCallId) as string, kind: update.kind ?? previous?.kind ?? null, status: update.status ?? previous?.status ?? null, - content: update.content !== undefined ? update.content : (previous?.content ?? null), - locations: update.locations !== undefined ? update.locations : (previous?.locations ?? null), + content: coalesceDefined(update.content, previous?.content, null), + locations: coalesceDefined(update.locations, previous?.locations, null), rawInput: update.rawInput !== undefined ? update.rawInput : previous?.rawInput, rawOutput: update.rawOutput !== undefined ? update.rawOutput : previous?.rawOutput, - ...(isFull ? {} : {}), }; } @@ -1958,108 +1970,149 @@ function mapToolStatus(status: ToolCallStatus | null | undefined): ToolCallTimel } } +interface MapToolDetailContext { + snapshot: ACPToolSnapshot; + firstLocation: string | undefined; + textContent: string | undefined; + diffContent: ReturnType; + terminalContent: ReturnType; + rawInput: ReturnType; + rawOutput: ReturnType; +} + function mapToolDetail( snapshot: ACPToolSnapshot, terminals: Map, ): ToolCallDetail { - const firstLocation = snapshot.locations?.[0]?.path; - const textContent = extractToolText(snapshot.content); - const diffContent = extractDiffContent(snapshot.content); - const terminalContent = extractTerminalContent(snapshot.content, terminals); - const rawInput = readRecord(snapshot.rawInput); - const rawOutput = readRecord(snapshot.rawOutput); + const context: MapToolDetailContext = { + snapshot, + firstLocation: snapshot.locations?.[0]?.path, + textContent: extractToolText(snapshot.content), + diffContent: extractDiffContent(snapshot.content), + terminalContent: extractTerminalContent(snapshot.content, terminals), + rawInput: readRecord(snapshot.rawInput), + rawOutput: readRecord(snapshot.rawOutput), + }; switch (snapshot.kind) { case "read": - return { - type: "read", - filePath: - firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, - content: textContent ?? readString(rawOutput, ["content", "text"]), - offset: readNumber(rawInput, ["offset", "line"]), - limit: readNumber(rawInput, ["limit"]), - }; + return buildReadToolDetail(context); case "edit": case "delete": - return { - type: "edit", - filePath: - firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, - oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]), - newString: - snapshot.kind === "delete" - ? "" - : (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])), - unifiedDiff: textContent ?? undefined, - }; + return buildEditToolDetail(context); case "search": - return { - type: "search", - query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title, - toolName: "search", - content: textContent ?? readString(rawOutput, ["content", "text"]), - filePaths: snapshot.locations?.map((location) => location.path), - }; + return buildSearchAcpToolDetail(context); case "execute": - return { - type: "shell", - command: - terminalContent?.command ?? - buildShellCommand(rawInput) ?? - readString(rawInput, ["command"]) ?? - snapshot.title, - cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]), - output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]), - exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]), - }; + return buildShellToolDetail(context); case "fetch": - return { - type: "fetch", - url: readString(rawInput, ["url"]) ?? snapshot.title, - prompt: readString(rawInput, ["prompt"]), - result: textContent ?? readString(rawOutput, ["result", "text", "content"]), - code: readNumber(rawOutput, ["status", "code"]), - }; + return buildFetchToolDetail(context); case "think": return { type: "plain_text", label: snapshot.title, icon: "brain", - text: textContent ?? stringifyUnknown(snapshot.rawOutput), + text: context.textContent ?? stringifyUnknown(snapshot.rawOutput), }; case "switch_mode": return { type: "plain_text", label: snapshot.title, icon: "sparkles", - text: textContent ?? stringifyUnknown(snapshot.rawInput), + text: context.textContent ?? stringifyUnknown(snapshot.rawInput), }; default: - if (terminalContent) { - return { - type: "shell", - command: terminalContent.command ?? snapshot.title, - cwd: terminalContent.cwd, - output: terminalContent.output, - exitCode: terminalContent.exitCode, - }; - } - if (textContent) { - return { - type: "plain_text", - label: snapshot.title, - text: textContent, - icon: "wrench", - }; - } - return { - type: "unknown", - input: snapshot.rawInput ?? null, - output: snapshot.rawOutput ?? null, - }; + return buildDefaultToolDetail(context); } } +function buildReadToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, firstLocation, textContent, rawInput, rawOutput } = context; + return { + type: "read", + filePath: firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + content: textContent ?? readString(rawOutput, ["content", "text"]), + offset: readNumber(rawInput, ["offset", "line"]), + limit: readNumber(rawInput, ["limit"]), + }; +} + +function buildEditToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, firstLocation, textContent, diffContent, rawInput } = context; + return { + type: "edit", + filePath: firstLocation ?? readString(rawInput, ["path", "filePath", "file"]) ?? snapshot.title, + oldString: diffContent?.oldText ?? readString(rawInput, ["oldText", "oldString"]), + newString: + snapshot.kind === "delete" + ? "" + : (diffContent?.newText ?? readString(rawInput, ["newText", "newString"])), + unifiedDiff: textContent ?? undefined, + }; +} + +function buildSearchAcpToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, textContent, rawInput, rawOutput } = context; + return { + type: "search", + query: readString(rawInput, ["query", "pattern"]) ?? snapshot.title, + toolName: "search", + content: textContent ?? readString(rawOutput, ["content", "text"]), + filePaths: snapshot.locations?.map((location) => location.path), + }; +} + +function buildShellToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, textContent, terminalContent, rawInput, rawOutput } = context; + return { + type: "shell", + command: + terminalContent?.command ?? + buildShellCommand(rawInput) ?? + readString(rawInput, ["command"]) ?? + snapshot.title, + cwd: terminalContent?.cwd ?? readString(rawInput, ["cwd"]), + output: terminalContent?.output ?? textContent ?? readString(rawOutput, ["output", "text"]), + exitCode: terminalContent?.exitCode ?? readNumber(rawOutput, ["exitCode"]), + }; +} + +function buildFetchToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, textContent, rawInput, rawOutput } = context; + return { + type: "fetch", + url: readString(rawInput, ["url"]) ?? snapshot.title, + prompt: readString(rawInput, ["prompt"]), + result: textContent ?? readString(rawOutput, ["result", "text", "content"]), + code: readNumber(rawOutput, ["status", "code"]), + }; +} + +function buildDefaultToolDetail(context: MapToolDetailContext): ToolCallDetail { + const { snapshot, textContent, terminalContent } = context; + if (terminalContent) { + return { + type: "shell", + command: terminalContent.command ?? snapshot.title, + cwd: terminalContent.cwd, + output: terminalContent.output, + exitCode: terminalContent.exitCode, + }; + } + if (textContent) { + return { + type: "plain_text", + label: snapshot.title, + text: textContent, + icon: "wrench", + }; + } + return { + type: "unknown", + input: snapshot.rawInput ?? null, + output: snapshot.rawOutput ?? null, + }; +} + function extractToolText(content: ToolCallContent[] | null | undefined): string | undefined { if (!content) { return undefined; diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index 08ea443b4e..3c0e158774 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -1080,6 +1080,16 @@ function isSyntheticUserEntry(entry: unknown): boolean { return candidate.isSynthetic === true || candidate.isMeta === true; } +function firstTrimmedString(sources: readonly unknown[]): string | null { + for (const source of sources) { + const value = readTrimmedString(source); + if (value) { + return value; + } + } + return null; +} + export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { const root = message as unknown as Record; const messageType = readTrimmedString(root.type); @@ -1087,28 +1097,30 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers { const streamEventMessage = streamEvent?.message as Record | undefined; const messageContainer = root.message as Record | undefined; + const messageIdFromUuid = messageType === "user" ? root.uuid : undefined; + return { - taskId: - readTrimmedString(root.task_id) ?? - readTrimmedString(streamEvent?.task_id) ?? - readTrimmedString(streamEventMessage?.task_id) ?? - readTrimmedString(messageContainer?.task_id) ?? - null, - parentMessageId: - readTrimmedString(root.parent_message_id) ?? - readTrimmedString(streamEvent?.parent_message_id) ?? - readTrimmedString(streamEventMessage?.parent_message_id) ?? - readTrimmedString(messageContainer?.parent_message_id) ?? - null, - messageId: - readTrimmedString(root.message_id) ?? - readTrimmedString(streamEvent?.message_id) ?? - readTrimmedString(streamEventMessage?.id) ?? - readTrimmedString(streamEventMessage?.message_id) ?? - readTrimmedString(messageContainer?.id) ?? - readTrimmedString(messageContainer?.message_id) ?? - (messageType === "user" ? readTrimmedString(root.uuid) : null) ?? - null, + taskId: firstTrimmedString([ + root.task_id, + streamEvent?.task_id, + streamEventMessage?.task_id, + messageContainer?.task_id, + ]), + parentMessageId: firstTrimmedString([ + root.parent_message_id, + streamEvent?.parent_message_id, + streamEventMessage?.parent_message_id, + messageContainer?.parent_message_id, + ]), + messageId: firstTrimmedString([ + root.message_id, + streamEvent?.message_id, + streamEventMessage?.id, + streamEventMessage?.message_id, + messageContainer?.id, + messageContainer?.message_id, + messageIdFromUuid, + ]), }; } @@ -2167,26 +2179,34 @@ class ClaudeAgentSession implements AgentSession { } } - private async buildOptions(): Promise { + private resolveThinkingConfig(): { + thinking: ClaudeOptions["thinking"]; + effort: ClaudeOptions["effort"]; + } { const thinkingOptionId = this.config.thinkingOptionId && this.config.thinkingOptionId !== "default" ? this.config.thinkingOptionId : undefined; - let thinking: ClaudeOptions["thinking"]; - let effort: ClaudeOptions["effort"]; if (thinkingOptionId && isClaudeThinkingEffort(thinkingOptionId)) { - thinking = { type: "adaptive" }; // SDK 0.2.71 types `effort` as 'low' | 'medium' | 'high' | 'max'; Opus 4.7 // adds 'xhigh' which the binary accepts but the typings don't yet expose. - effort = thinkingOptionId as ClaudeOptions["effort"]; + return { + thinking: { type: "adaptive" }, + effort: thinkingOptionId as ClaudeOptions["effort"], + }; } + return { thinking: undefined, effort: undefined }; + } - const appendedSystemPrompt = [ - getOrchestratorModeInstructions(), - this.config.systemPrompt?.trim(), - ] + private buildAppendedSystemPrompt(): string { + return [getOrchestratorModeInstructions(), this.config.systemPrompt?.trim()] .filter((entry): entry is string => typeof entry === "string" && entry.length > 0) .join("\n\n"); + } + + private async buildOptions(): Promise { + const { thinking, effort } = this.resolveThinkingConfig(); + const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const claudeBinary = await findExecutable("claude"); this.logger.debug( @@ -2621,7 +2641,19 @@ class ClaudeAgentSession implements AgentSession { } } - private routeSdkMessageFromPump(message: SDKMessage): void { + private isEchoedForegroundUserMessage(event: AgentStreamEvent): boolean { + if ( + event.type !== "timeline" || + event.item.type !== "user_message" || + !this.activeForegroundTurnId || + !this.lastForegroundPromptText + ) { + return false; + } + return event.item.text.trim() === this.lastForegroundPromptText.trim(); + } + + private shouldSuppressStaleResult(message: SDKMessage): boolean { // Suppress stale results from interrupted requests. The cancel path already // emitted the terminal event; this result is leftover from the killed API // request. Consume the flag on ANY result so it doesn't linger. @@ -2629,22 +2661,32 @@ class ClaudeAgentSession implements AgentSession { this.pendingInterruptAbort = false; if (message.subtype !== "success") { this.logger.debug("Suppressing stale non-success result from interrupted request"); - return; + return true; } } if (message.type === "result" && message.subtype !== "success" && this.isAbortError(message)) { this.logger.debug("Suppressing abort result by content"); + return true; + } + return false; + } + + private isAssistantishMessage(message: SDKMessage): boolean { + return ( + message.type === "assistant" || + message.type === "stream_event" || + message.type === "tool_progress" || + (message.type === "system" && message.subtype === "task_notification") + ); + } + + private routeSdkMessageFromPump(message: SDKMessage): void { + if (this.shouldSuppressStaleResult(message)) { return; } const isForeground = Boolean(this.activeForegroundTurnId); - const assistantishMessage = - message.type === "assistant" || - message.type === "stream_event" || - message.type === "tool_progress" || - (message.type === "system" && message.subtype === "task_notification"); - - if (!isForeground && assistantishMessage) { + if (!isForeground && this.isAssistantishMessage(message)) { this.startAutonomousTurn(); } if (!isForeground && !this.autonomousTurn && message.type === "result") { @@ -2685,19 +2727,9 @@ class ClaudeAgentSession implements AgentSession { ); // User message dedup: suppress echoed user messages that match the foreground prompt - const filteredMessageEvents = messageEvents.filter((event) => { - if ( - event.type === "timeline" && - event.item.type === "user_message" && - this.activeForegroundTurnId && - this.lastForegroundPromptText - ) { - if (event.item.text.trim() === this.lastForegroundPromptText.trim()) { - return false; - } - } - return true; - }); + const filteredMessageEvents = messageEvents.filter( + (event) => !this.isEchoedForegroundUserMessage(event), + ); const events = [...filteredMessageEvents, ...assistantTimelineEvents]; @@ -2813,114 +2845,11 @@ class ClaudeAgentSession implements AgentSession { switch (message.type) { case "system": - if (message.subtype === "init") { - const threadSessionId = this.handleSystemMessage(message); - if (threadSessionId) { - events.push({ - type: "thread_started", - provider: "claude", - sessionId: threadSessionId, - }); - } - } else if (message.subtype === "status") { - const status = (message as { status?: string }).status; - if (status === "compacting") { - this.compacting = true; - events.push({ - type: "timeline", - item: { type: "compaction", status: "loading" }, - provider: "claude", - }); - } - } else if (message.subtype === "compact_boundary") { - const compactMetadata = readCompactionMetadata(message as Record); - events.push({ - type: "timeline", - item: { - type: "compaction", - status: "completed", - trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto", - preTokens: compactMetadata?.preTokens, - }, - provider: "claude", - }); - } else if (message.subtype === "task_notification") { - const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message); - if (taskNotificationItem) { - events.push({ - type: "timeline", - item: taskNotificationItem, - provider: "claude", - }); - } - const usage = readUsageFromTaskNotification(message); - if (typeof usage === "number") { - this.lastContextWindowUsedTokens = usage; - events.push(this.createUsageUpdatedEvent(usage)); - } - } else if (message.subtype === "task_progress") { - this.lastContextWindowUsedTokens = - readContextWindowUsedTokensFromTaskProgress(message) ?? - this.lastContextWindowUsedTokens; - if (typeof this.lastContextWindowUsedTokens === "number") { - events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens)); - } - } + this.appendSystemMessageEvents(message, events); break; - case "user": { - if (isSyntheticUserEntry(message)) { - break; - } - if (this.compacting) { - this.compacting = false; - break; - } - const messageId = - typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined; - this.rememberUserMessageId(messageId); - const content = message.message?.content; - const taskNotificationItem = mapTaskNotificationUserContentToToolCall({ - content, - messageId, - }); - if (taskNotificationItem) { - events.push({ - type: "timeline", - item: taskNotificationItem, - provider: "claude", - }); - break; - } - if (typeof content === "string" && content.length > 0) { - if (!isClaudeTranscriptNoiseText(content)) { - events.push({ - type: "timeline", - item: { - type: "user_message", - text: content, - ...(messageId ? { messageId } : {}), - }, - provider: "claude", - }); - } - } else if (Array.isArray(content)) { - const timelineItems = this.mapBlocksToTimeline(content, { - textMessageType: "user_message", - }); - for (const item of timelineItems) { - if (item.type === "user_message" && messageId && !item.messageId) { - events.push({ - type: "timeline", - item: { ...item, messageId }, - provider: "claude", - }); - continue; - } - events.push({ type: "timeline", item, provider: "claude" }); - } - } + case "user": + this.appendUserMessageEvents(message, events); break; - } case "assistant": { const timelineItems = this.mapBlocksToTimeline(message.message.content, { suppressAssistantText: options?.suppressAssistantText ?? false, @@ -2931,33 +2860,12 @@ class ClaudeAgentSession implements AgentSession { } break; } - case "stream_event": { - const usageUpdatedEvent = this.trackStreamEventUsage(message.event); - if (usageUpdatedEvent) { - events.push(usageUpdatedEvent); - } - const timelineItems = this.mapPartialEvent(message.event, { - suppressAssistantText: options?.suppressAssistantText ?? false, - suppressReasoning: options?.suppressReasoning ?? false, - }); - for (const item of timelineItems) { - events.push({ type: "timeline", item, provider: "claude" }); - } + case "stream_event": + this.appendStreamEventEvents(message, events, options); break; - } - case "result": { - const usage = this.convertUsage(message, message.modelUsage); - if (message.subtype === "success") { - events.push({ type: "turn_completed", provider: "claude", usage }); - } else { - const errorMessage = - "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 - ? message.errors.join("\n") - : "Claude run failed"; - events.push(this.buildTurnFailedEvent(errorMessage)); - } + case "result": + this.appendResultEvents(message, events); break; - } default: break; } @@ -2965,6 +2873,173 @@ class ClaudeAgentSession implements AgentSession { return events; } + private appendSystemMessageEvents( + message: Extract, + events: AgentStreamEvent[], + ): void { + if (message.subtype === "init") { + const threadSessionId = this.handleSystemMessage(message); + if (threadSessionId) { + events.push({ + type: "thread_started", + provider: "claude", + sessionId: threadSessionId, + }); + } + return; + } + if (message.subtype === "status") { + const status = (message as { status?: string }).status; + if (status === "compacting") { + this.compacting = true; + events.push({ + type: "timeline", + item: { type: "compaction", status: "loading" }, + provider: "claude", + }); + } + return; + } + if (message.subtype === "compact_boundary") { + const compactMetadata = readCompactionMetadata(message as Record); + events.push({ + type: "timeline", + item: { + type: "compaction", + status: "completed", + trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto", + preTokens: compactMetadata?.preTokens, + }, + provider: "claude", + }); + return; + } + if (message.subtype === "task_notification") { + const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message); + if (taskNotificationItem) { + events.push({ + type: "timeline", + item: taskNotificationItem, + provider: "claude", + }); + } + const usage = readUsageFromTaskNotification(message); + if (typeof usage === "number") { + this.lastContextWindowUsedTokens = usage; + events.push(this.createUsageUpdatedEvent(usage)); + } + return; + } + if (message.subtype === "task_progress") { + this.lastContextWindowUsedTokens = + readContextWindowUsedTokensFromTaskProgress(message) ?? this.lastContextWindowUsedTokens; + if (typeof this.lastContextWindowUsedTokens === "number") { + events.push(this.createUsageUpdatedEvent(this.lastContextWindowUsedTokens)); + } + } + } + + private appendUserMessageEvents( + message: Extract, + events: AgentStreamEvent[], + ): void { + if (isSyntheticUserEntry(message)) { + return; + } + if (this.compacting) { + this.compacting = false; + return; + } + const messageId = + typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined; + this.rememberUserMessageId(messageId); + const content = message.message?.content; + const taskNotificationItem = mapTaskNotificationUserContentToToolCall({ + content, + messageId, + }); + if (taskNotificationItem) { + events.push({ + type: "timeline", + item: taskNotificationItem, + provider: "claude", + }); + return; + } + if (typeof content === "string" && content.length > 0) { + if (!isClaudeTranscriptNoiseText(content)) { + events.push({ + type: "timeline", + item: { + type: "user_message", + text: content, + ...(messageId ? { messageId } : {}), + }, + provider: "claude", + }); + } + return; + } + if (Array.isArray(content)) { + this.appendUserContentArrayEvents(content, messageId, events); + } + } + + private appendUserContentArrayEvents( + content: ClaudeContentChunk[], + messageId: string | undefined, + events: AgentStreamEvent[], + ): void { + const timelineItems = this.mapBlocksToTimeline(content, { + textMessageType: "user_message", + }); + for (const item of timelineItems) { + if (item.type === "user_message" && messageId && !item.messageId) { + events.push({ + type: "timeline", + item: { ...item, messageId }, + provider: "claude", + }); + continue; + } + events.push({ type: "timeline", item, provider: "claude" }); + } + } + + private appendStreamEventEvents( + message: Extract, + events: AgentStreamEvent[], + options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined, + ): void { + const usageUpdatedEvent = this.trackStreamEventUsage(message.event); + if (usageUpdatedEvent) { + events.push(usageUpdatedEvent); + } + const timelineItems = this.mapPartialEvent(message.event, { + suppressAssistantText: options?.suppressAssistantText ?? false, + suppressReasoning: options?.suppressReasoning ?? false, + }); + for (const item of timelineItems) { + events.push({ type: "timeline", item, provider: "claude" }); + } + } + + private appendResultEvents( + message: Extract, + events: AgentStreamEvent[], + ): void { + const usage = this.convertUsage(message, message.modelUsage); + if (message.subtype === "success") { + events.push({ type: "turn_completed", provider: "claude", usage }); + return; + } + const errorMessage = + "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 + ? message.errors.join("\n") + : "Claude run failed"; + events.push(this.buildTurnFailedEvent(errorMessage)); + } + private captureSessionIdFromMessage(message: SDKMessage): string | null { const msg = message as unknown as { session_id?: unknown; @@ -3423,51 +3498,13 @@ class ClaudeAgentSession implements AgentSession { // User SDK entries can arrive as multiple text blocks, but Paseo treats them as one message. const userTextParts: string[] = []; for (const block of content) { - switch (block.type) { - case "text": - case "text_delta": - if ( - block.text && - block.text !== INTERRUPT_TOOL_USE_PLACEHOLDER && - !isClaudeTranscriptNoiseText(block.text) - ) { - if (textMessageType === "user_message") { - const trimmed = block.text.trim(); - if (trimmed) { - userTextParts.push(trimmed); - } - } else if (!suppressText) { - items.push({ type: "assistant_message", text: block.text }); - } - } - break; - case "thinking": - case "thinking_delta": - if (block.thinking) { - if (!suppressReasoning) { - items.push({ type: "reasoning", text: block.thinking }); - } - } - break; - case "tool_use": - case "server_tool_use": - case "mcp_tool_use": { - this.handleToolUseStart(block, items); - break; - } - case "tool_result": - case "mcp_tool_result": - case "web_fetch_tool_result": - case "web_search_tool_result": - case "code_execution_tool_result": - case "bash_code_execution_tool_result": - case "text_editor_code_execution_tool_result": { - this.handleToolResult(block, items); - break; - } - default: - break; - } + this.mapBlockToTimeline(block, { + items, + userTextParts, + textMessageType, + suppressText, + suppressReasoning, + }); } if (textMessageType === "user_message" && userTextParts.length > 0) { @@ -3480,6 +3517,75 @@ class ClaudeAgentSession implements AgentSession { return items; } + private appendTextBlockToTimeline( + block: ClaudeContentChunk, + context: { + items: AgentTimelineItem[]; + userTextParts: string[]; + textMessageType: "assistant_message" | "user_message"; + suppressText: boolean; + }, + ): void { + const { items, userTextParts, textMessageType, suppressText } = context; + if ( + !block.text || + block.text === INTERRUPT_TOOL_USE_PLACEHOLDER || + isClaudeTranscriptNoiseText(block.text) + ) { + return; + } + if (textMessageType === "user_message") { + const trimmed = block.text.trim(); + if (trimmed) { + userTextParts.push(trimmed); + } + return; + } + if (!suppressText) { + items.push({ type: "assistant_message", text: block.text }); + } + } + + private mapBlockToTimeline( + block: ClaudeContentChunk, + context: { + items: AgentTimelineItem[]; + userTextParts: string[]; + textMessageType: "assistant_message" | "user_message"; + suppressText: boolean; + suppressReasoning: boolean; + }, + ): void { + switch (block.type) { + case "text": + case "text_delta": + this.appendTextBlockToTimeline(block, context); + break; + case "thinking": + case "thinking_delta": + if (block.thinking && !context.suppressReasoning) { + context.items.push({ type: "reasoning", text: block.thinking }); + } + break; + case "tool_use": + case "server_tool_use": + case "mcp_tool_use": + this.handleToolUseStart(block, context.items); + break; + case "tool_result": + case "mcp_tool_result": + case "web_fetch_tool_result": + case "web_search_tool_result": + case "code_execution_tool_result": + case "bash_code_execution_tool_result": + case "text_editor_code_execution_tool_result": + this.handleToolResult(block, context.items); + break; + default: + break; + } + } + private handleToolUseStart(block: ClaudeContentChunk, items: AgentTimelineItem[]): void { const entry = this.upsertToolUseEntry(block); if (!entry) { @@ -3583,6 +3689,53 @@ class ClaudeAgentSession implements AgentSession { return Object.keys(result).length > 0 ? result : undefined; } + private isCommandExecutionTool( + normalizedServer: string, + normalizedTool: string, + input: AgentMetadata | null | undefined, + ): boolean { + if ( + normalizedServer.includes("bash") || + normalizedServer.includes("shell") || + normalizedServer.includes("command") + ) { + return true; + } + if ( + normalizedTool.includes("bash") || + normalizedTool.includes("shell") || + normalizedTool.includes("command") + ) { + return true; + } + return Boolean(input && (typeof input.command === "string" || Array.isArray(input.command))); + } + + private static isFileWriteTool(normalizedTool: string): boolean { + return ( + normalizedTool.includes("write") || + normalizedTool === "write_file" || + normalizedTool === "create_file" + ); + } + + private static isFileEditTool(normalizedTool: string): boolean { + return ( + normalizedTool.includes("edit") || + normalizedTool.includes("patch") || + normalizedTool === "apply_patch" || + normalizedTool === "apply_diff" + ); + } + + private static isFileReadTool(normalizedTool: string): boolean { + return ( + normalizedTool.includes("read") || + normalizedTool === "read_file" || + normalizedTool === "view_file" + ); + } + private buildStructuredToolResult( server: string, tool: string, @@ -3592,16 +3745,7 @@ class ClaudeAgentSession implements AgentSession { const normalizedServer = server.toLowerCase(); const normalizedTool = tool.toLowerCase(); - // Command execution tools - if ( - normalizedServer.includes("bash") || - normalizedServer.includes("shell") || - normalizedServer.includes("command") || - normalizedTool.includes("bash") || - normalizedTool.includes("shell") || - normalizedTool.includes("command") || - (input && (typeof input.command === "string" || Array.isArray(input.command))) - ) { + if (this.isCommandExecutionTool(normalizedServer, normalizedTool, input)) { const command = this.extractCommandText(input ?? {}) ?? "command"; return { type: "command", @@ -3611,69 +3755,53 @@ class ClaudeAgentSession implements AgentSession { }; } - // File write tools (new files or complete replacements) if ( - normalizedTool.includes("write") || - normalizedTool === "write_file" || - normalizedTool === "create_file" + ClaudeAgentSession.isFileWriteTool(normalizedTool) && + input && + typeof input.file_path === "string" ) { - if (input && typeof input.file_path === "string") { - return { - type: "file_write", - filePath: input.file_path, - oldContent: "", - newContent: typeof input.content === "string" ? input.content : output, - }; - } + return { + type: "file_write", + filePath: input.file_path, + oldContent: "", + newContent: typeof input.content === "string" ? input.content : output, + }; } - // File edit/patch tools if ( - normalizedTool.includes("edit") || - normalizedTool.includes("patch") || - normalizedTool === "apply_patch" || - normalizedTool === "apply_diff" + ClaudeAgentSession.isFileEditTool(normalizedTool) && + input && + typeof input.file_path === "string" ) { - if (input && typeof input.file_path === "string") { - // Support both old_str/new_str and old_string/new_string parameter names - const oldContent = firstStringField(input, "old_str", "old_string"); - const newContent = firstStringField(input, "new_str", "new_string"); - const diff = firstStringField(input, "patch", "diff"); - return { - type: "file_edit", - filePath: input.file_path, - diff, - oldContent, - newContent, - }; - } + // Support both old_str/new_str and old_string/new_string parameter names + const oldContent = firstStringField(input, "old_str", "old_string"); + const newContent = firstStringField(input, "new_str", "new_string"); + const diff = firstStringField(input, "patch", "diff"); + return { + type: "file_edit", + filePath: input.file_path, + diff, + oldContent, + newContent, + }; } - // File read tools if ( - normalizedTool.includes("read") || - normalizedTool === "read_file" || - normalizedTool === "view_file" + ClaudeAgentSession.isFileReadTool(normalizedTool) && + input && + typeof input.file_path === "string" ) { - if (input && typeof input.file_path === "string") { - return { - type: "file_read", - filePath: input.file_path, - content: output, - }; - } + return { + type: "file_read", + filePath: input.file_path, + content: output, + }; } return undefined; } - private mapPartialEvent( - event: SDKPartialAssistantMessage["event"], - options?: { - suppressAssistantText?: boolean; - suppressReasoning?: boolean; - }, - ): AgentTimelineItem[] { + private updatePartialEventToolState(event: SDKPartialAssistantMessage["event"]): boolean { if (event.type === "content_block_start") { const block = isClaudeContentChunk(event.content_block) ? event.content_block : null; if ( @@ -3684,20 +3812,37 @@ class ClaudeAgentSession implements AgentSession { this.toolUseIndexToId.set(event.index, block.id); this.toolUseInputBuffers.delete(block.id); } - } else if (event.type === "content_block_delta") { + return false; + } + if (event.type === "content_block_delta") { const delta = isClaudeContentChunk(event.delta) ? event.delta : null; if (delta?.type === "input_json_delta") { const partialJson = typeof delta.partial_json === "string" ? delta.partial_json : undefined; this.handleToolInputDelta(event.index, partialJson); - return []; + return true; } - } else if (event.type === "content_block_stop" && typeof event.index === "number") { + return false; + } + if (event.type === "content_block_stop" && typeof event.index === "number") { const toolId = this.toolUseIndexToId.get(event.index); if (toolId) { this.toolUseIndexToId.delete(event.index); this.toolUseInputBuffers.delete(toolId); } } + return false; + } + + private mapPartialEvent( + event: SDKPartialAssistantMessage["event"], + options?: { + suppressAssistantText?: boolean; + suppressReasoning?: boolean; + }, + ): AgentTimelineItem[] { + if (this.updatePartialEventToolState(event)) { + return []; + } switch (event.type) { case "content_block_start": @@ -3968,37 +4113,38 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null { return null; } -export function convertClaudeHistoryEntry( +function convertClaudeHistoryEntryPreamble( entry: any, - mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], -): AgentTimelineItem[] { +): { shortCircuit: AgentTimelineItem[] } | { proceed: { content: unknown } } { if (entry.type === "system" && entry.subtype === "compact_boundary") { const compactMetadata = readCompactionMetadata(entry as Record); - return [ - { - type: "compaction", - status: "completed", - trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto", - preTokens: compactMetadata?.preTokens, - }, - ]; + return { + shortCircuit: [ + { + type: "compaction", + status: "completed", + trigger: compactMetadata?.trigger === "manual" ? "manual" : "auto", + preTokens: compactMetadata?.preTokens, + }, + ], + }; } const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(entry); if (taskNotificationItem) { - return [taskNotificationItem]; + return { shortCircuit: [taskNotificationItem] }; } if (entry.isCompactSummary) { - return []; + return { shortCircuit: [] }; } if (entry.type === "user" && isSyntheticUserEntry(entry)) { - return []; + return { shortCircuit: [] }; } const message = entry?.message; if (!message || !("content" in message)) { - return []; + return { shortCircuit: [] }; } const content = message.content; @@ -4006,8 +4152,21 @@ export function convertClaudeHistoryEntry( (entry.type === "user" || entry.type === "assistant") && isClaudeTranscriptNoiseContent(content) ) { - return []; + return { shortCircuit: [] }; } + + return { proceed: { content } }; +} + +export function convertClaudeHistoryEntry( + entry: any, + mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[], +): AgentTimelineItem[] { + const preamble = convertClaudeHistoryEntryPreamble(entry); + if ("shortCircuit" in preamble) { + return preamble.shortCircuit; + } + const { content } = preamble.proceed; const normalizedBlocks = normalizeHistoryBlocks(content); const contentValue = typeof content === "string" ? content : normalizedBlocks; const hasToolBlock = normalizedBlocks?.some((block) => hasToolLikeBlock(block)) ?? false; @@ -4160,6 +4319,51 @@ async function collectRecentClaudeSessions( return candidates.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()).slice(0, limit); } +interface ClaudeSessionDescriptorAccumulator { + sessionId: string | null; + cwd: string | null; + title: string | null; + timeline: AgentTimelineItem[]; +} + +function isFinishedAccumulator(acc: ClaudeSessionDescriptorAccumulator): boolean { + return Boolean(acc.sessionId && acc.cwd && acc.title); +} + +function applyClaudeSessionEntryToAccumulator( + entry: any, + acc: ClaudeSessionDescriptorAccumulator, +): void { + if (entry?.isSidechain) { + return; + } + if (entry?.type === "user" && isSyntheticUserEntry(entry)) { + return; + } + if (!acc.sessionId && typeof entry.sessionId === "string") { + acc.sessionId = entry.sessionId; + } + if (!acc.cwd && typeof entry.cwd === "string") { + acc.cwd = entry.cwd; + } + if (entry.type === "user" && entry.message) { + const text = extractClaudeUserText(entry.message); + if (text) { + if (!acc.title) { + acc.title = text; + } + acc.timeline.push({ type: "user_message", text }); + } + return; + } + if (entry.type === "assistant" && entry.message) { + const text = extractClaudeUserText(entry.message); + if (text) { + acc.timeline.push({ type: "assistant_message", text }); + } + } +} + async function parseClaudeSessionDescriptor( filePath: string, mtime: Date, @@ -4171,10 +4375,12 @@ async function parseClaudeSessionDescriptor( return null; } - let sessionId: string | null = null; - let cwd: string | null = null; - let title: string | null = null; - const timeline: AgentTimelineItem[] = []; + const acc: ClaudeSessionDescriptorAccumulator = { + sessionId: null, + cwd: null, + title: null, + timeline: [], + }; for (const rawLine of content.split(/\r?\n/)) { const line = rawLine.trim(); @@ -4185,37 +4391,14 @@ async function parseClaudeSessionDescriptor( } catch { continue; } - if (entry?.isSidechain) { - continue; - } - if (entry?.type === "user" && isSyntheticUserEntry(entry)) { - continue; - } - if (!sessionId && typeof entry.sessionId === "string") { - sessionId = entry.sessionId; - } - if (!cwd && typeof entry.cwd === "string") { - cwd = entry.cwd; - } - if (entry.type === "user" && entry.message) { - const text = extractClaudeUserText(entry.message); - if (text) { - if (!title) { - title = text; - } - timeline.push({ type: "user_message", text }); - } - } else if (entry.type === "assistant" && entry.message) { - const text = extractClaudeUserText(entry.message); - if (text) { - timeline.push({ type: "assistant_message", text }); - } - } - if (sessionId && cwd && title) { + applyClaudeSessionEntryToAccumulator(entry, acc); + if (isFinishedAccumulator(acc)) { break; } } + const { sessionId, cwd, title, timeline } = acc; + if (!sessionId || !cwd) { return null; } diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index f79cfc20a7..d836e5625c 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -158,43 +158,17 @@ export class ClaudeSidechainTracker { return `${normalized.slice(0, MAX_SUB_AGENT_SUMMARY_CHARS)}...`; } - private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { - if (message.type === "assistant") { - const content = message.message?.content; - if (!Array.isArray(content)) { - return []; - } - const actions: SubAgentActionCandidate[] = []; - for (const block of content) { - if ( - !isClaudeContentChunk(block) || - !( - block.type === "tool_use" || - block.type === "mcp_tool_use" || - block.type === "server_tool_use" - ) || - typeof block.name !== "string" - ) { - continue; - } - const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`; - actions.push({ - key, - toolName: block.name, - input: block.input ?? null, - }); - } - return actions; + private extractAssistantMessageActions( + message: Extract, + ): SubAgentActionCandidate[] { + const content = message.message?.content; + if (!Array.isArray(content)) { + return []; } - - if (message.type === "stream_event") { - const event = message.event; - if (event.type !== "content_block_start") { - return []; - } - const block = isClaudeContentChunk(event.content_block) ? event.content_block : null; + const actions: SubAgentActionCandidate[] = []; + for (const block of content) { if ( - !block || + !isClaudeContentChunk(block) || !( block.type === "tool_use" || block.type === "mcp_tool_use" || @@ -202,18 +176,56 @@ export class ClaudeSidechainTracker { ) || typeof block.name !== "string" ) { - return []; + continue; } - const key = - readTrimmedString(block.id) ?? - `stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`; - return [ - { - key, - toolName: block.name, - input: block.input ?? null, - }, - ]; + const key = readTrimmedString(block.id) ?? `assistant:${block.name}:${actions.length}`; + actions.push({ + key, + toolName: block.name, + input: block.input ?? null, + }); + } + return actions; + } + + private extractStreamEventActions( + message: Extract, + ): SubAgentActionCandidate[] { + const event = message.event; + if (event.type !== "content_block_start") { + return []; + } + const block = isClaudeContentChunk(event.content_block) ? event.content_block : null; + if ( + !block || + !( + block.type === "tool_use" || + block.type === "mcp_tool_use" || + block.type === "server_tool_use" + ) || + typeof block.name !== "string" + ) { + return []; + } + const key = + readTrimmedString(block.id) ?? + `stream:${block.name}:${typeof event.index === "number" ? event.index : 0}`; + return [ + { + key, + toolName: block.name, + input: block.input ?? null, + }, + ]; + } + + private extractSubAgentActionCandidates(message: SDKMessage): SubAgentActionCandidate[] { + if (message.type === "assistant") { + return this.extractAssistantMessageActions(message); + } + + if (message.type === "stream_event") { + return this.extractStreamEventActions(message); } if (message.type === "tool_progress") { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5cb3479110..fc6982c891 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1474,6 +1474,34 @@ function mapCodexTerminalInteractionToToolCall(params: { }; } +function mapCodexThreadPlanItem(normalizedItem: any): AgentTimelineItem | null { + const callId = + nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ?? + `plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`; + return mapCodexPlanToToolCall({ + callId, + text: normalizedItem.text ?? "", + }); +} + +function mapCodexThreadReasoningItem(normalizedItem: any): AgentTimelineItem | null { + const summary = Array.isArray(normalizedItem.summary) ? normalizedItem.summary.join("\n") : ""; + const content = Array.isArray(normalizedItem.content) ? normalizedItem.content.join("\n") : ""; + const text = summary || content; + return text ? { type: "reasoning", text } : null; +} + +function mapCodexThreadUserMessageItem( + normalizedItem: any, + includeUserMessage: boolean, +): AgentTimelineItem | null { + if (!includeUserMessage) { + return null; + } + const text = extractUserText(normalizedItem.content) ?? ""; + return { type: "user_message", text }; +} + function threadItemToTimeline( item: any, options?: { includeUserMessage?: boolean; cwd?: string | null }, @@ -1490,34 +1518,14 @@ function threadItemToTimeline( : item; switch (normalizedType) { - case "userMessage": { - if (!includeUserMessage) { - return null; - } - const text = extractUserText(normalizedItem.content) ?? ""; - return { type: "user_message", text }; - } - case "agentMessage": { + case "userMessage": + return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage); + case "agentMessage": return { type: "assistant_message", text: normalizedItem.text ?? "" }; - } - case "plan": { - return mapCodexPlanToToolCall({ - callId: - nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ?? - `plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`, - text: normalizedItem.text ?? "", - }); - } - case "reasoning": { - const summary = Array.isArray(normalizedItem.summary) - ? normalizedItem.summary.join("\n") - : ""; - const content = Array.isArray(normalizedItem.content) - ? normalizedItem.content.join("\n") - : ""; - const text = summary || content; - return text ? { type: "reasoning", text } : null; - } + case "plan": + return mapCodexThreadPlanItem(normalizedItem); + case "reasoning": + return mapCodexThreadReasoningItem(normalizedItem); case "commandExecution": case "fileChange": case "mcpToolCall": @@ -3175,26 +3183,7 @@ class CodexAppServerAgentSession implements AgentSession { const pendingRequest = this.pendingPermissions.get(requestId) ?? null; if (pending.kind === "plan") { - let followUpPrompt: string | undefined; - if (response.behavior === "allow") { - followUpPrompt = this.preparePlanImplementation({ - planText: pending.planText ?? pendingRequest?.metadata?.planText, - }); - } - - this.pendingPermissionHandlers.delete(requestId); - this.pendingPermissions.delete(requestId); - this.resolvedPermissionRequests.add(requestId); - this.emitEvent({ - type: "permission_resolved", - provider: CODEX_PROVIDER, - requestId, - resolution: response, - }); - if (followUpPrompt) { - return { followUpPrompt }; - } - return; + return this.handlePlanPermissionResponse({ requestId, response, pending, pendingRequest }); } this.pendingPermissionHandlers.delete(requestId); @@ -3202,34 +3191,7 @@ class CodexAppServerAgentSession implements AgentSession { this.resolvedPermissionRequests.add(requestId); if (response.behavior === "deny" && pendingRequest?.kind === "tool") { - let fallbackName: string; - if (pendingRequest.name === "CodexBash") { - fallbackName = "shell"; - } else if (pendingRequest.name === "CodexFileChange") { - fallbackName = "apply_patch"; - } else { - fallbackName = pendingRequest.name; - } - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { - type: "tool_call", - callId: requestId, - name: fallbackName, - status: "failed", - error: { message: response.message ?? "Permission denied" }, - detail: pendingRequest.detail ?? { - type: "unknown", - input: pendingRequest.input ?? null, - output: null, - }, - metadata: { - permissionRequestId: requestId, - denied: true, - }, - }, - }); + this.emitDeniedToolCallTimelineEvent({ requestId, response, pendingRequest }); } this.emitEvent({ @@ -3298,6 +3260,75 @@ class CodexAppServerAgentSession implements AgentSession { pending.resolve({ answers: {} }); } + private handlePlanPermissionResponse(params: { + requestId: string; + response: AgentPermissionResponse; + pending: { + resolve: (value: unknown) => void; + kind: "command" | "file" | "question" | "plan"; + questions?: CodexQuestionPrompt[]; + planText?: string; + }; + pendingRequest: AgentPermissionRequest | null; + }): AgentPermissionResult | void { + const { requestId, response, pending, pendingRequest } = params; + let followUpPrompt: string | undefined; + if (response.behavior === "allow") { + followUpPrompt = this.preparePlanImplementation({ + planText: pending.planText ?? pendingRequest?.metadata?.planText, + }); + } + + this.pendingPermissionHandlers.delete(requestId); + this.pendingPermissions.delete(requestId); + this.resolvedPermissionRequests.add(requestId); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: response, + }); + if (followUpPrompt) { + return { followUpPrompt }; + } + } + + private emitDeniedToolCallTimelineEvent(params: { + requestId: string; + response: Extract; + pendingRequest: AgentPermissionRequest; + }): void { + const { requestId, response, pendingRequest } = params; + let fallbackName: string; + if (pendingRequest.name === "CodexBash") { + fallbackName = "shell"; + } else if (pendingRequest.name === "CodexFileChange") { + fallbackName = "apply_patch"; + } else { + fallbackName = pendingRequest.name; + } + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { + type: "tool_call", + callId: requestId, + name: fallbackName, + status: "failed", + error: { message: response.message ?? "Permission denied" }, + detail: pendingRequest.detail ?? { + type: "unknown", + input: pendingRequest.input ?? null, + output: null, + }, + metadata: { + permissionRequestId: requestId, + denied: true, + }, + }, + }); + } + describePersistence(): { provider: typeof CODEX_PROVIDER; sessionId: string; @@ -3380,11 +3411,13 @@ class CodexAppServerAgentSession implements AgentSession { ); } - private async ensureThread(): Promise { - if (!this.client) return; - if (this.currentThreadId) return; - - // Resolve model + thinking defaults when omitted. + private async resolveModelAndThinking(): Promise<{ + model: string; + thinkingOptionId: string | undefined; + }> { + if (!this.client) { + throw new Error("Codex client is not initialized"); + } let configuredDefaults: CodexConfiguredDefaults = {}; let model = this.config.model; let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); @@ -3415,6 +3448,17 @@ class CodexAppServerAgentSession implements AgentSession { } } + if (!model) { + throw new Error("Unable to resolve Codex model"); + } + return { model, thinkingOptionId }; + } + + private async ensureThread(): Promise { + if (!this.client) return; + if (this.currentThreadId) return; + + const { model, thinkingOptionId } = await this.resolveModelAndThinking(); this.config.model = model; this.config.thinkingOptionId = thinkingOptionId; @@ -3489,329 +3533,389 @@ class CodexAppServerAgentSession implements AgentSession { private handleNotification(method: string, params: unknown): void { const parsed = CodexNotificationSchema.parse({ method, params }); - - if (parsed.kind === "thread_started") { - this.currentThreadId = parsed.threadId; - this.emitEvent({ - type: "thread_started", - provider: CODEX_PROVIDER, - sessionId: parsed.threadId, - }); - return; + switch (parsed.kind) { + case "thread_started": + this.handleThreadStartedNotification(parsed); + return; + case "turn_started": + this.handleTurnStartedNotification(parsed); + return; + case "turn_completed": + this.handleTurnCompletedNotification(parsed); + return; + case "plan_updated": + this.handlePlanUpdatedNotification(parsed); + return; + case "diff_updated": + // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications + // containing a full accumulated unified diff for the *entire turn*. + // This is not a concrete file-change tool call; it is progress telemetry. + return; + case "token_usage_updated": + this.handleTokenUsageUpdatedNotification(parsed); + return; + case "agent_message_delta": + case "reasoning_delta": + case "exec_command_output_delta": + case "file_change_output_delta": + this.handleCodexDeltaNotification(parsed); + return; + case "exec_command_started": + this.handleExecCommandStartedNotification(parsed); + return; + case "exec_command_completed": + this.handleExecCommandCompletedNotification(parsed); + return; + case "terminal_interaction": + this.handleTerminalInteractionNotification(parsed); + return; + case "patch_apply_started": + this.handlePatchApplyStartedNotification(parsed); + return; + case "patch_apply_completed": + this.handlePatchApplyCompletedNotification(parsed); + return; + case "item_completed": + this.handleItemCompletedNotification(parsed); + return; + case "item_started": + this.handleItemStartedNotification(parsed); + return; + case "invalid_payload": + this.warnInvalidNotificationPayload(parsed.method, parsed.params); + return; + default: + this.warnUnknownNotificationMethod(parsed.method, parsed.params); } + } - if (parsed.kind === "turn_started") { - this.currentTurnId = parsed.turnId; - this.latestPlanResult = null; - this.emittedItemStartedIds.clear(); - this.emittedItemCompletedIds.clear(); - this.emittedExecCommandStartedCallIds.clear(); - this.emittedExecCommandCompletedCallIds.clear(); - this.pendingCommandOutputDeltas.clear(); - this.pendingFileChangeOutputDeltas.clear(); - this.warnedIncompleteEditToolCallIds.clear(); - this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); - return; - } - - if (parsed.kind === "turn_completed") { - if (parsed.status === "failed") { - this.emitEvent({ - type: "turn_failed", - provider: CODEX_PROVIDER, - error: parsed.errorMessage ?? "Codex turn failed", - }); - } else if (parsed.status === "interrupted") { - this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); - } else { - if (this.planModeEnabled && this.latestPlanResult?.text) { - this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text); - } - this.emitEvent({ - type: "turn_completed", - provider: CODEX_PROVIDER, - usage: this.latestUsage, - }); + private handleCodexDeltaNotification( + parsed: Extract< + ParsedCodexNotification, + { + kind: + | "agent_message_delta" + | "reasoning_delta" + | "exec_command_output_delta" + | "file_change_output_delta"; } - this.activeForegroundTurnId = null; - this.latestPlanResult = null; - this.emittedItemStartedIds.clear(); - this.emittedItemCompletedIds.clear(); - this.emittedExecCommandStartedCallIds.clear(); - this.emittedExecCommandCompletedCallIds.clear(); - this.pendingCommandOutputDeltas.clear(); - this.pendingFileChangeOutputDeltas.clear(); - this.warnedIncompleteEditToolCallIds.clear(); - return; - } - - if (parsed.kind === "plan_updated") { - const timelineItem = mapCodexPlanToToolCall({ - callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, - text: planStepsToMarkdown( - parsed.plan.map((entry) => ({ - step: entry.step ?? "", - status: entry.status ?? "pending", - })), - ), - }); - if (timelineItem) { - this.rememberPlanResult(timelineItem); - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: timelineItem, - }); - } - return; - } - - if (parsed.kind === "diff_updated") { - // NOTE: Codex app-server emits frequent `turn/diff/updated` notifications - // containing a full accumulated unified diff for the *entire turn*. - // This is not a concrete file-change tool call; it is progress telemetry. - // We intentionally do NOT store every diff update in the timeline. - return; - } - - if (parsed.kind === "token_usage_updated") { - this.latestUsage = toAgentUsage(parsed.tokenUsage); - if (this.latestUsage) { - this.notifySubscribers({ - type: "usage_updated", - provider: CODEX_PROVIDER, - usage: this.latestUsage, - }); - } - return; - } - + >, + ): void { if (parsed.kind === "agent_message_delta") { const prev = this.pendingAgentMessages.get(parsed.itemId) ?? ""; this.pendingAgentMessages.set(parsed.itemId, prev + parsed.delta); return; } - if (parsed.kind === "reasoning_delta") { const prev = this.pendingReasoning.get(parsed.itemId) ?? []; prev.push(parsed.delta); this.pendingReasoning.set(parsed.itemId, prev); return; } - if (parsed.kind === "exec_command_output_delta") { this.appendOutputDeltaChunk(this.pendingCommandOutputDeltas, parsed.callId, parsed.chunk, { decodeBase64: true, }); return; } + this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta); + } - if (parsed.kind === "file_change_output_delta") { - this.appendOutputDeltaChunk(this.pendingFileChangeOutputDeltas, parsed.itemId, parsed.delta); - return; - } + private handleThreadStartedNotification( + parsed: Extract, + ): void { + this.currentThreadId = parsed.threadId; + this.emitEvent({ + type: "thread_started", + provider: CODEX_PROVIDER, + sessionId: parsed.threadId, + }); + } - if (parsed.kind === "exec_command_started") { - if (parsed.callId) { - this.emittedExecCommandStartedCallIds.add(parsed.callId); - this.pendingCommandOutputDeltas.delete(parsed.callId); - } - const timelineItem = mapCodexExecNotificationToToolCall({ - callId: parsed.callId, - command: parsed.command, - cwd: parsed.cwd ?? this.config.cwd ?? null, - running: true, + private handleTurnStartedNotification( + parsed: Extract, + ): void { + this.currentTurnId = parsed.turnId; + this.resetTurnTrackingState(); + this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); + } + + private handleTurnCompletedNotification( + parsed: Extract, + ): void { + if (parsed.status === "failed") { + this.emitEvent({ + type: "turn_failed", + provider: CODEX_PROVIDER, + error: parsed.errorMessage ?? "Codex turn failed", }); - if (timelineItem) { - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } else if (parsed.status === "interrupted") { + this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); + } else { + if (this.planModeEnabled && this.latestPlanResult?.text) { + this.emitSyntheticPlanApprovalRequest(this.latestPlanResult.text); } - return; - } - - if (parsed.kind === "exec_command_completed") { - const bufferedOutput = this.consumeOutputDelta( - this.pendingCommandOutputDeltas, - parsed.callId, - ); - const resolvedOutput = parsed.output ?? bufferedOutput; - this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput); - const timelineItem = mapCodexExecNotificationToToolCall({ - callId: parsed.callId, - command: parsed.command, - cwd: parsed.cwd ?? this.config.cwd ?? null, - output: resolvedOutput, - exitCode: parsed.exitCode, - success: parsed.success, - stderr: parsed.stderr, - running: false, + this.emitEvent({ + type: "turn_completed", + provider: CODEX_PROVIDER, + usage: this.latestUsage, }); - if (timelineItem) { - this.emittedExecCommandCompletedCallIds.add(timelineItem.callId); - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - } + } + this.activeForegroundTurnId = null; + this.resetTurnTrackingState(); + } + + private resetTurnTrackingState(): void { + this.latestPlanResult = null; + this.emittedItemStartedIds.clear(); + this.emittedItemCompletedIds.clear(); + this.emittedExecCommandStartedCallIds.clear(); + this.emittedExecCommandCompletedCallIds.clear(); + this.pendingCommandOutputDeltas.clear(); + this.pendingFileChangeOutputDeltas.clear(); + this.warnedIncompleteEditToolCallIds.clear(); + } + + private handlePlanUpdatedNotification( + parsed: Extract, + ): void { + const timelineItem = mapCodexPlanToToolCall({ + callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, + text: planStepsToMarkdown( + parsed.plan.map((entry) => ({ + step: entry.step ?? "", + status: entry.status ?? "pending", + })), + ), + }); + if (timelineItem) { + this.rememberPlanResult(timelineItem); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + } + + private handleTokenUsageUpdatedNotification( + parsed: Extract, + ): void { + this.latestUsage = toAgentUsage(parsed.tokenUsage); + if (this.latestUsage) { + this.notifySubscribers({ + type: "usage_updated", + provider: CODEX_PROVIDER, + usage: this.latestUsage, + }); + } + } + + private handleExecCommandStartedNotification( + parsed: Extract, + ): void { + if (parsed.callId) { + this.emittedExecCommandStartedCallIds.add(parsed.callId); + this.pendingCommandOutputDeltas.delete(parsed.callId); + } + const timelineItem = mapCodexExecNotificationToToolCall({ + callId: parsed.callId, + command: parsed.command, + cwd: parsed.cwd ?? this.config.cwd ?? null, + running: true, + }); + if (timelineItem) { + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + } + + private handleExecCommandCompletedNotification( + parsed: Extract, + ): void { + const bufferedOutput = this.consumeOutputDelta(this.pendingCommandOutputDeltas, parsed.callId); + const resolvedOutput = parsed.output ?? bufferedOutput; + this.rememberTerminalProcessForCommand(parsed.command, resolvedOutput); + const timelineItem = mapCodexExecNotificationToToolCall({ + callId: parsed.callId, + command: parsed.command, + cwd: parsed.cwd ?? this.config.cwd ?? null, + output: resolvedOutput, + exitCode: parsed.exitCode, + success: parsed.success, + stderr: parsed.stderr, + running: false, + }); + if (timelineItem) { + this.emittedExecCommandCompletedCallIds.add(timelineItem.callId); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + } + + private handleTerminalInteractionNotification( + parsed: Extract, + ): void { + const interactionKey = [parsed.processId ?? "", parsed.stdin ?? ""].join("\u0000"); + if (!this.shouldEmitTerminalInteractionKey(interactionKey)) { return; } + const command = + (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ?? + null; + if (!command && parsed.processId) { + this.pendingUnlabeledTerminalInteractions.add(parsed.processId); + } + const timelineItem = mapCodexTerminalInteractionToToolCall({ + processId: parsed.processId, + fallbackCallId: parsed.callId, + command, + }); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } - if (parsed.kind === "terminal_interaction") { - const interactionKey = [parsed.processId ?? "", parsed.stdin ?? ""].join("\u0000"); - if (!this.shouldEmitTerminalInteractionKey(interactionKey)) { - return; - } - const command = - (parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ?? - null; - if (!command && parsed.processId) { - this.pendingUnlabeledTerminalInteractions.add(parsed.processId); - } - const timelineItem = mapCodexTerminalInteractionToToolCall({ - processId: parsed.processId, - fallbackCallId: parsed.callId, - command, + private handlePatchApplyStartedNotification( + parsed: Extract, + ): void { + if (parsed.callId) { + this.pendingFileChangeOutputDeltas.delete(parsed.callId); + } + const timelineItem = mapCodexPatchNotificationToToolCall({ + callId: parsed.callId, + changes: parsed.changes, + cwd: this.config.cwd ?? null, + running: true, + }); + if (timelineItem) { + this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", { + callId: parsed.callId, + changes: parsed.changes, }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - return; } + } - if (parsed.kind === "patch_apply_started") { - if (parsed.callId) { - this.pendingFileChangeOutputDeltas.delete(parsed.callId); - } - const timelineItem = mapCodexPatchNotificationToToolCall({ + private handlePatchApplyCompletedNotification( + parsed: Extract, + ): void { + const bufferedOutput = this.consumeOutputDelta( + this.pendingFileChangeOutputDeltas, + parsed.callId, + ); + const timelineItem = mapCodexPatchNotificationToToolCall({ + callId: parsed.callId, + changes: parsed.changes, + cwd: this.config.cwd ?? null, + stdout: parsed.stdout ?? bufferedOutput, + stderr: parsed.stderr, + success: parsed.success, + running: false, + }); + if (timelineItem) { + this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", { callId: parsed.callId, changes: parsed.changes, - cwd: this.config.cwd ?? null, - running: true, + stdout: parsed.stdout, }); - if (timelineItem) { - this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_started", { - callId: parsed.callId, - changes: parsed.changes, - }); - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - } + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + } + } + + private handleItemCompletedNotification( + parsed: Extract, + ): void { + // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` + // and canonical `item/*`. We render only the canonical channel to avoid + // duplicated assistant/reasoning rows. + if (parsed.source === "codex_event") { return; } - - if (parsed.kind === "patch_apply_completed") { - const bufferedOutput = this.consumeOutputDelta( - this.pendingFileChangeOutputDeltas, - parsed.callId, - ); - const timelineItem = mapCodexPatchNotificationToToolCall({ - callId: parsed.callId, - changes: parsed.changes, - cwd: this.config.cwd ?? null, - stdout: parsed.stdout ?? bufferedOutput, - stderr: parsed.stderr, - success: parsed.success, - running: false, - }); - if (timelineItem) { - this.warnOnIncompleteEditToolCall(timelineItem, "patch_apply_completed", { - callId: parsed.callId, - changes: parsed.changes, - stdout: parsed.stdout, - }); - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - } + const timelineItem = threadItemToTimeline(parsed.item, { + includeUserMessage: false, + cwd: this.config.cwd ?? null, + }); + if (!timelineItem) { return; } - - if (parsed.kind === "item_completed") { - // Codex emits mirrored lifecycle notifications via both `codex/event/item_*` - // and canonical `item/*`. We render only the canonical channel to avoid - // duplicated assistant/reasoning rows. - if (parsed.source === "codex_event") { + const normalizedItemType = normalizeCodexThreadItemType( + typeof parsed.item.type === "string" ? parsed.item.type : undefined, + ); + const itemId = parsed.item.id; + // For commandExecution items, codex/event/exec_command_* is authoritative. + if (timelineItem.type === "tool_call" && normalizedItemType === "commandExecution") { + const callId = timelineItem.callId || itemId; + if (callId && this.emittedExecCommandCompletedCallIds.has(callId)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { - includeUserMessage: false, - cwd: this.config.cwd ?? null, - }); - if (timelineItem) { - const normalizedItemType = normalizeCodexThreadItemType( - typeof parsed.item.type === "string" ? parsed.item.type : undefined, - ); - const itemId = parsed.item.id; - // For commandExecution items, codex/event/exec_command_* is authoritative. - // Keep item/completed as fallback only when no exec_command completion was seen. - if (timelineItem.type === "tool_call" && normalizedItemType === "commandExecution") { - const callId = timelineItem.callId || itemId; - if (callId && this.emittedExecCommandCompletedCallIds.has(callId)) { - return; - } - } - if (itemId && this.emittedItemCompletedIds.has(itemId)) { - return; - } - if (timelineItem.type === "assistant_message" && itemId) { - const buffered = this.pendingAgentMessages.get(itemId); - if (buffered && buffered.length > 0) { - timelineItem.text = buffered; - } - } - if (timelineItem.type === "reasoning" && itemId) { - const buffered = this.pendingReasoning.get(itemId); - if (buffered && buffered.length > 0) { - timelineItem.text = buffered.join(""); - } - } - if (timelineItem.type === "tool_call") { - if (timelineItem.detail.type === "plan") { - this.rememberPlanResult(timelineItem); - } - this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); - } - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - if (itemId) { - this.emittedItemCompletedIds.add(itemId); - this.emittedItemStartedIds.delete(itemId); - this.pendingCommandOutputDeltas.delete(itemId); - this.pendingFileChangeOutputDeltas.delete(itemId); - } + } + if (itemId && this.emittedItemCompletedIds.has(itemId)) { + return; + } + this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId); + if (timelineItem.type === "tool_call") { + if (timelineItem.detail.type === "plan") { + this.rememberPlanResult(timelineItem); + } + this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); + } + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + if (itemId) { + this.emittedItemCompletedIds.add(itemId); + this.emittedItemStartedIds.delete(itemId); + this.pendingCommandOutputDeltas.delete(itemId); + this.pendingFileChangeOutputDeltas.delete(itemId); + } + } + + private applyBufferedDeltaTextToTimelineItem( + timelineItem: AgentTimelineItem, + itemId: string | null | undefined, + ): void { + if (!itemId) { + return; + } + if (timelineItem.type === "assistant_message") { + const buffered = this.pendingAgentMessages.get(itemId); + if (buffered && buffered.length > 0) { + timelineItem.text = buffered; } return; } + if (timelineItem.type === "reasoning") { + const buffered = this.pendingReasoning.get(itemId); + if (buffered && buffered.length > 0) { + timelineItem.text = buffered.join(""); + } + } + } - if (parsed.kind === "item_started") { - if (parsed.source === "codex_event") { + private handleItemStartedNotification( + parsed: Extract, + ): void { + if (parsed.source === "codex_event") { + return; + } + const timelineItem = threadItemToTimeline(parsed.item, { + includeUserMessage: false, + cwd: this.config.cwd ?? null, + }); + if (!timelineItem || timelineItem.type !== "tool_call") { + return; + } + const normalizedItemType = normalizeCodexThreadItemType( + typeof parsed.item.type === "string" ? parsed.item.type : undefined, + ); + const itemId = parsed.item.id; + if (normalizedItemType === "commandExecution") { + const callId = timelineItem.callId || itemId; + if (callId && this.emittedExecCommandStartedCallIds.has(callId)) { return; } - const timelineItem = threadItemToTimeline(parsed.item, { - includeUserMessage: false, - cwd: this.config.cwd ?? null, - }); - if (timelineItem && timelineItem.type === "tool_call") { - const normalizedItemType = normalizeCodexThreadItemType( - typeof parsed.item.type === "string" ? parsed.item.type : undefined, - ); - const itemId = parsed.item.id; - if (normalizedItemType === "commandExecution") { - const callId = timelineItem.callId || itemId; - if (callId && this.emittedExecCommandStartedCallIds.has(callId)) { - return; - } - } - if (itemId && this.emittedItemStartedIds.has(itemId)) { - return; - } - this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item); - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - if (itemId) { - this.emittedItemStartedIds.add(itemId); - this.pendingCommandOutputDeltas.delete(itemId); - this.pendingFileChangeOutputDeltas.delete(itemId); - } - } + } + if (itemId && this.emittedItemStartedIds.has(itemId)) { return; } - - if (parsed.kind === "invalid_payload") { - this.warnInvalidNotificationPayload(parsed.method, parsed.params); - return; + this.warnOnIncompleteEditToolCall(timelineItem, "item_started", parsed.item); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); + if (itemId) { + this.emittedItemStartedIds.add(itemId); + this.pendingCommandOutputDeltas.delete(itemId); + this.pendingFileChangeOutputDeltas.delete(itemId); } - - this.warnUnknownNotificationMethod(parsed.method, parsed.params); } private warnUnknownNotificationMethod(method: string, params: unknown): void { diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index e771ad5d3b..601fe347c9 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -447,33 +447,22 @@ function asEditTextFields(text: string | undefined): { unifiedDiff?: string; new return { newString: text }; } -function normalizeRolloutEditInput(input: unknown): unknown { - if (typeof input === "string") { - const textFields = asEditTextFields(input); - const path = extractPatchPrimaryFilePath(input); - return { - ...(path ? { path } : {}), - ...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}), - ...(textFields.newString ? { content: textFields.newString } : {}), - }; - } - if (!isRecord(input)) { - return input; - } - - const candidatePatchText = +function findRolloutEditPatchText(input: Record): string | undefined { + return ( (typeof input.patch === "string" && input.patch) || (typeof input.diff === "string" && input.diff) || (typeof input.unified_diff === "string" && input.unified_diff) || (typeof input.unifiedDiff === "string" && input.unifiedDiff) || (typeof input.content === "string" && input.content) || - undefined; - if (!candidatePatchText) { - return input; - } + undefined + ); +} - const textFields = asEditTextFields(candidatePatchText); - const rawPath = +function findRolloutEditInputPath( + input: Record, + patchText: string, +): string | undefined { + return ( (typeof input.path === "string" && input.path.trim().length > 0 ? input.path : undefined) || (typeof input.file_path === "string" && input.file_path.trim().length > 0 ? input.file_path @@ -481,7 +470,18 @@ function normalizeRolloutEditInput(input: unknown): unknown { (typeof input.filePath === "string" && input.filePath.trim().length > 0 ? input.filePath : undefined) || - extractPatchPrimaryFilePath(candidatePatchText); + extractPatchPrimaryFilePath(patchText) + ); +} + +function normalizeRolloutEditRecordInput(input: Record): unknown { + const candidatePatchText = findRolloutEditPatchText(input); + if (!candidatePatchText) { + return input; + } + + const textFields = asEditTextFields(candidatePatchText); + const rawPath = findRolloutEditInputPath(input, candidatePatchText); const { patch: _patch, @@ -505,6 +505,22 @@ function normalizeRolloutEditInput(input: unknown): unknown { return normalized; } +function normalizeRolloutEditInput(input: unknown): unknown { + if (typeof input === "string") { + const textFields = asEditTextFields(input); + const path = extractPatchPrimaryFilePath(input); + return { + ...(path ? { path } : {}), + ...(textFields.unifiedDiff ? { patch: textFields.unifiedDiff } : {}), + ...(textFields.newString ? { content: textFields.newString } : {}), + }; + } + if (!isRecord(input)) { + return input; + } + return normalizeRolloutEditRecordInput(input); +} + function asEditFileOutputFields(text: string | undefined): { patch?: string; content?: string } { if (typeof text !== "string" || text.length === 0) { return {}; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 8e34da8dc0..b21cc13866 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1431,292 +1431,51 @@ export function translateOpenCodeEvent( switch (event.type) { case "session.created": - case "session.updated": { - if (event.properties.info.id === state.sessionId) { - events.push({ - type: "thread_started", - sessionId: state.sessionId, - provider: "opencode", - }); - } + case "session.updated": + appendOpenCodeSessionCreatedOrUpdated(event, state, events); break; - } - - case "message.updated": { - const info = event.properties.info; - if (info.sessionID !== state.sessionId) { - break; - } - - state.messageRoles.set(info.id, info.role); - if (info.role === "assistant") { - const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); - if (modelLookupKey) { - const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); - if (contextWindowMaxTokens !== undefined) { - state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens); - } - } - - if (!state.emittedStructuredMessageIds.has(info.id) && info.time?.completed !== undefined) { - const text = stringifyStructuredAssistantMessage(info.structured); - if (text) { - state.emittedStructuredMessageIds.add(info.id); - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "assistant_message", text }, - }); - } - } - } + case "message.updated": + appendOpenCodeMessageUpdated(event, state, events); break; - } - - case "message.part.updated": { - const part = event.properties.part; - if (part.sessionID !== state.sessionId) { - break; - } - - const messageRole = state.messageRoles.get(part.messageID); - state.partTypes.set(part.id, part.type); - - if (part.type === "text") { - const partKey = resolvePartDedupeKey(part, "text"); - if (messageRole === "user") { - break; - } - if (part.time?.end) { - if (partKey && state.streamedPartKeys.delete(partKey)) { - break; - } - if (part.text) { - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "assistant_message", text: part.text }, - }); - } - } - } else if (part.type === "reasoning") { - const partKey = resolvePartDedupeKey(part, "reasoning"); - if (part.time.end) { - if (partKey && state.streamedPartKeys.delete(partKey)) { - break; - } - if (part.text) { - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "reasoning", text: part.text }, - }); - } - } - } else if (part.type === "tool") { - const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); - if (parsedToolPart.success && parsedToolPart.data) { - events.push({ - type: "timeline", - provider: "opencode", - item: parsedToolPart.data, - }); - } - } else if (part.type === "compaction") { - events.push({ - type: "timeline", - provider: "opencode", - item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"), - }); - } else if (part.type === "step-finish") { - mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); - if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { - events.push({ - type: "usage_updated", - provider: "opencode", - usage: { ...state.accumulatedUsage }, - }); - } - } + case "message.part.updated": + appendOpenCodeMessagePartUpdated(event, state, events); break; - } - - case "message.part.delta": { - const { sessionID, messageID, partID, field, delta } = event.properties; - if (sessionID !== state.sessionId) { - break; - } - - if (!delta || !field) { - break; - } - - const messageRole = messageID ? state.messageRoles.get(messageID) : undefined; - const knownPartType = partID ? state.partTypes.get(partID) : undefined; - const isReasoning = knownPartType === "reasoning" || field === "reasoning"; - - if (isReasoning) { - if (partID) { - state.streamedPartKeys.add(`reasoning:${partID}`); - } - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "reasoning", text: delta }, - }); - } else if (field === "text") { - if (messageRole === "user") { - break; - } - if (partID) { - state.streamedPartKeys.add(`text:${partID}`); - } - events.push({ - type: "timeline", - provider: "opencode", - item: { type: "assistant_message", text: delta }, - }); - } + case "message.part.delta": + appendOpenCodeMessagePartDelta(event, state, events); break; - } - - case "permission.asked": { - if (event.properties.sessionID !== state.sessionId) { - break; - } - - const metadata = readOpenCodeRecord(event.properties.metadata); - const tool = readOpenCodeRecord(event.properties.tool); - const patterns = Array.isArray(event.properties.patterns) - ? event.properties.patterns.filter((value): value is string => typeof value === "string") - : []; - const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS); - const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS); - const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS); - const input = buildOpenCodePermissionInput({ - patterns, - metadata, - tool, - command, - }); - const detail = buildOpenCodePermissionDetail({ - permission: event.properties.permission, - input, - command, - cwd, - }); - const description = buildOpenCodePermissionDescription({ - reason, - patterns, - }); - - events.push({ - type: "permission_requested", - provider: "opencode", - request: { - id: event.properties.id, - provider: "opencode", - name: event.properties.permission, - kind: "tool", - title: toHumanReadablePermissionTitle(event.properties.permission), - ...(description ? { description } : {}), - input, - detail, - }, - }); + case "permission.asked": + appendOpenCodePermissionAsked(event, state, events); break; - } - - case "question.asked": { - if (event.properties.sessionID !== state.sessionId) { - break; - } - - const questions = event.properties.questions.flatMap((q) => { - if (!q.question || !q.header) { - return []; - } - const options = - q.options?.map((o) => ({ - label: o.label, - ...(o.description ? { description: o.description } : {}), - })) ?? []; - return [ - { - question: q.question, - header: q.header, - options, - ...(q.multiple === true ? { multiSelect: true } : {}), - }, - ]; - }); - - if (questions.length === 0) { - break; - } - - events.push({ - type: "permission_requested", - provider: "opencode", - request: { - id: event.properties.id, - provider: "opencode", - name: "question", - kind: "question", - title: "Question", - input: { questions }, - metadata: { - source: "opencode_question", - ...event.properties.tool, - }, - }, - }); + case "question.asked": + appendOpenCodeQuestionAsked(event, state, events); break; - } - - case "todo.updated": { - if (event.properties.sessionID !== state.sessionId) { - break; - } - - events.push({ - type: "timeline", - provider: "opencode", - item: mapOpenCodeTodosToTimelineItems(event.properties.todos), - }); - break; - } - - case "session.compacted": { - if (event.properties.sessionID !== state.sessionId) { - break; - } - - events.push({ - type: "timeline", - provider: "opencode", - item: createCompactionTimelineItem("completed"), - }); - break; - } - - case "session.idle": { + case "todo.updated": if (event.properties.sessionID === state.sessionId) { - state.streamedPartKeys.clear(); - state.partTypes.clear(); events.push({ - type: "turn_completed", + type: "timeline", provider: "opencode", - usage: undefined, + item: mapOpenCodeTodosToTimelineItems(event.properties.todos), }); } break; - } - - case "session.error": { + case "session.compacted": if (event.properties.sessionID === state.sessionId) { - state.streamedPartKeys.clear(); - state.partTypes.clear(); + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("completed"), + }); + } + break; + case "session.idle": + if (event.properties.sessionID === state.sessionId) { + resetOpenCodeTurnTrackingState(state); + events.push({ type: "turn_completed", provider: "opencode", usage: undefined }); + } + break; + case "session.error": + if (event.properties.sessionID === state.sessionId) { + resetOpenCodeTurnTrackingState(state); events.push({ type: "turn_failed", provider: "opencode", @@ -1724,38 +1483,325 @@ export function translateOpenCodeEvent( }); } break; - } - - case "session.status": { - if (event.properties.sessionID !== state.sessionId) { - break; - } - const { status } = event.properties; - if (status.type === "idle") { - state.streamedPartKeys.clear(); - state.partTypes.clear(); - events.push({ - type: "turn_completed", - provider: "opencode", - usage: undefined, - }); - } else if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) { - state.streamedPartKeys.clear(); - state.partTypes.clear(); - events.push({ - type: "turn_failed", - provider: "opencode", - error: normalizeTurnFailureError(status.message), - }); - } - // "retry" and "busy" are transient — no terminal event. + case "session.status": + appendOpenCodeSessionStatus(event, state, events); break; - } } return events; } +function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): void { + state.streamedPartKeys.clear(); + state.partTypes.clear(); +} + +function appendOpenCodeSessionCreatedOrUpdated( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (event.properties.info.id === state.sessionId) { + events.push({ + type: "thread_started", + sessionId: state.sessionId, + provider: "opencode", + }); + } +} + +function appendOpenCodeMessageUpdated( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const info = event.properties.info; + if (info.sessionID !== state.sessionId) { + return; + } + state.messageRoles.set(info.id, info.role); + if (info.role !== "assistant") { + return; + } + const modelLookupKey = resolveOpenCodeModelLookupKeyFromAssistantMessage(info); + if (modelLookupKey) { + const contextWindowMaxTokens = state.modelContextWindowsByModelKey?.get(modelLookupKey); + if (contextWindowMaxTokens !== undefined) { + state.onAssistantModelContextWindowResolved?.(contextWindowMaxTokens); + } + } + if (state.emittedStructuredMessageIds.has(info.id) || info.time?.completed === undefined) { + return; + } + const text = stringifyStructuredAssistantMessage(info.structured); + if (!text) { + return; + } + state.emittedStructuredMessageIds.add(info.id); + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text }, + }); +} + +function appendOpenCodeMessagePartUpdated( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const part = event.properties.part; + if (part.sessionID !== state.sessionId) { + return; + } + const messageRole = state.messageRoles.get(part.messageID); + state.partTypes.set(part.id, part.type); + + if (part.type === "text") { + appendOpenCodeTextPart(part, messageRole, state, events); + return; + } + if (part.type === "reasoning") { + appendOpenCodeReasoningPart(part, state, events); + return; + } + if (part.type === "tool") { + const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); + if (parsedToolPart.success && parsedToolPart.data) { + events.push({ type: "timeline", provider: "opencode", item: parsedToolPart.data }); + } + return; + } + if (part.type === "compaction") { + events.push({ + type: "timeline", + provider: "opencode", + item: createCompactionTimelineItem("loading", part.auto ? "auto" : "manual"), + }); + return; + } + if (part.type === "step-finish") { + mergeOpenCodeStepFinishUsage(state.accumulatedUsage, part); + if (hasNormalizedOpenCodeUsage(state.accumulatedUsage)) { + events.push({ + type: "usage_updated", + provider: "opencode", + usage: { ...state.accumulatedUsage }, + }); + } + } +} + +function appendOpenCodeTextPart( + part: Extract< + Extract["properties"]["part"], + { type: "text" } + >, + messageRole: OpenCodeMessageRole | undefined, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (messageRole === "user") { + return; + } + if (!part.time?.end) { + return; + } + const partKey = resolvePartDedupeKey(part, "text"); + if (partKey && state.streamedPartKeys.delete(partKey)) { + return; + } + if (part.text) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: part.text }, + }); + } +} + +function appendOpenCodeReasoningPart( + part: Extract< + Extract["properties"]["part"], + { type: "reasoning" } + >, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (!part.time.end) { + return; + } + const partKey = resolvePartDedupeKey(part, "reasoning"); + if (partKey && state.streamedPartKeys.delete(partKey)) { + return; + } + if (part.text) { + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: part.text }, + }); + } +} + +function appendOpenCodeMessagePartDelta( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const { sessionID, messageID, partID, field, delta } = event.properties; + if (sessionID !== state.sessionId) { + return; + } + if (!delta || !field) { + return; + } + const messageRole = messageID ? state.messageRoles.get(messageID) : undefined; + const knownPartType = partID ? state.partTypes.get(partID) : undefined; + const isReasoning = knownPartType === "reasoning" || field === "reasoning"; + + if (isReasoning) { + if (partID) { + state.streamedPartKeys.add(`reasoning:${partID}`); + } + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "reasoning", text: delta }, + }); + return; + } + if (field !== "text") { + return; + } + if (messageRole === "user") { + return; + } + if (partID) { + state.streamedPartKeys.add(`text:${partID}`); + } + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "assistant_message", text: delta }, + }); +} + +function appendOpenCodePermissionAsked( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (event.properties.sessionID !== state.sessionId) { + return; + } + const metadata = readOpenCodeRecord(event.properties.metadata); + const tool = readOpenCodeRecord(event.properties.tool); + const patterns = Array.isArray(event.properties.patterns) + ? event.properties.patterns.filter((value): value is string => typeof value === "string") + : []; + const command = readPermissionField(metadata, PERMISSION_COMMAND_KEYS); + const cwd = readPermissionField(metadata, PERMISSION_CWD_KEYS); + const reason = readPermissionField(metadata, PERMISSION_REASON_KEYS); + const input = buildOpenCodePermissionInput({ patterns, metadata, tool, command }); + const detail = buildOpenCodePermissionDetail({ + permission: event.properties.permission, + input, + command, + cwd, + }); + const description = buildOpenCodePermissionDescription({ reason, patterns }); + + events.push({ + type: "permission_requested", + provider: "opencode", + request: { + id: event.properties.id, + provider: "opencode", + name: event.properties.permission, + kind: "tool", + title: toHumanReadablePermissionTitle(event.properties.permission), + ...(description ? { description } : {}), + input, + detail, + }, + }); +} + +function appendOpenCodeQuestionAsked( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (event.properties.sessionID !== state.sessionId) { + return; + } + const questions = event.properties.questions.flatMap((q) => { + if (!q.question || !q.header) { + return []; + } + const options = + q.options?.map((o) => ({ + label: o.label, + ...(o.description ? { description: o.description } : {}), + })) ?? []; + return [ + { + question: q.question, + header: q.header, + options, + ...(q.multiple === true ? { multiSelect: true } : {}), + }, + ]; + }); + + if (questions.length === 0) { + return; + } + + events.push({ + type: "permission_requested", + provider: "opencode", + request: { + id: event.properties.id, + provider: "opencode", + name: "question", + kind: "question", + title: "Question", + input: { questions }, + metadata: { + source: "opencode_question", + ...event.properties.tool, + }, + }, + }); +} + +function appendOpenCodeSessionStatus( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + if (event.properties.sessionID !== state.sessionId) { + return; + } + const { status } = event.properties; + if (status.type === "idle") { + resetOpenCodeTurnTrackingState(state); + events.push({ type: "turn_completed", provider: "opencode", usage: undefined }); + return; + } + if (status.type === "retry" && isFatalOpenCodeRetryMessage(status.message)) { + resetOpenCodeTurnTrackingState(state); + events.push({ + type: "turn_failed", + provider: "opencode", + error: normalizeTurnFailureError(status.message), + }); + } + // "retry" and "busy" are transient — no terminal event. +} + class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; diff --git a/packages/server/src/server/agent/providers/pi-direct-agent.ts b/packages/server/src/server/agent/providers/pi-direct-agent.ts index 15da7ef7f8..c1717d938e 100644 --- a/packages/server/src/server/agent/providers/pi-direct-agent.ts +++ b/packages/server/src/server/agent/providers/pi-direct-agent.ts @@ -504,68 +504,41 @@ function normalizeLegacyEditArgs(rawArgs: unknown): EditToolInput | null { }; } +function parseEditToolArgs(rawArgs: unknown): PiTrackedToolCall { + const parsed = EditToolInputSchema.safeParse(rawArgs); + if (parsed.success) { + return { kind: "edit", toolName: "edit", args: parsed.data }; + } + const legacyArgs = normalizeLegacyEditArgs(rawArgs); + if (legacyArgs) { + return { kind: "edit", toolName: "edit", args: legacyArgs }; + } + return { kind: "unknown", toolName: "edit", args: rawArgs ?? null }; +} + +type SimpleToolKind = "bash" | "read" | "write" | "find" | "grep" | "ls"; +const SIMPLE_TOOL_SCHEMAS: { + [K in SimpleToolKind]: { safeParse: (data: unknown) => { success: boolean; data?: unknown } }; +} = { + bash: BashToolInputSchema, + read: ReadToolInputSchema, + write: WriteToolInputSchema, + find: FindToolInputSchema, + grep: GrepToolInputSchema, + ls: LsToolInputSchema, +}; + function parseToolArgs(toolName: string, rawArgs: unknown): PiTrackedToolCall { - if (toolName === "bash") { - const parsed = BashToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "bash", toolName, args: parsed.data }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; - } - - if (toolName === "read") { - const parsed = ReadToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "read", toolName, args: parsed.data }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; - } - if (toolName === "edit") { - const parsed = EditToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "edit", toolName, args: parsed.data }; - } - - const legacyArgs = normalizeLegacyEditArgs(rawArgs); - if (legacyArgs) { - return { kind: "edit", toolName, args: legacyArgs }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; + return parseEditToolArgs(rawArgs); } - - if (toolName === "write") { - const parsed = WriteToolInputSchema.safeParse(rawArgs); + const schema = SIMPLE_TOOL_SCHEMAS[toolName as SimpleToolKind]; + if (schema) { + const parsed = schema.safeParse(rawArgs); if (parsed.success) { - return { kind: "write", toolName, args: parsed.data }; + return { kind: toolName as SimpleToolKind, toolName, args: parsed.data } as PiTrackedToolCall; } - return { kind: "unknown", toolName, args: rawArgs ?? null }; } - - if (toolName === "find") { - const parsed = FindToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "find", toolName, args: parsed.data }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; - } - - if (toolName === "grep") { - const parsed = GrepToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "grep", toolName, args: parsed.data }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; - } - - if (toolName === "ls") { - const parsed = LsToolInputSchema.safeParse(rawArgs); - if (parsed.success) { - return { kind: "ls", toolName, args: parsed.data }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; - } - return { kind: "unknown", toolName, args: rawArgs ?? null }; } @@ -887,6 +860,38 @@ export class PiDirectAgentSession implements AgentSession { }); } + private handleMessageUpdate( + event: Extract, + turnId: string | undefined, + ): void { + if (event.message.role !== "assistant") { + return; + } + if (event.assistantMessageEvent.type === "text_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "assistant_message", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + return; + } + if (event.assistantMessageEvent.type === "thinking_delta") { + this.emit({ + type: "timeline", + provider: PI_PROVIDER, + turnId, + item: { + type: "reasoning", + text: event.assistantMessageEvent.delta ?? "", + }, + }); + } + } + private handleSessionEvent(event: AgentSessionEvent): void { const turnId = this.currentTurnIdForEvent(); @@ -906,32 +911,7 @@ export class PiDirectAgentSession implements AgentSession { }); return; case "message_update": - if (event.message.role !== "assistant") { - return; - } - if (event.assistantMessageEvent.type === "text_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "assistant_message", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - return; - } - if (event.assistantMessageEvent.type === "thinking_delta") { - this.emit({ - type: "timeline", - provider: PI_PROVIDER, - turnId, - item: { - type: "reasoning", - text: event.assistantMessageEvent.delta ?? "", - }, - }); - } + this.handleMessageUpdate(event, turnId); return; case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); diff --git a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts index 440335a19d..6ee9e00086 100644 --- a/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts +++ b/packages/server/src/server/agent/providers/tool-call-detail-primitives.ts @@ -94,40 +94,64 @@ const ToolShellOutputObjectSchema = z }) .passthrough(); +function firstNonEmptyString(values: Array): string | undefined { + for (const value of values) { + const resolved = nonEmptyString(value); + if (resolved) { + return resolved; + } + } + return undefined; +} + +function resolveShellOutputRawText( + value: z.infer, +): string | undefined { + return firstNonEmptyString([ + value.output, + value.text, + value.content, + value.aggregated_output, + value.aggregatedOutput, + value.structuredContent?.output, + value.structuredContent?.text, + value.structuredContent?.content, + value.structured_content?.output, + value.structured_content?.text, + value.structured_content?.content, + value.result?.output, + value.result?.text, + value.result?.content, + ]); +} + +function resolveShellOutputExitCode( + value: z.infer, +): number | null | undefined { + return ( + value.exitCode ?? + value.exit_code ?? + value.metadata?.exitCode ?? + value.metadata?.exit_code ?? + undefined + ); +} + +function transformShellOutputObject(value: z.infer) { + return { + command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), + output: extractCodexShellOutput(resolveShellOutputRawText(value)), + exitCode: resolveShellOutputExitCode(value), + }; +} + export const ToolShellOutputSchema = z.union([ z.string().transform((value) => ({ command: undefined, output: extractCodexShellOutput(value), exitCode: undefined, })), - ToolShellOutputObjectSchema.transform((value) => { - const rawOutput = - nonEmptyString(value.output) ?? - nonEmptyString(value.text) ?? - nonEmptyString(value.content) ?? - nonEmptyString(value.aggregated_output) ?? - nonEmptyString(value.aggregatedOutput) ?? - nonEmptyString(value.structuredContent?.output) ?? - nonEmptyString(value.structuredContent?.text) ?? - nonEmptyString(value.structuredContent?.content) ?? - nonEmptyString(value.structured_content?.output) ?? - nonEmptyString(value.structured_content?.text) ?? - nonEmptyString(value.structured_content?.content) ?? - nonEmptyString(value.result?.output) ?? - nonEmptyString(value.result?.text) ?? - nonEmptyString(value.result?.content); - - return { - command: nonEmptyString(value.command) ?? nonEmptyString(value.result?.command), - output: extractCodexShellOutput(rawOutput), - exitCode: - value.exitCode ?? - value.exit_code ?? - value.metadata?.exitCode ?? - value.metadata?.exit_code ?? - undefined, - }; - }), + ToolShellOutputObjectSchema.transform((value) => transformShellOutputObject(value)), ]); export const ToolPathInputSchema = z.union([ @@ -735,6 +759,54 @@ export function toEditToolDetail( }; } +function buildWebSearchExtraFields(output: ParsedToolWebSearchOutput): Record { + const webResults = output.results.flatMap((entry) => + typeof entry === "string" ? [] : entry.content, + ); + const annotations = output.results.filter((entry): entry is string => typeof entry === "string"); + return { + ...(webResults.length > 0 ? { webResults } : {}), + ...(annotations.length > 0 ? { annotations } : {}), + durationSeconds: output.durationSeconds, + }; +} + +function buildGrepExtraFields(output: ParsedToolGrepOutput): Record { + const filePaths = output.filenames.length > 0 ? output.filenames : undefined; + return { + ...(output.content ? { content: output.content } : {}), + ...(filePaths ? { filePaths } : {}), + numFiles: output.numFiles, + ...(output.numMatches !== undefined ? { numMatches: output.numMatches } : {}), + ...(output.mode ? { mode: output.mode } : {}), + }; +} + +function buildGlobExtraFields(output: ParsedToolGlobOutput): Record { + const filePaths = output.filenames.length > 0 ? output.filenames : undefined; + return { + ...(filePaths ? { filePaths } : {}), + numFiles: output.numFiles, + durationMs: output.durationMs, + truncated: output.truncated, + }; +} + +function buildSearchToolDetailOutputFields( + output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null, +): Record { + if (isParsedToolGrepOutput(output)) { + return buildGrepExtraFields(output); + } + if (isParsedToolGlobOutput(output)) { + return buildGlobExtraFields(output); + } + if (isParsedToolWebSearchOutput(output)) { + return buildWebSearchExtraFields(output); + } + return {}; +} + export function toSearchToolDetail(params: { input: ParsedToolSearchInput | null; output?: ParsedToolGrepOutput | ParsedToolGlobOutput | ParsedToolWebSearchOutput | null; @@ -745,35 +817,11 @@ export function toSearchToolDetail(params: { return undefined; } - let filePaths: string[] | undefined; - if (isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output)) { - filePaths = output.filenames.length > 0 ? output.filenames : undefined; - } - const webResults = isParsedToolWebSearchOutput(output) - ? output.results.flatMap((entry) => (typeof entry === "string" ? [] : entry.content)) - : undefined; - const annotations = isParsedToolWebSearchOutput(output) - ? output.results.filter((entry): entry is string => typeof entry === "string") - : undefined; - return { type: "search", query: input.query, ...(toolName ? { toolName } : {}), - ...(isParsedToolGrepOutput(output) && output.content ? { content: output.content } : {}), - ...(filePaths ? { filePaths } : {}), - ...(webResults && webResults.length > 0 ? { webResults } : {}), - ...(annotations && annotations.length > 0 ? { annotations } : {}), - ...(isParsedToolGrepOutput(output) || isParsedToolGlobOutput(output) - ? { numFiles: output.numFiles } - : {}), - ...(isParsedToolGrepOutput(output) && output.numMatches !== undefined - ? { numMatches: output.numMatches } - : {}), - ...(isParsedToolGlobOutput(output) ? { durationMs: output.durationMs } : {}), - ...(isParsedToolWebSearchOutput(output) ? { durationSeconds: output.durationSeconds } : {}), - ...(isParsedToolGlobOutput(output) ? { truncated: output.truncated } : {}), - ...(isParsedToolGrepOutput(output) && output.mode ? { mode: output.mode } : {}), + ...buildSearchToolDetailOutputFields(output), }; } diff --git a/packages/server/src/server/agent/stt-manager.ts b/packages/server/src/server/agent/stt-manager.ts index e4f5c90a39..084c816dc6 100644 --- a/packages/server/src/server/agent/stt-manager.ts +++ b/packages/server/src/server/agent/stt-manager.ts @@ -15,6 +15,84 @@ const BATCH_APPEND_CHUNK_SECONDS = 1; const DEFAULT_BATCH_COMMIT_EVERY_SECONDS = 15; const BATCH_FINAL_TIMEOUT_MS = 120_000; +interface TranscriptSegmentMeta { + language?: string; + logprobs?: TranscriptionResult["logprobs"]; + avgLogprob?: number; + isLowConfidence?: boolean; +} + +function assembleTranscriptionResult(params: { + committedSegmentIds: string[]; + transcriptsBySegmentId: Map; + finalTranscriptSegmentIds: Set; + transcriptMetaBySegmentId: Map; + durationMs: number; +}): TranscriptionResult { + const { + committedSegmentIds, + transcriptsBySegmentId, + finalTranscriptSegmentIds, + transcriptMetaBySegmentId, + durationMs, + } = params; + const committedSet = new Set(committedSegmentIds); + const orderedSegmentIds: string[] = [...committedSegmentIds]; + for (const segmentId of transcriptsBySegmentId.keys()) { + if (!committedSet.has(segmentId)) { + orderedSegmentIds.push(segmentId); + } + } + + const transcript = orderedSegmentIds + .map((segmentId) => transcriptsBySegmentId.get(segmentId) ?? "") + .join(" ") + .trim(); + const orderedFinalMeta = orderedSegmentIds + .filter((segmentId) => finalTranscriptSegmentIds.has(segmentId)) + .map((segmentId) => transcriptMetaBySegmentId.get(segmentId)) + .filter((meta): meta is TranscriptSegmentMeta => Boolean(meta)); + const language = orderedFinalMeta.find((meta) => meta.language)?.language; + const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null; + const allLowConfidence = + orderedFinalMeta.length > 0 && orderedFinalMeta.every((meta) => meta.isLowConfidence === true); + + return { + text: transcript, + ...(language ? { language } : {}), + ...(singleSegmentMeta?.logprobs ? { logprobs: singleSegmentMeta.logprobs } : {}), + ...(singleSegmentMeta?.avgLogprob !== undefined + ? { avgLogprob: singleSegmentMeta.avgLogprob } + : {}), + ...(allLowConfidence ? { isLowConfidence: true } : {}), + duration: durationMs, + }; +} + +function preparePcmForModel(audio: Buffer, format: string, requiredSampleRate: number): Buffer { + let inputRate: number; + let pcm16: Buffer; + if (format.toLowerCase().includes("audio/wav")) { + const parsed = parsePcm16MonoWav(audio); + inputRate = parsed.sampleRate; + pcm16 = parsed.pcm16; + } else if (format.toLowerCase().includes("audio/pcm")) { + inputRate = parsePcmRateFromFormat(format, requiredSampleRate) ?? requiredSampleRate; + pcm16 = audio; + } else { + throw new Error(`Unsupported audio format for STT: ${format}`); + } + + if (inputRate === requiredSampleRate) { + return pcm16; + } + const resampler = new Pcm16MonoResampler({ + inputRate, + outputRate: requiredSampleRate, + }); + return resampler.processChunk(pcm16); +} + function resolveBatchCommitEverySeconds(): number { const fromEnv = process.env.PASEO_STT_BATCH_COMMIT_EVERY_SECONDS; if (!fromEnv) { @@ -92,29 +170,7 @@ export class STTManager { language: "en", }); - let inputRate: number; - let pcm16: Buffer; - if (format.toLowerCase().includes("audio/wav")) { - const parsed = parsePcm16MonoWav(audio); - inputRate = parsed.sampleRate; - pcm16 = parsed.pcm16; - } else if (format.toLowerCase().includes("audio/pcm")) { - inputRate = - parsePcmRateFromFormat(format, session.requiredSampleRate) ?? session.requiredSampleRate; - pcm16 = audio; - } else { - throw new Error(`Unsupported audio format for STT: ${format}`); - } - - let pcmForModel = pcm16; - if (inputRate !== session.requiredSampleRate) { - const resampler = new Pcm16MonoResampler({ - inputRate, - outputRate: session.requiredSampleRate, - }); - pcmForModel = resampler.processChunk(pcm16); - inputRate = session.requiredSampleRate; - } + const pcmForModel = preparePcmForModel(audio, format, session.requiredSampleRate); try { const startedAt = Date.now(); @@ -123,15 +179,7 @@ export class STTManager { const committedSegmentIds: string[] = []; const transcriptsBySegmentId = new Map(); const finalTranscriptSegmentIds = new Set(); - const transcriptMetaBySegmentId = new Map< - string, - { - language?: string; - logprobs?: TranscriptionResult["logprobs"]; - avgLogprob?: number; - isLowConfidence?: boolean; - } - >(); + const transcriptMetaBySegmentId = new Map(); let expectedFinals = 0; let settle: (() => void) | null = null; @@ -241,47 +289,13 @@ export class STTManager { await allFinalsReady; clearTimeout(finalTimeout); - const committedSet = new Set(committedSegmentIds); - const orderedSegmentIds: string[] = [...committedSegmentIds]; - for (const segmentId of transcriptsBySegmentId.keys()) { - if (!committedSet.has(segmentId)) { - orderedSegmentIds.push(segmentId); - } - } - - const transcript = orderedSegmentIds - .map((segmentId) => transcriptsBySegmentId.get(segmentId) ?? "") - .join(" ") - .trim(); - const orderedFinalMeta = orderedSegmentIds - .filter((segmentId) => finalTranscriptSegmentIds.has(segmentId)) - .map((segmentId) => transcriptMetaBySegmentId.get(segmentId)) - .filter( - ( - meta, - ): meta is { - language?: string; - logprobs?: TranscriptionResult["logprobs"]; - avgLogprob?: number; - isLowConfidence?: boolean; - } => Boolean(meta), - ); - const language = orderedFinalMeta.find((meta) => meta.language)?.language; - const singleSegmentMeta = orderedFinalMeta.length === 1 ? orderedFinalMeta[0] : null; - const allLowConfidence = - orderedFinalMeta.length > 0 && - orderedFinalMeta.every((meta) => meta.isLowConfidence === true); - - const result: TranscriptionResult = { - text: transcript, - ...(language ? { language } : {}), - ...(singleSegmentMeta?.logprobs ? { logprobs: singleSegmentMeta.logprobs } : {}), - ...(singleSegmentMeta?.avgLogprob !== undefined - ? { avgLogprob: singleSegmentMeta.avgLogprob } - : {}), - ...(allLowConfidence ? { isLowConfidence: true } : {}), - duration: Date.now() - startedAt, - }; + const result = assembleTranscriptionResult({ + committedSegmentIds, + transcriptsBySegmentId, + finalTranscriptSegmentIds, + transcriptMetaBySegmentId, + durationMs: Date.now() - startedAt, + }); // Filter out low-confidence transcriptions (non-speech sounds) if (result.isLowConfidence) { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index e839fc1cc2..d6b83777ae 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -102,6 +102,105 @@ function extractAgentProviderSettings( : undefined; } +interface ResolveRelayInput { + env: NodeJS.ProcessEnv; + persisted: ReturnType; + cliRelayEnabled: boolean | undefined; +} + +interface ResolvedRelay { + enabled: boolean; + endpoint: string; + publicEndpoint: string; +} + +function resolveRelayConfig(input: ResolveRelayInput): ResolvedRelay { + const enabled = + input.cliRelayEnabled ?? + parseBooleanEnv(input.env.PASEO_RELAY_ENABLED) ?? + input.persisted.daemon?.relay?.enabled ?? + true; + const endpoint = + input.env.PASEO_RELAY_ENDPOINT ?? + input.persisted.daemon?.relay?.endpoint ?? + DEFAULT_RELAY_ENDPOINT; + const publicEndpoint = + input.env.PASEO_RELAY_PUBLIC_ENDPOINT ?? + input.persisted.daemon?.relay?.publicEndpoint ?? + endpoint; + return { enabled, endpoint, publicEndpoint }; +} + +interface ResolvedVoiceLlm { + provider: AgentProvider | null; + providerExplicit: boolean; + model: string | null; +} + +function resolveVoiceLlmConfig( + env: NodeJS.ProcessEnv, + persisted: ReturnType, +): ResolvedVoiceLlm { + const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER); + const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider( + persisted.features?.voiceMode?.llm?.provider, + ); + return { + provider: envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null, + providerExplicit: envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null, + model: persisted.features?.voiceMode?.llm?.model ?? null, + }; +} + +function resolveCorsAllowedOrigins( + env: NodeJS.ProcessEnv, + persisted: ReturnType, +): string[] { + const envCorsOrigins = env.PASEO_CORS_ORIGINS + ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim()) + : []; + const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; + return Array.from( + new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)), + ); +} + +// PASEO_LISTEN can be: +// - host:port (TCP) +// - /path/to/socket (Unix socket) +// - unix:///path/to/socket (Unix socket) +// Default is TCP at 127.0.0.1:6767 +function resolveListenAddress( + env: NodeJS.ProcessEnv, + cli: CliConfigOverrides | undefined, + persisted: ReturnType, +): string { + return ( + cli?.listen ?? + env.PASEO_LISTEN ?? + persisted.daemon?.listen ?? + `127.0.0.1:${env.PORT ?? DEFAULT_PORT}` + ); +} + +function resolveStaticLoadConfigSettings( + env: NodeJS.ProcessEnv, + cli: CliConfigOverrides | undefined, + persisted: ReturnType, +) { + return { + mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, + mcpInjectIntoAgents: + cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, + hostnames: mergeHostnames([ + persisted.daemon?.hostnames, + parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), + cli?.hostnames, + ]), + appBaseUrl: env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL, + }; +} + export function loadConfig( paseoHome: string, options?: { @@ -112,46 +211,15 @@ export function loadConfig( const env = options?.env ?? process.env; const persisted = loadPersistedConfig(paseoHome); - // PASEO_LISTEN can be: - // - host:port (TCP) - // - /path/to/socket (Unix socket) - // - unix:///path/to/socket (Unix socket) - // Default is TCP at 127.0.0.1:6767 - const listen = - options?.cli?.listen ?? - env.PASEO_LISTEN ?? - persisted.daemon?.listen ?? - `127.0.0.1:${env.PORT ?? DEFAULT_PORT}`; + const listen = resolveListenAddress(env, options?.cli, persisted); + const { mcpEnabled, mcpInjectIntoAgents, hostnames, appBaseUrl } = + resolveStaticLoadConfigSettings(env, options?.cli, persisted); - const envCorsOrigins = env.PASEO_CORS_ORIGINS - ? env.PASEO_CORS_ORIGINS.split(",").map((s) => s.trim()) - : []; - - const persistedCorsOrigins = persisted.daemon?.cors?.allowedOrigins ?? []; - - const hostnames = mergeHostnames([ - persisted.daemon?.hostnames, - parseHostnamesEnv(env.PASEO_HOSTNAMES ?? env.PASEO_ALLOWED_HOSTS), - options?.cli?.hostnames, - ]); - - const mcpEnabled = options?.cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true; - const mcpInjectIntoAgents = - options?.cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false; - - const relayEnabled = - options?.cli?.relayEnabled ?? - parseBooleanEnv(env.PASEO_RELAY_ENABLED) ?? - persisted.daemon?.relay?.enabled ?? - true; - - const relayEndpoint = - env.PASEO_RELAY_ENDPOINT ?? persisted.daemon?.relay?.endpoint ?? DEFAULT_RELAY_ENDPOINT; - - const relayPublicEndpoint = - env.PASEO_RELAY_PUBLIC_ENDPOINT ?? persisted.daemon?.relay?.publicEndpoint ?? relayEndpoint; - - const appBaseUrl = env.PASEO_APP_BASE_URL ?? persisted.app?.baseUrl ?? DEFAULT_APP_BASE_URL; + const relay = resolveRelayConfig({ + env, + persisted, + cliRelayEnabled: options?.cli?.relayEnabled, + }); const { openai, speech } = resolveSpeechConfig({ paseoHome, @@ -159,14 +227,7 @@ export function loadConfig( persisted, }); - const envVoiceLlmProvider = parseOptionalVoiceLlmProvider(env.PASEO_VOICE_LLM_PROVIDER); - const persistedVoiceLlmProvider = parseOptionalVoiceLlmProvider( - persisted.features?.voiceMode?.llm?.provider, - ); - const voiceLlmProvider = envVoiceLlmProvider ?? persistedVoiceLlmProvider ?? null; - const voiceLlmProviderExplicit = - envVoiceLlmProvider !== null || persistedVoiceLlmProvider !== null; - const voiceLlmModel = persisted.features?.voiceMode?.llm?.model ?? null; + const voiceLlm = resolveVoiceLlmConfig(env, persisted); const providerOverrides = extractProviderOverrides( persisted.agents?.providers as Record | undefined, ); @@ -174,9 +235,7 @@ export function loadConfig( return { listen, paseoHome, - corsAllowedOrigins: Array.from( - new Set([...persistedCorsOrigins, ...envCorsOrigins].filter((s) => s.length > 0)), - ), + corsAllowedOrigins: resolveCorsAllowedOrigins(env, persisted), hostnames, mcpEnabled, mcpInjectIntoAgents, @@ -185,15 +244,15 @@ export function loadConfig( agentStoragePath: path.join(paseoHome, "agents"), staticDir: "public", agentClients: {}, - relayEnabled, - relayEndpoint, - relayPublicEndpoint, + relayEnabled: relay.enabled, + relayEndpoint: relay.endpoint, + relayPublicEndpoint: relay.publicEndpoint, appBaseUrl, openai, speech, - voiceLlmProvider, - voiceLlmProviderExplicit, - voiceLlmModel, + voiceLlmProvider: voiceLlm.provider, + voiceLlmProviderExplicit: voiceLlm.providerExplicit, + voiceLlmModel: voiceLlm.model, agentProviderSettings: extractAgentProviderSettings(providerOverrides), providerOverrides, }; diff --git a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts index 98f941411e..ce9b10547b 100644 --- a/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/claude-autonomous-wake.real.e2e.test.ts @@ -351,6 +351,76 @@ function summarizeTimelineEntry(entry: { return `[${item.type}]`; } +async function waitForTranscriptRaceEvidence(params: { + transcriptPath: string; + waitError: Error | null; + afterWaitStatus: string; + cancelRecovered: boolean; +}): Promise { + const { transcriptPath, waitError, afterWaitStatus, cancelRecovered } = params; + let evidence: TranscriptRaceEvidence | null = null; + const transcriptDeadline = Date.now() + 20_000; + while (Date.now() < transcriptDeadline) { + const lines = readTranscriptLines(transcriptPath); + evidence = extractTranscriptRaceEvidence(lines); + if (evidence) { + break; + } + await sleep(250); + } + if (!evidence) { + const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); + throw new Error( + [ + "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", + `transcriptPath=${transcriptPath}`, + `waitError=${waitError ? waitError.message : "null"}`, + `afterWaitStatus=${afterWaitStatus}`, + `cancelRecovered=${cancelRecovered}`, + "transcriptTail:", + transcriptDump, + ].join("\n"), + ); + } + return evidence; +} + +async function waitForAssistantTextCombined(params: { + client: DaemonClient; + agentId: string; + helloAssistant: string; + notificationAssistant: string; +}): Promise { + const { client, agentId, helloAssistant, notificationAssistant } = params; + let assistantTextCombined = ""; + const timelineDeadline = Date.now() + 20_000; + while (Date.now() < timelineDeadline) { + const timeline = await client.fetchAgentTimeline(agentId, { + direction: "tail", + limit: 0, + projection: "canonical", + }); + const assistantTexts = timeline.entries + .filter( + ( + entry, + ): entry is { + item: { type: "assistant_message"; text: string }; + } => entry.item.type === "assistant_message", + ) + .map((entry) => compactText(entry.item.text)); + assistantTextCombined = assistantTexts.join(""); + if ( + assistantTextCombined.includes(helloAssistant) && + assistantTextCombined.includes(notificationAssistant) + ) { + break; + } + await sleep(250); + } + return assistantTextCombined; +} + describe("daemon E2E (real claude) - autonomous wake from background task", () => { let canRun = false; @@ -1019,60 +1089,21 @@ describe("daemon E2E (real claude) - autonomous wake from background task", () = sessionId: sessionId as string, }); - let evidence: TranscriptRaceEvidence | null = null; - const transcriptDeadline = Date.now() + 20_000; - while (Date.now() < transcriptDeadline) { - const lines = readTranscriptLines(transcriptPath); - evidence = extractTranscriptRaceEvidence(lines); - if (evidence) { - break; - } - await sleep(250); - } - if (!evidence) { - const transcriptDump = summarizeTranscriptTail(readTranscriptLines(transcriptPath)); - throw new Error( - [ - "Failed to extract transcript race evidence (hello assistant and notification-turn assistant).", - `transcriptPath=${transcriptPath}`, - `waitError=${waitError ? waitError.message : "null"}`, - `afterWaitStatus=${afterWait?.status ?? "unknown"}`, - `cancelRecovered=${cancelRecovered}`, - "transcriptTail:", - transcriptDump, - ].join("\n"), - ); - } + const evidence = await waitForTranscriptRaceEvidence({ + transcriptPath, + waitError, + afterWaitStatus: afterWait?.status ?? "unknown", + cancelRecovered, + }); const helloAssistant = compactText(evidence.helloAssistantText); const notificationAssistant = compactText(evidence.notificationOutcomeAssistantText); - let assistantTexts: string[] = []; - let assistantTextCombined = ""; - const timelineDeadline = Date.now() + 20_000; - while (Date.now() < timelineDeadline) { - const timeline = await client.fetchAgentTimeline(agent.id, { - direction: "tail", - limit: 0, - projection: "canonical", - }); - assistantTexts = timeline.entries - .filter( - ( - entry, - ): entry is { - item: { type: "assistant_message"; text: string }; - } => entry.item.type === "assistant_message", - ) - .map((entry) => compactText(entry.item.text)); - assistantTextCombined = assistantTexts.join(""); - if ( - assistantTextCombined.includes(helloAssistant) && - assistantTextCombined.includes(notificationAssistant) - ) { - break; - } - await sleep(250); - } + const assistantTextCombined = await waitForAssistantTextCombined({ + client, + agentId: agent.id, + helloAssistant, + notificationAssistant, + }); expect(assistantTextCombined.includes(helloAssistant)).toBe(true); expect(assistantTextCombined.includes(notificationAssistant)).toBe(true); diff --git a/packages/server/src/server/index.ts b/packages/server/src/server/index.ts index 4768c6a723..b86abb05b3 100644 --- a/packages/server/src/server/index.ts +++ b/packages/server/src/server/index.ts @@ -15,27 +15,27 @@ type SupervisorLifecycleMessage = reason?: string; }; -async function main() { - let paseoHome: string; - let logger: ReturnType; - let config: ReturnType; - let daemon: Awaited> | null = null; - let shutdownPromise: Promise | null = null; - let exitHookInstalled = false; - const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function"; - let pidLockAcquired = false; +interface BootstrapResult { + paseoHome: string; + logger: ReturnType; + config: ReturnType; +} +function bootstrapFromEnvironment(): BootstrapResult { try { - paseoHome = resolvePaseoHome(); + const paseoHome = resolvePaseoHome(); const persistedConfig = loadPersistedConfig(paseoHome); - logger = createRootLogger(persistedConfig, { paseoHome }); - config = loadConfig(paseoHome); + const logger = createRootLogger(persistedConfig, { paseoHome }); + const config = loadConfig(paseoHome); + return { paseoHome, logger, config }; } catch (err) { const message = err instanceof Error ? err.message : String(err); process.stderr.write(`${message}\n`); process.exit(1); } +} +function applyCliFlagOverrides(config: ReturnType): void { if (process.argv.includes("--no-relay")) { config.relayEnabled = false; } @@ -45,6 +45,17 @@ async function main() { if (process.argv.includes("--no-inject-mcp")) { config.mcpInjectIntoAgents = false; } +} + +async function main() { + const { paseoHome, logger, config } = bootstrapFromEnvironment(); + let daemon: Awaited> | null = null; + let shutdownPromise: Promise | null = null; + let exitHookInstalled = false; + const supervised = process.env.PASEO_SUPERVISED === "1" && typeof process.send === "function"; + let pidLockAcquired = false; + + applyCliFlagOverrides(config); const installExitHook = () => { if (exitHookInstalled || !shutdownPromise) { diff --git a/packages/server/src/server/logger.ts b/packages/server/src/server/logger.ts index 91f1a41c33..d900fecbd4 100644 --- a/packages/server/src/server/logger.ts +++ b/packages/server/src/server/logger.ts @@ -179,6 +179,59 @@ function toRotatingFileStreamSize(size: string): string { return `${value}${unit}`; } +interface LogLevelResolution { + consoleLevel: LogLevel; + fileLevel: LogLevel; + consoleFormat: LogFormat; +} + +function resolveLogLevelsAndFormat( + env: NodeJS.ProcessEnv, + persistedLog: NonNullable>["log"] | undefined, +): LogLevelResolution { + const envGlobalLevel = parseLogLevel(env.PASEO_LOG); + const persistedGlobalLevel = persistedLog?.level; + const consoleLevel: LogLevel = + parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ?? + envGlobalLevel ?? + persistedLog?.console?.level ?? + persistedGlobalLevel ?? + DEFAULT_CONSOLE_LEVEL; + const fileLevel: LogLevel = + parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ?? + envGlobalLevel ?? + persistedLog?.file?.level ?? + persistedGlobalLevel ?? + DEFAULT_FILE_LEVEL; + const consoleFormat: LogFormat = + parseLogFormat(env.PASEO_LOG_FORMAT) ?? + persistedLog?.console?.format ?? + persistedLog?.format ?? + DEFAULT_CONSOLE_FORMAT; + return { consoleLevel, fileLevel, consoleFormat }; +} + +interface RotateResolution { + maxSize: string; + maxFiles: number; +} + +function resolveRotateConfig( + env: NodeJS.ProcessEnv, + persistedLog: NonNullable>["log"] | undefined, +): RotateResolution { + return { + maxSize: + env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() || + persistedLog?.file?.rotate?.maxSize || + DEFAULT_FILE_ROTATE_SIZE, + maxFiles: + parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ?? + persistedLog?.file?.rotate?.maxFiles ?? + DEFAULT_FILE_ROTATE_MAX_FILES, + }; +} + export function resolveLogConfig( configInput: LoggerConfigInput, options?: ResolveLogConfigOptions, @@ -188,39 +241,9 @@ export function resolveLogConfig( const paseoHome = resolveConfiguredPaseoHome(options); const persistedLog = persistedConfig?.log; - const envGlobalLevel = parseLogLevel(env.PASEO_LOG); - const persistedGlobalLevel = persistedLog?.level; - const consoleLevel: LogLevel = - parseLogLevel(env.PASEO_LOG_CONSOLE_LEVEL) ?? - envGlobalLevel ?? - persistedLog?.console?.level ?? - persistedGlobalLevel ?? - DEFAULT_CONSOLE_LEVEL; - - const fileLevel: LogLevel = - parseLogLevel(env.PASEO_LOG_FILE_LEVEL) ?? - envGlobalLevel ?? - persistedLog?.file?.level ?? - persistedGlobalLevel ?? - DEFAULT_FILE_LEVEL; - - const consoleFormat: LogFormat = - parseLogFormat(env.PASEO_LOG_FORMAT) ?? - persistedLog?.console?.format ?? - persistedLog?.format ?? - DEFAULT_CONSOLE_FORMAT; - + const { consoleLevel, fileLevel, consoleFormat } = resolveLogLevelsAndFormat(env, persistedLog); const filePath = resolveFilePath(paseoHome, env.PASEO_LOG_FILE_PATH ?? persistedLog?.file?.path); - - const rotateMaxSize = - env.PASEO_LOG_FILE_ROTATE_SIZE?.trim() || - persistedLog?.file?.rotate?.maxSize || - DEFAULT_FILE_ROTATE_SIZE; - - const rotateMaxFiles = - parsePositiveInteger(env.PASEO_LOG_FILE_ROTATE_COUNT) ?? - persistedLog?.file?.rotate?.maxFiles ?? - DEFAULT_FILE_ROTATE_MAX_FILES; + const rotate = resolveRotateConfig(env, persistedLog); return { level: minLogLevel([consoleLevel, fileLevel]), @@ -231,10 +254,7 @@ export function resolveLogConfig( file: { level: fileLevel, path: filePath, - rotate: { - maxSize: rotateMaxSize, - maxFiles: rotateMaxFiles, - }, + rotate, }, }; } diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index b049c04f1c..f4ab513d78 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -563,33 +563,41 @@ export class LoopService { } } } catch (error) { - if (isAbortError(error)) { - this.finishLoop(loop, "stopped", "Loop stopped."); - const iteration = loop.activeIteration - ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) - : null; - if (iteration && iteration.status === "running") { - iteration.status = "stopped"; - iteration.failureReason = "Loop stopped"; - iteration.workerCompletedAt = nowIso(); - } - await this.persist(); - return; - } + await this.handleExecuteLoopError(loop, loopId, error); + } + } - const message = error instanceof Error ? error.message : String(error); - this.logger.error({ err: error, loopId }, "Loop execution failed"); - this.finishLoop(loop, "failed", message); + private async handleExecuteLoopError( + loop: LoopRecord, + loopId: string, + error: unknown, + ): Promise { + if (isAbortError(error)) { + this.finishLoop(loop, "stopped", "Loop stopped."); const iteration = loop.activeIteration ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) : null; if (iteration && iteration.status === "running") { - iteration.status = "failed"; - iteration.failureReason = message; + iteration.status = "stopped"; + iteration.failureReason = "Loop stopped"; iteration.workerCompletedAt = nowIso(); } await this.persist(); + return; } + + const message = error instanceof Error ? error.message : String(error); + this.logger.error({ err: error, loopId }, "Loop execution failed"); + this.finishLoop(loop, "failed", message); + const iteration = loop.activeIteration + ? loop.iterations.find((candidate) => candidate.index === loop.activeIteration) + : null; + if (iteration && iteration.status === "running") { + iteration.status = "failed"; + iteration.failureReason = message; + iteration.workerCompletedAt = nowIso(); + } + await this.persist(); } private async runWorkerIteration( diff --git a/packages/server/src/server/persistence-hooks.test.ts b/packages/server/src/server/persistence-hooks.test.ts index ffab6992a0..4d5e8cc08e 100644 --- a/packages/server/src/server/persistence-hooks.test.ts +++ b/packages/server/src/server/persistence-hooks.test.ts @@ -1,12 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import type { ManagedAgent } from "./agent/agent-manager.js"; import type { StoredAgentRecord } from "./agent/agent-storage.js"; import { buildConfigOverrides, buildSessionConfig } from "./persistence-hooks.js"; -import type { - AgentPermissionRequest, - AgentSession, - AgentSessionConfig, -} from "./agent/agent-sdk-types.js"; const testLogger = { child: () => testLogger, @@ -14,67 +8,6 @@ const testLogger = { warn: vi.fn(), } as any; -type ManagedAgentOverrides = Omit< - Partial, - "config" | "pendingPermissions" | "session" | "activeForegroundTurnId" -> & { - config?: Partial; - pendingPermissions?: Map; - session?: AgentSession | null; - activeForegroundTurnId?: string | null; -}; - -function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent { - const now = overrides.updatedAt ?? new Date("2025-01-01T00:00:00.000Z"); - const provider = overrides.provider ?? "claude"; - const cwd = overrides.cwd ?? "/tmp/project"; - const lifecycle = overrides.lifecycle ?? "idle"; - const configOverrides = overrides.config ?? {}; - const config: AgentSessionConfig = { - provider, - cwd, - modeId: configOverrides.modeId ?? "plan", - model: configOverrides.model ?? "claude-3.5-sonnet", - extra: configOverrides.extra ?? { claude: { tone: "focused" } }, - }; - const session = lifecycle === "closed" ? null : (overrides.session ?? ({} as AgentSession)); - const activeForegroundTurnId = - overrides.activeForegroundTurnId ?? (lifecycle === "running" ? "test-turn-id" : null); - - const agent: ManagedAgent = { - id: overrides.id ?? "agent-1", - provider, - cwd, - session, - capabilities: overrides.capabilities ?? { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }, - config, - lifecycle, - createdAt: overrides.createdAt ?? now, - updatedAt: overrides.updatedAt ?? now, - availableModes: overrides.availableModes ?? [], - currentModeId: overrides.currentModeId ?? config.modeId ?? null, - pendingPermissions: overrides.pendingPermissions ?? new Map(), - activeForegroundTurnId, - foregroundTurnWaiters: new Set(), - unsubscribeSession: null, - timeline: overrides.timeline ?? [], - persistence: overrides.persistence ?? null, - historyPrimed: overrides.historyPrimed ?? true, - lastUserMessageAt: overrides.lastUserMessageAt ?? now, - lastUsage: overrides.lastUsage, - lastError: overrides.lastError, - }; - - return agent; -} - function createRecord(overrides?: Partial): StoredAgentRecord { const now = new Date().toISOString(); return { diff --git a/packages/server/src/server/script-status-projection.ts b/packages/server/src/server/script-status-projection.ts index 95a6aa0b41..b834af1598 100644 --- a/packages/server/src/server/script-status-projection.ts +++ b/packages/server/src/server/script-status-projection.ts @@ -57,6 +57,78 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa ); } +type RuntimeEntry = ReturnType[number]; +type RouteEntry = ReturnType[number]; + +interface BuildPayloadContext { + projectSlug: string; + branchName: string | null; + daemonPort: number | null; + resolveHealth?: (hostname: string) => ScriptHealthState | null; +} + +function buildConfiguredScriptPayload( + scriptName: string, + config: ReturnType extends Map ? V : never, + runtimeEntry: RuntimeEntry | null, + routeEntry: RouteEntry | null, + ctx: BuildPayloadContext, +): WorkspaceScriptPayload { + const configIsService = isServiceScript(config); + const type = configIsService ? "service" : "script"; + const configuredPort = configIsService ? (config.port ?? null) : null; + const hostname = + type === "service" + ? (routeEntry?.hostname ?? + buildScriptHostname({ + projectSlug: ctx.projectSlug, + branchName: ctx.branchName, + scriptName, + })) + : scriptName; + + return { + scriptName, + type, + hostname, + port: type === "service" ? (routeEntry?.port ?? configuredPort) : null, + proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null, + lifecycle: runtimeEntry?.lifecycle ?? "stopped", + health: type === "service" ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, + exitCode: runtimeEntry?.exitCode ?? null, + terminalId: runtimeEntry?.terminalId ?? null, + }; +} + +function buildOrphanRuntimePayload( + runtimeEntry: RuntimeEntry, + routeEntry: RouteEntry | null, + ctx: BuildPayloadContext, +): WorkspaceScriptPayload { + const type = runtimeEntry.type; + const hostname = + type === "service" + ? (routeEntry?.hostname ?? + buildScriptHostname({ + projectSlug: ctx.projectSlug, + branchName: ctx.branchName, + scriptName: runtimeEntry.scriptName, + })) + : runtimeEntry.scriptName; + return { + scriptName: runtimeEntry.scriptName, + type, + hostname, + port: type === "service" ? (routeEntry?.port ?? null) : null, + proxyUrl: type === "service" ? toServiceProxyUrl(hostname, ctx.daemonPort) : null, + lifecycle: runtimeEntry.lifecycle, + health: + type === "service" && routeEntry ? toWireHealth(ctx.resolveHealth?.(hostname) ?? null) : null, + exitCode: runtimeEntry.exitCode, + terminalId: runtimeEntry.terminalId, + }; +} + export function buildWorkspaceScriptPayloads( options: BuildWorkspaceScriptPayloadsOptions, ): WorkspaceScriptPayload[] { @@ -76,67 +148,27 @@ export function buildWorkspaceScriptPayloads( .map((entry) => [entry.scriptName, entry] as const), ); + const ctx: BuildPayloadContext = { + projectSlug, + branchName, + daemonPort: options.daemonPort, + resolveHealth: options.resolveHealth, + }; + const payloads: WorkspaceScriptPayload[] = []; for (const [scriptName, config] of scriptConfigs.entries()) { - const configIsService = isServiceScript(config); - const type = configIsService ? "service" : "script"; - const configuredPort = configIsService ? (config.port ?? null) : null; const runtimeEntry = runtimeEntries.get(scriptName) ?? null; const routeEntry = routesByScriptName.get(scriptName) ?? null; - const hostname = - type === "service" - ? (routeEntry?.hostname ?? - buildScriptHostname({ - projectSlug, - branchName, - scriptName, - })) - : scriptName; - - payloads.push({ - scriptName, - type, - hostname, - port: type === "service" ? (routeEntry?.port ?? configuredPort) : null, - proxyUrl: type === "service" ? toServiceProxyUrl(hostname, options.daemonPort) : null, - lifecycle: runtimeEntry?.lifecycle ?? "stopped", - health: type === "service" ? toWireHealth(options.resolveHealth?.(hostname) ?? null) : null, - exitCode: runtimeEntry?.exitCode ?? null, - terminalId: runtimeEntry?.terminalId ?? null, - }); + payloads.push(buildConfiguredScriptPayload(scriptName, config, runtimeEntry, routeEntry, ctx)); } for (const runtimeEntry of runtimeEntries.values()) { if (scriptConfigs.has(runtimeEntry.scriptName) || runtimeEntry.lifecycle !== "running") { continue; } - const routeEntry = routesByScriptName.get(runtimeEntry.scriptName) ?? null; - const type = runtimeEntry.type; - const hostname = - type === "service" - ? (routeEntry?.hostname ?? - buildScriptHostname({ - projectSlug, - branchName, - scriptName: runtimeEntry.scriptName, - })) - : runtimeEntry.scriptName; - payloads.push({ - scriptName: runtimeEntry.scriptName, - type, - hostname, - port: type === "service" ? (routeEntry?.port ?? null) : null, - proxyUrl: type === "service" ? toServiceProxyUrl(hostname, options.daemonPort) : null, - lifecycle: runtimeEntry.lifecycle, - health: - type === "service" && routeEntry - ? toWireHealth(options.resolveHealth?.(hostname) ?? null) - : null, - exitCode: runtimeEntry.exitCode, - terminalId: runtimeEntry.terminalId, - }); + payloads.push(buildOrphanRuntimePayload(runtimeEntry, routeEntry, ctx)); } return sortPayloads(payloads); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 06054d1bf7..9037220de9 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -367,6 +367,62 @@ export function resolveCreateAgentTitles(options: { }; } +function parseFetchWorkspacesCursorSort(raw: unknown[]): FetchWorkspacesRequestSort[] { + const cursorSort: FetchWorkspacesRequestSort[] = []; + for (const item of raw) { + if ( + !item || + typeof item !== "object" || + typeof (item as { key?: unknown }).key !== "string" || + typeof (item as { direction?: unknown }).direction !== "string" + ) { + throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); + } + + const key = (item as { key: string }).key; + const direction = (item as { direction: string }).direction; + if ( + (key !== "status_priority" && + key !== "activity_at" && + key !== "name" && + key !== "project_id") || + (direction !== "asc" && direction !== "desc") + ) { + throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); + } + cursorSort.push({ key, direction }); + } + return cursorSort; +} + +function parseFetchAgentsCursorSort(raw: unknown[]): FetchAgentsRequestSort[] { + const cursorSort: FetchAgentsRequestSort[] = []; + for (const item of raw) { + if ( + !item || + typeof item !== "object" || + typeof (item as { key?: unknown }).key !== "string" || + typeof (item as { direction?: unknown }).direction !== "string" + ) { + throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + } + + const key = (item as { key: string }).key; + const direction = (item as { direction: string }).direction; + if ( + (key !== "status_priority" && + key !== "created_at" && + key !== "updated_at" && + key !== "title") || + (direction !== "asc" && direction !== "desc") + ) { + throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); + } + cursorSort.push({ key, direction }); + } + return cursorSort; +} + export function resolveWaitForFinishError(options: { status: "permission" | "error" | "idle"; final: AgentSnapshotPayload | null; @@ -709,8 +765,8 @@ export class Session { private pendingVoiceSpeechStartAt: number | null = null; private pendingVoiceSpeechTimer: ReturnType | null = null; - private readonly dictationStreamManager: DictationStreamManager; - private readonly resolveVoiceTurnDetection: () => TurnDetectionProvider | null; + private dictationStreamManager!: DictationStreamManager; + private resolveVoiceTurnDetection!: () => TurnDetectionProvider | null; private voiceTurnController: VoiceTurnController | null = null; private voiceInputChunkCount = 0; private voiceInputBytes = 0; @@ -725,8 +781,8 @@ export class Session { private readonly ttsDebugStreams = new Map(); // Per-session managers - private readonly ttsManager: TTSManager; - private readonly sttManager: STTManager; + private ttsManager!: TTSManager; + private sttManager!: STTManager; // Per-session MCP client and tools private agentMcpClient: Awaited> | null = null; @@ -798,17 +854,11 @@ export class Session { private readonly workspaceSetupSnapshots: Map; private readonly workspaceGitFetchSubscriptions = new Map void>(); private readonly workspaceGitSubscriptions = new Map void>(); - private readonly registerVoiceSpeakHandler?: ( - agentId: string, - handler: VoiceSpeakHandler, - ) => void; - private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void; - private readonly registerVoiceCallerContext?: ( - agentId: string, - context: VoiceCallerContext, - ) => void; - private readonly unregisterVoiceCallerContext?: (agentId: string) => void; - private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot; + private registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void; + private unregisterVoiceSpeakHandler?: (agentId: string) => void; + private registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void; + private unregisterVoiceCallerContext?: (agentId: string) => void; + private getSpeechReadiness?: () => SpeechReadinessSnapshot; private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; private readonly providerOverrides: Record | undefined; private readonly isDev: boolean; @@ -891,37 +941,8 @@ export class Session { this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpHost = getDaemonTcpHost ?? null; this.resolveScriptHealth = resolveScriptHealth ?? null; - if (this.terminalManager) { - this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => - this.handleTerminalsChanged(event), - ); - } - if (this.providerSnapshotManager) { - const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { - // COMPAT(providersSnapshot): keep provider visibility gating for older clients. - const visibleEntries = entries.filter((entry) => - this.isProviderVisibleToClient(entry.provider), - ); - this.emit({ - type: "providers_snapshot_update", - payload: { - cwd, - entries: visibleEntries, - generatedAt: new Date().toISOString(), - }, - }); - }; - this.providerSnapshotManager.on("change", handleProviderSnapshotChange); - this.unsubscribeProviderSnapshotEvents = () => { - this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); - }; - } - this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); - this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; - this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; - this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; - this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; - this.getSpeechReadiness = dictation?.getSpeechReadiness; + this.subscribeToOptionalManagers(); + this.bindVoiceBridges({ voice, voiceBridge, dictation }); this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; this.providerOverrides = providerOverrides; this.isDev = isDev === true; @@ -933,16 +954,7 @@ export class Session { isDev: this.isDev, }); - // Initialize per-session managers - this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); - this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt); - this.dictationStreamManager = new DictationStreamManager({ - logger: this.sessionLogger, - sessionId: this.sessionId, - emit: (msg) => this.handleDictationManagerMessage(msg), - stt: dictation?.stt ?? null, - finalTimeoutMs: dictation?.finalTimeoutMs, - }); + this.initializePerSessionManagers({ tts, stt, dictation }); // Initialize agent MCP client asynchronously void this.initializeAgentMcp(); @@ -1180,6 +1192,65 @@ export class Session { /** * Subscribe to AgentManager events and forward them to the client */ + private subscribeToOptionalManagers(): void { + if (this.terminalManager) { + this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) => + this.handleTerminalsChanged(event), + ); + } + if (this.providerSnapshotManager) { + const handleProviderSnapshotChange = (entries: ProviderSnapshotEntry[], cwd: string) => { + // COMPAT(providersSnapshot): keep provider visibility gating for older clients. + const visibleEntries = entries.filter((entry) => + this.isProviderVisibleToClient(entry.provider), + ); + this.emit({ + type: "providers_snapshot_update", + payload: { + cwd, + entries: visibleEntries, + generatedAt: new Date().toISOString(), + }, + }); + }; + this.providerSnapshotManager.on("change", handleProviderSnapshotChange); + this.unsubscribeProviderSnapshotEvents = () => { + this.providerSnapshotManager?.off("change", handleProviderSnapshotChange); + }; + } + } + + private bindVoiceBridges(params: { + voice: SessionOptions["voice"]; + voiceBridge: SessionOptions["voiceBridge"]; + dictation: SessionOptions["dictation"]; + }): void { + const { voice, voiceBridge, dictation } = params; + this.resolveVoiceTurnDetection = toResolver(voice?.turnDetection ?? null); + this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler; + this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler; + this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext; + this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext; + this.getSpeechReadiness = dictation?.getSpeechReadiness; + } + + private initializePerSessionManagers(params: { + tts: SessionOptions["tts"]; + stt: SessionOptions["stt"]; + dictation: SessionOptions["dictation"]; + }): void { + const { tts, stt, dictation } = params; + this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts); + this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt); + this.dictationStreamManager = new DictationStreamManager({ + logger: this.sessionLogger, + sessionId: this.sessionId, + emit: (msg) => this.handleDictationManagerMessage(msg), + stt: dictation?.stt ?? null, + finalTimeoutMs: dictation?.finalTimeoutMs, + }); + } + private subscribeToAgentEvents(): void { if (this.unsubscribeAgentEvents) { this.unsubscribeAgentEvents(); @@ -1313,6 +1384,53 @@ export class Session { return editors.filter((editor) => isLegacyEditorTargetId(editor.id)); } + private agentThinkingOptionMatchesFilter( + agent: AgentSnapshotPayload, + filter: AgentUpdatesFilter, + ): boolean { + if (filter.thinkingOptionId === undefined) { + return true; + } + const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({ + configuredThinkingOptionId: filter.thinkingOptionId ?? null, + }); + const resolvedThinkingOptionId = + agent.effectiveThinkingOptionId ?? + resolveEffectiveThinkingOptionId({ + runtimeInfo: agent.runtimeInfo, + configuredThinkingOptionId: agent.thinkingOptionId ?? null, + }); + return resolvedThinkingOptionId === expectedThinkingOptionId; + } + + private matchesAgentStructuralFilter( + agent: AgentSnapshotPayload, + project: ProjectPlacementPayload, + filter: AgentUpdatesFilter, + ): boolean { + if (filter.statuses && filter.statuses.length > 0) { + const statuses = new Set(filter.statuses); + if (!statuses.has(agent.status)) { + return false; + } + } + + if (typeof filter.requiresAttention === "boolean") { + const requiresAttention = agent.requiresAttention ?? false; + if (requiresAttention !== filter.requiresAttention) { + return false; + } + } + + if (filter.projectKeys && filter.projectKeys.length > 0) { + const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0)); + if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) { + return false; + } + } + return true; + } + private matchesAgentFilter(options: { agent: AgentSnapshotPayload; project: ProjectPlacementPayload; @@ -1334,40 +1452,12 @@ export class Session { return false; } - if (filter?.thinkingOptionId !== undefined) { - const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({ - configuredThinkingOptionId: filter.thinkingOptionId ?? null, - }); - const resolvedThinkingOptionId = - agent.effectiveThinkingOptionId ?? - resolveEffectiveThinkingOptionId({ - runtimeInfo: agent.runtimeInfo, - configuredThinkingOptionId: agent.thinkingOptionId ?? null, - }); - if (resolvedThinkingOptionId !== expectedThinkingOptionId) { - return false; - } + if (filter && !this.agentThinkingOptionMatchesFilter(agent, filter)) { + return false; } - if (filter?.statuses && filter.statuses.length > 0) { - const statuses = new Set(filter.statuses); - if (!statuses.has(agent.status)) { - return false; - } - } - - if (typeof filter?.requiresAttention === "boolean") { - const requiresAttention = agent.requiresAttention ?? false; - if (requiresAttention !== filter.requiresAttention) { - return false; - } - } - - if (filter?.projectKeys && filter.projectKeys.length > 0) { - const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0)); - if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) { - return false; - } + if (filter && !this.matchesAgentStructuralFilter(agent, project, filter)) { + return false; } return true; @@ -1551,471 +1641,7 @@ export class Session { "inbound message", ); try { - switch (msg.type) { - case "voice_audio_chunk": - await this.handleAudioChunk(msg); - break; - - case "abort_request": - await this.handleAbort(); - break; - - case "audio_played": - this.handleAudioPlayed(msg.id); - break; - - case "fetch_agents_request": - await this.handleFetchAgents(msg); - break; - - case "fetch_agent_history_request": - await this.handleFetchAgentHistory(msg); - break; - - case "fetch_workspaces_request": - await this.handleFetchWorkspacesRequest(msg); - break; - - case "fetch_agent_request": - await this.handleFetchAgent(msg.agentId, msg.requestId); - break; - - case "delete_agent_request": - await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); - break; - - case "archive_agent_request": - await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); - break; - - case "close_items_request": - await this.handleCloseItemsRequest(msg); - break; - - case "update_agent_request": - await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); - break; - - case "set_voice_mode": - await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); - break; - - case "send_agent_message_request": - await this.handleSendAgentMessageRequest(msg); - break; - - case "wait_for_finish_request": - await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs); - break; - - case "get_daemon_config_request": - this.emit({ - type: "get_daemon_config_response", - payload: { - requestId: msg.requestId, - config: this.daemonConfigStore.get(), - }, - }); - break; - - case "set_daemon_config_request": - this.emit({ - type: "set_daemon_config_response", - payload: { - requestId: msg.requestId, - config: this.daemonConfigStore.patch(msg.config), - }, - }); - break; - - case "dictation_stream_start": - { - const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation"); - if (unavailable) { - this.emit({ - type: "dictation_stream_error", - payload: { - dictationId: msg.dictationId, - error: unavailable.message, - retryable: unavailable.retryable, - reasonCode: unavailable.reasonCode, - missingModelIds: unavailable.missingModelIds, - }, - }); - break; - } - } - await this.dictationStreamManager.handleStart(msg.dictationId, msg.format); - break; - - case "dictation_stream_chunk": - await this.dictationStreamManager.handleChunk({ - dictationId: msg.dictationId, - seq: msg.seq, - audioBase64: msg.audio, - format: msg.format, - }); - break; - - case "dictation_stream_finish": - await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq); - break; - - case "dictation_stream_cancel": - this.dictationStreamManager.handleCancel(msg.dictationId); - break; - - case "create_agent_request": - await this.handleCreateAgentRequest(msg); - break; - - case "resume_agent_request": - await this.handleResumeAgentRequest(msg); - break; - - case "refresh_agent_request": - await this.handleRefreshAgentRequest(msg); - break; - - case "cancel_agent_request": - await this.handleCancelAgentRequest(msg.agentId, msg.requestId); - break; - - case "restart_server_request": - await this.handleRestartServerRequest(msg.requestId, msg.reason); - break; - - case "shutdown_server_request": - await this.handleShutdownServerRequest(msg.requestId); - break; - - case "fetch_agent_timeline_request": - await this.handleFetchAgentTimelineRequest(msg); - break; - - case "set_agent_mode_request": - await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); - break; - - case "set_agent_model_request": - await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); - break; - - case "set_agent_feature_request": - await this.handleSetAgentFeatureRequest( - msg.agentId, - msg.featureId, - msg.value, - msg.requestId, - ); - break; - - case "set_agent_thinking_request": - await this.handleSetAgentThinkingRequest( - msg.agentId, - msg.thinkingOptionId, - msg.requestId, - ); - break; - - case "agent_permission_response": - await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); - break; - - case "checkout_status_request": - await this.handleCheckoutStatusRequest(msg); - break; - - case "validate_branch_request": - await this.handleValidateBranchRequest(msg); - break; - - case "branch_suggestions_request": - await this.handleBranchSuggestionsRequest(msg); - break; - - case "directory_suggestions_request": - await this.handleDirectorySuggestionsRequest(msg); - break; - - case "subscribe_checkout_diff_request": - await this.handleSubscribeCheckoutDiffRequest(msg); - break; - - case "unsubscribe_checkout_diff_request": - this.handleUnsubscribeCheckoutDiffRequest(msg); - break; - - case "checkout_switch_branch_request": - await this.handleCheckoutSwitchBranchRequest(msg); - break; - - case "stash_save_request": - await this.handleStashSaveRequest(msg); - break; - - case "stash_pop_request": - await this.handleStashPopRequest(msg); - break; - - case "stash_list_request": - await this.handleStashListRequest(msg); - break; - - case "checkout_commit_request": - await this.handleCheckoutCommitRequest(msg); - break; - - case "checkout_merge_request": - await this.handleCheckoutMergeRequest(msg); - break; - - case "checkout_merge_from_base_request": - await this.handleCheckoutMergeFromBaseRequest(msg); - break; - - case "checkout_pull_request": - await this.handleCheckoutPullRequest(msg); - break; - - case "checkout_push_request": - await this.handleCheckoutPushRequest(msg); - break; - - case "checkout_pr_create_request": - await this.handleCheckoutPrCreateRequest(msg); - break; - - case "checkout_pr_status_request": - await this.handleCheckoutPrStatusRequest(msg); - break; - - case "pull_request_timeline_request": - await this.handlePullRequestTimelineRequest(msg); - break; - - case "github_search_request": - await this.handleGitHubSearchRequest(msg); - break; - - case "paseo_worktree_list_request": - await this.handlePaseoWorktreeListRequest(msg); - break; - - case "paseo_worktree_archive_request": - await this.handlePaseoWorktreeArchiveRequest(msg); - break; - - case "create_paseo_worktree_request": - await this.handleCreatePaseoWorktreeRequest(msg); - break; - - case "workspace_setup_status_request": - await this.handleWorkspaceSetupStatusRequest(msg); - break; - - case "list_available_editors_request": - await this.handleListAvailableEditorsRequest(msg); - break; - - case "open_in_editor_request": - await this.handleOpenInEditorRequest(msg); - break; - - case "open_project_request": - await this.handleOpenProjectRequest(msg); - break; - - case "archive_workspace_request": - await this.handleArchiveWorkspaceRequest(msg); - break; - - case "file_explorer_request": - await this.handleFileExplorerRequest(msg); - break; - - case "project_icon_request": - await this.handleProjectIconRequest(msg); - break; - - case "file_download_token_request": - await this.handleFileDownloadTokenRequest(msg); - break; - - case "list_provider_models_request": - await this.handleListProviderModelsRequest(msg); - break; - - case "list_provider_modes_request": - await this.handleListProviderModesRequest(msg); - break; - - case "list_provider_features_request": - await this.handleListProviderFeaturesRequest(msg); - break; - - case "list_available_providers_request": - await this.handleListAvailableProvidersRequest(msg); - break; - - case "get_providers_snapshot_request": - await this.handleGetProvidersSnapshotRequest(msg); - break; - - case "refresh_providers_snapshot_request": - await this.handleRefreshProvidersSnapshotRequest(msg); - break; - - case "provider_diagnostic_request": - await this.handleProviderDiagnosticRequest(msg); - break; - - case "clear_agent_attention": - await this.handleClearAgentAttention(msg.agentId, msg.requestId); - break; - - case "client_heartbeat": - this.handleClientHeartbeat(msg); - break; - - case "ping": { - const now = Date.now(); - this.emit({ - type: "pong", - payload: { - requestId: msg.requestId, - clientSentAt: msg.clientSentAt, - serverReceivedAt: now, - serverSentAt: now, - }, - }); - break; - } - - case "list_commands_request": - await this.handleListCommandsRequest(msg); - break; - - case "register_push_token": - this.handleRegisterPushToken(msg.token); - break; - - case "subscribe_terminals_request": - this.handleSubscribeTerminalsRequest(msg); - break; - - case "unsubscribe_terminals_request": - this.handleUnsubscribeTerminalsRequest(msg); - break; - - case "list_terminals_request": - await this.handleListTerminalsRequest(msg); - break; - - case "create_terminal_request": - await this.handleCreateTerminalRequest(msg); - break; - - case "start_workspace_script_request": - await this.handleStartWorkspaceScriptRequest(msg); - break; - - case "subscribe_terminal_request": - await this.handleSubscribeTerminalRequest(msg); - break; - - case "unsubscribe_terminal_request": - this.handleUnsubscribeTerminalRequest(msg); - break; - - case "terminal_input": - this.handleTerminalInput(msg); - break; - - case "kill_terminal_request": - await this.handleKillTerminalRequest(msg); - break; - - case "capture_terminal_request": - await this.handleCaptureTerminalRequest(msg); - break; - - case "chat/create": - await this.handleChatCreateRequest(msg); - break; - - case "chat/list": - await this.handleChatListRequest(msg); - break; - - case "chat/inspect": - await this.handleChatInspectRequest(msg); - break; - - case "chat/delete": - await this.handleChatDeleteRequest(msg); - break; - - case "chat/post": - await this.handleChatPostRequest(msg); - break; - - case "chat/read": - await this.handleChatReadRequest(msg); - break; - - case "chat/wait": - await this.handleChatWaitRequest(msg); - break; - - case "schedule/create": - await this.handleScheduleCreateRequest(msg); - break; - - case "schedule/list": - await this.handleScheduleListRequest(msg); - break; - - case "schedule/inspect": - await this.handleScheduleInspectRequest(msg); - break; - - case "schedule/logs": - await this.handleScheduleLogsRequest(msg); - break; - - case "schedule/pause": - await this.handleSchedulePauseRequest(msg); - break; - - case "schedule/resume": - await this.handleScheduleResumeRequest(msg); - break; - - case "schedule/delete": - await this.handleScheduleDeleteRequest(msg); - break; - - case "loop/run": - await this.handleLoopRunRequest(msg); - break; - - case "loop/list": - await this.handleLoopListRequest(msg); - break; - - case "loop/inspect": - await this.handleLoopInspectRequest(msg); - break; - - case "loop/logs": - await this.handleLoopLogsRequest(msg); - break; - - case "loop/stop": - await this.handleLoopStopRequest(msg); - break; - } + await this.dispatchInboundMessage(msg); } catch (error: any) { const err = error instanceof Error ? error : new Error(String(error)); this.sessionLogger.error({ err }, "Error handling message"); @@ -2052,6 +1678,437 @@ export class Session { } } + private async dispatchInboundMessage(msg: SessionInboundMessage): Promise { + if (await this.dispatchVoiceAndControlMessage(msg)) return; + if (await this.dispatchAgentLifecycleMessage(msg)) return; + if (await this.dispatchAgentConfigMessage(msg)) return; + if (await this.dispatchCheckoutMessage(msg)) return; + if (await this.dispatchWorkspaceAndProjectMessage(msg)) return; + if (await this.dispatchProviderMessage(msg)) return; + if (await this.dispatchTerminalMessage(msg)) return; + if (await this.dispatchChatScheduleLoopMessage(msg)) return; + await this.dispatchMiscMessage(msg); + } + + private async dispatchVoiceAndControlMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "voice_audio_chunk": + await this.handleAudioChunk(msg); + return true; + case "abort_request": + await this.handleAbort(); + return true; + case "audio_played": + this.handleAudioPlayed(msg.id); + return true; + case "set_voice_mode": + await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId); + return true; + case "dictation_stream_start": + await this.handleDictationStreamStart(msg); + return true; + case "dictation_stream_chunk": + await this.dictationStreamManager.handleChunk({ + dictationId: msg.dictationId, + seq: msg.seq, + audioBase64: msg.audio, + format: msg.format, + }); + return true; + case "dictation_stream_finish": + await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq); + return true; + case "dictation_stream_cancel": + this.dictationStreamManager.handleCancel(msg.dictationId); + return true; + case "restart_server_request": + await this.handleRestartServerRequest(msg.requestId, msg.reason); + return true; + case "shutdown_server_request": + await this.handleShutdownServerRequest(msg.requestId); + return true; + case "client_heartbeat": + this.handleClientHeartbeat(msg); + return true; + case "ping": { + const now = Date.now(); + this.emit({ + type: "pong", + payload: { + requestId: msg.requestId, + clientSentAt: msg.clientSentAt, + serverReceivedAt: now, + serverSentAt: now, + }, + }); + return true; + } + default: + return false; + } + } + + private async handleDictationStreamStart( + msg: Extract, + ): Promise { + const unavailable = this.resolveVoiceFeatureUnavailableContext("dictation"); + if (unavailable) { + this.emit({ + type: "dictation_stream_error", + payload: { + dictationId: msg.dictationId, + error: unavailable.message, + retryable: unavailable.retryable, + reasonCode: unavailable.reasonCode, + missingModelIds: unavailable.missingModelIds, + }, + }); + return; + } + await this.dictationStreamManager.handleStart(msg.dictationId, msg.format); + } + + private async dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "fetch_agents_request": + await this.handleFetchAgents(msg); + return true; + case "fetch_agent_history_request": + await this.handleFetchAgentHistory(msg); + return true; + case "fetch_agent_request": + await this.handleFetchAgent(msg.agentId, msg.requestId); + return true; + case "delete_agent_request": + await this.handleDeleteAgentRequest(msg.agentId, msg.requestId); + return true; + case "archive_agent_request": + await this.handleArchiveAgentRequest(msg.agentId, msg.requestId); + return true; + case "close_items_request": + await this.handleCloseItemsRequest(msg); + return true; + case "update_agent_request": + await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId); + return true; + case "send_agent_message_request": + await this.handleSendAgentMessageRequest(msg); + return true; + case "wait_for_finish_request": + await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs); + return true; + case "create_agent_request": + await this.handleCreateAgentRequest(msg); + return true; + case "resume_agent_request": + await this.handleResumeAgentRequest(msg); + return true; + case "refresh_agent_request": + await this.handleRefreshAgentRequest(msg); + return true; + case "cancel_agent_request": + await this.handleCancelAgentRequest(msg.agentId, msg.requestId); + return true; + case "fetch_agent_timeline_request": + await this.handleFetchAgentTimelineRequest(msg); + return true; + case "agent_permission_response": + await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response); + return true; + case "clear_agent_attention": + await this.handleClearAgentAttention(msg.agentId, msg.requestId); + return true; + default: + return false; + } + } + + private async dispatchAgentConfigMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "set_agent_mode_request": + await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId); + return true; + case "set_agent_model_request": + await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId); + return true; + case "set_agent_feature_request": + await this.handleSetAgentFeatureRequest( + msg.agentId, + msg.featureId, + msg.value, + msg.requestId, + ); + return true; + case "set_agent_thinking_request": + await this.handleSetAgentThinkingRequest(msg.agentId, msg.thinkingOptionId, msg.requestId); + return true; + case "get_daemon_config_request": + this.emit({ + type: "get_daemon_config_response", + payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, + }); + return true; + case "set_daemon_config_request": + this.emit({ + type: "set_daemon_config_response", + payload: { + requestId: msg.requestId, + config: this.daemonConfigStore.patch(msg.config), + }, + }); + return true; + default: + return false; + } + } + + private async dispatchCheckoutMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "checkout_status_request": + await this.handleCheckoutStatusRequest(msg); + return true; + case "validate_branch_request": + await this.handleValidateBranchRequest(msg); + return true; + case "branch_suggestions_request": + await this.handleBranchSuggestionsRequest(msg); + return true; + case "directory_suggestions_request": + await this.handleDirectorySuggestionsRequest(msg); + return true; + case "subscribe_checkout_diff_request": + await this.handleSubscribeCheckoutDiffRequest(msg); + return true; + case "unsubscribe_checkout_diff_request": + this.handleUnsubscribeCheckoutDiffRequest(msg); + return true; + case "checkout_switch_branch_request": + await this.handleCheckoutSwitchBranchRequest(msg); + return true; + case "stash_save_request": + await this.handleStashSaveRequest(msg); + return true; + case "stash_pop_request": + await this.handleStashPopRequest(msg); + return true; + case "stash_list_request": + await this.handleStashListRequest(msg); + return true; + case "checkout_commit_request": + await this.handleCheckoutCommitRequest(msg); + return true; + case "checkout_merge_request": + await this.handleCheckoutMergeRequest(msg); + return true; + case "checkout_merge_from_base_request": + await this.handleCheckoutMergeFromBaseRequest(msg); + return true; + case "checkout_pull_request": + await this.handleCheckoutPullRequest(msg); + return true; + case "checkout_push_request": + await this.handleCheckoutPushRequest(msg); + return true; + case "checkout_pr_create_request": + await this.handleCheckoutPrCreateRequest(msg); + return true; + case "checkout_pr_status_request": + await this.handleCheckoutPrStatusRequest(msg); + return true; + case "pull_request_timeline_request": + await this.handlePullRequestTimelineRequest(msg); + return true; + case "github_search_request": + await this.handleGitHubSearchRequest(msg); + return true; + default: + return false; + } + } + + private async dispatchWorkspaceAndProjectMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "fetch_workspaces_request": + await this.handleFetchWorkspacesRequest(msg); + return true; + case "paseo_worktree_list_request": + await this.handlePaseoWorktreeListRequest(msg); + return true; + case "paseo_worktree_archive_request": + await this.handlePaseoWorktreeArchiveRequest(msg); + return true; + case "create_paseo_worktree_request": + await this.handleCreatePaseoWorktreeRequest(msg); + return true; + case "workspace_setup_status_request": + await this.handleWorkspaceSetupStatusRequest(msg); + return true; + case "list_available_editors_request": + await this.handleListAvailableEditorsRequest(msg); + return true; + case "open_in_editor_request": + await this.handleOpenInEditorRequest(msg); + return true; + case "open_project_request": + await this.handleOpenProjectRequest(msg); + return true; + case "archive_workspace_request": + await this.handleArchiveWorkspaceRequest(msg); + return true; + case "file_explorer_request": + await this.handleFileExplorerRequest(msg); + return true; + case "project_icon_request": + await this.handleProjectIconRequest(msg); + return true; + case "file_download_token_request": + await this.handleFileDownloadTokenRequest(msg); + return true; + default: + return false; + } + } + + private async dispatchProviderMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "list_provider_models_request": + await this.handleListProviderModelsRequest(msg); + return true; + case "list_provider_modes_request": + await this.handleListProviderModesRequest(msg); + return true; + case "list_provider_features_request": + await this.handleListProviderFeaturesRequest(msg); + return true; + case "list_available_providers_request": + await this.handleListAvailableProvidersRequest(msg); + return true; + case "get_providers_snapshot_request": + await this.handleGetProvidersSnapshotRequest(msg); + return true; + case "refresh_providers_snapshot_request": + await this.handleRefreshProvidersSnapshotRequest(msg); + return true; + case "provider_diagnostic_request": + await this.handleProviderDiagnosticRequest(msg); + return true; + default: + return false; + } + } + + private async dispatchTerminalMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "subscribe_terminals_request": + this.handleSubscribeTerminalsRequest(msg); + return true; + case "unsubscribe_terminals_request": + this.handleUnsubscribeTerminalsRequest(msg); + return true; + case "list_terminals_request": + await this.handleListTerminalsRequest(msg); + return true; + case "create_terminal_request": + await this.handleCreateTerminalRequest(msg); + return true; + case "start_workspace_script_request": + await this.handleStartWorkspaceScriptRequest(msg); + return true; + case "subscribe_terminal_request": + await this.handleSubscribeTerminalRequest(msg); + return true; + case "unsubscribe_terminal_request": + this.handleUnsubscribeTerminalRequest(msg); + return true; + case "terminal_input": + this.handleTerminalInput(msg); + return true; + case "kill_terminal_request": + await this.handleKillTerminalRequest(msg); + return true; + case "capture_terminal_request": + await this.handleCaptureTerminalRequest(msg); + return true; + default: + return false; + } + } + + private async dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "chat/create": + await this.handleChatCreateRequest(msg); + return true; + case "chat/list": + await this.handleChatListRequest(msg); + return true; + case "chat/inspect": + await this.handleChatInspectRequest(msg); + return true; + case "chat/delete": + await this.handleChatDeleteRequest(msg); + return true; + case "chat/post": + await this.handleChatPostRequest(msg); + return true; + case "chat/read": + await this.handleChatReadRequest(msg); + return true; + case "chat/wait": + await this.handleChatWaitRequest(msg); + return true; + case "schedule/create": + await this.handleScheduleCreateRequest(msg); + return true; + case "schedule/list": + await this.handleScheduleListRequest(msg); + return true; + case "schedule/inspect": + await this.handleScheduleInspectRequest(msg); + return true; + case "schedule/logs": + await this.handleScheduleLogsRequest(msg); + return true; + case "schedule/pause": + await this.handleSchedulePauseRequest(msg); + return true; + case "schedule/resume": + await this.handleScheduleResumeRequest(msg); + return true; + case "schedule/delete": + await this.handleScheduleDeleteRequest(msg); + return true; + case "loop/run": + await this.handleLoopRunRequest(msg); + return true; + case "loop/list": + await this.handleLoopListRequest(msg); + return true; + case "loop/inspect": + await this.handleLoopInspectRequest(msg); + return true; + case "loop/logs": + await this.handleLoopLogsRequest(msg); + return true; + case "loop/stop": + await this.handleLoopStopRequest(msg); + return true; + default: + return false; + } + } + + private async dispatchMiscMessage(msg: SessionInboundMessage): Promise { + switch (msg.type) { + case "list_commands_request": + await this.handleListCommandsRequest(msg); + return; + case "register_push_token": + this.handleRegisterPushToken(msg.token); + return; + } + } + public resetPeakInflight(): void { this.peakInflightRequests = this.inflightRequests; } @@ -2919,32 +2976,15 @@ export class Session { ); await this.forwardAgentUpdate(snapshot); - if (trimmedPrompt || (images?.length ?? 0) > 0 || (attachments?.length ?? 0) > 0) { - scheduleAgentMetadataGeneration({ - agentManager: this.agentManager, - agentId: snapshot.id, - cwd: snapshot.cwd, - initialPrompt: trimmedPrompt, - explicitTitle, - paseoHome: this.paseoHome, - logger: this.sessionLogger, - deps: { - workspaceGitService: this.workspaceGitService, - }, - }); - - const started = await this.handleSendAgentMessage( - snapshot.id, - trimmedPrompt || "", - resolveClientMessageId(clientMessageId), - images, - attachments, - outputSchema ? { outputSchema } : undefined, - ); - if (!started.ok) { - throw new Error(started.error); - } - } + await this.sendInitialCreateAgentPrompt({ + snapshot, + trimmedPrompt, + images, + attachments, + clientMessageId, + outputSchema, + explicitTitle, + }); if (requestId) { const agentPayload = await this.buildAgentPayload(snapshot); @@ -3011,6 +3051,48 @@ export class Session { } } + private async sendInitialCreateAgentPrompt(params: { + snapshot: { id: string; cwd: string }; + trimmedPrompt: string | undefined; + images: Array<{ data: string; mimeType: string }> | undefined; + attachments: AgentAttachment[] | undefined; + clientMessageId: string | undefined; + outputSchema: Record | undefined; + explicitTitle: string | null; + }): Promise { + const { snapshot, trimmedPrompt, images, attachments, clientMessageId, outputSchema } = params; + const hasPrompt = Boolean(trimmedPrompt); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasPrompt && !hasImages && !hasAttachments) { + return; + } + scheduleAgentMetadataGeneration({ + agentManager: this.agentManager, + agentId: snapshot.id, + cwd: snapshot.cwd, + initialPrompt: trimmedPrompt, + explicitTitle: params.explicitTitle, + paseoHome: this.paseoHome, + logger: this.sessionLogger, + deps: { + workspaceGitService: this.workspaceGitService, + }, + }); + + const started = await this.handleSendAgentMessage( + snapshot.id, + trimmedPrompt || "", + resolveClientMessageId(clientMessageId), + images, + attachments, + outputSchema ? { outputSchema } : undefined, + ); + if (!started.ok) { + throw new Error(started.error); + } + } + private async handleResumeAgentRequest( msg: Extract, ): Promise { @@ -5575,30 +5657,7 @@ export class Session { throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); } - const cursorSort: FetchAgentsRequestSort[] = []; - for (const item of payload.sort) { - if ( - !item || - typeof item !== "object" || - typeof (item as { key?: unknown }).key !== "string" || - typeof (item as { direction?: unknown }).direction !== "string" - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - - const key = (item as { key: string }).key; - const direction = (item as { direction: string }).direction; - if ( - (key !== "status_priority" && - key !== "created_at" && - key !== "updated_at" && - key !== "title") || - (direction !== "asc" && direction !== "desc") - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_agents cursor"); - } - cursorSort.push({ key, direction }); - } + const cursorSort = parseFetchAgentsCursorSort(payload.sort); if ( cursorSort.length !== sort.length || @@ -5669,6 +5728,49 @@ export class Session { return placementsByCwd; } + private async collectFetchAgentsEntries(params: { + candidates: AgentSnapshotPayload[]; + limit: number; + getPlacement: (cwd: string) => Promise; + filter: AgentUpdatesFilter | undefined; + }): Promise { + const { candidates, limit, getPlacement, filter } = params; + const matchedEntries: FetchAgentsResponseEntry[] = []; + const batchSize = 25; + for ( + let start = 0; + start < candidates.length && matchedEntries.length <= limit; + start += batchSize + ) { + const batch = candidates.slice(start, start + batchSize); + const batchEntries = await Promise.all( + batch.map(async (agent) => { + const project = await getPlacement(agent.cwd); + return project ? { agent, project } : null; + }), + ); + for (const entry of batchEntries) { + if (!entry) { + continue; + } + if ( + !this.matchesAgentFilter({ + agent: entry.agent, + project: entry.project, + filter, + }) + ) { + continue; + } + matchedEntries.push(entry); + if (matchedEntries.length > limit) { + break; + } + } + } + return matchedEntries; + } + private async listFetchAgentsEntries(request: AgentDirectoryRequestMessage): Promise<{ entries: FetchAgentsResponseEntry[]; pageInfo: FetchAgentsResponsePageInfo; @@ -5721,39 +5823,12 @@ export class Session { const limit = request.page?.limit ?? 200; - const matchedEntries: FetchAgentsResponseEntry[] = []; - const batchSize = 25; - for ( - let start = 0; - start < candidates.length && matchedEntries.length <= limit; - start += batchSize - ) { - const batch = candidates.slice(start, start + batchSize); - const batchEntries = await Promise.all( - batch.map(async (agent) => { - const project = await getPlacement(agent.cwd); - return project ? { agent, project } : null; - }), - ); - for (const entry of batchEntries) { - if (!entry) { - continue; - } - if ( - !this.matchesAgentFilter({ - agent: entry.agent, - project: entry.project, - filter, - }) - ) { - continue; - } - matchedEntries.push(entry); - if (matchedEntries.length > limit) { - break; - } - } - } + const matchedEntries = await this.collectFetchAgentsEntries({ + candidates, + limit, + getPlacement, + filter, + }); const pagedEntries = matchedEntries.slice(0, limit); const hasMore = matchedEntries.length > limit; @@ -6131,30 +6206,7 @@ export class Session { throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); } - const cursorSort: FetchWorkspacesRequestSort[] = []; - for (const item of payload.sort) { - if ( - !item || - typeof item !== "object" || - typeof (item as { key?: unknown }).key !== "string" || - typeof (item as { direction?: unknown }).direction !== "string" - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - - const key = (item as { key: string }).key; - const direction = (item as { direction: string }).direction; - if ( - (key !== "status_priority" && - key !== "activity_at" && - key !== "name" && - key !== "project_id") || - (direction !== "asc" && direction !== "desc") - ) { - throw new SessionRequestError("invalid_cursor", "Invalid fetch_workspaces cursor"); - } - cursorSort.push({ key, direction }); - } + const cursorSort = parseFetchWorkspacesCursorSort(payload.sort); if ( cursorSort.length !== sort.length || @@ -7100,6 +7152,75 @@ export class Session { }); } + private loadProjectedTimelineWindow(params: { + agentId: string; + direction: AgentTimelineFetchDirection; + cursor: AgentTimelineCursor | undefined; + requestedLimit: number; + provider: AgentSnapshotPayload["provider"]; + timeline: ReturnType; + }): { + timeline: ReturnType; + selectedRows: ReturnType["selectedRows"]; + minSeq: number | null; + maxSeq: number | null; + } { + const { agentId, direction, cursor, requestedLimit, provider } = params; + let timeline = params.timeline; + const projectedLimit = Math.max(1, Math.floor(requestedLimit)); + let fetchLimit = projectedLimit; + let projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }); + + while (timeline.hasOlder) { + const needsMoreProjectedEntries = projectedWindow.projectedEntries.length < projectedLimit; + const firstLoadedRow = timeline.rows[0]; + const firstSelectedRow = projectedWindow.selectedRows[0]; + const startsAtLoadedBoundary = + firstLoadedRow != null && + firstSelectedRow != null && + firstSelectedRow.seq === firstLoadedRow.seq; + const boundaryIsAssistantChunk = + startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message"; + + if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) { + break; + } + + const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1); + const nextFetchLimit = Math.min(maxRows, fetchLimit * 2); + if (nextFetchLimit <= fetchLimit) { + break; + } + + fetchLimit = nextFetchLimit; + timeline = this.agentManager.fetchTimeline(agentId, { + direction, + cursor, + limit: fetchLimit, + }); + projectedWindow = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + provider, + direction, + limit: projectedLimit, + collapseToolLifecycle: false, + }); + } + + return { + timeline, + selectedRows: projectedWindow.selectedRows, + minSeq: projectedWindow.minSeq, + maxSeq: projectedWindow.maxSeq, + }; + } + private async handleFetchAgentTimelineRequest( msg: Extract, ): Promise { @@ -7142,61 +7263,20 @@ export class Session { let entries: ReturnType; if (shouldLimitByProjectedWindow) { - const projectedLimit = Math.max(1, Math.floor(requestedLimit)); - let fetchLimit = projectedLimit; - let projectedWindow = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - provider: snapshot.provider, + const projectedResult = this.loadProjectedTimelineWindow({ + agentId: msg.agentId, direction, - limit: projectedLimit, - collapseToolLifecycle: false, + cursor, + requestedLimit, + provider: snapshot.provider, + timeline, }); - - while (timeline.hasOlder) { - const needsMoreProjectedEntries = - projectedWindow.projectedEntries.length < projectedLimit; - const firstLoadedRow = timeline.rows[0]; - const firstSelectedRow = projectedWindow.selectedRows[0]; - const startsAtLoadedBoundary = - firstLoadedRow != null && - firstSelectedRow != null && - firstSelectedRow.seq === firstLoadedRow.seq; - const boundaryIsAssistantChunk = - startsAtLoadedBoundary && firstLoadedRow.item.type === "assistant_message"; - - if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) { - break; - } - - const maxRows = Math.max(0, timeline.window.maxSeq - timeline.window.minSeq + 1); - const nextFetchLimit = Math.min(maxRows, fetchLimit * 2); - if (nextFetchLimit <= fetchLimit) { - break; - } - - fetchLimit = nextFetchLimit; - timeline = this.agentManager.fetchTimeline(msg.agentId, { - direction, - cursor, - limit: fetchLimit, - }); - projectedWindow = selectTimelineWindowByProjectedLimit({ - rows: timeline.rows, - provider: snapshot.provider, - direction, - limit: projectedLimit, - collapseToolLifecycle: false, - }); - } - - const selectedRows = projectedWindow.selectedRows; - - entries = projectTimelineRows(selectedRows, snapshot.provider, projection); - - if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) { - startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq }; - endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq }; - hasOlder = projectedWindow.minSeq > timeline.window.minSeq; + timeline = projectedResult.timeline; + entries = projectTimelineRows(projectedResult.selectedRows, snapshot.provider, projection); + if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) { + startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq }; + endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq }; + hasOlder = projectedResult.minSeq > timeline.window.minSeq; hasNewer = false; } } else { @@ -7474,58 +7554,10 @@ export class Session { /** * Handle audio chunk for buffering and transcription */ - private async handleAudioChunk( - msg: Extract, - ): Promise { - if (!this.isVoiceMode) { - this.sessionLogger.warn( - "Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped", - ); - } - - const chunkFormat = msg.format || "audio/wav"; - - if (this.isVoiceMode) { - if (!this.voiceTurnController) { - throw new Error("Voice mode is enabled but the voice turn controller is not running"); - } - const chunkBytes = Buffer.byteLength(msg.audio, "base64"); - this.voiceInputChunkCount += 1; - this.voiceInputBytes += chunkBytes; - if (this.voiceInputChunkCount === 1) { - this.sessionLogger.info( - { - format: chunkFormat, - audioBytes: chunkBytes, - }, - "Received first voice_audio_chunk for active voice mode", - ); - } - const now = Date.now(); - if (this.voiceInputChunkCount % 50 === 0 || now - this.voiceInputWindowStartedAt >= 1000) { - this.sessionLogger.info( - { - chunkCount: this.voiceInputChunkCount, - audioBytes: this.voiceInputBytes, - windowMs: now - this.voiceInputWindowStartedAt, - format: chunkFormat, - }, - "Voice input chunk summary", - ); - this.voiceInputWindowStartedAt = now; - this.voiceInputChunkCount = 0; - this.voiceInputBytes = 0; - } - await this.voiceTurnController.appendClientChunk({ - audioBase64: msg.audio, - format: chunkFormat, - }); - return; - } - - const chunkBuffer = Buffer.from(msg.audio, "base64"); - const isPCMChunk = chunkFormat.toLowerCase().includes("pcm"); - + private async ensureAudioBufferForFormat( + chunkFormat: string, + isPCMChunk: boolean, + ): Promise { if (!this.audioBuffer) { this.audioBuffer = { chunks: [], @@ -7533,9 +7565,8 @@ export class Session { isPCM: isPCMChunk, totalPCMBytes: 0, }; + return this.audioBuffer; } - - // If the format changes mid-stream, flush what we have first if (this.audioBuffer.isPCM !== isPCMChunk) { this.sessionLogger.debug( { @@ -7554,21 +7585,83 @@ export class Session { isPCM: isPCMChunk, totalPCMBytes: 0, }; - } else if (!this.audioBuffer.isPCM) { - // Keep latest format info for non-PCM blobs + return this.audioBuffer; + } + if (!this.audioBuffer.isPCM) { this.audioBuffer.format = chunkFormat; } + return this.audioBuffer; + } - this.audioBuffer.chunks.push(chunkBuffer); - if (this.audioBuffer.isPCM) { - this.audioBuffer.totalPCMBytes += chunkBuffer.length; + private async forwardAudioChunkToVoiceTurn( + msg: Extract, + chunkFormat: string, + ): Promise { + if (!this.voiceTurnController) { + throw new Error("Voice mode is enabled but the voice turn controller is not running"); + } + const chunkBytes = Buffer.byteLength(msg.audio, "base64"); + this.voiceInputChunkCount += 1; + this.voiceInputBytes += chunkBytes; + if (this.voiceInputChunkCount === 1) { + this.sessionLogger.info( + { + format: chunkFormat, + audioBytes: chunkBytes, + }, + "Received first voice_audio_chunk for active voice mode", + ); + } + const now = Date.now(); + if (this.voiceInputChunkCount % 50 === 0 || now - this.voiceInputWindowStartedAt >= 1000) { + this.sessionLogger.info( + { + chunkCount: this.voiceInputChunkCount, + audioBytes: this.voiceInputBytes, + windowMs: now - this.voiceInputWindowStartedAt, + format: chunkFormat, + }, + "Voice input chunk summary", + ); + this.voiceInputWindowStartedAt = now; + this.voiceInputChunkCount = 0; + this.voiceInputBytes = 0; + } + await this.voiceTurnController.appendClientChunk({ + audioBase64: msg.audio, + format: chunkFormat, + }); + } + + private async handleAudioChunk( + msg: Extract, + ): Promise { + if (!this.isVoiceMode) { + this.sessionLogger.warn( + "Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped", + ); + } + + const chunkFormat = msg.format || "audio/wav"; + + if (this.isVoiceMode) { + await this.forwardAudioChunkToVoiceTurn(msg, chunkFormat); + return; + } + + const chunkBuffer = Buffer.from(msg.audio, "base64"); + const isPCMChunk = chunkFormat.toLowerCase().includes("pcm"); + + const buffer = await this.ensureAudioBufferForFormat(chunkFormat, isPCMChunk); + + buffer.chunks.push(chunkBuffer); + if (buffer.isPCM) { + buffer.totalPCMBytes += chunkBuffer.length; } // In non-voice mode, use streaming threshold to process chunks const reachedStreamingThreshold = - !this.isVoiceMode && - this.audioBuffer.isPCM && - this.audioBuffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES; + !this.isVoiceMode && buffer.isPCM && buffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES; if (!msg.isLast && reachedStreamingThreshold) { return; diff --git a/packages/server/src/server/speech/providers/local/config.ts b/packages/server/src/server/speech/providers/local/config.ts index 0a158c77d8..fbefe2d3df 100644 --- a/packages/server/src/server/speech/providers/local/config.ts +++ b/packages/server/src/server/speech/providers/local/config.ts @@ -81,6 +81,68 @@ function shouldIncludeLocalProviderConfig(params: { ); } +function firstDefinedValue(values: Array): T | undefined { + for (const value of values) { + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function buildLocalSpeechResolutionInput(params: { + paseoHome: string; + env: NodeJS.ProcessEnv; + persisted: PersistedConfig; + providers: RequestedSpeechProviders; + includeProviderConfig: boolean; +}): Record { + const { paseoHome, env, persisted, providers, includeProviderConfig } = params; + return { + includeProviderConfig, + modelsDir: firstDefinedValue([ + env.PASEO_LOCAL_MODELS_DIR, + persisted.providers?.local?.modelsDir, + path.join(paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR), + ]), + dictationLocalSttModel: firstDefinedValue([ + env.PASEO_DICTATION_LOCAL_STT_MODEL, + persistedLocalFeatureModel( + providers.dictationStt.provider, + providers.dictationStt.enabled, + persisted.features?.dictation?.stt?.model, + ), + DEFAULT_LOCAL_STT_MODEL, + ]), + voiceLocalSttModel: firstDefinedValue([ + env.PASEO_VOICE_LOCAL_STT_MODEL, + persistedLocalFeatureModel( + providers.voiceStt.provider, + providers.voiceStt.enabled, + persisted.features?.voiceMode?.stt?.model, + ), + DEFAULT_LOCAL_STT_MODEL, + ]), + voiceLocalTtsModel: firstDefinedValue([ + env.PASEO_VOICE_LOCAL_TTS_MODEL, + persistedLocalFeatureModel( + providers.voiceTts.provider, + providers.voiceTts.enabled, + persisted.features?.voiceMode?.tts?.model, + ), + DEFAULT_LOCAL_TTS_MODEL, + ]), + voiceLocalTtsSpeakerId: firstDefinedValue([ + env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID, + persisted.features?.voiceMode?.tts?.speakerId, + ]), + voiceLocalTtsSpeed: firstDefinedValue([ + env.PASEO_VOICE_LOCAL_TTS_SPEED, + persisted.features?.voiceMode?.tts?.speed, + ]), + }; +} + export function resolveLocalSpeechConfig(params: { paseoHome: string; env: NodeJS.ProcessEnv; @@ -88,43 +150,9 @@ export function resolveLocalSpeechConfig(params: { providers: RequestedSpeechProviders; }): ResolvedLocalSpeechConfig { const includeProviderConfig = shouldIncludeLocalProviderConfig(params); - - const parsed = LocalSpeechResolutionSchema.parse({ - includeProviderConfig, - modelsDir: - params.env.PASEO_LOCAL_MODELS_DIR ?? - params.persisted.providers?.local?.modelsDir ?? - path.join(params.paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR), - dictationLocalSttModel: - params.env.PASEO_DICTATION_LOCAL_STT_MODEL ?? - persistedLocalFeatureModel( - params.providers.dictationStt.provider, - params.providers.dictationStt.enabled, - params.persisted.features?.dictation?.stt?.model, - ) ?? - DEFAULT_LOCAL_STT_MODEL, - voiceLocalSttModel: - params.env.PASEO_VOICE_LOCAL_STT_MODEL ?? - persistedLocalFeatureModel( - params.providers.voiceStt.provider, - params.providers.voiceStt.enabled, - params.persisted.features?.voiceMode?.stt?.model, - ) ?? - DEFAULT_LOCAL_STT_MODEL, - voiceLocalTtsModel: - params.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? - persistedLocalFeatureModel( - params.providers.voiceTts.provider, - params.providers.voiceTts.enabled, - params.persisted.features?.voiceMode?.tts?.model, - ) ?? - DEFAULT_LOCAL_TTS_MODEL, - voiceLocalTtsSpeakerId: - params.env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID ?? - params.persisted.features?.voiceMode?.tts?.speakerId, - voiceLocalTtsSpeed: - params.env.PASEO_VOICE_LOCAL_TTS_SPEED ?? params.persisted.features?.voiceMode?.tts?.speed, - }); + const parsed = LocalSpeechResolutionSchema.parse( + buildLocalSpeechResolutionInput({ ...params, includeProviderConfig }), + ); const resolvedVoiceTtsSpeakerId = parsed.voiceLocalTtsSpeakerId ?? diff --git a/packages/server/src/server/speech/providers/local/runtime.ts b/packages/server/src/server/speech/providers/local/runtime.ts index 1b7d5c0807..7056baadee 100644 --- a/packages/server/src/server/speech/providers/local/runtime.ts +++ b/packages/server/src/server/speech/providers/local/runtime.ts @@ -183,6 +183,132 @@ async function createLocalSttEngine(params: { throw new Error(`Unsupported local STT model '${modelId}'`); } +type LocalConfig = NonNullable; + +function isLocalProviderEnabled(provider: { enabled?: boolean; provider: string }): boolean { + return provider.enabled !== false && provider.provider === "local"; +} + +async function initializeLocalTurnDetection( + localConfig: LocalConfig | null, + logger: Logger, +): Promise { + let vadModelPath: string | undefined; + if (localConfig) { + try { + vadModelPath = await ensureSileroVadModel(localConfig.modelsDir, logger); + } catch (err) { + logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled"); + } + } + return new SherpaSileroTurnDetectionProvider({ modelPath: vadModelPath }, logger); +} + +async function initializeLocalVoiceStt(params: { + localConfig: LocalConfig | null; + modelId: LocalSttModelId; + logger: Logger; + getLocalSttEngine: (modelId: LocalSttModelId) => Promise; +}): Promise { + const { localConfig, modelId, logger, getLocalSttEngine } = params; + if (!localConfig) { + logger.warn( + { configured: false }, + "Local STT selected for voice but local provider config is missing; STT will be unavailable", + ); + return null; + } + const voiceEngine = await getLocalSttEngine(modelId); + if (voiceEngine?.kind === "offline") { + return new SherpaOnnxParakeetSTT({ engine: voiceEngine.engine }, logger); + } + if (voiceEngine?.kind === "online") { + return new SherpaOnnxSTT({ engine: voiceEngine.engine }, logger); + } + return null; +} + +async function initializeLocalDictationStt(params: { + localConfig: LocalConfig | null; + modelId: LocalSttModelId; + logger: Logger; + getLocalSttEngine: (modelId: LocalSttModelId) => Promise; +}): Promise { + const { localConfig, modelId, logger, getLocalSttEngine } = params; + if (!localConfig) { + logger.warn( + { configured: false }, + "Local STT selected for dictation but local provider config is missing; dictation STT will be unavailable", + ); + return null; + } + const dictationEngine = await getLocalSttEngine(modelId); + if (dictationEngine?.kind === "offline") { + return { + id: "local", + createSession: () => + new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine.engine }), + }; + } + if (dictationEngine?.kind === "online") { + return { + id: "local", + createSession: () => + new SherpaRealtimeTranscriptionSession({ engine: dictationEngine.engine }), + }; + } + return null; +} + +async function initializeLocalVoiceTts(params: { + localConfig: LocalConfig | null; + speechConfig: PaseoSpeechConfig | null; + localModels: ResolvedLocalModels; + logger: Logger; +}): Promise { + const { localConfig, speechConfig, localModels, logger } = params; + if (!localConfig) { + logger.warn( + { configured: false }, + "Local TTS selected for voice but local provider config is missing; TTS will be unavailable", + ); + return null; + } + try { + const modelDir = getLocalSpeechModelDir(localConfig.modelsDir, localModels.voiceLocalTtsModel); + if (localModels.voiceLocalTtsModel === "pocket-tts-onnx-int8") { + return await PocketTtsOnnxTTS.create( + { + modelDir, + precision: "int8", + targetChunkMs: 50, + }, + logger, + ); + } + return new SherpaOnnxTTS( + { + preset: localModels.voiceLocalTtsModel, + modelDir, + speakerId: speechConfig?.local?.models.voiceTtsSpeakerId, + speed: speechConfig?.local?.models.voiceTtsSpeed, + }, + logger, + ); + } catch (err) { + logger.warn( + { + err, + modelsDir: localConfig.modelsDir, + modelId: localModels.voiceLocalTtsModel, + hint: buildModelDownloadHint(localModels.voiceLocalTtsModel), + }, + "Local TTS engine unavailable", + ); + return null; + } +} + export async function initializeLocalSpeechServices(params: { providers: RequestedSpeechProviders; speechConfig: PaseoSpeechConfig | null; @@ -235,112 +361,37 @@ export async function initializeLocalSpeechServices(params: { } }; - if ( - providers.voiceTurnDetection.enabled !== false && - providers.voiceTurnDetection.provider === "local" - ) { - let vadModelPath: string | undefined; - if (localConfig) { - try { - vadModelPath = await ensureSileroVadModel(localConfig.modelsDir, logger); - } catch (err) { - logger.warn({ err }, "Failed to provision Silero VAD model, falling back to bundled"); - } - } - turnDetectionService = new SherpaSileroTurnDetectionProvider( - { modelPath: vadModelPath }, + if (isLocalProviderEnabled(providers.voiceTurnDetection)) { + turnDetectionService = await initializeLocalTurnDetection(localConfig, logger); + } + + if (isLocalProviderEnabled(providers.voiceStt)) { + sttService = await initializeLocalVoiceStt({ + localConfig, + modelId: localModels.voiceLocalSttModel, logger, - ); + getLocalSttEngine, + }); } - if (providers.voiceStt.enabled !== false && providers.voiceStt.provider === "local") { - if (!localConfig) { - logger.warn( - { configured: false }, - "Local STT selected for voice but local provider config is missing; STT will be unavailable", - ); - } else { - const voiceEngine = await getLocalSttEngine(localModels.voiceLocalSttModel); - if (voiceEngine?.kind === "offline") { - sttService = new SherpaOnnxParakeetSTT({ engine: voiceEngine.engine }, logger); - } else if (voiceEngine?.kind === "online") { - sttService = new SherpaOnnxSTT({ engine: voiceEngine.engine }, logger); - } - } + if (isLocalProviderEnabled(providers.dictationStt)) { + dictationSttService = await initializeLocalDictationStt({ + localConfig, + modelId: localModels.dictationLocalSttModel, + logger, + getLocalSttEngine, + }); } - if (providers.dictationStt.enabled !== false && providers.dictationStt.provider === "local") { - if (!localConfig) { - logger.warn( - { configured: false }, - "Local STT selected for dictation but local provider config is missing; dictation STT will be unavailable", - ); - } else { - const dictationEngine = await getLocalSttEngine(localModels.dictationLocalSttModel); - if (dictationEngine?.kind === "offline") { - dictationSttService = { - id: "local", - createSession: () => - new SherpaParakeetRealtimeTranscriptionSession({ engine: dictationEngine.engine }), - }; - } else if (dictationEngine?.kind === "online") { - dictationSttService = { - id: "local", - createSession: () => - new SherpaRealtimeTranscriptionSession({ engine: dictationEngine.engine }), - }; - } - } - } - - if (providers.voiceTts.enabled !== false && providers.voiceTts.provider === "local") { - if (!localConfig) { - logger.warn( - { configured: false }, - "Local TTS selected for voice but local provider config is missing; TTS will be unavailable", - ); - } else { - try { - if (localModels.voiceLocalTtsModel === "pocket-tts-onnx-int8") { - const modelDir = getLocalSpeechModelDir( - localConfig.modelsDir, - localModels.voiceLocalTtsModel, - ); - localVoiceTtsProvider = await PocketTtsOnnxTTS.create( - { - modelDir, - precision: "int8", - targetChunkMs: 50, - }, - logger, - ); - } else { - const modelDir = getLocalSpeechModelDir( - localConfig.modelsDir, - localModels.voiceLocalTtsModel, - ); - localVoiceTtsProvider = new SherpaOnnxTTS( - { - preset: localModels.voiceLocalTtsModel, - modelDir, - speakerId: speechConfig?.local?.models.voiceTtsSpeakerId, - speed: speechConfig?.local?.models.voiceTtsSpeed, - }, - logger, - ); - } - ttsService = localVoiceTtsProvider; - } catch (err) { - logger.warn( - { - err, - modelsDir: localConfig.modelsDir, - modelId: localModels.voiceLocalTtsModel, - hint: buildModelDownloadHint(localModels.voiceLocalTtsModel), - }, - "Local TTS engine unavailable", - ); - } + if (isLocalProviderEnabled(providers.voiceTts)) { + localVoiceTtsProvider = await initializeLocalVoiceTts({ + localConfig, + speechConfig, + localModels, + logger, + }); + if (localVoiceTtsProvider) { + ttsService = localVoiceTtsProvider; } } diff --git a/packages/server/src/server/speech/providers/local/sherpa/sherpa-online-recognizer.ts b/packages/server/src/server/speech/providers/local/sherpa/sherpa-online-recognizer.ts index 0794a28410..fb87cb5e25 100644 --- a/packages/server/src/server/speech/providers/local/sherpa/sherpa-online-recognizer.ts +++ b/packages/server/src/server/speech/providers/local/sherpa/sherpa-online-recognizer.ts @@ -40,6 +40,40 @@ function assertFileExists(filePath: string, label: string): void { } } +function validateModelFiles(model: SherpaOnlineRecognizerModel): void { + if (model.kind === "transducer") { + assertFileExists(model.encoder, "transducer encoder"); + assertFileExists(model.decoder, "transducer decoder"); + assertFileExists(model.joiner, "transducer joiner"); + assertFileExists(model.tokens, "tokens"); + } else { + assertFileExists(model.encoder, "paraformer encoder"); + assertFileExists(model.decoder, "paraformer decoder"); + assertFileExists(model.tokens, "tokens"); + } +} + +function buildModelConfig(model: SherpaOnlineRecognizerModel): object { + if (model.kind === "transducer") { + return { + transducer: { + encoder: model.encoder, + decoder: model.decoder, + joiner: model.joiner, + }, + tokens: model.tokens, + modelType: model.modelType ?? "zipformer", + }; + } + return { + paraformer: { + encoder: model.encoder, + decoder: model.decoder, + }, + tokens: model.tokens, + }; +} + export class SherpaOnlineRecognizerEngine { public readonly recognizer: any; public readonly sampleRate: number; @@ -53,36 +87,10 @@ export class SherpaOnlineRecognizerEngine { }); const { model } = config; - if (model.kind === "transducer") { - assertFileExists(model.encoder, "transducer encoder"); - assertFileExists(model.decoder, "transducer decoder"); - assertFileExists(model.joiner, "transducer joiner"); - assertFileExists(model.tokens, "tokens"); - } else { - assertFileExists(model.encoder, "paraformer encoder"); - assertFileExists(model.decoder, "paraformer decoder"); - assertFileExists(model.tokens, "tokens"); - } + validateModelFiles(model); const sherpa = loadSherpaOnnx(); - const modelConfig = - model.kind === "transducer" - ? { - transducer: { - encoder: model.encoder, - decoder: model.decoder, - joiner: model.joiner, - }, - tokens: model.tokens, - modelType: model.modelType ?? "zipformer", - } - : { - paraformer: { - encoder: model.encoder, - decoder: model.decoder, - }, - tokens: model.tokens, - }; + const modelConfig = buildModelConfig(model); const featConfig = { sampleRate: config.sampleRate ?? 16000, diff --git a/packages/server/src/server/speech/providers/openai/config.ts b/packages/server/src/server/speech/providers/openai/config.ts index 3b70763a84..c1a3abf2e1 100644 --- a/packages/server/src/server/speech/providers/openai/config.ts +++ b/packages/server/src/server/speech/providers/openai/config.ts @@ -45,48 +45,91 @@ const OpenAiSpeechResolutionSchema = z.object({ ), }); +function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean { + return provider.enabled !== false && provider.provider === "openai"; +} + +function pickIfOpenAi( + provider: { enabled?: boolean; provider: string }, + value: T | undefined, +): T | undefined { + return isOpenAiProviderActive(provider) ? value : undefined; +} + +function firstDefined(values: Array): T | undefined { + for (const value of values) { + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function buildOpenAiSttInput(params: { + env: NodeJS.ProcessEnv; + persisted: PersistedConfig; + providers: RequestedSpeechProviders; +}): Record { + const { env, persisted, providers } = params; + return { + sttConfidenceThreshold: firstDefined([ + env.STT_CONFIDENCE_THRESHOLD, + persisted.features?.dictation?.stt?.confidenceThreshold, + ]), + sttModel: firstDefined([ + env.STT_MODEL, + pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model), + pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), + ]), + realtimeTranscriptionModel: firstDefined([ + env.OPENAI_REALTIME_TRANSCRIPTION_MODEL, + pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model), + DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, + ]), + }; +} + +function buildOpenAiTtsInput(params: { + env: NodeJS.ProcessEnv; + persisted: PersistedConfig; + providers: RequestedSpeechProviders; +}): Record { + const { env, persisted, providers } = params; + return { + ttsVoice: firstDefined([ + env.TTS_VOICE, + pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice), + "alloy", + ]), + ttsModel: firstDefined([ + env.TTS_MODEL, + pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.model), + DEFAULT_OPENAI_TTS_MODEL, + ]), + }; +} + +function buildOpenAiResolutionInput(params: { + env: NodeJS.ProcessEnv; + persisted: PersistedConfig; + providers: RequestedSpeechProviders; +}): Record { + return { + apiKey: firstDefined([ + params.env.OPENAI_API_KEY, + params.persisted.providers?.openai?.apiKey, + ]), + ...buildOpenAiSttInput(params), + ...buildOpenAiTtsInput(params), + }; +} + export function resolveOpenAiSpeechConfig(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; providers: RequestedSpeechProviders; }): OpenAiSpeechProviderConfig | undefined { - const parsed = OpenAiSpeechResolutionSchema.parse({ - apiKey: params.env.OPENAI_API_KEY ?? params.persisted.providers?.openai?.apiKey, - sttConfidenceThreshold: - params.env.STT_CONFIDENCE_THRESHOLD ?? - params.persisted.features?.dictation?.stt?.confidenceThreshold, - sttModel: - params.env.STT_MODEL ?? - (params.providers.voiceStt.enabled !== false && - params.providers.voiceStt.provider === "openai" - ? params.persisted.features?.voiceMode?.stt?.model - : undefined) ?? - (params.providers.dictationStt.enabled !== false && - params.providers.dictationStt.provider === "openai" - ? params.persisted.features?.dictation?.stt?.model - : undefined), - ttsVoice: - params.env.TTS_VOICE ?? - (params.providers.voiceTts.enabled !== false && - params.providers.voiceTts.provider === "openai" - ? params.persisted.features?.voiceMode?.tts?.voice - : undefined) ?? - "alloy", - ttsModel: - params.env.TTS_MODEL ?? - (params.providers.voiceTts.enabled !== false && - params.providers.voiceTts.provider === "openai" - ? params.persisted.features?.voiceMode?.tts?.model - : undefined) ?? - DEFAULT_OPENAI_TTS_MODEL, - realtimeTranscriptionModel: - params.env.OPENAI_REALTIME_TRANSCRIPTION_MODEL ?? - (params.providers.dictationStt.enabled !== false && - params.providers.dictationStt.provider === "openai" - ? params.persisted.features?.dictation?.stt?.model - : undefined) ?? - DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, - }); + const parsed = OpenAiSpeechResolutionSchema.parse(buildOpenAiResolutionInput(params)); if (!parsed.apiKey) { return undefined; diff --git a/packages/server/src/server/speech/providers/openai/runtime.ts b/packages/server/src/server/speech/providers/openai/runtime.ts index e9d49605cd..79a0585c24 100644 --- a/packages/server/src/server/speech/providers/openai/runtime.ts +++ b/packages/server/src/server/speech/providers/openai/runtime.ts @@ -102,6 +102,52 @@ export function validateOpenAiCredentialRequirements(params: { } } +function createOpenAiStt( + apiKey: string, + openaiConfig: OpenAiSpeechProviderConfig | undefined, + logger: Logger, +): SpeechToTextProvider { + const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; + return new OpenAISTT({ apiKey, ...sttConfig }, logger); +} + +function createOpenAiTts( + apiKey: string, + openaiConfig: OpenAiSpeechProviderConfig | undefined, + logger: Logger, +): TextToSpeechProvider { + const { apiKey: _ttsApiKey, ...ttsConfig } = openaiConfig?.tts ?? {}; + return new OpenAITTS( + { + apiKey, + voice: "alloy", + model: DEFAULT_OPENAI_TTS_MODEL, + responseFormat: "pcm", + ...ttsConfig, + }, + logger, + ); +} + +function createOpenAiDictationService( + apiKey: string, + openaiConfig: OpenAiSpeechProviderConfig | undefined, +): SpeechToTextProvider { + return { + id: "openai", + createSession: ({ logger: sessionLogger, language, prompt }) => + new OpenAIRealtimeTranscriptionSession({ + apiKey, + logger: sessionLogger, + transcriptionModel: + openaiConfig?.realtimeTranscriptionModel ?? DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, + ...(language ? { language } : {}), + ...(prompt ? { prompt } : {}), + turnDetection: null, + }), + }; +} + export function initializeOpenAiSpeechServices(params: { providers: RequestedSpeechProviders; openaiConfig: OpenAiSpeechProviderConfig | undefined; @@ -125,57 +171,30 @@ export function initializeOpenAiSpeechServices(params: { providers.dictationStt.enabled !== false && providers.dictationStt.provider === "openai"; - if ( - (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) && - (openAiCredentials.openaiSttApiKey || - openAiCredentials.openaiTtsApiKey || - openAiCredentials.openaiDictationApiKey) - ) { + const needsAnyOpenAi = needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation; + const hasAnyOpenAiCredential = + Boolean(openAiCredentials.openaiSttApiKey) || + Boolean(openAiCredentials.openaiTtsApiKey) || + Boolean(openAiCredentials.openaiDictationApiKey); + + if (needsAnyOpenAi && hasAnyOpenAiCredential) { logger.info("OpenAI speech provider initialized"); if (needsOpenAiStt && openAiCredentials.openaiSttApiKey) { - const { apiKey: _sttApiKey, ...sttConfig } = openaiConfig?.stt ?? {}; - sttService = new OpenAISTT( - { - apiKey: openAiCredentials.openaiSttApiKey, - ...sttConfig, - }, - logger, - ); + sttService = createOpenAiStt(openAiCredentials.openaiSttApiKey, openaiConfig, logger); } if (needsOpenAiTts && openAiCredentials.openaiTtsApiKey) { - const { apiKey: _ttsApiKey, ...ttsConfig } = openaiConfig?.tts ?? {}; - ttsService = new OpenAITTS( - { - apiKey: openAiCredentials.openaiTtsApiKey, - voice: "alloy", - model: DEFAULT_OPENAI_TTS_MODEL, - responseFormat: "pcm", - ...ttsConfig, - }, - logger, - ); + ttsService = createOpenAiTts(openAiCredentials.openaiTtsApiKey, openaiConfig, logger); } - const dictationApiKey = openAiCredentials.openaiDictationApiKey; - if (needsOpenAiDictation && dictationApiKey) { - dictationSttService = { - id: "openai", - createSession: ({ logger: sessionLogger, language, prompt }) => - new OpenAIRealtimeTranscriptionSession({ - apiKey: dictationApiKey, - logger: sessionLogger, - transcriptionModel: - openaiConfig?.realtimeTranscriptionModel ?? - DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL, - ...(language ? { language } : {}), - ...(prompt ? { prompt } : {}), - turnDetection: null, - }), - }; + if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) { + dictationSttService = createOpenAiDictationService( + openAiCredentials.openaiDictationApiKey, + openaiConfig, + ); } - } else if (needsOpenAiStt || needsOpenAiTts || needsOpenAiDictation) { + } else if (needsAnyOpenAi) { logger.warn("OpenAI speech providers are configured but credentials are missing"); } diff --git a/packages/server/src/server/speech/speech-config-resolver.ts b/packages/server/src/server/speech/speech-config-resolver.ts index a51826a530..c2a0c27f9f 100644 --- a/packages/server/src/server/speech/speech-config-resolver.ts +++ b/packages/server/src/server/speech/speech-config-resolver.ts @@ -47,55 +47,83 @@ function resolveOptionalBooleanFlag(value: unknown): boolean { return OptionalBooleanFlagSchema.parse(value) ?? true; } +interface FeatureProviderInputs { + configuredValue: string | undefined; + enabled: boolean; +} + +function firstSpeechDefinedValue(values: Array): T | undefined { + for (const value of values) { + if (value !== undefined && value !== null) { + return value; + } + } + return undefined; +} + +function buildFeatureProviderInputs(params: { + env: NodeJS.ProcessEnv; + persisted: PersistedConfig; +}): Record { + const voiceModeEnabled = resolveOptionalBooleanFlag( + firstSpeechDefinedValue([ + params.env.PASEO_VOICE_MODE_ENABLED, + params.persisted.features?.voiceMode?.enabled, + ]), + ); + return { + dictationStt: { + configuredValue: firstSpeechDefinedValue([ + params.env.PASEO_DICTATION_STT_PROVIDER, + params.persisted.features?.dictation?.stt?.provider, + ]), + enabled: resolveOptionalBooleanFlag( + firstSpeechDefinedValue([ + params.env.PASEO_DICTATION_ENABLED, + params.persisted.features?.dictation?.enabled, + ]), + ), + }, + voiceTurnDetection: { + configuredValue: firstSpeechDefinedValue([ + params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER, + params.persisted.features?.voiceMode?.turnDetection?.provider, + ]), + enabled: voiceModeEnabled, + }, + voiceStt: { + configuredValue: firstSpeechDefinedValue([ + params.env.PASEO_VOICE_STT_PROVIDER, + params.persisted.features?.voiceMode?.stt?.provider, + ]), + enabled: voiceModeEnabled, + }, + voiceTts: { + configuredValue: firstSpeechDefinedValue([ + params.env.PASEO_VOICE_TTS_PROVIDER, + params.persisted.features?.voiceMode?.tts?.provider, + ]), + enabled: voiceModeEnabled, + }, + }; +} + +function buildRequestedFeatureProvider( + inputs: FeatureProviderInputs, + parsedValue: z.infer, +): RequestedSpeechProvider { + return { + provider: parsedValue, + explicit: inputs.configuredValue !== undefined, + enabled: inputs.enabled, + }; +} + function resolveRequestedSpeechProviders(params: { env: NodeJS.ProcessEnv; persisted: PersistedConfig; }): RequestedSpeechProviders { - const resolveFeatureProvider = ( - configuredValue: string | undefined, - parsedValue: z.infer, - enabled: boolean, - ): RequestedSpeechProvider => ({ - provider: parsedValue, - explicit: configuredValue !== undefined, - enabled, - }); - - const voiceModeEnabled = resolveOptionalBooleanFlag( - params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled, - ); - const featureProviders = { - dictationStt: { - configuredValue: - params.env.PASEO_DICTATION_STT_PROVIDER ?? - params.persisted.features?.dictation?.stt?.provider, - enabled: resolveOptionalBooleanFlag( - params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled, - ), - }, - voiceTurnDetection: { - configuredValue: - params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER ?? - params.persisted.features?.voiceMode?.turnDetection?.provider, - enabled: voiceModeEnabled, - }, - voiceStt: { - configuredValue: - params.env.PASEO_VOICE_STT_PROVIDER ?? params.persisted.features?.voiceMode?.stt?.provider, - enabled: voiceModeEnabled, - }, - voiceTts: { - configuredValue: - params.env.PASEO_VOICE_TTS_PROVIDER ?? params.persisted.features?.voiceMode?.tts?.provider, - enabled: voiceModeEnabled, - }, - } satisfies Record< - keyof RequestedSpeechProviders, - { - configuredValue: string | undefined; - enabled: boolean; - } - >; + const featureProviders = buildFeatureProviderInputs(params); const parsed = RequestedSpeechProvidersSchema.parse({ dictationStt: featureProviders.dictationStt.configuredValue ?? "local", @@ -105,26 +133,13 @@ function resolveRequestedSpeechProviders(params: { }); return { - dictationStt: resolveFeatureProvider( - featureProviders.dictationStt.configuredValue, - parsed.dictationStt, - featureProviders.dictationStt.enabled, - ), - voiceTurnDetection: resolveFeatureProvider( - featureProviders.voiceTurnDetection.configuredValue, + dictationStt: buildRequestedFeatureProvider(featureProviders.dictationStt, parsed.dictationStt), + voiceTurnDetection: buildRequestedFeatureProvider( + featureProviders.voiceTurnDetection, parsed.voiceTurnDetection, - featureProviders.voiceTurnDetection.enabled, - ), - voiceStt: resolveFeatureProvider( - featureProviders.voiceStt.configuredValue, - parsed.voiceStt, - featureProviders.voiceStt.enabled, - ), - voiceTts: resolveFeatureProvider( - featureProviders.voiceTts.configuredValue, - parsed.voiceTts, - featureProviders.voiceTts.enabled, ), + voiceStt: buildRequestedFeatureProvider(featureProviders.voiceStt, parsed.voiceStt), + voiceTts: buildRequestedFeatureProvider(featureProviders.voiceTts, parsed.voiceTts), }; } diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 0e690dbba9..4453c58ba4 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -93,6 +93,68 @@ function buildPersistence( return { provider, sessionId, ...(metadata ? { metadata } : {}) }; } +function buildClaudeToolCall(text: string) { + if (text.includes("read") && text.includes("/etc/hosts")) { + return { name: "Read", input: { path: "/etc/hosts" }, output: undefined }; + } + if (text.includes("rm -f permission.txt")) { + return { name: "Bash", input: { command: "rm -f permission.txt" }, output: { ok: true } }; + } + if (text.includes("rm -f mcp-smoke.txt")) { + return { name: "Bash", input: { command: "rm -f mcp-smoke.txt" }, output: { ok: true } }; + } + if (text.includes("echo hello")) { + return { name: "Bash", input: { command: "echo hello" }, output: { stdout: "hello\n" } }; + } + if (text.includes("edit") && text.includes(".txt")) { + return { name: "Edit", input: { file: "test.txt" }, output: { applied: true } }; + } + return null; +} + +function buildCodexToolCall(text: string) { + if (text.includes("echo hello")) { + return { name: "shell", input: { command: "echo hello" }, output: { stdout: "hello\n" } }; + } + if (text.includes("read") && text.includes("/etc/hosts")) { + return { name: "read_file", input: { path: "/etc/hosts" }, output: undefined }; + } + if (text.includes("read") && text.includes("tool-create.txt")) { + return { name: "read_file", input: { path: "tool-create.txt" }, output: undefined }; + } + if (text.includes("edit") && text.includes(".txt")) { + const output = text.includes("tool-create.txt") + ? { applied: true, file: "tool-create.txt" } + : { applied: true }; + return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output }; + } + const printfMatch = + /printf\s+"ok"\s*>\s*([^\s`]+)/i.exec(text) ?? /printf\s+ok\s*>\s*([^\s`]+)/i.exec(text); + if (printfMatch) { + const fileName = printfMatch[1] ?? "permission.txt"; + return { + name: "shell", + input: { command: `printf "ok" > ${fileName}` }, + output: { ok: true }, + }; + } + if (text.includes("sleep")) { + return { name: "shell", input: { command: "sleep 30" }, output: null }; + } + return null; +} + +function buildOpenCodeToolCall(text: string) { + if (text.includes("reason")) { + return { + name: "shell", + input: { command: "echo reasoning" }, + output: { stdout: "reasoning\n" }, + }; + } + return null; +} + function buildToolCallForPrompt(provider: string, prompt: string) { const text = prompt.toLowerCase(); const createFileMatch = @@ -115,69 +177,14 @@ function buildToolCallForPrompt(provider: string, prompt: string) { }; } if (provider === "claude") { - if (text.includes("read") && text.includes("/etc/hosts")) { - return { name: "Read", input: { path: "/etc/hosts" }, output: undefined }; - } - if (text.includes("rm -f permission.txt")) { - return { name: "Bash", input: { command: "rm -f permission.txt" }, output: { ok: true } }; - } - if (text.includes("rm -f mcp-smoke.txt")) { - return { name: "Bash", input: { command: "rm -f mcp-smoke.txt" }, output: { ok: true } }; - } - if (text.includes("echo hello")) { - return { name: "Bash", input: { command: "echo hello" }, output: { stdout: "hello\n" } }; - } - if (text.includes("edit") && text.includes(".txt")) { - return { name: "Edit", input: { file: "test.txt" }, output: { applied: true } }; - } - return null; + return buildClaudeToolCall(text); } - if (provider === "codex") { - if (text.includes("echo hello")) { - return { name: "shell", input: { command: "echo hello" }, output: { stdout: "hello\n" } }; - } - if (text.includes("read") && text.includes("/etc/hosts")) { - return { name: "read_file", input: { path: "/etc/hosts" }, output: undefined }; - } - if (text.includes("read") && text.includes("tool-create.txt")) { - return { name: "read_file", input: { path: "tool-create.txt" }, output: undefined }; - } - if (text.includes("edit") && text.includes(".txt")) { - const output = text.includes("tool-create.txt") - ? { applied: true, file: "tool-create.txt" } - : { applied: true }; - return { name: "apply_patch", input: { patch: "*** Begin Patch\n*** End Patch\n" }, output }; - } - const printfMatch = - /printf\s+"ok"\s*>\s*([^\s`]+)/i.exec(text) ?? /printf\s+ok\s*>\s*([^\s`]+)/i.exec(text); - if (printfMatch) { - const fileName = printfMatch[1] ?? "permission.txt"; - return { - name: "shell", - input: { command: `printf "ok" > ${fileName}` }, - output: { ok: true }, - }; - } - if (text.includes("sleep")) { - // Long-running command to test cancellation/overlap. - return { name: "shell", input: { command: "sleep 30" }, output: null }; - } - return null; + return buildCodexToolCall(text); } - - // opencode: used by a small set of tests if (provider === "opencode") { - if (text.includes("reason")) { - return { - name: "shell", - input: { command: "echo reasoning" }, - output: { stdout: "reasoning\n" }, - }; - } - return null; + return buildOpenCodeToolCall(text); } - return null; } @@ -409,45 +416,253 @@ class FakeAgentSession implements AgentSession { } } + private async emitSlashCommandTurn(slashCommand: { + commandName: string; + args: string; + }): Promise { + const threadStarted: AgentStreamEvent = { + type: "thread_started", + provider: this.providerName, + sessionId: this.id, + }; + await this.appendHistoryEvent(threadStarted); + this.notifySubscribers(threadStarted); + + const turnStarted: AgentStreamEvent = { + type: "turn_started", + provider: this.providerName, + }; + await this.appendHistoryEvent(turnStarted); + this.notifySubscribers(turnStarted); + + const result = await this.runSlashCommand(slashCommand.commandName, slashCommand.args); + for (const item of result.timeline) { + const timelineEvent: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item, + }; + await this.appendHistoryEvent(timelineEvent); + this.notifySubscribers(timelineEvent); + } + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: result.usage ?? { inputTokens: 1, outputTokens: 1 }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + } + + private async emitStressTurn(stress: { count: number; coalesced: boolean }): Promise { + for (let index = 0; index < stress.count; index += 1) { + const stressUpdate: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: stress.coalesced + ? { + type: "assistant_message", + text: `stress-update-${index}`, + } + : { + type: "todo", + items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], + }, + }; + await this.appendHistoryEvent(stressUpdate); + this.notifySubscribers(stressUpdate); + await new Promise((resolve) => setImmediate(resolve)); + } + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: stress.count }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + } + + private async emitLargePayloadTurn( + largePayload: ReturnType & object, + ): Promise { + const largeUpdate = buildLargeTimelineItem({ + ...largePayload, + callId: randomUUID(), + provider: this.providerName, + }); + await this.appendHistoryEvent(largeUpdate); + this.notifySubscribers(largeUpdate); + + const completed: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: largePayload.bytes }, + }; + await this.appendHistoryEvent(completed); + this.notifySubscribers(completed); + } + + private async resolveToolPermission(tool: { + name: string; + input?: Record; + }): Promise<{ denied: boolean; interrupted: boolean }> { + const request: AgentPermissionRequest = { + id: randomUUID(), + provider: this.providerName, + name: tool.name, + kind: "tool", + title: "Permission required", + description: "Test permission request", + input: tool.input ?? {}, + }; + this.pendingPermissions = [request]; + this.permissionGate = createDeferred(); + const permissionRequested: AgentStreamEvent = { + type: "permission_requested", + provider: this.providerName, + request, + }; + await this.appendHistoryEvent(permissionRequested); + this.notifySubscribers(permissionRequested); + + const response = await Promise.race([ + this.permissionGate.promise, + this.interruptSignal.promise.then( + () => + ({ + behavior: "deny", + interrupt: true, + message: "Interrupted", + }) satisfies AgentPermissionResponse, + ), + ]); + this.pendingPermissions = []; + this.permissionGate = null; + const permissionResolved: AgentStreamEvent = { + type: "permission_resolved", + provider: this.providerName, + requestId: request.id, + resolution: response, + }; + await this.appendHistoryEvent(permissionResolved); + this.notifySubscribers(permissionResolved); + + return { + denied: response.behavior === "deny", + interrupted: response.behavior === "deny" && response.interrupt === true, + }; + } + + private async emitDeniedToolTurn(interrupted: boolean): Promise { + if (interrupted) { + const canceled: AgentStreamEvent = { + type: "turn_canceled", + provider: this.providerName, + reason: "permission denied", + }; + await this.appendHistoryEvent(canceled); + this.notifySubscribers(canceled); + return; + } + + const deniedCompleted: AgentStreamEvent = { + type: "turn_completed", + provider: this.providerName, + usage: { inputTokens: 1, outputTokens: 0 }, + }; + await this.appendHistoryEvent(deniedCompleted); + this.notifySubscribers(deniedCompleted); + } + + private resolveReadToolOutput(tool: { + name: string; + input?: Record; + output?: unknown; + }): unknown { + if (tool.output) { + return tool.output; + } + if (tool.name !== "Read" && tool.name !== "read_file") { + return undefined; + } + const pathInput = typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts"; + const resolvedPath = path.isAbsolute(pathInput) + ? pathInput + : path.join(this.config.cwd ?? process.cwd(), pathInput); + try { + const content = readFileSync(resolvedPath, "utf8"); + return { path: pathInput, content }; + } catch { + return { path: pathInput, content: "" }; + } + } + + private async emitToolCallTurn( + tool: NonNullable>, + textPrompt: string, + ): Promise { + const needsPermission = this.needsPermissionForTool(tool.name, tool.input ?? {}); + const callId = randomUUID(); + const toolRunning: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { + type: "tool_call", + name: tool.name, + callId, + status: "running", + detail: { + type: "unknown", + input: tool.input ?? null, + output: null, + }, + error: null, + }, + }; + await this.appendHistoryEvent(toolRunning); + this.notifySubscribers(toolRunning); + + if (needsPermission) { + const permission = await this.resolveToolPermission(tool); + if (permission.denied) { + await this.emitDeniedToolTurn(permission.interrupted); + return true; + } + } + + await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt); + + const toolOutput = this.resolveReadToolOutput(tool); + const toolCompleted: AgentStreamEvent = { + type: "timeline", + provider: this.providerName, + item: { + type: "tool_call", + name: tool.name, + callId, + status: "completed", + detail: { + type: "unknown", + input: tool.input ?? null, + output: toolOutput ?? { ok: true }, + }, + error: null, + }, + }; + await this.appendHistoryEvent(toolCompleted); + this.notifySubscribers(toolCompleted); + return false; + } + private async emitTurnEvents(prompt: AgentPromptInput): Promise { this.interruptSignal = createDeferred(); const slashCommand = await this.resolveSlashCommandInput(prompt); const textPrompt = typeof prompt === "string" ? prompt : JSON.stringify(prompt); try { if (slashCommand) { - const threadStarted: AgentStreamEvent = { - type: "thread_started", - provider: this.providerName, - sessionId: this.id, - }; - await this.appendHistoryEvent(threadStarted); - this.notifySubscribers(threadStarted); - - const turnStarted: AgentStreamEvent = { - type: "turn_started", - provider: this.providerName, - }; - await this.appendHistoryEvent(turnStarted); - this.notifySubscribers(turnStarted); - - const result = await this.runSlashCommand(slashCommand.commandName, slashCommand.args); - for (const item of result.timeline) { - const timelineEvent: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item, - }; - await this.appendHistoryEvent(timelineEvent); - this.notifySubscribers(timelineEvent); - } - - const completed: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: result.usage ?? { inputTokens: 1, outputTokens: 1 }, - }; - await this.appendHistoryEvent(completed); - this.notifySubscribers(completed); + await this.emitSlashCommandTurn(slashCommand); return; } @@ -475,177 +690,22 @@ class FakeAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(textPrompt); if (stress !== null) { - for (let index = 0; index < stress.count; index += 1) { - const stressUpdate: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: stress.coalesced - ? { - type: "assistant_message", - text: `stress-update-${index}`, - } - : { - type: "todo", - items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }], - }, - }; - await this.appendHistoryEvent(stressUpdate); - this.notifySubscribers(stressUpdate); - await new Promise((resolve) => setImmediate(resolve)); - } - - const completed: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: { inputTokens: 1, outputTokens: stress.count }, - }; - await this.appendHistoryEvent(completed); - this.notifySubscribers(completed); + await this.emitStressTurn(stress); return; } const largePayload = parseLargeAgentStreamPayloadPrompt(textPrompt); if (largePayload !== null) { - const largeUpdate = buildLargeTimelineItem({ - ...largePayload, - callId: randomUUID(), - provider: this.providerName, - }); - await this.appendHistoryEvent(largeUpdate); - this.notifySubscribers(largeUpdate); - - const completed: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: { inputTokens: 1, outputTokens: largePayload.bytes }, - }; - await this.appendHistoryEvent(completed); - this.notifySubscribers(completed); + await this.emitLargePayloadTurn(largePayload); return; } const tool = buildToolCallForPrompt(this.providerName, textPrompt); if (tool) { - const needsPermission = this.needsPermissionForTool(tool.name, tool.input ?? {}); - const callId = randomUUID(); - const toolRunning: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { - type: "tool_call", - name: tool.name, - callId, - status: "running", - detail: { - type: "unknown", - input: tool.input ?? null, - output: null, - }, - error: null, - }, - }; - await this.appendHistoryEvent(toolRunning); - this.notifySubscribers(toolRunning); - - if (needsPermission) { - const request: AgentPermissionRequest = { - id: randomUUID(), - provider: this.providerName, - name: tool.name, - kind: "tool", - title: "Permission required", - description: "Test permission request", - input: tool.input ?? {}, - }; - this.pendingPermissions = [request]; - this.permissionGate = createDeferred(); - const permissionRequested: AgentStreamEvent = { - type: "permission_requested", - provider: this.providerName, - request, - }; - await this.appendHistoryEvent(permissionRequested); - this.notifySubscribers(permissionRequested); - - const response = await Promise.race([ - this.permissionGate.promise, - this.interruptSignal.promise.then( - () => - ({ - behavior: "deny", - interrupt: true, - message: "Interrupted", - }) satisfies AgentPermissionResponse, - ), - ]); - this.pendingPermissions = []; - this.permissionGate = null; - const permissionResolved: AgentStreamEvent = { - type: "permission_resolved", - provider: this.providerName, - requestId: request.id, - resolution: response, - }; - await this.appendHistoryEvent(permissionResolved); - this.notifySubscribers(permissionResolved); - - if (response.behavior === "deny") { - if (response.interrupt) { - const canceled: AgentStreamEvent = { - type: "turn_canceled", - provider: this.providerName, - reason: "permission denied", - }; - await this.appendHistoryEvent(canceled); - this.notifySubscribers(canceled); - return; - } - - const deniedCompleted: AgentStreamEvent = { - type: "turn_completed", - provider: this.providerName, - usage: { inputTokens: 1, outputTokens: 0 }, - }; - await this.appendHistoryEvent(deniedCompleted); - this.notifySubscribers(deniedCompleted); - return; - } + const returnedEarly = await this.emitToolCallTurn(tool, textPrompt); + if (returnedEarly) { + return; } - - await this.applyToolSideEffects(tool.name, tool.input ?? {}, textPrompt); - - let toolOutput: unknown = tool.output; - if (!toolOutput && (tool.name === "Read" || tool.name === "read_file")) { - const pathInput = typeof tool.input?.path === "string" ? tool.input.path : "/etc/hosts"; - const resolvedPath = path.isAbsolute(pathInput) - ? pathInput - : path.join(this.config.cwd ?? process.cwd(), pathInput); - try { - const content = readFileSync(resolvedPath, "utf8"); - toolOutput = { path: pathInput, content }; - } catch { - toolOutput = { path: pathInput, content: "" }; - } - } - - const toolCompleted: AgentStreamEvent = { - type: "timeline", - provider: this.providerName, - item: { - type: "tool_call", - name: tool.name, - callId, - status: "completed", - detail: { - type: "unknown", - input: tool.input ?? null, - output: toolOutput ?? { ok: true }, - }, - error: null, - }, - }; - await this.appendHistoryEvent(toolCompleted); - this.notifySubscribers(toolCompleted); } const assistantText = this.buildAssistantText(textPrompt); @@ -870,127 +930,157 @@ class FakeAgentSession implements AgentSession { return "Hello world"; } + private async applyReadToolSideEffect(toolInput: Record): Promise { + const p = typeof toolInput.path === "string" ? toolInput.path : "/etc/hosts"; + try { + readFileSync(p, "utf8"); + } catch { + // ignore - tests only assert tool call presence + } + } + + private async applyBashRemovalSideEffect(fileName: string): Promise { + const dest = path.join(this.config.cwd ?? process.cwd(), fileName); + try { + rmSync(dest, { force: true }); + } catch { + // ignore + } + } + + private async applyBashSleepSideEffect(): Promise<"completed" | "interrupted"> { + const interrupt = this.interruptSignal.promise.then(() => "interrupted" as const); + const completed = new Promise<"completed">((resolve) => + setTimeout(() => resolve("completed"), 250), + ); + return await Promise.race([interrupt, completed]); + } + + private async applyBashAbortableWriteSideEffect(fileName: string): Promise { + const dest = path.join(this.config.cwd ?? process.cwd(), fileName); + let interrupted = false; + const interrupt = this.interruptSignal.promise.then(() => { + interrupted = true; + }); + await Promise.race([interrupt, new Promise((r) => setTimeout(r, 500))]); + if (!interrupted) { + writeFileSync(dest, "ok"); + } + } + + private applyBashPrintfRedirectSideEffect(command: string, lower: string): void { + if (!(lower.includes("printf") && lower.includes(">") && lower.includes(".txt"))) { + return; + } + const destMatch = />\s*([^\s`]+)\s*$/i.exec(command) ?? />\s*([^\s`]+)/i.exec(lower); + const fileName = destMatch?.[1]; + if (!fileName) { + return; + } + const dest = path.join(this.config.cwd ?? process.cwd(), fileName); + writeFileSync(dest, "ok"); + } + + private async applyBashSpecialCommandSideEffect( + lower: string, + command: string, + ): Promise<"handled" | "not-handled"> { + if (lower.includes("rm -f permission.txt") || command.includes("rm -f permission.txt")) { + await this.applyBashRemovalSideEffect("permission.txt"); + return "handled"; + } + if (lower.includes("rm -f mcp-smoke.txt") || command.includes("rm -f mcp-smoke.txt")) { + await this.applyBashRemovalSideEffect("mcp-smoke.txt"); + return "handled"; + } + if (lower.includes("printf") && lower.includes("permission.txt")) { + const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt"); + writeFileSync(dest, "ok"); + return "handled"; + } + return "not-handled"; + } + + private async applyBashSideEffect( + toolInput: Record, + prompt: string, + createFileMatch: RegExpExecArray | null, + ): Promise { + const lower = prompt.toLowerCase(); + const command = typeof toolInput.command === "string" ? toolInput.command : ""; + + if (createFileMatch) { + const fileName = createFileMatch[1] ?? "test.txt"; + const content = createFileMatch[2] ?? ""; + const dest = path.join(this.config.cwd ?? process.cwd(), fileName); + writeFileSync(dest, content); + return; + } + + const special = await this.applyBashSpecialCommandSideEffect(lower, command); + if (special === "handled") { + return; + } + + if (command.includes("sleep")) { + const outcome = await this.applyBashSleepSideEffect(); + if (outcome === "interrupted") { + return; + } + } + + if (lower.includes("abort-test-file.txt")) { + await this.applyBashAbortableWriteSideEffect("abort-test-file.txt"); + return; + } + + this.applyBashPrintfRedirectSideEffect(command, lower); + } + + private async applyEditSideEffect(prompt: string): Promise { + const lowerPrompt = prompt.toLowerCase(); + const match = /edit the file\s+([^\s]+)\s+and change/i.exec(prompt); + const filePath = + match?.[1] ?? (lowerPrompt.includes("tool-create.txt") ? "tool-create.txt" : null); + if (!filePath) { + return; + } + try { + const resolved = path.isAbsolute(filePath) + ? filePath + : path.join(this.config.cwd ?? process.cwd(), filePath); + const before = readFileSync(resolved, "utf8"); + let after = before.replace(/hello/g, "goodbye"); + if (lowerPrompt.includes("alpha") && lowerPrompt.includes("beta")) { + after = after.replace(/alpha/g, "beta"); + } + writeFileSync(resolved, after); + } catch { + // ignore + } + } + private async applyToolSideEffects( toolName: string, toolInput: Record, prompt: string, ): Promise { - const lower = prompt.toLowerCase(); const createFileMatch = /create a file named\s+"([^"]+)"\s+with the content\s+"([^"]*)"/i.exec(prompt) ?? /create a file named\s+"([^"]+)"\s+with the content\s+'([^']*)'/i.exec(prompt); if (toolName === "Read" || toolName === "read_file") { - const p = typeof toolInput.path === "string" ? toolInput.path : "/etc/hosts"; - try { - readFileSync(p, "utf8"); - } catch { - // ignore - tests only assert tool call presence - } + await this.applyReadToolSideEffect(toolInput); return; } if (toolName === "Bash" || toolName === "shell") { - const command = typeof toolInput.command === "string" ? toolInput.command : ""; - - // Deterministic file-create behavior for permission prompt tests: - // Prompt: Create a file named "X" with the content "Y" - if (createFileMatch) { - const fileName = createFileMatch[1] ?? "test.txt"; - const content = createFileMatch[2] ?? ""; - const dest = path.join(this.config.cwd ?? process.cwd(), fileName); - writeFileSync(dest, content); - return; - } - - if (lower.includes("rm -f permission.txt") || command.includes("rm -f permission.txt")) { - const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt"); - try { - rmSync(dest, { force: true }); - } catch { - // ignore - } - return; - } - - if (lower.includes("rm -f mcp-smoke.txt") || command.includes("rm -f mcp-smoke.txt")) { - const dest = path.join(this.config.cwd ?? process.cwd(), "mcp-smoke.txt"); - try { - rmSync(dest, { force: true }); - } catch { - // ignore - } - return; - } - - if (lower.includes("printf") && lower.includes("permission.txt")) { - const dest = path.join(this.config.cwd ?? process.cwd(), "permission.txt"); - writeFileSync(dest, "ok"); - return; - } - - if (command.includes("sleep")) { - // Simulate a long-running operation that can be interrupted. - // Keep the duration small so tests stay fast. - const interrupt = this.interruptSignal.promise.then(() => "interrupted" as const); - const completed = new Promise<"completed">((resolve) => - setTimeout(() => resolve("completed"), 250), - ); - const outcome = await Promise.race([interrupt, completed]); - if (outcome === "interrupted") { - return; - } - // Continue after "sleep" completes. - } - - if (lower.includes("abort-test-file.txt")) { - const dest = path.join(this.config.cwd ?? process.cwd(), "abort-test-file.txt"); - // Simulate a delayed write that should be prevented by interrupt(). - let interrupted = false; - const interrupt = this.interruptSignal.promise.then(() => { - interrupted = true; - }); - await Promise.race([interrupt, new Promise((r) => setTimeout(r, 500))]); - if (!interrupted) { - writeFileSync(dest, "ok"); - } - return; - } - - if (lower.includes("printf") && lower.includes(">") && lower.includes(".txt")) { - const destMatch = />\s*([^\s`]+)\s*$/i.exec(command) ?? />\s*([^\s`]+)/i.exec(lower); - const fileName = destMatch?.[1]; - if (fileName) { - const dest = path.join(this.config.cwd ?? process.cwd(), fileName); - writeFileSync(dest, "ok"); - return; - } - } - + await this.applyBashSideEffect(toolInput, prompt, createFileMatch); return; } if (toolName === "Edit" || toolName === "apply_patch") { - const lowerPrompt = prompt.toLowerCase(); - const match = /edit the file\s+([^\s]+)\s+and change/i.exec(prompt); - const filePath = - match?.[1] ?? (lowerPrompt.includes("tool-create.txt") ? "tool-create.txt" : null); - if (filePath) { - try { - const resolved = path.isAbsolute(filePath) - ? filePath - : path.join(this.config.cwd ?? process.cwd(), filePath); - const before = readFileSync(resolved, "utf8"); - let after = before.replace(/hello/g, "goodbye"); - if (lowerPrompt.includes("alpha") && lowerPrompt.includes("beta")) { - after = after.replace(/alpha/g, "beta"); - } - writeFileSync(resolved, after); - } catch { - // ignore - } - } - return; + await this.applyEditSideEffect(prompt); } } diff --git a/packages/server/src/server/test-utils/paseo-daemon.ts b/packages/server/src/server/test-utils/paseo-daemon.ts index 85edde9c5c..e9ee185b7b 100644 --- a/packages/server/src/server/test-utils/paseo-daemon.ts +++ b/packages/server/src/server/test-utils/paseo-daemon.ts @@ -75,34 +75,7 @@ export async function createTestPaseoDaemon( let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const paseoHomeRoot = - options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-"))); - const paseoHome = path.join(paseoHomeRoot, ".paseo"); - await mkdir(paseoHome, { recursive: true }); - const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-"))); - const listenHost = options.listen ?? "127.0.0.1"; - const config: PaseoDaemonConfig = { - listen: `${listenHost}:0`, - paseoHome, - corsAllowedOrigins: options.corsAllowedOrigins ?? [], - hostnames: true, - mcpEnabled: true, - staticDir, - mcpDebug: false, - agentClients: options.agentClients ?? createTestAgentClients(), - agentStoragePath: path.join(paseoHome, "agents"), - relayEnabled: options.relayEnabled ?? false, - relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", - appBaseUrl: "https://app.paseo.sh", - openai: options.openai, - speech: options.speech, - voiceLlmProvider: options.voiceLlmProvider ?? null, - voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, - voiceLlmModel: options.voiceLlmModel ?? null, - dictationFinalTimeoutMs: options.dictationFinalTimeoutMs, - downloadTokenTtlMs: options.downloadTokenTtlMs, - }; - + const { config, paseoHomeRoot, paseoHome, staticDir } = await prepareTestDaemonConfig(options); const logger = options.logger ?? pino({ level: "silent" }); const daemon = await createPaseoDaemon(config, logger); try { @@ -148,6 +121,46 @@ export async function createTestPaseoDaemon( throw lastError ?? new Error("Failed to start test daemon"); } +interface PreparedTestDaemonConfig { + config: PaseoDaemonConfig; + paseoHomeRoot: string; + paseoHome: string; + staticDir: string; +} + +async function prepareTestDaemonConfig( + options: TestPaseoDaemonOptions, +): Promise { + const paseoHomeRoot = + options.paseoHomeRoot ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-home-"))); + const paseoHome = path.join(paseoHomeRoot, ".paseo"); + await mkdir(paseoHome, { recursive: true }); + const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-"))); + const listenHost = options.listen ?? "127.0.0.1"; + const config: PaseoDaemonConfig = { + listen: `${listenHost}:0`, + paseoHome, + corsAllowedOrigins: options.corsAllowedOrigins ?? [], + hostnames: true, + mcpEnabled: true, + staticDir, + mcpDebug: false, + agentClients: options.agentClients ?? createTestAgentClients(), + agentStoragePath: path.join(paseoHome, "agents"), + relayEnabled: options.relayEnabled ?? false, + relayEndpoint: options.relayEndpoint ?? "relay.paseo.sh:443", + appBaseUrl: "https://app.paseo.sh", + openai: options.openai, + speech: options.speech, + voiceLlmProvider: options.voiceLlmProvider ?? null, + voiceLlmProviderExplicit: options.voiceLlmProviderExplicit ?? false, + voiceLlmModel: options.voiceLlmModel ?? null, + dictationFinalTimeoutMs: options.dictationFinalTimeoutMs, + downloadTokenTtlMs: options.downloadTokenTtlMs, + }; + return { config, paseoHomeRoot, paseoHome, staticDir }; +} + function isAddressInUseError(error: unknown): boolean { if (!error || typeof error !== "object") { return false; diff --git a/packages/server/src/server/utils/diff-highlighter.ts b/packages/server/src/server/utils/diff-highlighter.ts index 47dcf713ff..1d314541e9 100644 --- a/packages/server/src/server/utils/diff-highlighter.ts +++ b/packages/server/src/server/utils/diff-highlighter.ts @@ -39,6 +39,80 @@ interface ParseAndHighlightDiffOptions { /** * Parse a unified diff into structured data */ +function extractPathFromDiffHeader(firstLine: string): string { + const pathMatch = firstLine.match(/a\/(.*?) b\//); + if (pathMatch) return pathMatch[1]; + const newFileMatch = firstLine.match(/b\/(.+)$/); + if (newFileMatch) return newFileMatch[1]; + return "unknown"; +} + +function isMetadataLine(line: string): boolean { + return ( + line.startsWith("index ") || + line.startsWith("--- ") || + line.startsWith("+++ ") || + line.startsWith("new file mode") || + line.startsWith("deleted file mode") + ); +} + +function parseHunkHeader(line: string): DiffHunk | null { + const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); + if (!hunkMatch) return null; + return { + oldStart: parseInt(hunkMatch[1], 10), + oldCount: parseInt(hunkMatch[2] ?? "1", 10), + newStart: parseInt(hunkMatch[3], 10), + newCount: parseInt(hunkMatch[4] ?? "1", 10), + lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], + }; +} + +interface ParsedSectionBody { + hunks: DiffHunk[]; + additions: number; + deletions: number; +} + +function parseSectionBody(lines: string[]): ParsedSectionBody { + const hunks: DiffHunk[] = []; + let currentHunk: DiffHunk | null = null; + let additions = 0; + let deletions = 0; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + + if (isMetadataLine(line)) continue; + + const newHunk = parseHunkHeader(line); + if (newHunk) { + if (currentHunk) hunks.push(currentHunk); + currentHunk = newHunk; + continue; + } + + if (!currentHunk) continue; + + if (line.startsWith("+")) { + currentHunk.lines.push({ type: "add", content: line.slice(1) }); + additions++; + } else if (line.startsWith("-")) { + currentHunk.lines.push({ type: "remove", content: line.slice(1) }); + deletions++; + } else if (line.startsWith(" ")) { + currentHunk.lines.push({ type: "context", content: line.slice(1) }); + } else if (line.length > 0 && !line.startsWith("\\")) { + currentHunk.lines.push({ type: "context", content: line }); + } + } + + if (currentHunk) hunks.push(currentHunk); + + return { hunks, additions, deletions }; +} + export function parseDiff(diffText: string): ParsedDiffFile[] { if (!diffText || diffText.trim().length === 0) { return []; @@ -51,72 +125,11 @@ export function parseDiff(diffText: string): ParsedDiffFile[] { const lines = section.split("\n"); const firstLine = lines[0]; - // Detect new/deleted file const isNew = section.includes("new file mode") || section.includes("--- /dev/null"); const isDeleted = section.includes("deleted file mode") || section.includes("+++ /dev/null"); + const path = extractPathFromDiffHeader(firstLine); - // Extract path - let path = "unknown"; - const pathMatch = firstLine.match(/a\/(.*?) b\//); - if (pathMatch) { - path = pathMatch[1]; - } else { - const newFileMatch = firstLine.match(/b\/(.+)$/); - if (newFileMatch) { - path = newFileMatch[1]; - } - } - - const hunks: DiffHunk[] = []; - let currentHunk: DiffHunk | null = null; - let additions = 0; - let deletions = 0; - - for (let i = 1; i < lines.length; i++) { - const line = lines[i]; - - // Skip metadata lines - if (line.startsWith("index ")) continue; - if (line.startsWith("--- ")) continue; - if (line.startsWith("+++ ")) continue; - if (line.startsWith("new file mode")) continue; - if (line.startsWith("deleted file mode")) continue; - - // Parse hunk header: @@ -oldStart,oldCount +newStart,newCount @@ - const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/); - if (hunkMatch) { - if (currentHunk) { - hunks.push(currentHunk); - } - currentHunk = { - oldStart: parseInt(hunkMatch[1], 10), - oldCount: parseInt(hunkMatch[2] ?? "1", 10), - newStart: parseInt(hunkMatch[3], 10), - newCount: parseInt(hunkMatch[4] ?? "1", 10), - lines: [{ type: "header", content: line.match(/^(@@ .+? @@)/)?.[1] ?? line }], - }; - continue; - } - - if (!currentHunk) continue; - - if (line.startsWith("+")) { - currentHunk.lines.push({ type: "add", content: line.slice(1) }); - additions++; - } else if (line.startsWith("-")) { - currentHunk.lines.push({ type: "remove", content: line.slice(1) }); - deletions++; - } else if (line.startsWith(" ")) { - currentHunk.lines.push({ type: "context", content: line.slice(1) }); - } else if (line.length > 0 && !line.startsWith("\\")) { - // Non-empty line that's not a "\ No newline" marker - currentHunk.lines.push({ type: "context", content: line }); - } - } - - if (currentHunk) { - hunks.push(currentHunk); - } + const { hunks, additions, deletions } = parseSectionBody(lines); files.push({ path, isNew, isDeleted, additions, deletions, hunks }); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 08a416a9c8..4e97128aab 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1,5 +1,5 @@ import { WebSocketServer } from "ws"; -import type { Server as HTTPServer } from "http"; +import type { IncomingMessage, Server as HTTPServer } from "http"; import { basename, join } from "path"; import { hostname as getHostname } from "node:os"; import type { AgentManager } from "./agent/agent-manager.js"; @@ -18,6 +18,7 @@ import { type SessionOutboundMessage, type WorkspaceSetupSnapshot, type WSHelloMessage, + type WSInboundMessage, WSInboundMessageSchema, type ServerCapabilityState, type ServerCapabilities, @@ -284,6 +285,35 @@ export class MissingDaemonVersionError extends Error { } } +interface RequiredWebSocketServices { + chatService: FileBackedChatService; + loopService: LoopService; + scheduleService: ScheduleService; + checkoutDiffManager: CheckoutDiffManager; +} + +function requireWebSocketServices(params: { + chatService?: FileBackedChatService; + loopService?: LoopService; + scheduleService?: ScheduleService; + checkoutDiffManager?: CheckoutDiffManager; +}): RequiredWebSocketServices { + const { chatService, loopService, scheduleService, checkoutDiffManager } = params; + if (!chatService) { + throw new Error("VoiceAssistantWebSocketServer requires a chat service."); + } + if (!loopService) { + throw new Error("VoiceAssistantWebSocketServer requires a loop service."); + } + if (!scheduleService) { + throw new Error("VoiceAssistantWebSocketServer requires a schedule service."); + } + if (!checkoutDiffManager) { + throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); + } + return { chatService, loopService, scheduleService, checkoutDiffManager }; +} + /** * WebSocket server that only accepts sockets + parses/forwards messages to the session layer. */ @@ -311,25 +341,25 @@ export class VoiceAssistantWebSocketServer { private readonly pushTokenStore: PushTokenStore; private readonly pushService: PushService; private readonly mcpBaseUrl: string | null; - private readonly speech: SpeechService | null; - private readonly terminalManager: TerminalManager | null; - private readonly scriptRouteStore: ScriptRouteStore | null; - private readonly scriptRuntimeStore: WorkspaceScriptRuntimeStore | null; - private readonly getDaemonTcpPort: (() => number | null) | null; - private readonly getDaemonTcpHost: (() => string | null) | null; - private readonly resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | null; - private readonly dictation: { + private speech!: SpeechService | null; + private terminalManager!: TerminalManager | null; + private scriptRouteStore!: ScriptRouteStore | null; + private scriptRuntimeStore!: WorkspaceScriptRuntimeStore | null; + private getDaemonTcpPort!: (() => number | null) | null; + private getDaemonTcpHost!: (() => string | null) | null; + private resolveScriptHealth!: ((hostname: string) => ScriptHealthState | null) | null; + private dictation!: { finalTimeoutMs?: number; } | null; private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); private readonly workspaceSetupSnapshots = new Map(); - private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; - private readonly providerOverrides: Record | undefined; - private readonly isDev: boolean; + private agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + private providerOverrides: Record | undefined; + private isDev!: boolean; private readonly providerSnapshotManager: ProviderSnapshotManager; - private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null; - private readonly onBranchChanged: + private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; + private onBranchChanged!: | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) | null; private serverCapabilities: ServerCapabilities | undefined; @@ -414,34 +444,37 @@ export class VoiceAssistantWebSocketServer { this.agentStorage = agentStorage; this.projectRegistry = projectRegistry ?? createNoopProjectRegistry(); this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry(); - if (!chatService) { - throw new Error("VoiceAssistantWebSocketServer requires a chat service."); - } - this.chatService = chatService; - if (!loopService) { - throw new Error("VoiceAssistantWebSocketServer requires a loop service."); - } - this.loopService = loopService; - if (!scheduleService) { - throw new Error("VoiceAssistantWebSocketServer requires a schedule service."); - } - this.scheduleService = scheduleService; - if (!checkoutDiffManager) { - throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager."); - } - this.checkoutDiffManager = checkoutDiffManager; + const requiredServices = requireWebSocketServices({ + chatService, + loopService, + scheduleService, + checkoutDiffManager, + }); + this.chatService = requiredServices.chatService; + this.loopService = requiredServices.loopService; + this.scheduleService = requiredServices.scheduleService; + this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; - this.speech = speech ?? null; - this.terminalManager = terminalManager ?? null; - this.dictation = dictation ?? null; - this.agentProviderRuntimeSettings = agentProviderRuntimeSettings; - this.providerOverrides = providerOverrides; - this.isDev = isDev === true; + this.assignOptionalServices({ + speech, + terminalManager, + dictation, + agentProviderRuntimeSettings, + providerOverrides, + isDev, + onLifecycleIntent, + scriptRouteStore, + scriptRuntimeStore, + onBranchChanged, + getDaemonTcpPort, + getDaemonTcpHost, + resolveScriptHealth, + }); const providerSnapshotLogger = this.logger.child({ module: "provider-snapshot-manager" }); this.providerSnapshotManager = new ProviderSnapshotManager( buildProviderRegistry(providerSnapshotLogger, { @@ -451,13 +484,6 @@ export class VoiceAssistantWebSocketServer { }), providerSnapshotLogger, ); - this.onLifecycleIntent = onLifecycleIntent ?? null; - this.scriptRouteStore = scriptRouteStore ?? null; - this.scriptRuntimeStore = scriptRuntimeStore ?? null; - this.onBranchChanged = onBranchChanged ?? null; - this.getDaemonTcpPort = getDaemonTcpPort ?? null; - this.getDaemonTcpHost = getDaemonTcpHost ?? null; - this.resolveScriptHealth = resolveScriptHealth ?? null; this.serverCapabilities = buildServerCapabilities({ readiness: this.speech?.getReadiness() ?? null, }); @@ -479,49 +505,100 @@ export class VoiceAssistantWebSocketServer { }); }); + this.wss = this.createWebSocketServer(server, wsConfig); + this.startRuntimeMetricsInterval(); + + this.logger.info("WebSocket server initialized on /ws"); + } + + private assignOptionalServices(params: { + speech: SpeechService | null | undefined; + terminalManager: TerminalManager | null | undefined; + dictation: { finalTimeoutMs?: number } | undefined; + agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined; + providerOverrides: Record | undefined; + isDev: boolean | undefined; + onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | undefined; + scriptRouteStore: ScriptRouteStore | null | undefined; + scriptRuntimeStore: WorkspaceScriptRuntimeStore | null | undefined; + onBranchChanged: + | ((workspaceId: string, oldBranch: string | null, newBranch: string | null) => void) + | undefined; + getDaemonTcpPort: (() => number | null) | undefined; + getDaemonTcpHost: (() => string | null) | undefined; + resolveScriptHealth: ((hostname: string) => ScriptHealthState | null) | undefined; + }): void { + this.speech = params.speech ?? null; + this.terminalManager = params.terminalManager ?? null; + this.dictation = params.dictation ?? null; + this.agentProviderRuntimeSettings = params.agentProviderRuntimeSettings; + this.providerOverrides = params.providerOverrides; + this.isDev = params.isDev === true; + this.onLifecycleIntent = params.onLifecycleIntent ?? null; + this.scriptRouteStore = params.scriptRouteStore ?? null; + this.scriptRuntimeStore = params.scriptRuntimeStore ?? null; + this.onBranchChanged = params.onBranchChanged ?? null; + this.getDaemonTcpPort = params.getDaemonTcpPort ?? null; + this.getDaemonTcpHost = params.getDaemonTcpHost ?? null; + this.resolveScriptHealth = params.resolveScriptHealth ?? null; + } + + private createWebSocketServer( + server: HTTPServer, + wsConfig: WebSocketServerConfig, + ): WebSocketServer { const { allowedOrigins, hostnames } = wsConfig; - this.wss = new WebSocketServer({ + const wss = new WebSocketServer({ server, path: "/ws", verifyClient: ({ req }, callback) => { - const requestMetadata = extractSocketRequestMetadata(req); - const origin = requestMetadata.origin; - const requestHost = requestMetadata.host ?? null; - if (requestHost && !isHostnameAllowed(requestHost, hostnames)) { - this.incrementRuntimeCounter("hostRejected"); - this.logger.warn( - { ...requestMetadata, host: requestHost }, - "Rejected connection from disallowed host", - ); - callback(false, 403, "Host not allowed"); - return; - } - const sameOrigin = - !!origin && - !!requestHost && - (origin === `http://${requestHost}` || origin === `https://${requestHost}`); - - if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) { - callback(true); - } else { - this.incrementRuntimeCounter("originRejected"); - this.logger.warn({ ...requestMetadata, origin }, "Rejected connection from origin"); - callback(false, 403, "Origin not allowed"); - } + this.verifyWsClient(req, allowedOrigins, hostnames, callback); }, }); - - this.wss.on("connection", (ws, request) => { + wss.on("connection", (ws, request) => { void this.attachSocket(ws, request); }); + return wss; + } + private startRuntimeMetricsInterval(): void { const runtimeMetricsInterval = setInterval(() => { this.flushRuntimeMetrics(); }, WS_RUNTIME_METRICS_FLUSH_MS); this.runtimeMetricsInterval = runtimeMetricsInterval; (runtimeMetricsInterval as unknown as { unref?: () => void }).unref?.(); + } - this.logger.info("WebSocket server initialized on /ws"); + private verifyWsClient( + req: IncomingMessage, + allowedOrigins: Set, + hostnames: HostnamesConfig | undefined, + callback: (res: boolean, code?: number, message?: string) => void, + ): void { + const requestMetadata = extractSocketRequestMetadata(req); + const origin = requestMetadata.origin; + const requestHost = requestMetadata.host ?? null; + if (requestHost && !isHostnameAllowed(requestHost, hostnames)) { + this.incrementRuntimeCounter("hostRejected"); + this.logger.warn( + { ...requestMetadata, host: requestHost }, + "Rejected connection from disallowed host", + ); + callback(false, 403, "Host not allowed"); + return; + } + const sameOrigin = + !!origin && + !!requestHost && + (origin === `http://${requestHost}` || origin === `https://${requestHost}`); + + if (!origin || allowedOrigins.has("*") || allowedOrigins.has(origin) || sameOrigin) { + callback(true); + } else { + this.incrementRuntimeCounter("originRejected"); + this.logger.warn({ ...requestMetadata, origin }, "Rejected connection from origin"); + callback(false, 403, "Origin not allowed"); + } } public broadcast(message: WSOutboundMessage): void { @@ -1086,6 +1163,137 @@ export class VoiceAssistantWebSocketServer { await connection.session.cleanup(); } + private handleInvalidInboundMessage(args: { + ws: WebSocketLike; + parsed: unknown; + parsedMessage: { success: false; error: { message: string } } & Record; + pendingConnection: PendingConnection | undefined; + activeConnection: SessionConnection | undefined; + log: pino.Logger; + }): void { + const { ws, parsed, parsedMessage, pendingConnection, activeConnection, log } = args; + this.incrementRuntimeCounter("validationFailed"); + if (pendingConnection) { + pendingConnection.connectionLogger.warn( + { error: parsedMessage.error.message }, + "Rejected pending message before hello", + ); + this.clearPendingConnection(ws); + try { + ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello"); + } catch { + // ignore close errors + } + return; + } + + const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed); + const isUnknownSchema = + requestInfo?.requestId != null && + typeof parsed === "object" && + parsed != null && + "type" in parsed && + (parsed as { type?: unknown }).type === "session"; + + log.warn( + { + clientId: activeConnection?.clientId, + requestId: requestInfo?.requestId, + requestType: requestInfo?.requestType, + error: parsedMessage.error.message, + }, + "WS inbound message validation failed", + ); + + if (requestInfo) { + this.sendToClient( + ws, + wrapSessionMessage({ + type: "rpc_error", + payload: { + requestId: requestInfo.requestId, + requestType: requestInfo.requestType, + error: isUnknownSchema ? "Unknown request schema" : "Invalid message", + code: isUnknownSchema ? "unknown_schema" : "invalid_message", + }, + }), + ); + return; + } + + const errorMessage = `Invalid message: ${parsedMessage.error.message}`; + this.sendToClient( + ws, + wrapSessionMessage({ + type: "status", + payload: { + status: "error", + message: errorMessage, + }, + }), + ); + } + + private maybeHandleBinaryFrame(params: { + ws: WebSocketLike; + buffer: Buffer; + activeConnection: SessionConnection | undefined; + log: pino.Logger; + }): boolean { + const { ws, buffer, activeConnection, log } = params; + const asBytes = asUint8Array(buffer); + if (!asBytes) { + return false; + } + const frame = decodeTerminalStreamFrame(asBytes); + if (!frame) { + return false; + } + if (!activeConnection) { + this.incrementRuntimeCounter("binaryBeforeHelloRejected"); + log.warn("Rejected binary frame before hello"); + this.clearPendingConnection(ws); + try { + ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); + } catch { + // ignore close errors + } + return true; + } + activeConnection.session.handleBinaryFrame(frame); + return true; + } + + private handlePendingConnectionMessage(params: { + ws: WebSocketLike; + message: WSInboundMessage; + pendingConnection: PendingConnection; + }): void { + const { ws, message, pendingConnection } = params; + if (message.type === "hello") { + this.handleHello({ + ws, + message, + pending: pendingConnection, + }); + return; + } + + pendingConnection.connectionLogger.warn( + { + messageType: message.type, + }, + "Rejected pending message before hello", + ); + this.incrementRuntimeCounter("pendingMessageRejectedBeforeHello"); + this.clearPendingConnection(ws); + try { + ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); + } catch { + // ignore close errors + } + } + private async handleRawMessage( ws: WebSocketLike, data: Buffer | ArrayBuffer | Buffer[] | string, @@ -1097,90 +1305,27 @@ export class VoiceAssistantWebSocketServer { try { const buffer = bufferFromWsData(data); - const asBytes = asUint8Array(buffer); - if (asBytes) { - const frame = decodeTerminalStreamFrame(asBytes); - if (frame) { - if (!activeConnection) { - this.incrementRuntimeCounter("binaryBeforeHelloRejected"); - log.warn("Rejected binary frame before hello"); - this.clearPendingConnection(ws); - try { - ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); - } catch { - // ignore close errors - } - return; - } - activeConnection.session.handleBinaryFrame(frame); - return; - } + const binaryHandled = this.maybeHandleBinaryFrame({ + ws, + buffer, + activeConnection, + log, + }); + if (binaryHandled) { + return; } + const parsed = JSON.parse(buffer.toString()); const parsedMessage = WSInboundMessageSchema.safeParse(parsed); if (!parsedMessage.success) { - this.incrementRuntimeCounter("validationFailed"); - if (pendingConnection) { - pendingConnection.connectionLogger.warn( - { - error: parsedMessage.error.message, - }, - "Rejected pending message before hello", - ); - this.clearPendingConnection(ws); - try { - ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello"); - } catch { - // ignore close errors - } - return; - } - - const requestInfo = extractRequestInfoFromUnknownWsInbound(parsed); - const isUnknownSchema = - requestInfo?.requestId != null && - typeof parsed === "object" && - parsed != null && - "type" in parsed && - (parsed as { type?: unknown }).type === "session"; - - log.warn( - { - clientId: activeConnection?.clientId, - requestId: requestInfo?.requestId, - requestType: requestInfo?.requestType, - error: parsedMessage.error.message, - }, - "WS inbound message validation failed", - ); - - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: isUnknownSchema ? "Unknown request schema" : "Invalid message", - code: isUnknownSchema ? "unknown_schema" : "invalid_message", - }, - }), - ); - return; - } - - const errorMessage = `Invalid message: ${parsedMessage.error.message}`; - this.sendToClient( + this.handleInvalidInboundMessage({ ws, - wrapSessionMessage({ - type: "status", - payload: { - status: "error", - message: errorMessage, - }, - }), - ); + parsed, + parsedMessage, + pendingConnection, + activeConnection, + log, + }); return; } @@ -1197,28 +1342,11 @@ export class VoiceAssistantWebSocketServer { } if (pendingConnection) { - if (message.type === "hello") { - this.handleHello({ - ws, - message, - pending: pendingConnection, - }); - return; - } - - pendingConnection.connectionLogger.warn( - { - messageType: message.type, - }, - "Rejected pending message before hello", - ); - this.incrementRuntimeCounter("pendingMessageRejectedBeforeHello"); - this.clearPendingConnection(ws); - try { - ws.close(WS_CLOSE_INVALID_HELLO, "Session message before hello"); - } catch { - // ignore close errors - } + this.handlePendingConnectionMessage({ + ws, + message, + pendingConnection, + }); return; } @@ -1240,92 +1368,116 @@ export class VoiceAssistantWebSocketServer { } if (message.type === "session") { - this.recordInboundSessionRequestType(message.message.type); - const startMs = performance.now(); - await activeConnection.session.handleMessage(message.message); - const durationMs = performance.now() - startMs; - this.recordRequestLatency(message.message.type, durationMs); - - if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) { - activeConnection.connectionLogger.warn( - { - requestType: message.message.type, - durationMs: Math.round(durationMs), - inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests, - }, - "ws_slow_request", - ); - } + await this.dispatchSessionMessage(activeConnection, message); } } catch (error) { - const err = error instanceof Error ? error : new Error(String(error)); - let rawPayload: string | null = null; - let parsedPayload: unknown = null; + this.handleRawMessageError({ ws, data, error, log }); + } + } - try { - const buffer = bufferFromWsData(data); - rawPayload = buffer.toString(); - parsedPayload = JSON.parse(rawPayload); - } catch (payloadError) { - rawPayload = rawPayload ?? ""; - parsedPayload = parsedPayload ?? rawPayload; - const payloadErr = - payloadError instanceof Error ? payloadError : new Error(String(payloadError)); - this.logger.error({ err: payloadErr }, "Failed to decode raw payload"); - } + private async dispatchSessionMessage( + activeConnection: SessionConnection, + message: Extract, + ): Promise { + this.recordInboundSessionRequestType(message.message.type); + const startMs = performance.now(); + await activeConnection.session.handleMessage(message.message); + const durationMs = performance.now() - startMs; + this.recordRequestLatency(message.message.type, durationMs); - const trimmedRawPayload = - typeof rawPayload === "string" && rawPayload.length > 2000 - ? `${rawPayload.slice(0, 2000)}... (truncated)` - : rawPayload; - - log.error( + if (durationMs >= SLOW_REQUEST_THRESHOLD_MS) { + activeConnection.connectionLogger.warn( { - err, - rawPayload: trimmedRawPayload, - parsedPayload, + requestType: message.message.type, + durationMs: Math.round(durationMs), + inflightRequests: activeConnection.session.getRuntimeMetrics().inflightRequests, }, - "Failed to parse/handle message", + "ws_slow_request", ); + } + } - if (this.pendingConnections.has(ws)) { - this.clearPendingConnection(ws); - try { - ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello"); - } catch { - // ignore close errors - } - return; - } + private handleRawMessageError(params: { + ws: WebSocketLike; + data: Buffer | ArrayBuffer | Buffer[] | string; + error: unknown; + log: pino.Logger; + }): void { + const { ws, data, error, log } = params; + const err = error instanceof Error ? error : new Error(String(error)); + const { rawPayload, parsedPayload } = this.decodeRawMessagePayloadForError(data); - const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); - if (requestInfo) { - this.sendToClient( - ws, - wrapSessionMessage({ - type: "rpc_error", - payload: { - requestId: requestInfo.requestId, - requestType: requestInfo.requestType, - error: "Invalid message", - code: "invalid_message", - }, - }), - ); - return; + const trimmedRawPayload = + typeof rawPayload === "string" && rawPayload.length > 2000 + ? `${rawPayload.slice(0, 2000)}... (truncated)` + : rawPayload; + + log.error( + { + err, + rawPayload: trimmedRawPayload, + parsedPayload, + }, + "Failed to parse/handle message", + ); + + if (this.pendingConnections.has(ws)) { + this.clearPendingConnection(ws); + try { + ws.close(WS_CLOSE_INVALID_HELLO, "Invalid hello"); + } catch { + // ignore close errors } + return; + } + const requestInfo = extractRequestInfoFromUnknownWsInbound(parsedPayload); + if (requestInfo) { this.sendToClient( ws, wrapSessionMessage({ - type: "status", + type: "rpc_error", payload: { - status: "error", - message: `Invalid message: ${err.message}`, + requestId: requestInfo.requestId, + requestType: requestInfo.requestType, + error: "Invalid message", + code: "invalid_message", }, }), ); + return; } + + this.sendToClient( + ws, + wrapSessionMessage({ + type: "status", + payload: { + status: "error", + message: `Invalid message: ${err.message}`, + }, + }), + ); + } + + private decodeRawMessagePayloadForError(data: Buffer | ArrayBuffer | Buffer[] | string): { + rawPayload: string | null; + parsedPayload: unknown; + } { + let rawPayload: string | null = null; + let parsedPayload: unknown = null; + try { + const buffer = bufferFromWsData(data); + rawPayload = buffer.toString(); + parsedPayload = JSON.parse(rawPayload); + } catch (payloadError) { + rawPayload = rawPayload ?? ""; + parsedPayload = parsedPayload ?? rawPayload; + const payloadErr = + payloadError instanceof Error ? payloadError : new Error(String(payloadError)); + this.logger.error({ err: payloadErr }, "Failed to decode raw payload"); + } + return { rawPayload, parsedPayload }; } private incrementRuntimeCounter(counter: keyof WebSocketRuntimeCounters): void { diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 9dab9b97fc..77b8e36cc7 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -191,7 +191,7 @@ function createGitHubServiceStub(): GitHubService { }; } -function createService(options?: { +interface CreateServiceOptions { getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; getPullRequestStatus?: ReturnType; @@ -207,46 +207,47 @@ function createService(options?: { runGitCommand?: ReturnType; watch?: ReturnType; now?: () => Date; -}) { +} + +function buildDefaultServiceDeps() { + return { + watch: (() => createWatcher()) as never, + readdir: vi.fn(async () => []), + getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), + getCheckoutShortstat: vi.fn(async () => ({ + additions: 1, + deletions: 0, + })), + getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), + getCheckoutDiff: vi.fn(async () => ({ diff: "", structured: [] })), + resolveBranchCheckout: vi.fn(async () => ({ kind: "not-found" })), + resolveRepositoryDefaultBranch: vi.fn(async () => "main"), + listBranchSuggestions: vi.fn(async () => []), + listPaseoWorktrees: vi.fn(async () => []), + github: createGitHubServiceStub(), + resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote: vi.fn(async () => false), + runGitFetch: vi.fn(async () => {}), + runGitCommand: vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), + now: () => new Date("2026-04-12T00:00:00.000Z"), + }; +} + +function buildServiceDeps(options?: CreateServiceOptions) { + return { ...buildDefaultServiceDeps(), ...(options ?? {}) }; +} + +function createService(options?: CreateServiceOptions) { return new WorkspaceGitServiceImpl({ logger: createLogger() as never, paseoHome: "/tmp/paseo-test", - deps: { - watch: options?.watch ?? ((() => createWatcher()) as never), - readdir: vi.fn(async () => []), - getCheckoutStatus: - options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), - getCheckoutShortstat: - options?.getCheckoutShortstat ?? - vi.fn(async () => ({ - additions: 1, - deletions: 0, - })), - getPullRequestStatus: - options?.getPullRequestStatus ?? vi.fn(async () => createPullRequestStatusResult()), - getCheckoutDiff: - options?.getCheckoutDiff ?? vi.fn(async () => ({ diff: "", structured: [] })), - resolveBranchCheckout: - options?.resolveBranchCheckout ?? vi.fn(async () => ({ kind: "not-found" })), - resolveRepositoryDefaultBranch: - options?.resolveRepositoryDefaultBranch ?? vi.fn(async () => "main"), - listBranchSuggestions: options?.listBranchSuggestions ?? vi.fn(async () => []), - listPaseoWorktrees: options?.listPaseoWorktrees ?? vi.fn(async () => []), - github: options?.github ?? createGitHubServiceStub(), - resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), - hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), - runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), - runGitCommand: - options?.runGitCommand ?? - vi.fn(async () => ({ - stdout: "/tmp/repo\n", - stderr: "", - truncated: false, - exitCode: 0, - signal: null, - })), - now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), - }, + deps: buildServiceDeps(options), }); } diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 8538bd362a..c879ccc599 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -167,7 +167,7 @@ function createGitHubServiceStub(): GitHubService { }; } -function createService(options?: { +interface CreateServiceTestOptions { getCheckoutStatus?: ReturnType; getCheckoutShortstat?: ReturnType; getPullRequestStatus?: ReturnType; @@ -179,38 +179,38 @@ function createService(options?: { readdir?: ReturnType; watch?: ReturnType; now?: () => Date; -}) { +} + +function buildDefaultTestServiceDeps() { + return { + watch: (() => createWatcher()) as unknown as any, + readdir: vi.fn(async () => []), + getCheckoutStatus: vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), + getCheckoutShortstat: vi.fn(async () => ({ + additions: 1, + deletions: 0, + })), + getPullRequestStatus: vi.fn(async () => createPullRequestStatusResult()), + github: createGitHubServiceStub(), + resolveAbsoluteGitDir: vi.fn(async () => "/tmp/repo/.git"), + hasOriginRemote: vi.fn(async () => false), + runGitFetch: vi.fn(async () => {}), + runGitCommand: vi.fn(async () => ({ + stdout: "/tmp/repo\n", + stderr: "", + truncated: false, + exitCode: 0, + signal: null, + })), + now: () => new Date("2026-04-12T00:00:00.000Z"), + }; +} + +function createService(options?: CreateServiceTestOptions) { return new WorkspaceGitServiceImpl({ logger: createLogger() as any, paseoHome: "/tmp/paseo-test", - deps: { - watch: options?.watch ?? ((() => createWatcher()) as unknown as any), - readdir: options?.readdir ?? vi.fn(async () => []), - getCheckoutStatus: - options?.getCheckoutStatus ?? vi.fn(async (cwd: string) => createCheckoutStatus(cwd)), - getCheckoutShortstat: - options?.getCheckoutShortstat ?? - vi.fn(async () => ({ - additions: 1, - deletions: 0, - })), - getPullRequestStatus: - options?.getPullRequestStatus ?? vi.fn(async () => createPullRequestStatusResult()), - github: options?.github ?? createGitHubServiceStub(), - resolveAbsoluteGitDir: options?.resolveAbsoluteGitDir ?? vi.fn(async () => "/tmp/repo/.git"), - hasOriginRemote: options?.hasOriginRemote ?? vi.fn(async () => false), - runGitFetch: options?.runGitFetch ?? vi.fn(async () => {}), - runGitCommand: - options?.runGitCommand ?? - vi.fn(async () => ({ - stdout: "/tmp/repo\n", - stderr: "", - truncated: false, - exitCode: 0, - signal: null, - })), - now: options?.now ?? (() => new Date("2026-04-12T00:00:00.000Z")), - }, + deps: { ...buildDefaultTestServiceDeps(), ...(options ?? {}) }, }); } diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 816f4f3a89..d53941d1a6 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -279,6 +279,33 @@ interface WorkspaceGitAuxiliaryReadCacheEntry { inFlight: Promise | null; } +function buildDefaultWorkspaceGitServiceDeps(): WorkspaceGitServiceDependencies { + return { + watch, + readdir, + getCheckoutStatus, + getCheckoutShortstat, + getCheckoutDiff, + getPullRequestStatus, + resolveBranchCheckout, + resolveRepositoryDefaultBranch, + listBranchSuggestions, + listPaseoWorktrees, + github: createGitHubService(), + resolveAbsoluteGitDir, + hasOriginRemote, + runGitFetch, + runGitCommand, + now: () => new Date(), + }; +} + +function resolveWorkspaceGitServiceDeps( + deps: Partial | undefined, +): WorkspaceGitServiceDependencies { + return { ...buildDefaultWorkspaceGitServiceDeps(), ...(deps ?? {}) }; +} + export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; @@ -319,25 +346,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; - this.deps = { - watch: options.deps?.watch ?? watch, - readdir: options.deps?.readdir ?? readdir, - getCheckoutStatus: options.deps?.getCheckoutStatus ?? getCheckoutStatus, - getCheckoutShortstat: options.deps?.getCheckoutShortstat ?? getCheckoutShortstat, - getCheckoutDiff: options.deps?.getCheckoutDiff ?? getCheckoutDiff, - getPullRequestStatus: options.deps?.getPullRequestStatus ?? getPullRequestStatus, - resolveBranchCheckout: options.deps?.resolveBranchCheckout ?? resolveBranchCheckout, - resolveRepositoryDefaultBranch: - options.deps?.resolveRepositoryDefaultBranch ?? resolveRepositoryDefaultBranch, - listBranchSuggestions: options.deps?.listBranchSuggestions ?? listBranchSuggestions, - listPaseoWorktrees: options.deps?.listPaseoWorktrees ?? listPaseoWorktrees, - github: options.deps?.github ?? createGitHubService(), - resolveAbsoluteGitDir: options.deps?.resolveAbsoluteGitDir ?? resolveAbsoluteGitDir, - hasOriginRemote: options.deps?.hasOriginRemote ?? hasOriginRemote, - runGitFetch: options.deps?.runGitFetch ?? runGitFetch, - runGitCommand: options.deps?.runGitCommand ?? runGitCommand, - now: options.deps?.now ?? (() => new Date()), - }; + this.deps = resolveWorkspaceGitServiceDeps(options.deps); } registerWorkspace( diff --git a/packages/server/src/server/workspace-reconciliation-service.ts b/packages/server/src/server/workspace-reconciliation-service.ts index 3e71788bb8..d4f164aa36 100644 --- a/packages/server/src/server/workspace-reconciliation-service.ts +++ b/packages/server/src/server/workspace-reconciliation-service.ts @@ -155,69 +155,7 @@ export class WorkspaceReconciliationService { const siblings = workspacesByProject.get(project.projectId) ?? []; if (siblings.length === 0) continue; if (!existsSync(project.rootPath)) continue; - - const directoryName = - project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath; - const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName); - - const projectUpdates: Partial< - Pick - > = {}; - - const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git"; - - // Detect kind change: directory → git - if (project.kind !== mappedKind) { - projectUpdates.kind = mappedKind; - projectUpdates.displayName = currentGit.projectDisplayName; - } - - // Detect display name change (e.g. remote renamed) - if ( - project.kind === "git" && - currentGit.projectKind === "git" && - project.displayName !== currentGit.projectDisplayName - ) { - projectUpdates.displayName = currentGit.projectDisplayName; - } - - if (Object.keys(projectUpdates).length > 0) { - const timestamp = new Date().toISOString(); - await this.projectRegistry.upsert({ - ...project, - ...projectUpdates, - updatedAt: timestamp, - }); - changes.push({ - kind: "project_updated", - projectId: project.projectId, - directory: project.rootPath, - fields: projectUpdates, - }); - } - - // 4. Reconcile workspace display names (branch name changes) - for (const workspace of siblings) { - if (!existsSync(workspace.cwd)) continue; - - const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd; - const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName); - - if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { - const timestamp = new Date().toISOString(); - await this.workspaceRegistry.upsert({ - ...workspace, - displayName: wsGit.workspaceDisplayName, - updatedAt: timestamp, - }); - changes.push({ - kind: "workspace_updated", - workspaceId: workspace.workspaceId, - directory: workspace.cwd, - fields: { displayName: wsGit.workspaceDisplayName }, - }); - } - } + await this.reconcileProject(project, siblings, changes); } if (changes.length > 0 && this.onChanges) { @@ -227,6 +165,72 @@ export class WorkspaceReconciliationService { return { changesApplied: changes, durationMs: Date.now() - start }; } + private async reconcileProject( + project: PersistedProjectRecord, + siblings: PersistedWorkspaceRecord[], + changes: ReconciliationChange[], + ): Promise { + const directoryName = + project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath; + const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName); + + const projectUpdates: Partial< + Pick + > = {}; + + const mappedKind = currentGit.projectKind === "git" ? "git" : "non_git"; + + if (project.kind !== mappedKind) { + projectUpdates.kind = mappedKind; + projectUpdates.displayName = currentGit.projectDisplayName; + } + + if ( + project.kind === "git" && + currentGit.projectKind === "git" && + project.displayName !== currentGit.projectDisplayName + ) { + projectUpdates.displayName = currentGit.projectDisplayName; + } + + if (Object.keys(projectUpdates).length > 0) { + const timestamp = new Date().toISOString(); + await this.projectRegistry.upsert({ + ...project, + ...projectUpdates, + updatedAt: timestamp, + }); + changes.push({ + kind: "project_updated", + projectId: project.projectId, + directory: project.rootPath, + fields: projectUpdates, + }); + } + + for (const workspace of siblings) { + if (!existsSync(workspace.cwd)) continue; + + const wsDirName = workspace.cwd.split(/[\\/]/).filter(Boolean).at(-1) ?? workspace.cwd; + const wsGit = await this.readWorkspaceGitMetadata(workspace.cwd, wsDirName); + + if (wsGit.projectKind === "git" && workspace.displayName !== wsGit.workspaceDisplayName) { + const timestamp = new Date().toISOString(); + await this.workspaceRegistry.upsert({ + ...workspace, + displayName: wsGit.workspaceDisplayName, + updatedAt: timestamp, + }); + changes.push({ + kind: "workspace_updated", + workspaceId: workspace.workspaceId, + directory: workspace.cwd, + fields: { displayName: wsGit.workspaceDisplayName }, + }); + } + } + } + private async readWorkspaceGitMetadata(cwd: string, directoryName: string) { if (!this.workspaceGitService) { return { diff --git a/packages/server/src/server/worktree-bootstrap.test.ts b/packages/server/src/server/worktree-bootstrap.test.ts index 6631a03767..a430e8d737 100644 --- a/packages/server/src/server/worktree-bootstrap.test.ts +++ b/packages/server/src/server/worktree-bootstrap.test.ts @@ -689,6 +689,66 @@ describe("runAsyncWorktreeBootstrap", () => { return env; } + function assertServiceTerminalCallSelfEnv(params: { + createTerminalCalls: CreateTerminalCall[]; + terminalRecords: StubTerminalRecord[]; + repoDir: string; + }): void { + const { createTerminalCalls, terminalRecords, repoDir } = params; + expect(createTerminalCalls).toHaveLength(1); + expect(createTerminalCalls[0]?.cwd).toBe(repoDir); + expect(createTerminalCalls[0]?.name).toBe("api"); + expect(terminalRecords[0]?.sentInputs).toEqual(["npm run api\r"]); + expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT"); + expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); + expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); + expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( + "http://api.feature-socket-service.repo.localhost:6767", + ); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe( + createTerminalCalls[0]?.env?.PASEO_PORT, + ); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( + "http://api.feature-socket-service.repo.localhost:6767", + ); + } + + async function assertServiceTerminalCallPeerEnv(params: { + createTerminalCalls: CreateTerminalCall[]; + repoDir: string; + }): Promise { + const { createTerminalCalls, repoDir } = params; + const plannedPorts = await ensureWorkspaceServicePortPlan({ + workspaceId: repoDir, + services: [{ scriptName: "api" }, { scriptName: "app-server" }], + allocatePort: async () => { + throw new Error("Peer env test should reuse the existing service port plan"); + }, + }); + const plannedAppServerPort = plannedPorts.get("app-server"); + if (plannedAppServerPort === undefined) { + throw new Error("Expected app-server to be present in the service port plan"); + } + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_PORT).toBe( + String(plannedAppServerPort), + ); + expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( + "http://app-server.feature-socket-service.repo.localhost:6767", + ); + } + + async function assertServiceTerminalCallEnv(params: { + createTerminalCalls: CreateTerminalCall[]; + terminalRecords: StubTerminalRecord[]; + repoDir: string; + }): Promise { + assertServiceTerminalCallSelfEnv(params); + await assertServiceTerminalCallPeerEnv({ + createTerminalCalls: params.createTerminalCalls, + repoDir: params.repoDir, + }); + } + function commitPaseoScripts( scripts: Record, message = "add script config", @@ -1024,39 +1084,11 @@ describe("runAsyncWorktreeBootstrap", () => { scriptName: "api", }, ]); - expect(createTerminalCalls).toHaveLength(1); - expect(createTerminalCalls[0]?.cwd).toBe(repoDir); - expect(createTerminalCalls[0]?.name).toBe("api"); - expect(terminalRecords[0]?.sentInputs).toEqual(["npm run api\r"]); - expect(createTerminalCalls[0]?.env).not.toHaveProperty("PORT"); - expect(createTerminalCalls[0]?.env?.PASEO_PORT).toEqual(expect.any(String)); - expect(createTerminalCalls[0]?.env?.HOST).toBe("127.0.0.1"); - expect(createTerminalCalls[0]?.env?.PASEO_URL).toBe( - "http://api.feature-socket-service.repo.localhost:6767", - ); - expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_PORT).toBe( - createTerminalCalls[0]?.env?.PASEO_PORT, - ); - expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_API_URL).toBe( - "http://api.feature-socket-service.repo.localhost:6767", - ); - const plannedPorts = await ensureWorkspaceServicePortPlan({ - workspaceId: repoDir, - services: [{ scriptName: "api" }, { scriptName: "app-server" }], - allocatePort: async () => { - throw new Error("Peer env test should reuse the existing service port plan"); - }, + await assertServiceTerminalCallEnv({ + createTerminalCalls, + terminalRecords, + repoDir, }); - const plannedAppServerPort = plannedPorts.get("app-server"); - if (plannedAppServerPort === undefined) { - throw new Error("Expected app-server to be present in the service port plan"); - } - expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_PORT).toBe( - String(plannedAppServerPort), - ); - expect(createTerminalCalls[0]?.env?.PASEO_SERVICE_APP_SERVER_URL).toBe( - "http://app-server.feature-socket-service.repo.localhost:6767", - ); expect(runtimeStore.get({ workspaceId: repoDir, scriptName: "api" })).toMatchObject({ type: "service", lifecycle: "running", diff --git a/packages/server/src/server/worktree-bootstrap.ts b/packages/server/src/server/worktree-bootstrap.ts index 47ebb8f8b4..9c11ffcf98 100644 --- a/packages/server/src/server/worktree-bootstrap.ts +++ b/packages/server/src/server/worktree-bootstrap.ts @@ -700,6 +700,117 @@ interface SpawnWorkspaceScriptOptions { onLifecycleChanged?: () => void; } +interface ServiceScriptSetupResult { + hostname: string; + port: number; + env: Record; +} + +async function setupServiceScriptRoute(params: { + scriptConfigs: ReturnType; + config: { port?: number }; + scriptName: string; + projectSlug: string; + branchName: string | null; + workspaceId: string; + daemonPort: number | null | undefined; + daemonListenHost: string | null | undefined; + existingRuntimeEntry: ReturnType; + routeStore: ScriptRouteStore; +}): Promise { + const { + scriptConfigs, + config, + scriptName, + projectSlug, + branchName, + workspaceId, + daemonPort, + daemonListenHost, + existingRuntimeEntry, + routeStore, + } = params; + const hostname = buildScriptHostname({ projectSlug, branchName, scriptName }); + + const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; + for (const [configuredScriptName, scriptConfig] of scriptConfigs) { + if (isServiceScript(scriptConfig)) { + serviceDeclarations.push({ + scriptName: configuredScriptName, + port: scriptConfig.port, + }); + } + } + assertNoServiceEnvNameCollisions( + serviceDeclarations.map((serviceDeclaration) => serviceDeclaration.scriptName), + ); + + const plannedPorts = await ensureWorkspaceServicePortPlan({ + workspaceId, + services: serviceDeclarations, + allocatePort: findFreePort, + }); + const port = + existingRuntimeEntry?.lifecycle === "stopped" + ? await refreshWorkspaceServicePort({ + workspaceId, + service: { scriptName, port: config.port }, + allocatePort: findFreePort, + }) + : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); + + const peers: WorkspaceServicePeer[] = []; + for (const [peerScriptName, peerPort] of plannedPorts) { + peers.push({ + scriptName: peerScriptName, + port: peerScriptName === scriptName ? port : peerPort, + }); + } + + const env = buildWorkspaceServiceEnv({ + scriptName, + projectSlug, + branchName, + daemonPort, + daemonListenHost, + peers, + }); + + routeStore.registerRoute({ + hostname, + port, + workspaceId, + projectSlug, + scriptName, + }); + return { hostname, port, env }; +} + +async function acquireWorkspaceScriptTerminal(params: { + serviceScript: boolean; + existingRuntimeEntry: ReturnType; + terminalManager: TerminalManager; + repoRoot: string; + scriptName: string; + env: Record | undefined; +}): Promise<{ terminal: TerminalSession; reusableTerminal: TerminalSession | null }> { + const { serviceScript, existingRuntimeEntry, terminalManager, repoRoot, scriptName, env } = + params; + let reusableTerminal: TerminalSession | null = null; + if (!serviceScript && existingRuntimeEntry?.terminalId) { + reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; + } + const terminal = + reusableTerminal ?? + (await terminalManager.createTerminal({ + cwd: repoRoot, + name: scriptName, + title: scriptName, + env, + })); + return { terminal, reusableTerminal }; +} + export async function spawnWorkspaceScript( options: SpawnWorkspaceScriptOptions, ): Promise { @@ -739,78 +850,32 @@ export async function spawnWorkspaceScript( const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName }); let env: Record | undefined; if (serviceScript) { - hostname = buildScriptHostname({ + const serviceSetup = await setupServiceScriptRoute({ + scriptConfigs, + config, + scriptName, projectSlug, branchName, - scriptName, - }); - - const serviceDeclarations: Array<{ scriptName: string; port?: number }> = []; - for (const [configuredScriptName, scriptConfig] of scriptConfigs) { - if (isServiceScript(scriptConfig)) { - serviceDeclarations.push({ - scriptName: configuredScriptName, - port: scriptConfig.port, - }); - } - } - assertNoServiceEnvNameCollisions( - serviceDeclarations.map((serviceDeclaration) => serviceDeclaration.scriptName), - ); - - const plannedPorts = await ensureWorkspaceServicePortPlan({ workspaceId, - services: serviceDeclarations, - allocatePort: findFreePort, - }); - port = - existingRuntimeEntry?.lifecycle === "stopped" - ? await refreshWorkspaceServicePort({ - workspaceId, - service: { scriptName, port: config.port }, - allocatePort: findFreePort, - }) - : requirePlannedWorkspaceServicePort(plannedPorts, scriptName); - - const peers: WorkspaceServicePeer[] = []; - for (const [peerScriptName, peerPort] of plannedPorts) { - peers.push({ - scriptName: peerScriptName, - port: peerScriptName === scriptName ? port : peerPort, - }); - } - - env = buildWorkspaceServiceEnv({ - scriptName, - projectSlug, - branchName, daemonPort, daemonListenHost, - peers, - }); - - routeStore.registerRoute({ - hostname, - port, - workspaceId, - projectSlug, - scriptName, + existingRuntimeEntry, + routeStore, }); + hostname = serviceSetup.hostname; + port = serviceSetup.port; + env = serviceSetup.env; routeRegistered = true; } - let reusableTerminal: TerminalSession | null = null; - if (!serviceScript && existingRuntimeEntry?.terminalId) { - reusableTerminal = terminalManager.getTerminal(existingRuntimeEntry.terminalId) ?? null; - } - const terminal = - reusableTerminal ?? - (await terminalManager.createTerminal({ - cwd: repoRoot, - name: scriptName, - title: scriptName, - env, - })); + const { terminal, reusableTerminal } = await acquireWorkspaceScriptTerminal({ + serviceScript, + existingRuntimeEntry, + terminalManager, + repoRoot, + scriptName, + env, + }); runtimeStore.set({ workspaceId, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 1180eae235..8de2472b97 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -205,6 +205,36 @@ export async function buildAgentSessionConfig( }; } +interface ValidateNormalizedGitOptionsInput { + baseBranch: string | undefined; + createNewBranch: boolean; + normalizedBranchName: string | undefined; + normalizedWorktreeSlug: string | undefined; +} + +function validateNormalizedGitOptions(input: ValidateNormalizedGitOptionsInput): void { + if (input.baseBranch) { + assertSafeGitRef(input.baseBranch, "base branch"); + } + + if (input.createNewBranch) { + if (!input.normalizedBranchName) { + throw new Error("New branch name is required"); + } + const validation = validateBranchSlug(input.normalizedBranchName); + if (!validation.valid) { + throw new Error(`Invalid branch name: ${validation.error}`); + } + } + + if (input.normalizedWorktreeSlug) { + const validation = validateBranchSlug(input.normalizedWorktreeSlug); + if (!validation.valid) { + throw new Error(`Invalid worktree name: ${validation.error}`); + } + } +} + export function normalizeGitOptions( gitOptions?: GitSetupOptions, legacyWorktreeName?: string, @@ -244,26 +274,12 @@ export function normalizeGitOptions( return null; } - if (baseBranch) { - assertSafeGitRef(baseBranch, "base branch"); - } - - if (createNewBranch) { - if (!normalizedBranchName) { - throw new Error("New branch name is required"); - } - const validation = validateBranchSlug(normalizedBranchName); - if (!validation.valid) { - throw new Error(`Invalid branch name: ${validation.error}`); - } - } - - if (normalizedWorktreeSlug) { - const validation = validateBranchSlug(normalizedWorktreeSlug); - if (!validation.valid) { - throw new Error(`Invalid worktree name: ${validation.error}`); - } - } + validateNormalizedGitOptions({ + baseBranch, + createNewBranch, + normalizedBranchName, + normalizedWorktreeSlug, + }); return { baseBranch, diff --git a/packages/server/src/shared/terminal-key-input.ts b/packages/server/src/shared/terminal-key-input.ts index 77b941ecc9..40614e53ac 100644 --- a/packages/server/src/shared/terminal-key-input.ts +++ b/packages/server/src/shared/terminal-key-input.ts @@ -19,47 +19,58 @@ function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string { return input.alt ? `\x1b${sequence}` : sequence; } +function ctrlSymbolCode(char: string): string | null { + switch (char) { + case " ": + case "@": + case "2": + return "\x00"; + case "[": + case "3": + return "\x1b"; + case "\\": + case "4": + return "\x1c"; + case "]": + case "5": + return "\x1d"; + case "^": + case "6": + return "\x1e"; + case "_": + case "/": + case "7": + return "\x1f"; + case "8": + case "?": + return "\x7f"; + default: + return null; + } +} + +function encodeCtrlChar(char: string, input: TerminalKeyInput): string { + const upper = char.toUpperCase(); + if (upper.length === 1 && upper >= "A" && upper <= "Z") { + return applyAltLikePrefix(String.fromCharCode(upper.charCodeAt(0) - 64), input); + } + const symbol = ctrlSymbolCode(char); + if (symbol !== null) { + return applyAltLikePrefix(symbol, input); + } + if (char.length === 1) { + const code = char.charCodeAt(0) & 0x1f; + return applyAltLikePrefix(String.fromCharCode(code), input); + } + return applyAltLikePrefix(char, input); +} + function encodePrintableKey(input: TerminalKeyInput): string { const raw = input.key; const char = input.shift ? raw.toUpperCase() : raw; if (input.ctrl) { - const upper = char.toUpperCase(); - if (upper.length === 1 && upper >= "A" && upper <= "Z") { - return applyAltLikePrefix(String.fromCharCode(upper.charCodeAt(0) - 64), input); - } - switch (char) { - case " ": - case "@": - case "2": - return applyAltLikePrefix("\x00", input); - case "[": - case "3": - return applyAltLikePrefix("\x1b", input); - case "\\": - case "4": - return applyAltLikePrefix("\x1c", input); - case "]": - case "5": - return applyAltLikePrefix("\x1d", input); - case "^": - case "6": - return applyAltLikePrefix("\x1e", input); - case "_": - case "/": - case "7": - return applyAltLikePrefix("\x1f", input); - case "8": - case "?": - return applyAltLikePrefix("\x7f", input); - default: - break; - } - - if (char.length === 1) { - const code = char.charCodeAt(0) & 0x1f; - return applyAltLikePrefix(String.fromCharCode(code), input); - } + return encodeCtrlChar(char, input); } return applyAltLikePrefix(char, input); @@ -75,6 +86,64 @@ function csiTilde(base: number, input: TerminalKeyInput): string { return mod === 1 ? `\x1b[${base}~` : `\x1b[${base};${mod}~`; } +function encodeFunctionKey(key: string, input: TerminalKeyInput): string | null { + switch (key) { + case "F1": + return modifierParam(input) === 1 ? "\x1bOP" : csiWithModifier("P", input); + case "F2": + return modifierParam(input) === 1 ? "\x1bOQ" : csiWithModifier("Q", input); + case "F3": + return modifierParam(input) === 1 ? "\x1bOR" : csiWithModifier("R", input); + case "F4": + return modifierParam(input) === 1 ? "\x1bOS" : csiWithModifier("S", input); + case "F5": + return csiTilde(15, input); + case "F6": + return csiTilde(17, input); + case "F7": + return csiTilde(18, input); + case "F8": + return csiTilde(19, input); + case "F9": + return csiTilde(20, input); + case "F10": + return csiTilde(21, input); + case "F11": + return csiTilde(23, input); + case "F12": + return csiTilde(24, input); + default: + return null; + } +} + +function encodeNavigationKey(key: string, input: TerminalKeyInput): string | null { + switch (key) { + case "ArrowUp": + return csiWithModifier("A", input); + case "ArrowDown": + return csiWithModifier("B", input); + case "ArrowRight": + return csiWithModifier("C", input); + case "ArrowLeft": + return csiWithModifier("D", input); + case "Home": + return csiWithModifier("H", input); + case "End": + return csiWithModifier("F", input); + case "Insert": + return csiTilde(2, input); + case "Delete": + return csiTilde(3, input); + case "PageUp": + return csiTilde(5, input); + case "PageDown": + return csiTilde(6, input); + default: + return null; + } +} + export function encodeTerminalKeyInput(input: TerminalKeyInput): string { const key = input.key; if (!key) { @@ -102,51 +171,13 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string { return applyAltLikePrefix("\x7f", input); case "Escape": return "\x1b"; - case "ArrowUp": - return csiWithModifier("A", input); - case "ArrowDown": - return csiWithModifier("B", input); - case "ArrowRight": - return csiWithModifier("C", input); - case "ArrowLeft": - return csiWithModifier("D", input); - case "Home": - return csiWithModifier("H", input); - case "End": - return csiWithModifier("F", input); - case "Insert": - return csiTilde(2, input); - case "Delete": - return csiTilde(3, input); - case "PageUp": - return csiTilde(5, input); - case "PageDown": - return csiTilde(6, input); - case "F1": - return modifierParam(input) === 1 ? "\x1bOP" : csiWithModifier("P", input); - case "F2": - return modifierParam(input) === 1 ? "\x1bOQ" : csiWithModifier("Q", input); - case "F3": - return modifierParam(input) === 1 ? "\x1bOR" : csiWithModifier("R", input); - case "F4": - return modifierParam(input) === 1 ? "\x1bOS" : csiWithModifier("S", input); - case "F5": - return csiTilde(15, input); - case "F6": - return csiTilde(17, input); - case "F7": - return csiTilde(18, input); - case "F8": - return csiTilde(19, input); - case "F9": - return csiTilde(20, input); - case "F10": - return csiTilde(21, input); - case "F11": - return csiTilde(23, input); - case "F12": - return csiTilde(24, input); default: - return ""; + break; } + + const nav = encodeNavigationKey(key, input); + if (nav !== null) return nav; + const fn = encodeFunctionKey(key, input); + if (fn !== null) return fn; + return ""; } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index faa3dc4eca..5b56b5bf74 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -1563,6 +1563,194 @@ export function warmCheckoutShortstatInBackground( }); } +interface AppendStructuredTrackedDiffsInput { + cwd: string; + trackedChanges: CheckoutFileChange[]; + trackedChangeByPath: Map; + trackedNumstatByPath: Map; + trackedPlaceholderByPath: Map; + trackedDiffText: string; + trackedDiffTruncated: boolean; + refsForDiff: CheckoutDiffRefs; + ignoreWhitespace: boolean; + structured: ParsedDiffFile[]; + appendDiff: (text: string) => void; + appendTrackedPlaceholderComment: ( + change: CheckoutFileChange, + status: "binary" | "too_large", + ) => void; +} + +async function appendStructuredTrackedDiffs( + input: AppendStructuredTrackedDiffsInput, +): Promise { + const { + cwd, + trackedChanges, + trackedChangeByPath, + trackedNumstatByPath, + trackedPlaceholderByPath, + trackedDiffText, + trackedDiffTruncated, + refsForDiff, + ignoreWhitespace, + structured, + appendTrackedPlaceholderComment, + } = input; + + const parsedTrackedFiles = + trackedDiffText.length > 0 + ? await parseAndHighlightDiff(trackedDiffText, cwd, { + getOldFileContent: async (file) => { + const change = trackedChangeByPath.get(file.path); + if (!change || change.isNew) { + return null; + } + const refPath = change.oldPath ?? change.path; + return readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath); + }, + getNewFileContent: async (file) => { + if (!refsForDiff.targetRef) { + return null; + } + return readGitFileContentAtRef(cwd, refsForDiff.targetRef, file.path); + }, + }) + : []; + const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); + + for (const change of trackedChanges) { + const placeholder = trackedPlaceholderByPath.get(change.path); + if (placeholder) { + structured.push( + buildPlaceholderParsedDiffFile(change, { + status: placeholder.status, + stat: placeholder.stat, + }), + ); + appendTrackedPlaceholderComment(change, placeholder.status); + continue; + } + + const stat = trackedNumstatByPath.get(change.path) ?? null; + const parsedFile = parsedTrackedByPath.get(change.path); + if (parsedFile) { + structured.push({ + ...parsedFile, + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + status: "ok", + }); + continue; + } + + // `git diff -w --name-status` can still report a modified path even when the + // whitespace-filtered patch and numstat are both empty. Skip emitting a + // structured placeholder in that case so whitespace-only edits truly disappear. + if ( + ignoreWhitespace && + !trackedDiffTruncated && + change.status.startsWith("M") && + (!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0)) + ) { + continue; + } + + structured.push({ + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + hunks: [], + status: trackedDiffTruncated ? "too_large" : "ok", + }); + } +} + +interface ProcessUntrackedChangeInput { + cwd: string; + change: CheckoutFileChange; + ignoreWhitespace: boolean; + includeStructured: boolean; + structured: ParsedDiffFile[]; + appendDiff: (text: string) => void; +} + +async function processUntrackedChange(input: ProcessUntrackedChangeInput): Promise { + const { cwd, change, ignoreWhitespace, includeStructured, structured, appendDiff } = input; + const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); + + if (!includeStructured) { + if (stat?.isBinary) { + appendDiff(`# ${change.path}: binary diff omitted\n`); + } else if (truncated) { + appendDiff(`# ${change.path}: diff too large omitted\n`); + } else { + appendDiff(text); + } + return; + } + + if (stat?.isBinary) { + structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat })); + appendDiff(`# ${change.path}: binary diff omitted\n`); + return; + } + + if (truncated) { + structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat })); + appendDiff(`# ${change.path}: diff too large omitted\n`); + return; + } + + appendDiff(text); + const parsed = await parseAndHighlightDiff(text, cwd); + const parsedFile = + parsed[0] ?? + ({ + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + additions: stat?.additions ?? 0, + deletions: stat?.deletions ?? 0, + hunks: [], + } satisfies ParsedDiffFile); + + structured.push({ + ...parsedFile, + path: change.path, + isNew: change.isNew, + isDeleted: change.isDeleted, + status: "ok", + }); +} + +async function resolveCheckoutDiffRefs( + cwd: string, + compare: CheckoutDiffCompare, + context: CheckoutContext | undefined, +): Promise { + if (compare.mode === "uncommitted") { + return { baseRef: "HEAD", includeUntracked: true }; + } + const configured = await getConfiguredBaseRefForCwd(cwd, context); + const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); + if (!baseRef) { + return null; + } + if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) { + throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); + } + const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); + return { + baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, + targetRef: "HEAD", + includeUntracked: false, + }; +} + export async function getCheckoutDiff( cwd: string, compare: CheckoutDiffCompare, @@ -1570,26 +1758,9 @@ export async function getCheckoutDiff( ): Promise { await requireGitRepo(cwd); - let refsForDiff: CheckoutDiffRefs; - - if (compare.mode === "uncommitted") { - refsForDiff = { baseRef: "HEAD", includeUntracked: true }; - } else { - const configured = await getConfiguredBaseRefForCwd(cwd, context); - const baseRef = configured.baseRef ?? compare.baseRef ?? (await resolveBaseRef(cwd)); - if (!baseRef) { - return { diff: "" }; - } - if (configured.isPaseoOwnedWorktree && compare.baseRef && compare.baseRef !== baseRef) { - throw new Error(`Base ref mismatch: expected ${baseRef}, got ${compare.baseRef}`); - } - - const bestBaseRef = await resolveBestComparisonBaseRef(cwd, baseRef); - refsForDiff = { - baseRef: (await tryResolveMergeBase(cwd, bestBaseRef)) ?? bestBaseRef, - targetRef: "HEAD", - includeUntracked: false, - }; + const refsForDiff = await resolveCheckoutDiffRefs(cwd, compare, context); + if (!refsForDiff) { + return { diff: "" }; } const ignoreWhitespace = compare.ignoreWhitespace === true; @@ -1679,75 +1850,20 @@ export async function getCheckoutDiff( }; if (compare.includeStructured) { - const parsedTrackedFiles = - trackedDiffText.length > 0 - ? await parseAndHighlightDiff(trackedDiffText, cwd, { - getOldFileContent: async (file) => { - const change = trackedChangeByPath.get(file.path); - if (!change || change.isNew) { - return null; - } - const refPath = change.oldPath ?? change.path; - return readGitFileContentAtRef(cwd, refsForDiff.baseRef, refPath); - }, - getNewFileContent: async (file) => { - if (!refsForDiff.targetRef) { - return null; - } - return readGitFileContentAtRef(cwd, refsForDiff.targetRef, file.path); - }, - }) - : []; - const parsedTrackedByPath = new Map(parsedTrackedFiles.map((file) => [file.path, file])); - - for (const change of trackedChanges) { - const placeholder = trackedPlaceholderByPath.get(change.path); - if (placeholder) { - structured.push( - buildPlaceholderParsedDiffFile(change, { - status: placeholder.status, - stat: placeholder.stat, - }), - ); - appendTrackedPlaceholderComment(change, placeholder.status); - continue; - } - - const stat = trackedNumstatByPath.get(change.path) ?? null; - const parsedFile = parsedTrackedByPath.get(change.path); - if (parsedFile) { - structured.push({ - ...parsedFile, - path: change.path, - isNew: change.isNew, - isDeleted: change.isDeleted, - status: "ok", - }); - continue; - } - - // `git diff -w --name-status` can still report a modified path even when the - // whitespace-filtered patch and numstat are both empty. Skip emitting a - // structured placeholder in that case so whitespace-only edits truly disappear. - if ( - ignoreWhitespace && - !trackedDiffTruncated && - change.status.startsWith("M") && - (!stat || (!stat.isBinary && stat.additions === 0 && stat.deletions === 0)) - ) { - continue; - } - - structured.push({ - path: change.path, - isNew: change.isNew, - isDeleted: change.isDeleted, - additions: stat?.additions ?? 0, - deletions: stat?.deletions ?? 0, - hunks: [], - status: trackedDiffTruncated ? "too_large" : "ok", - }); - } + await appendStructuredTrackedDiffs({ + cwd, + trackedChanges, + trackedChangeByPath, + trackedNumstatByPath, + trackedPlaceholderByPath, + trackedDiffText, + trackedDiffTruncated, + refsForDiff, + ignoreWhitespace, + structured, + appendDiff, + appendTrackedPlaceholderComment, + }); } else { for (const change of trackedChanges) { const placeholder = trackedPlaceholderByPath.get(change.path); @@ -1761,50 +1877,13 @@ export async function getCheckoutDiff( if (diffBytes >= TOTAL_DIFF_MAX_BYTES) { break; } - const { text, truncated, stat } = await getUntrackedDiffText(cwd, change, ignoreWhitespace); - - if (!compare.includeStructured) { - if (stat?.isBinary) { - appendDiff(`# ${change.path}: binary diff omitted\n`); - } else if (truncated) { - appendDiff(`# ${change.path}: diff too large omitted\n`); - } else { - appendDiff(text); - } - continue; - } - - if (stat?.isBinary) { - structured.push(buildPlaceholderParsedDiffFile(change, { status: "binary", stat })); - appendDiff(`# ${change.path}: binary diff omitted\n`); - continue; - } - - if (truncated) { - structured.push(buildPlaceholderParsedDiffFile(change, { status: "too_large", stat })); - appendDiff(`# ${change.path}: diff too large omitted\n`); - continue; - } - - appendDiff(text); - const parsed = await parseAndHighlightDiff(text, cwd); - const parsedFile = - parsed[0] ?? - ({ - path: change.path, - isNew: change.isNew, - isDeleted: change.isDeleted, - additions: stat?.additions ?? 0, - deletions: stat?.deletions ?? 0, - hunks: [], - } satisfies ParsedDiffFile); - - structured.push({ - ...parsedFile, - path: change.path, - isNew: change.isNew, - isDeleted: change.isDeleted, - status: "ok", + await processUntrackedChange({ + cwd, + change, + ignoreWhitespace, + includeStructured: compare.includeStructured === true, + structured, + appendDiff, }); } @@ -1832,6 +1911,67 @@ export async function commitAll(cwd: string, message: string): Promise { await commitChanges(cwd, { message, addAll: true }); } +interface DetectMergeToBaseConflictInput { + operationCwd: string; + error: unknown; + baseRef: string; + currentBranch: string; +} + +async function detectAndThrowMergeToBaseConflict( + input: DetectMergeToBaseConflictInput, +): Promise { + const { operationCwd, error, baseRef, currentBranch } = input; + const errorDetails = + error instanceof Error + ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` + : String(error); + try { + const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }), + runGitCommand(["ls-files", "-u"], { cwd: operationCwd }), + runGitCommand(["status", "--porcelain"], { cwd: operationCwd }), + ]); + const statusConflicts = statusOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) + .map((line) => line.slice(3).trim()); + const conflicts = [ + ...unmergedOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ...lsFilesOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => line.split("\t").pop() as string), + ...statusConflicts, + ].filter(Boolean); + const conflictDetected = + conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); + if (conflictDetected) { + try { + await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 }); + } catch { + // ignore + } + throw new MergeConflictError({ + baseRef, + currentBranch, + conflictFiles: conflicts.length > 0 ? conflicts : [], + }); + } + } catch (innerError) { + if (innerError instanceof MergeConflictError) { + throw innerError; + } + // ignore detection failures + } +} + export async function mergeToBase( cwd: string, options: MergeToBaseOptions = {}, @@ -1882,55 +2022,12 @@ export async function mergeToBase( await runGitCommand(["merge", currentBranch], { cwd: operationCwd, timeout: 120_000 }); } } catch (error) { - const errorDetails = - error instanceof Error - ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` - : String(error); - try { - const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd: operationCwd }), - runGitCommand(["ls-files", "-u"], { cwd: operationCwd }), - runGitCommand(["status", "--porcelain"], { cwd: operationCwd }), - ]); - const statusConflicts = statusOutput.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) - .map((line) => line.slice(3).trim()); - const conflicts = [ - ...unmergedOutput.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean), - ...lsFilesOutput.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.split("\t").pop() as string), - ...statusConflicts, - ].filter(Boolean); - const conflictDetected = - conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); - if (conflictDetected) { - try { - await runGitCommand(["merge", "--abort"], { cwd: operationCwd, timeout: 120_000 }); - } catch { - // ignore - } - throw new MergeConflictError({ - baseRef: normalizedBaseRef, - currentBranch, - conflictFiles: conflicts.length > 0 ? conflicts : [], - }); - } - } catch (innerError) { - if (innerError instanceof MergeConflictError) { - throw innerError; - } - // ignore detection failures - } - + await detectAndThrowMergeToBaseConflict({ + operationCwd, + error, + baseRef: normalizedBaseRef, + currentBranch, + }); throw error; } finally { if (isSameCheckout && originalBranch && originalBranch !== normalizedBaseRef) { @@ -1987,56 +2084,74 @@ export async function mergeFromBase( try { await runGitCommand(["merge", bestBaseRef], { cwd, timeout: 120_000 }); } catch (error) { - const errorDetails = - error instanceof Error - ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` - : String(error); - try { - const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ - runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }), - runGitCommand(["ls-files", "-u"], { cwd }), - runGitCommand(["status", "--porcelain"], { cwd }), - ]); - const statusConflicts = statusOutput.stdout + await detectAndThrowMergeFromBaseConflict({ + cwd, + error, + baseRef: bestBaseRef, + currentBranch, + }); + throw error; + } +} + +interface DetectMergeFromBaseConflictInput { + cwd: string; + error: unknown; + baseRef: string; + currentBranch: string; +} + +async function detectAndThrowMergeFromBaseConflict( + input: DetectMergeFromBaseConflictInput, +): Promise { + const { cwd, error, baseRef, currentBranch } = input; + const errorDetails = + error instanceof Error + ? `${error.message}\n${(error as any).stderr ?? ""}\n${(error as any).stdout ?? ""}` + : String(error); + try { + const [unmergedOutput, lsFilesOutput, statusOutput] = await Promise.all([ + runGitCommand(["diff", "--name-only", "--diff-filter=U"], { cwd }), + runGitCommand(["ls-files", "-u"], { cwd }), + runGitCommand(["status", "--porcelain"], { cwd }), + ]); + const statusConflicts = statusOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) + .map((line) => line.slice(3).trim()); + const conflicts = [ + ...unmergedOutput.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + ...lsFilesOutput.stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) - .filter((line) => /^(UU|AA|DD|AU|UA|UD|DU)\s/.test(line)) - .map((line) => line.slice(3).trim()); - const conflicts = [ - ...unmergedOutput.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean), - ...lsFilesOutput.stdout - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => line.split("\t").pop() as string), - ...statusConflicts, - ].filter(Boolean); - const conflictDetected = - conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); - if (conflictDetected) { - try { - await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); - } catch { - // ignore - } - throw new MergeFromBaseConflictError({ - baseRef: bestBaseRef, - currentBranch, - conflictFiles: conflicts.length > 0 ? conflicts : [], - }); + .map((line) => line.split("\t").pop() as string), + ...statusConflicts, + ].filter(Boolean); + const conflictDetected = + conflicts.length > 0 || /CONFLICT|Automatic merge failed/i.test(errorDetails); + if (conflictDetected) { + try { + await runGitCommand(["merge", "--abort"], { cwd, timeout: 120_000 }); + } catch { + // ignore } - } catch (innerError) { - if (innerError instanceof MergeFromBaseConflictError) { - throw innerError; - } - // ignore detection failures + throw new MergeFromBaseConflictError({ + baseRef, + currentBranch, + conflictFiles: conflicts.length > 0 ? conflicts : [], + }); } - - throw error; + } catch (innerError) { + if (innerError instanceof MergeFromBaseConflictError) { + throw innerError; + } + // ignore detection failures } }