refactor(app): replace voice hooks with voice runtime and audio engine architecture
This commit is contained in:
@@ -44,6 +44,7 @@ import {
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../provider-launch-config.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
@@ -1054,6 +1055,32 @@ function mapCodexPatchNotificationToToolCall(params: {
|
||||
return params.running ? toRunningToolCall(mapped) : mapped;
|
||||
}
|
||||
|
||||
function mapCodexTerminalInteractionToToolCall(params: {
|
||||
processId?: string | null;
|
||||
fallbackCallId?: string | null;
|
||||
command?: string | null;
|
||||
}): ToolCallTimelineItem {
|
||||
const processId = nonEmptyString(params.processId ?? undefined);
|
||||
const callId =
|
||||
processId
|
||||
? `terminal-session-${processId}`
|
||||
: nonEmptyString(params.fallbackCallId ?? undefined) ?? "terminal-interaction";
|
||||
const label = nonEmptyString(params.command ?? undefined);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
...(label ? { label } : {}),
|
||||
icon: "square_terminal",
|
||||
},
|
||||
...(processId ? { metadata: { processId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function threadItemToTimeline(
|
||||
item: any,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null }
|
||||
@@ -1278,6 +1305,23 @@ const CodexEventExecCommandOutputDeltaNotificationSchema = z.object({
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventTerminalInteractionNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
type: z.literal("terminal_interaction"),
|
||||
call_id: z.string().optional(),
|
||||
process_id: z.union([z.string(), z.number()]).optional(),
|
||||
stdin: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
}).passthrough();
|
||||
|
||||
const ItemCommandExecutionTerminalInteractionNotificationSchema = z.object({
|
||||
itemId: z.string().optional(),
|
||||
processId: z.union([z.string(), z.number()]).optional(),
|
||||
stdin: z.string().optional(),
|
||||
}).passthrough();
|
||||
|
||||
const CodexEventPatchApplyBeginNotificationSchema = z.object({
|
||||
msg: z
|
||||
.object({
|
||||
@@ -1358,6 +1402,13 @@ type ParsedCodexNotification =
|
||||
stream: string | null;
|
||||
chunk: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "terminal_interaction";
|
||||
source: "item" | "codex_event";
|
||||
callId: string | null;
|
||||
processId: string | null;
|
||||
stdin: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "patch_apply_started";
|
||||
callId: string | null;
|
||||
@@ -1544,6 +1595,45 @@ const CodexNotificationSchema = z.union([
|
||||
}).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/terminal_interaction"),
|
||||
params: CodexEventTerminalInteractionNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "terminal_interaction",
|
||||
source: "codex_event",
|
||||
callId: params.msg.call_id ?? null,
|
||||
processId:
|
||||
typeof params.msg.process_id === "number"
|
||||
? String(params.msg.process_id)
|
||||
: params.msg.process_id ?? null,
|
||||
stdin: params.msg.stdin ?? null,
|
||||
})
|
||||
),
|
||||
z.object({ method: z.literal("codex/event/terminal_interaction"), params: z.unknown() }).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("item/commandExecution/terminalInteraction"),
|
||||
params: ItemCommandExecutionTerminalInteractionNotificationSchema,
|
||||
}).transform(
|
||||
({ params }): ParsedCodexNotification => ({
|
||||
kind: "terminal_interaction",
|
||||
source: "item",
|
||||
callId: params.itemId ?? null,
|
||||
processId:
|
||||
typeof params.processId === "number"
|
||||
? String(params.processId)
|
||||
: params.processId ?? null,
|
||||
stdin: params.stdin ?? null,
|
||||
})
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("item/commandExecution/terminalInteraction"),
|
||||
params: z.unknown(),
|
||||
}).transform(
|
||||
({ method, params }): ParsedCodexNotification => ({ kind: "invalid_payload", method, params })
|
||||
),
|
||||
z.object({
|
||||
method: z.literal("codex/event/patch_apply_begin"),
|
||||
params: CodexEventPatchApplyBeginNotificationSchema,
|
||||
@@ -1758,6 +1848,8 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
private pendingReasoning = new Map<string, string[]>();
|
||||
private pendingCommandOutputDeltas = new Map<string, string[]>();
|
||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||
private terminalCommandByProcessId = new Map<string, string>();
|
||||
private emittedTerminalInteractionKeys = new Set<string>();
|
||||
private emittedExecCommandStartedCallIds = new Set<string>();
|
||||
private emittedExecCommandCompletedCallIds = new Set<string>();
|
||||
private emittedItemStartedIds = new Set<string>();
|
||||
@@ -2667,11 +2759,13 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
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: parsed.output ?? bufferedOutput,
|
||||
output: resolvedOutput,
|
||||
exitCode: parsed.exitCode,
|
||||
success: parsed.success,
|
||||
stderr: parsed.stderr,
|
||||
@@ -2684,6 +2778,25 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "terminal_interaction") {
|
||||
const interactionKey = [
|
||||
parsed.processId ?? "",
|
||||
parsed.stdin ?? "",
|
||||
].join("\u0000");
|
||||
if (!this.shouldEmitTerminalInteractionKey(interactionKey)) {
|
||||
return;
|
||||
}
|
||||
const timelineItem = mapCodexTerminalInteractionToToolCall({
|
||||
processId: parsed.processId,
|
||||
fallbackCallId: parsed.callId,
|
||||
command:
|
||||
(parsed.processId ? this.terminalCommandByProcessId.get(parsed.processId) : undefined) ??
|
||||
null,
|
||||
});
|
||||
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.kind === "patch_apply_started") {
|
||||
if (parsed.callId) {
|
||||
this.pendingFileChangeOutputDeltas.delete(parsed.callId);
|
||||
@@ -2876,6 +2989,33 @@ class CodexAppServerAgentSession implements AgentSession {
|
||||
return buffered.join("");
|
||||
}
|
||||
|
||||
private rememberTerminalProcessForCommand(command: unknown, output: string | null): void {
|
||||
const normalizedCommand = normalizeCodexCommandValue(command);
|
||||
if (!normalizedCommand) {
|
||||
return;
|
||||
}
|
||||
const displayCommand =
|
||||
typeof normalizedCommand === "string"
|
||||
? normalizedCommand
|
||||
: normalizedCommand.join(" ").trim();
|
||||
if (!displayCommand) {
|
||||
return;
|
||||
}
|
||||
const processId = extractCodexTerminalSessionId(output ?? undefined);
|
||||
if (!processId) {
|
||||
return;
|
||||
}
|
||||
this.terminalCommandByProcessId.set(processId, displayCommand);
|
||||
}
|
||||
|
||||
private shouldEmitTerminalInteractionKey(key: string): boolean {
|
||||
if (this.emittedTerminalInteractionKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
this.emittedTerminalInteractionKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
private warnOnIncompleteEditToolCall(
|
||||
item: ToolCallTimelineItem,
|
||||
source: string,
|
||||
|
||||
@@ -86,7 +86,7 @@ describe("codex rollout parsing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("skips write_stdin function calls (polling)", async () => {
|
||||
test("maps write_stdin polling calls into terminal interaction tool calls", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
@@ -99,6 +99,16 @@ describe("codex rollout parsing", () => {
|
||||
call_id: "call_real",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:09:01.785Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call_output",
|
||||
call_id: "call_real",
|
||||
output:
|
||||
"Chunk ID: 13d232\nWall time: 0.2667 seconds\nProcess exited with code 0\nOriginal token count: 10\nOutput:\nProcess running with session ID 7144",
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:28:16.497Z",
|
||||
type: "response_item",
|
||||
@@ -116,8 +126,54 @@ describe("codex rollout parsing", () => {
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls.length).toBe(1);
|
||||
expect(toolCalls[0].name).toBe("Bash");
|
||||
expect(toolCalls.length).toBe(2);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "Bash",
|
||||
});
|
||||
expect(toolCalls[1]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "terminal",
|
||||
callId: "terminal-session-7144",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
metadata: {
|
||||
processId: "7144",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to generic terminal interaction label when command cannot be resolved", async () => {
|
||||
const rolloutPath = join(tmpDir, "rollout.jsonl");
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
timestamp: "2026-01-22T07:28:16.497Z",
|
||||
type: "response_item",
|
||||
payload: {
|
||||
type: "function_call",
|
||||
name: "write_stdin",
|
||||
arguments:
|
||||
'{"session_id":7144,"chars":"","yield_time_ms":1000,"max_output_tokens":6000}',
|
||||
call_id: "call_polling",
|
||||
},
|
||||
}),
|
||||
];
|
||||
writeFileSync(rolloutPath, lines.join("\n") + "\n");
|
||||
|
||||
const timeline = await parseRolloutFile(rolloutPath);
|
||||
const toolCalls = timeline.filter((i) => i.type === "tool_call");
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0]).toMatchObject({
|
||||
type: "tool_call",
|
||||
name: "terminal",
|
||||
callId: "terminal-session-7144",
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
import { mapCodexRolloutToolCall } from "./codex/tool-call-mapper.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
|
||||
const MAX_ROLLOUT_SEARCH_DEPTH = 4;
|
||||
|
||||
@@ -272,6 +273,74 @@ function parseJsonLikeValue(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function readTerminalSessionId(value: unknown): string | undefined {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return nonEmptyString(value.trim());
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function mapCodexTerminalInteractionToToolCall(params: {
|
||||
processId?: string | undefined;
|
||||
fallbackCallId?: string | undefined;
|
||||
command?: string | undefined;
|
||||
}): Extract<AgentTimelineItem, { type: "tool_call" }> {
|
||||
const processId = nonEmptyString(params.processId);
|
||||
const callId = processId
|
||||
? `terminal-session-${processId}`
|
||||
: nonEmptyString(params.fallbackCallId) ?? "terminal-interaction";
|
||||
const label = nonEmptyString(params.command);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId,
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
...(label ? { label } : {}),
|
||||
icon: "square_terminal",
|
||||
},
|
||||
metadata: processId ? { processId } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTerminalCommandBySessionId(
|
||||
parsedRecords: ParsedRolloutRecord[]
|
||||
): Map<string, string> {
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
|
||||
const commandsBySessionId = new Map<string, string>();
|
||||
for (const record of parsedRecords) {
|
||||
if (record.kind !== "call" || record.name === "write_stdin") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
input: record.input ?? null,
|
||||
output: record.callId ? outputsByCallId.get(record.callId) ?? null : null,
|
||||
});
|
||||
if (!mapped || mapped.detail.type !== "shell") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId = extractCodexTerminalSessionId(mapped.detail.output);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
commandsBySessionId.set(sessionId, mapped.detail.command);
|
||||
}
|
||||
|
||||
return commandsBySessionId;
|
||||
}
|
||||
|
||||
function readOutputPayloadValue(payload: Record<string, unknown>): unknown {
|
||||
if (payload.output !== undefined) {
|
||||
return parseJsonLikeValue(payload.output);
|
||||
@@ -319,15 +388,12 @@ const RolloutResponseRecordSchema = z
|
||||
rawName === "exec_command" || rawName === "shell"
|
||||
? FunctionCallInputNormalizationSchema.parse(parsedArguments)
|
||||
: { name: rawName, input: parsedArguments };
|
||||
const skip = rawName === "write_stdin";
|
||||
return skip
|
||||
? { kind: "ignore" }
|
||||
: {
|
||||
kind: "call",
|
||||
name: normalized.name,
|
||||
callId: payload.call_id,
|
||||
input: normalized.input,
|
||||
};
|
||||
return {
|
||||
kind: "call",
|
||||
name: normalized.name,
|
||||
callId: payload.call_id,
|
||||
input: normalized.input,
|
||||
};
|
||||
}),
|
||||
z
|
||||
.union([
|
||||
@@ -498,12 +564,29 @@ export async function parseRolloutFile(
|
||||
const outputsByCallId = parsedRecords
|
||||
.filter((record): record is Extract<ParsedRolloutRecord, { kind: "output" }> => record.kind === "output")
|
||||
.reduce((map, record) => map.set(record.callId, record.output), new Map<string, unknown>());
|
||||
const terminalCommandsBySessionId = buildTerminalCommandBySessionId(parsedRecords);
|
||||
|
||||
const timeline = parsedRecords.flatMap((record): AgentTimelineItem[] =>
|
||||
record.kind === "timeline"
|
||||
? [record.item]
|
||||
: record.kind === "call"
|
||||
? (() => {
|
||||
if (record.name === "write_stdin") {
|
||||
const input =
|
||||
record.input && typeof record.input === "object"
|
||||
? (record.input as { session_id?: unknown; sessionId?: unknown })
|
||||
: null;
|
||||
const sessionId =
|
||||
readTerminalSessionId(input?.session_id) ??
|
||||
readTerminalSessionId(input?.sessionId);
|
||||
return [
|
||||
mapCodexTerminalInteractionToToolCall({
|
||||
processId: sessionId,
|
||||
fallbackCallId: record.callId,
|
||||
command: sessionId ? terminalCommandsBySessionId.get(sessionId) : undefined,
|
||||
}),
|
||||
];
|
||||
}
|
||||
const mapped = mapCodexRolloutToolCall({
|
||||
callId: record.callId ?? null,
|
||||
name: record.name,
|
||||
|
||||
@@ -72,6 +72,16 @@ export function extractCodexShellOutput(value: string | undefined): string | und
|
||||
return nonEmptyString(lines.slice(firstBodyLineIndex).join("\n"));
|
||||
}
|
||||
|
||||
export function extractCodexTerminalSessionId(value: string | undefined): string | undefined {
|
||||
const text = nonEmptyString(value);
|
||||
if (!text) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const match = text.match(/process running with session id\s+([A-Za-z0-9._:-]+)/i);
|
||||
return nonEmptyString(match?.[1]);
|
||||
}
|
||||
|
||||
export function flattenReadContent<Chunk extends ReadChunkLike>(
|
||||
value: string | Chunk | Chunk[] | undefined
|
||||
): string | undefined {
|
||||
|
||||
@@ -1255,6 +1255,132 @@ export class Session {
|
||||
})
|
||||
}
|
||||
|
||||
private buildPersistedProjectRecord(input: {
|
||||
workspaceId: string
|
||||
placement: ProjectPlacementPayload
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}): PersistedProjectRecord {
|
||||
return createPersistedProjectRecord({
|
||||
projectId: input.placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(input.placement.checkout),
|
||||
displayName: input.placement.projectName,
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
private buildPersistedWorkspaceRecord(input: {
|
||||
workspaceId: string
|
||||
placement: ProjectPlacementPayload
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}): PersistedWorkspaceRecord {
|
||||
return createPersistedWorkspaceRecord({
|
||||
workspaceId: input.workspaceId,
|
||||
projectId: input.placement.projectKey,
|
||||
cwd: input.workspaceId,
|
||||
kind: deriveWorkspaceKind(input.placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: input.workspaceId,
|
||||
checkout: input.placement.checkout,
|
||||
}),
|
||||
createdAt: input.createdAt,
|
||||
updatedAt: input.updatedAt,
|
||||
archivedAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
private async archiveProjectRecordIfEmpty(projectId: string, archivedAt: string): Promise<void> {
|
||||
const siblingWorkspaces = (await this.workspaceRegistry.list()).filter(
|
||||
(workspace) => workspace.projectId === projectId && !workspace.archivedAt
|
||||
)
|
||||
if (siblingWorkspaces.length === 0) {
|
||||
await this.projectRegistry.archive(projectId, archivedAt)
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileWorkspaceRecord(workspaceId: string): Promise<{
|
||||
workspace: PersistedWorkspaceRecord
|
||||
changed: boolean
|
||||
}> {
|
||||
const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId)
|
||||
const existing = await this.workspaceRegistry.get(normalizedWorkspaceId)
|
||||
const placement = await this.buildProjectPlacement(normalizedWorkspaceId)
|
||||
const now = new Date().toISOString()
|
||||
const nextProjectCreatedAt = existing?.createdAt ?? now
|
||||
const nextWorkspaceCreatedAt = existing?.createdAt ?? now
|
||||
const currentProjectRecord = await this.projectRegistry.get(placement.projectKey)
|
||||
const nextProjectRecord = this.buildPersistedProjectRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({
|
||||
workspaceId: normalizedWorkspaceId,
|
||||
placement,
|
||||
createdAt: nextWorkspaceCreatedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
|
||||
const needsWorkspaceUpdate =
|
||||
!existing ||
|
||||
existing.archivedAt ||
|
||||
existing.projectId !== nextWorkspaceRecord.projectId ||
|
||||
existing.kind !== nextWorkspaceRecord.kind ||
|
||||
existing.displayName !== nextWorkspaceRecord.displayName
|
||||
|
||||
const needsProjectUpdate =
|
||||
!currentProjectRecord ||
|
||||
currentProjectRecord.archivedAt ||
|
||||
currentProjectRecord.rootPath !== nextProjectRecord.rootPath ||
|
||||
currentProjectRecord.kind !== nextProjectRecord.kind ||
|
||||
currentProjectRecord.displayName !== nextProjectRecord.displayName
|
||||
|
||||
if (!needsWorkspaceUpdate && !needsProjectUpdate) {
|
||||
return {
|
||||
workspace: existing!,
|
||||
changed: false,
|
||||
}
|
||||
}
|
||||
|
||||
await this.projectRegistry.upsert(nextProjectRecord)
|
||||
await this.workspaceRegistry.upsert(nextWorkspaceRecord)
|
||||
|
||||
if (
|
||||
existing &&
|
||||
!existing.archivedAt &&
|
||||
existing.projectId !== nextWorkspaceRecord.projectId
|
||||
) {
|
||||
await this.archiveProjectRecordIfEmpty(existing.projectId, now)
|
||||
}
|
||||
|
||||
return {
|
||||
workspace: nextWorkspaceRecord,
|
||||
changed: true,
|
||||
}
|
||||
}
|
||||
|
||||
private async reconcileActiveWorkspaceRecords(): Promise<Set<string>> {
|
||||
const changedWorkspaceIds = new Set<string>()
|
||||
const activeWorkspaces = (await this.workspaceRegistry.list()).filter((workspace) => !workspace.archivedAt)
|
||||
|
||||
for (const workspace of activeWorkspaces) {
|
||||
const result = await this.reconcileWorkspaceRecord(workspace.workspaceId)
|
||||
if (result.changed) {
|
||||
changedWorkspaceIds.add(result.workspace.workspaceId)
|
||||
}
|
||||
}
|
||||
|
||||
return changedWorkspaceIds
|
||||
}
|
||||
|
||||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||||
try {
|
||||
await this.ensureWorkspaceRegistered(agent.cwd)
|
||||
@@ -5275,7 +5401,7 @@ export class Session {
|
||||
}
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
private async listWorkspaceDescriptorsSnapshot(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
const [agents, persistedWorkspaces, persistedProjects] = await Promise.all([
|
||||
this.listAgentPayloads(),
|
||||
this.workspaceRegistry.list(),
|
||||
@@ -5318,6 +5444,11 @@ export class Session {
|
||||
return Array.from(descriptorsByWorkspaceId.values())
|
||||
}
|
||||
|
||||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||||
await this.reconcileActiveWorkspaceRecords()
|
||||
return this.listWorkspaceDescriptorsSnapshot()
|
||||
}
|
||||
|
||||
private normalizeFetchWorkspacesSort(
|
||||
sort: FetchWorkspacesRequestSort[] | undefined
|
||||
): FetchWorkspacesRequestSort[] {
|
||||
@@ -5597,43 +5728,7 @@ export class Session {
|
||||
|
||||
private async ensureWorkspaceRegistered(cwd: string): Promise<PersistedWorkspaceRecord> {
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||
const existing = await this.workspaceRegistry.get(workspaceId)
|
||||
if (existing && !existing.archivedAt) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const placement = await this.buildProjectPlacement(workspaceId)
|
||||
const now = new Date().toISOString()
|
||||
const projectExisting = await this.projectRegistry.get(placement.projectKey)
|
||||
const projectRecord: PersistedProjectRecord = createPersistedProjectRecord({
|
||||
projectId: placement.projectKey,
|
||||
rootPath: deriveProjectRootPath({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
kind: deriveProjectKind(placement.checkout),
|
||||
displayName: placement.projectName,
|
||||
createdAt: projectExisting?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
})
|
||||
await this.projectRegistry.upsert(projectRecord)
|
||||
|
||||
const workspaceRecord = createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: placement.projectKey,
|
||||
cwd: workspaceId,
|
||||
kind: deriveWorkspaceKind(placement.checkout),
|
||||
displayName: deriveWorkspaceDisplayName({
|
||||
cwd: workspaceId,
|
||||
checkout: placement.checkout,
|
||||
}),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
archivedAt: null,
|
||||
})
|
||||
await this.workspaceRegistry.upsert(workspaceRecord)
|
||||
return workspaceRecord
|
||||
return (await this.reconcileWorkspaceRecord(workspaceId)).workspace
|
||||
}
|
||||
|
||||
private async archiveWorkspaceRecord(workspaceId: string, archivedAt?: string): Promise<void> {
|
||||
@@ -5660,28 +5755,26 @@ export class Session {
|
||||
}
|
||||
|
||||
const workspaceId = normalizePersistedWorkspaceId(cwd)
|
||||
const all = await this.listWorkspaceDescriptors()
|
||||
const workspace = all.find((entry) => entry.id === workspaceId)
|
||||
if (!workspace) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'remove',
|
||||
id: workspaceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords()
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot()
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const))
|
||||
const workspaceIdsToEmit = new Set<string>([workspaceId, ...changedWorkspaceIds])
|
||||
|
||||
if (!this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'remove',
|
||||
id: workspaceId,
|
||||
})
|
||||
return
|
||||
}
|
||||
for (const nextWorkspaceId of workspaceIdsToEmit) {
|
||||
const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId)
|
||||
if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'remove',
|
||||
id: nextWorkspaceId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'upsert',
|
||||
workspace,
|
||||
})
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'upsert',
|
||||
workspace,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||||
@@ -5689,7 +5782,8 @@ export class Session {
|
||||
return
|
||||
}
|
||||
|
||||
const uniqueWorkspaceCwds = new Set<string>()
|
||||
const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords()
|
||||
const uniqueWorkspaceCwds = new Set<string>(changedWorkspaceIds)
|
||||
for (const cwd of cwds) {
|
||||
const normalized = normalizePersistedWorkspaceId(cwd)
|
||||
if (!normalized) {
|
||||
@@ -5698,8 +5792,24 @@ export class Session {
|
||||
uniqueWorkspaceCwds.add(normalized)
|
||||
}
|
||||
|
||||
for (const workspaceCwd of uniqueWorkspaceCwds) {
|
||||
await this.emitWorkspaceUpdateForCwd(workspaceCwd)
|
||||
const subscription = this.workspaceUpdatesSubscription
|
||||
const all = await this.listWorkspaceDescriptorsSnapshot()
|
||||
const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const))
|
||||
|
||||
for (const workspaceId of uniqueWorkspaceCwds) {
|
||||
const workspace = descriptorsByWorkspaceId.get(workspaceId)
|
||||
if (!workspace || !this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'remove',
|
||||
id: workspaceId,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||||
kind: 'upsert',
|
||||
workspace,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -284,8 +284,9 @@ describe('workspace aggregation', () => {
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set()
|
||||
|
||||
session.listWorkspaceDescriptors = async () => [
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
@@ -300,7 +301,7 @@ describe('workspace aggregation', () => {
|
||||
]
|
||||
await session.emitWorkspaceUpdateForCwd('/tmp/repo')
|
||||
|
||||
session.listWorkspaceDescriptors = async () => [
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
@@ -335,16 +336,40 @@ describe('workspace aggregation', () => {
|
||||
})
|
||||
|
||||
test('workspace update fanout for multiple cwd values is deduplicated', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: 'sub-dedupe',
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
|
||||
const emitWorkspaceUpdateForCwd = vi.fn(async () => {})
|
||||
session.emitWorkspaceUpdateForCwd = emitWorkspaceUpdateForCwd
|
||||
session.reconcileActiveWorkspaceRecords = async () => new Set()
|
||||
session.listWorkspaceDescriptorsSnapshot = async () => [
|
||||
{
|
||||
id: '/tmp/repo',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'repo',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
{
|
||||
id: '/tmp/repo/sub',
|
||||
projectId: '/tmp/repo',
|
||||
projectDisplayName: 'repo',
|
||||
projectRootPath: '/tmp/repo',
|
||||
projectKind: 'non_git',
|
||||
workspaceKind: 'directory',
|
||||
name: 'sub',
|
||||
status: 'done',
|
||||
activityAt: null,
|
||||
},
|
||||
]
|
||||
|
||||
await session.emitWorkspaceUpdatesForCwds([
|
||||
'/tmp/repo',
|
||||
@@ -354,9 +379,12 @@ describe('workspace aggregation', () => {
|
||||
'/tmp/repo/sub/',
|
||||
])
|
||||
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenCalledTimes(2)
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(1, '/tmp/repo')
|
||||
expect(emitWorkspaceUpdateForCwd).toHaveBeenNthCalledWith(2, '/tmp/repo/sub')
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||
expect(workspaceUpdates).toHaveLength(2)
|
||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id)).toEqual([
|
||||
'/tmp/repo',
|
||||
'/tmp/repo/sub',
|
||||
])
|
||||
})
|
||||
|
||||
test('open_project_request registers a workspace before any agent exists', async () => {
|
||||
@@ -434,4 +462,180 @@ describe('workspace aggregation', () => {
|
||||
const response = emitted.find((message) => message.type === 'archive_workspace_response') as any
|
||||
expect(response?.payload.error).toBeNull()
|
||||
})
|
||||
|
||||
test('opening a new worktree reconciles older local workspaces into the remote project', async () => {
|
||||
const emitted: Array<{ type: string; payload: unknown }> = []
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
||||
|
||||
const mainWorkspaceId = '/tmp/inkwell'
|
||||
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
|
||||
const localProjectId = mainWorkspaceId
|
||||
const remoteProjectId = 'remote:github.com/zimakki/inkwell'
|
||||
|
||||
projects.set(
|
||||
localProjectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: localProjectId,
|
||||
rootPath: mainWorkspaceId,
|
||||
kind: 'git',
|
||||
displayName: 'inkwell',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
})
|
||||
)
|
||||
workspaces.set(
|
||||
mainWorkspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: mainWorkspaceId,
|
||||
projectId: localProjectId,
|
||||
cwd: mainWorkspaceId,
|
||||
kind: 'local_checkout',
|
||||
displayName: 'main',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
})
|
||||
)
|
||||
|
||||
session.emit = (message: any) => emitted.push(message)
|
||||
session.workspaceUpdatesSubscription = {
|
||||
subscriptionId: 'sub-reconcile',
|
||||
filter: undefined,
|
||||
isBootstrapping: false,
|
||||
pendingUpdatesByWorkspaceId: new Map(),
|
||||
}
|
||||
session.listAgentPayloads = async () => []
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
|
||||
session.projectRegistry.list = async () => Array.from(projects.values())
|
||||
session.projectRegistry.upsert = async (record: ReturnType<typeof createPersistedProjectRecord>) => {
|
||||
projects.set(record.projectId, record)
|
||||
}
|
||||
session.projectRegistry.archive = async (projectId: string, archivedAt: string) => {
|
||||
const existing = projects.get(projectId)
|
||||
if (!existing) return
|
||||
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt })
|
||||
}
|
||||
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
}
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: remoteProjectId,
|
||||
projectName: 'zimakki/inkwell',
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: cwd === mainWorkspaceId ? 'main' : 'feature-a',
|
||||
remoteUrl: 'https://github.com/zimakki/inkwell.git',
|
||||
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
||||
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
await session.handleMessage({
|
||||
type: 'open_project_request',
|
||||
cwd: worktreeWorkspaceId,
|
||||
requestId: 'req-open-worktree',
|
||||
})
|
||||
|
||||
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId)
|
||||
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId)
|
||||
expect(projects.get(localProjectId)?.archivedAt).toBeTruthy()
|
||||
|
||||
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
|
||||
expect(workspaceUpdates).toHaveLength(2)
|
||||
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
|
||||
mainWorkspaceId,
|
||||
worktreeWorkspaceId,
|
||||
])
|
||||
expect(
|
||||
workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('fetch_workspaces_request reconciles remote URL changes for existing workspaces', async () => {
|
||||
const session = createSessionForWorkspaceTests() as any
|
||||
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
|
||||
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
|
||||
|
||||
const mainWorkspaceId = '/tmp/inkwell'
|
||||
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
|
||||
const oldProjectId = 'remote:github.com/old-owner/inkwell'
|
||||
const newProjectId = 'remote:github.com/new-owner/inkwell'
|
||||
|
||||
projects.set(
|
||||
oldProjectId,
|
||||
createPersistedProjectRecord({
|
||||
projectId: oldProjectId,
|
||||
rootPath: mainWorkspaceId,
|
||||
kind: 'git',
|
||||
displayName: 'old-owner/inkwell',
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
})
|
||||
)
|
||||
|
||||
for (const [workspaceId, displayName] of [
|
||||
[mainWorkspaceId, 'main'],
|
||||
[worktreeWorkspaceId, 'feature-a'],
|
||||
] as const) {
|
||||
workspaces.set(
|
||||
workspaceId,
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId,
|
||||
projectId: oldProjectId,
|
||||
cwd: workspaceId,
|
||||
kind: workspaceId === mainWorkspaceId ? 'local_checkout' : 'worktree',
|
||||
displayName,
|
||||
createdAt: '2026-03-01T12:00:00.000Z',
|
||||
updatedAt: '2026-03-01T12:00:00.000Z',
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
session.listAgentPayloads = async () => []
|
||||
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null
|
||||
session.projectRegistry.list = async () => Array.from(projects.values())
|
||||
session.projectRegistry.upsert = async (record: ReturnType<typeof createPersistedProjectRecord>) => {
|
||||
projects.set(record.projectId, record)
|
||||
}
|
||||
session.projectRegistry.archive = async (projectId: string, archivedAt: string) => {
|
||||
const existing = projects.get(projectId)
|
||||
if (!existing) return
|
||||
projects.set(projectId, { ...existing, archivedAt, updatedAt: archivedAt })
|
||||
}
|
||||
session.workspaceRegistry.get = async (workspaceId: string) => workspaces.get(workspaceId) ?? null
|
||||
session.workspaceRegistry.list = async () => Array.from(workspaces.values())
|
||||
session.workspaceRegistry.upsert = async (
|
||||
record: ReturnType<typeof createPersistedWorkspaceRecord>
|
||||
) => {
|
||||
workspaces.set(record.workspaceId, record)
|
||||
}
|
||||
session.buildProjectPlacement = async (cwd: string) => ({
|
||||
projectKey: newProjectId,
|
||||
projectName: 'new-owner/inkwell',
|
||||
checkout: {
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: cwd === mainWorkspaceId ? 'main' : 'feature-a',
|
||||
remoteUrl: 'https://github.com/new-owner/inkwell.git',
|
||||
isPaseoOwnedWorktree: cwd !== mainWorkspaceId,
|
||||
mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId,
|
||||
},
|
||||
})
|
||||
|
||||
const result = await session.listFetchWorkspacesEntries({
|
||||
type: 'fetch_workspaces_request',
|
||||
requestId: 'req-fetch-reconcile',
|
||||
})
|
||||
|
||||
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId])
|
||||
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId)
|
||||
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId)
|
||||
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -106,4 +106,38 @@ describe("shared tool-call display mapping", () => {
|
||||
|
||||
expect(display.errorText).toBe('{\n "message": "boom"\n}');
|
||||
});
|
||||
|
||||
it("labels terminal interaction rows without a summary when no command is available", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Interacted with terminal",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the command as terminal interaction summary when available", () => {
|
||||
const display = buildToolCallDisplayModel({
|
||||
name: "terminal",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: {
|
||||
type: "plain_text",
|
||||
label: "npm run test",
|
||||
icon: "square_terminal",
|
||||
},
|
||||
});
|
||||
|
||||
expect(display).toEqual({
|
||||
displayName: "Interacted with terminal",
|
||||
summary: "npm run test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,6 +103,12 @@ export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCall
|
||||
summary = isRecord(input.metadata) ? readString(input.metadata.subAgentActivity) : undefined;
|
||||
} else if (lowerName === "thinking" && input.detail.type === "unknown") {
|
||||
displayName = "Thinking";
|
||||
} else if (lowerName === "terminal") {
|
||||
displayName = "Interacted with terminal";
|
||||
summary =
|
||||
input.detail.type === "plain_text"
|
||||
? readString(input.detail.label)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
|
||||
|
||||
Reference in New Issue
Block a user