mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
chore(lint): reduce cyclomatic complexity in server
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+36
-28
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user