refactor(app): replace voice hooks with voice runtime and audio engine architecture

This commit is contained in:
Mohamed Boudra
2026-03-13 20:53:55 +07:00
parent 730f34753a
commit 80a2f8052c
51 changed files with 4308 additions and 2380 deletions
@@ -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 {
+171 -61
View File
@@ -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;