chore(lint): reduce cyclomatic complexity in server

This commit is contained in:
Mohamed Boudra
2026-04-24 01:24:58 +07:00
parent 5f620a36d6
commit 339fc6b316
43 changed files with 6227 additions and 4566 deletions
+42 -23
View File
@@ -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;
+379 -216
View File
@@ -156,6 +156,16 @@ function resolveInitialAttention(input: AttentionState | undefined): AttentionSt
};
}
interface StreamEventFlags {
shouldDispatchEvent: boolean;
shouldNotifyWaiters: boolean;
}
interface HandleStreamEventOptions {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
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<AgentProvider, AgentClient>();
private readonly agents = new Map<string, LiveManagedAgent>();
@@ -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<string, string>;
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<string, string>;
},
options?: HandleStreamEventOptions,
): Promise<boolean> {
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<void> {
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<AgentStreamEvent, { type: "timeline" }>;
options:
| {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
| undefined;
isForegroundEvent: boolean;
flags: StreamEventFlags;
}): Promise<void> {
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<AgentStreamEvent, { type: "turn_completed" }>;
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<AgentStreamEvent, { type: "turn_failed" }>;
eventTurnId: string | undefined;
isForegroundEvent: boolean;
options:
| {
fromHistory?: boolean;
canonicalUserMessagesById?: ReadonlyMap<string, string>;
}
| undefined;
}): Promise<void> {
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<AgentStreamEvent, { type: "turn_canceled" }>;
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<AgentStreamEvent, { type: "permission_requested" }>,
): 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<AgentStreamEvent, { type: "permission_resolved" }>;
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(
@@ -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<void> {
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",
);
}
}
@@ -143,6 +143,32 @@ export function toAgentPayload(
return payload;
}
function buildStoredRuntimeInfo(
record: StoredAgentRecord,
providerRegistry: ReturnType<typeof buildProviderRegistry>,
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<typeof buildProviderRegistry>,
@@ -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<keyof AgentUsage, never>;
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;
}
@@ -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>,
): 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<string, AgentPermissionRequest>(),
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", () => {
@@ -34,6 +34,93 @@ function normalizeTimelineMessageId(messageId: string | undefined): string | und
return normalized.length > 0 ? normalized : undefined;
}
interface FetchContext {
state: AgentTimelineState;
direction: NonNullable<AgentTimelineFetchOptions["direction"]>;
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<string, AgentTimelineState>();
@@ -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(
+179 -131
View File
@@ -329,60 +329,71 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
return expandUserPath(trimmedCwd);
};
const buildCallerAgentScheduleConfigExtras = (
callerAgent: NonNullable<ReturnType<typeof resolveCallerAgent>>,
): Record<string, unknown> => {
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<ReturnType<typeof resolveCallerAgent>>,
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<string, string> | 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<ResolvedCreateAgentArgs> => {
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<string, string> | 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 = {
@@ -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<T>(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<typeof extractDiffContent>;
terminalContent: ReturnType<typeof extractTerminalContent>;
rawInput: ReturnType<typeof readRecord>;
rawOutput: ReturnType<typeof readRecord>;
}
function mapToolDetail(
snapshot: ACPToolSnapshot,
terminals: Map<string, TerminalEntry>,
): 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;
File diff suppressed because it is too large Load Diff
@@ -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<SDKMessage, { type: "assistant" }>,
): 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<SDKMessage, { type: "stream_event" }>,
): 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") {
@@ -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<AgentPermissionResponse, { behavior: "deny" }>;
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<void> {
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<void> {
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<ParsedCodexNotification, { kind: "thread_started" }>,
): 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<ParsedCodexNotification, { kind: "turn_started" }>,
): void {
this.currentTurnId = parsed.turnId;
this.resetTurnTrackingState();
this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER });
}
private handleTurnCompletedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "turn_completed" }>,
): 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<ParsedCodexNotification, { kind: "plan_updated" }>,
): 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<ParsedCodexNotification, { kind: "token_usage_updated" }>,
): void {
this.latestUsage = toAgentUsage(parsed.tokenUsage);
if (this.latestUsage) {
this.notifySubscribers({
type: "usage_updated",
provider: CODEX_PROVIDER,
usage: this.latestUsage,
});
}
}
private handleExecCommandStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "exec_command_started" }>,
): 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<ParsedCodexNotification, { kind: "exec_command_completed" }>,
): 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<ParsedCodexNotification, { kind: "terminal_interaction" }>,
): 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<ParsedCodexNotification, { kind: "patch_apply_started" }>,
): 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<ParsedCodexNotification, { kind: "patch_apply_completed" }>,
): 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<ParsedCodexNotification, { kind: "item_completed" }>,
): 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<ParsedCodexNotification, { kind: "item_started" }>,
): 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 {
@@ -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, unknown>): 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<string, unknown>,
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<string, unknown>): 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 {};
@@ -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<OpenCodeEvent, { type: "session.created" | "session.updated" }>,
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<OpenCodeEvent, { type: "message.updated" }>,
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<OpenCodeEvent, { type: "message.part.updated" }>,
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<OpenCodeEvent, { type: "message.part.updated" }>["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<OpenCodeEvent, { type: "message.part.updated" }>["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<OpenCodeEvent, { type: "message.part.delta" }>,
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<OpenCodeEvent, { type: "permission.asked" }>,
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<OpenCodeEvent, { type: "question.asked" }>,
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<OpenCodeEvent, { type: "session.status" }>,
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;
@@ -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<AgentSessionEvent, { type: "message_update" }>,
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);
@@ -94,40 +94,64 @@ const ToolShellOutputObjectSchema = z
})
.passthrough();
function firstNonEmptyString(values: Array<unknown>): string | undefined {
for (const value of values) {
const resolved = nonEmptyString(value);
if (resolved) {
return resolved;
}
}
return undefined;
}
function resolveShellOutputRawText(
value: z.infer<typeof ToolShellOutputObjectSchema>,
): 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<typeof ToolShellOutputObjectSchema>,
): number | null | undefined {
return (
value.exitCode ??
value.exit_code ??
value.metadata?.exitCode ??
value.metadata?.exit_code ??
undefined
);
}
function transformShellOutputObject(value: z.infer<typeof ToolShellOutputObjectSchema>) {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
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),
};
}
+87 -73
View File
@@ -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<string, string>;
finalTranscriptSegmentIds: Set<string>;
transcriptMetaBySegmentId: Map<string, TranscriptSegmentMeta>;
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<string, string>();
const finalTranscriptSegmentIds = new Set<string>();
const transcriptMetaBySegmentId = new Map<
string,
{
language?: string;
logprobs?: TranscriptionResult["logprobs"];
avgLogprob?: number;
isLowConfidence?: boolean;
}
>();
const transcriptMetaBySegmentId = new Map<string, TranscriptSegmentMeta>();
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) {
+115 -56
View File
@@ -102,6 +102,105 @@ function extractAgentProviderSettings(
: undefined;
}
interface ResolveRelayInput {
env: NodeJS.ProcessEnv;
persisted: ReturnType<typeof loadPersistedConfig>;
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<typeof loadPersistedConfig>,
): 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<typeof loadPersistedConfig>,
): 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<typeof loadPersistedConfig>,
): 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<typeof loadPersistedConfig>,
) {
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<string, unknown> | 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,
};
@@ -351,6 +351,76 @@ function summarizeTimelineEntry(entry: {
return `[${item.type}]`;
}
async function waitForTranscriptRaceEvidence(params: {
transcriptPath: string;
waitError: Error | null;
afterWaitStatus: string;
cancelRecovered: boolean;
}): Promise<TranscriptRaceEvidence> {
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<string> {
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);
+23 -12
View File
@@ -15,27 +15,27 @@ type SupervisorLifecycleMessage =
reason?: string;
};
async function main() {
let paseoHome: string;
let logger: ReturnType<typeof createRootLogger>;
let config: ReturnType<typeof loadConfig>;
let daemon: Awaited<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | 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<typeof createRootLogger>;
config: ReturnType<typeof loadConfig>;
}
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<typeof loadConfig>): 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<ReturnType<typeof createPaseoDaemon>> | null = null;
let shutdownPromise: Promise<number> | 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) {
+56 -36
View File
@@ -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<ReturnType<typeof normalizeLoggerConfigInput>>["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<ReturnType<typeof normalizeLoggerConfigInput>>["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,
},
};
}
+26 -18
View File
@@ -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<void> {
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(
@@ -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<ManagedAgent>,
"config" | "pendingPermissions" | "session" | "activeForegroundTurnId"
> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
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<string, AgentPermissionRequest>(),
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>): StoredAgentRecord {
const now = new Date().toISOString();
return {
@@ -57,6 +57,78 @@ function sortPayloads(payloads: WorkspaceScriptPayload[]): WorkspaceScriptPayloa
);
}
type RuntimeEntry = ReturnType<WorkspaceScriptRuntimeStore["listForWorkspace"]>[number];
type RouteEntry = ReturnType<ScriptRouteStore["listRoutesForWorkspace"]>[number];
interface BuildPayloadContext {
projectSlug: string;
branchName: string | null;
daemonPort: number | null;
resolveHealth?: (hostname: string) => ScriptHealthState | null;
}
function buildConfiguredScriptPayload(
scriptName: string,
config: ReturnType<typeof getScriptConfigs> extends Map<string, infer V> ? 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);
File diff suppressed because it is too large Load Diff
@@ -81,6 +81,68 @@ function shouldIncludeLocalProviderConfig(params: {
);
}
function firstDefinedValue<T>(values: Array<T | null | undefined>): 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<string, unknown> {
const { paseoHome, env, persisted, providers, includeProviderConfig } = params;
return {
includeProviderConfig,
modelsDir: firstDefinedValue<string>([
env.PASEO_LOCAL_MODELS_DIR,
persisted.providers?.local?.modelsDir,
path.join(paseoHome, DEFAULT_LOCAL_MODELS_SUBDIR),
]),
dictationLocalSttModel: firstDefinedValue<string>([
env.PASEO_DICTATION_LOCAL_STT_MODEL,
persistedLocalFeatureModel(
providers.dictationStt.provider,
providers.dictationStt.enabled,
persisted.features?.dictation?.stt?.model,
),
DEFAULT_LOCAL_STT_MODEL,
]),
voiceLocalSttModel: firstDefinedValue<string>([
env.PASEO_VOICE_LOCAL_STT_MODEL,
persistedLocalFeatureModel(
providers.voiceStt.provider,
providers.voiceStt.enabled,
persisted.features?.voiceMode?.stt?.model,
),
DEFAULT_LOCAL_STT_MODEL,
]),
voiceLocalTtsModel: firstDefinedValue<string>([
env.PASEO_VOICE_LOCAL_TTS_MODEL,
persistedLocalFeatureModel(
providers.voiceTts.provider,
providers.voiceTts.enabled,
persisted.features?.voiceMode?.tts?.model,
),
DEFAULT_LOCAL_TTS_MODEL,
]),
voiceLocalTtsSpeakerId: firstDefinedValue<string | number>([
env.PASEO_VOICE_LOCAL_TTS_SPEAKER_ID,
persisted.features?.voiceMode?.tts?.speakerId,
]),
voiceLocalTtsSpeed: firstDefinedValue<string | number>([
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 ??
@@ -183,6 +183,132 @@ async function createLocalSttEngine(params: {
throw new Error(`Unsupported local STT model '${modelId}'`);
}
type LocalConfig = NonNullable<PaseoSpeechConfig["local"]>;
function isLocalProviderEnabled(provider: { enabled?: boolean; provider: string }): boolean {
return provider.enabled !== false && provider.provider === "local";
}
async function initializeLocalTurnDetection(
localConfig: LocalConfig | null,
logger: Logger,
): Promise<TurnDetectionProvider> {
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<LocalSttEngine | null>;
}): Promise<SpeechToTextProvider | null> {
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<LocalSttEngine | null>;
}): Promise<SpeechToTextProvider | null> {
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<TextToSpeechProvider | null> {
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;
}
}
@@ -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,
@@ -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<T>(
provider: { enabled?: boolean; provider: string },
value: T | undefined,
): T | undefined {
return isOpenAiProviderActive(provider) ? value : undefined;
}
function firstDefined<T>(values: Array<T | null | undefined>): 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<string, unknown> {
const { env, persisted, providers } = params;
return {
sttConfidenceThreshold: firstDefined<string | number>([
env.STT_CONFIDENCE_THRESHOLD,
persisted.features?.dictation?.stt?.confidenceThreshold,
]),
sttModel: firstDefined<string>([
env.STT_MODEL,
pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model),
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
]),
realtimeTranscriptionModel: firstDefined<string>([
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<string, unknown> {
const { env, persisted, providers } = params;
return {
ttsVoice: firstDefined<string>([
env.TTS_VOICE,
pickIfOpenAi(providers.voiceTts, persisted.features?.voiceMode?.tts?.voice),
"alloy",
]),
ttsModel: firstDefined<string>([
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<string, unknown> {
return {
apiKey: firstDefined<string>([
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;
@@ -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");
}
@@ -47,55 +47,83 @@ function resolveOptionalBooleanFlag(value: unknown): boolean {
return OptionalBooleanFlagSchema.parse(value) ?? true;
}
interface FeatureProviderInputs {
configuredValue: string | undefined;
enabled: boolean;
}
function firstSpeechDefinedValue<T>(values: Array<T | null | undefined>): 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<keyof RequestedSpeechProviders, FeatureProviderInputs> {
const voiceModeEnabled = resolveOptionalBooleanFlag(
firstSpeechDefinedValue<string | boolean>([
params.env.PASEO_VOICE_MODE_ENABLED,
params.persisted.features?.voiceMode?.enabled,
]),
);
return {
dictationStt: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_DICTATION_STT_PROVIDER,
params.persisted.features?.dictation?.stt?.provider,
]),
enabled: resolveOptionalBooleanFlag(
firstSpeechDefinedValue<string | boolean>([
params.env.PASEO_DICTATION_ENABLED,
params.persisted.features?.dictation?.enabled,
]),
),
},
voiceTurnDetection: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER,
params.persisted.features?.voiceMode?.turnDetection?.provider,
]),
enabled: voiceModeEnabled,
},
voiceStt: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_STT_PROVIDER,
params.persisted.features?.voiceMode?.stt?.provider,
]),
enabled: voiceModeEnabled,
},
voiceTts: {
configuredValue: firstSpeechDefinedValue<string>([
params.env.PASEO_VOICE_TTS_PROVIDER,
params.persisted.features?.voiceMode?.tts?.provider,
]),
enabled: voiceModeEnabled,
},
};
}
function buildRequestedFeatureProvider(
inputs: FeatureProviderInputs,
parsedValue: z.infer<typeof SpeechProviderIdSchema>,
): 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<typeof SpeechProviderIdSchema>,
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),
};
}
@@ -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<void> {
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<void> {
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<typeof parseLargeAgentStreamPayloadPrompt> & object,
): Promise<void> {
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<string, unknown>;
}): 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<AgentPermissionResponse>();
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<void> {
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<string, unknown>;
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<ReturnType<typeof buildToolCallForPrompt>>,
textPrompt: string,
): Promise<boolean> {
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<void> {
this.interruptSignal = createDeferred<void>();
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<AgentPermissionResponse>();
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<string, unknown>): Promise<void> {
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<void> {
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<void> {
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<string, unknown>,
prompt: string,
createFileMatch: RegExpExecArray | null,
): Promise<void> {
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<void> {
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<string, unknown>,
prompt: string,
): Promise<void> {
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);
}
}
@@ -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<PreparedTestDaemonConfig> {
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;
@@ -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 });
}
+391 -239
View File
@@ -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<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private readonly providerOverrides: Record<string, ProviderOverride> | undefined;
private readonly isDev: boolean;
private agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined;
private providerOverrides: Record<string, ProviderOverride> | 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<string, ProviderOverride> | 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<string>,
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<string, unknown>;
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 ?? "<unreadable>";
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<WSInboundMessage, { type: "session" }>,
): Promise<void> {
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 ?? "<unreadable>";
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 {
@@ -191,7 +191,7 @@ function createGitHubServiceStub(): GitHubService {
};
}
function createService(options?: {
interface CreateServiceOptions {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -207,46 +207,47 @@ function createService(options?: {
runGitCommand?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
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),
});
}
@@ -167,7 +167,7 @@ function createGitHubServiceStub(): GitHubService {
};
}
function createService(options?: {
interface CreateServiceTestOptions {
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -179,38 +179,38 @@ function createService(options?: {
readdir?: ReturnType<typeof vi.fn>;
watch?: ReturnType<typeof vi.fn>;
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 ?? {}) },
});
}
@@ -279,6 +279,33 @@ interface WorkspaceGitAuxiliaryReadCacheEntry<T> {
inFlight: Promise<T> | 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<WorkspaceGitServiceDependencies> | 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(
@@ -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<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
> = {};
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<void> {
const directoryName =
project.rootPath.split(/[\\/]/).filter(Boolean).at(-1) ?? project.rootPath;
const currentGit = await this.readWorkspaceGitMetadata(project.rootPath, directoryName);
const projectUpdates: Partial<
Pick<PersistedProjectRecord, "kind" | "displayName" | "rootPath">
> = {};
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 {
@@ -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<void> {
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<void> {
assertServiceTerminalCallSelfEnv(params);
await assertServiceTerminalCallPeerEnv({
createTerminalCalls: params.createTerminalCalls,
repoDir: params.repoDir,
});
}
function commitPaseoScripts(
scripts: Record<string, { command: string; type?: "script" | "service" }>,
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",
+128 -63
View File
@@ -700,6 +700,117 @@ interface SpawnWorkspaceScriptOptions {
onLifecycleChanged?: () => void;
}
interface ServiceScriptSetupResult {
hostname: string;
port: number;
env: Record<string, string>;
}
async function setupServiceScriptRoute(params: {
scriptConfigs: ReturnType<typeof getScriptConfigs>;
config: { port?: number };
scriptName: string;
projectSlug: string;
branchName: string | null;
workspaceId: string;
daemonPort: number | null | undefined;
daemonListenHost: string | null | undefined;
existingRuntimeEntry: ReturnType<WorkspaceScriptRuntimeStore["get"]>;
routeStore: ScriptRouteStore;
}): Promise<ServiceScriptSetupResult> {
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<WorkspaceScriptRuntimeStore["get"]>;
terminalManager: TerminalManager;
repoRoot: string;
scriptName: string;
env: Record<string, string> | 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<WorktreeScriptResult> {
@@ -739,78 +850,32 @@ export async function spawnWorkspaceScript(
const existingRuntimeEntry = runtimeStore.get({ workspaceId, scriptName });
let env: Record<string, string> | 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,
+36 -20
View File
@@ -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,
+112 -81
View File
@@ -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 "";
}
+342 -227
View File
@@ -1563,6 +1563,194 @@ export function warmCheckoutShortstatInBackground(
});
}
interface AppendStructuredTrackedDiffsInput {
cwd: string;
trackedChanges: CheckoutFileChange[];
trackedChangeByPath: Map<string, CheckoutFileChange>;
trackedNumstatByPath: Map<string, FileStat>;
trackedPlaceholderByPath: Map<string, { status: "binary" | "too_large"; stat: FileStat }>;
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<void> {
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<void> {
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<CheckoutDiffRefs | null> {
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<CheckoutDiffResult> {
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<void> {
await commitChanges(cwd, { message, addAll: true });
}
interface DetectMergeToBaseConflictInput {
operationCwd: string;
error: unknown;
baseRef: string;
currentBranch: string;
}
async function detectAndThrowMergeToBaseConflict(
input: DetectMergeToBaseConflictInput,
): Promise<void> {
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<void> {
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
}
}