Merge branch 'fix-tests-suite-loop'

# Conflicts:
#	package-lock.json
#	packages/server/src/server/speech/speech-config-resolver.ts
#	packages/server/src/shared/tool-call-display.ts
This commit is contained in:
Mohamed Boudra
2026-03-14 22:06:42 +07:00
50 changed files with 1330 additions and 2544 deletions
@@ -1387,11 +1387,46 @@ describe("AgentManager", () => {
cwd: workdir,
});
const runningStateEvents: string[] = [];
let resolveAutonomousTurnStarted!: () => void;
const autonomousTurnStarted = new Promise<void>((resolve) => {
resolveAutonomousTurnStarted = resolve;
});
let resolveSecondRunningState!: () => void;
const secondRunningState = new Promise<void>((resolve) => {
resolveSecondRunningState = resolve;
});
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
if (event.agent.lifecycle !== "running") {
return;
}
runningStateEvents.push(event.agent.lifecycle);
if (runningStateEvents.length >= 2) {
resolveSecondRunningState();
}
return;
}
if (
event.type === "agent_stream" &&
event.agentId === snapshot.id &&
event.event.type === "turn_started"
) {
resolveAutonomousTurnStarted();
}
},
{ agentId: snapshot.id, replayState: true }
);
const foreground = manager.streamAgent(snapshot.id, "foreground run");
const consumeForeground = (async () => {
for await (const _event of foreground) {
// Drain foreground stream.
const foregroundResults = (async () => {
const events: AgentStreamEvent[] = [];
for await (const event of foreground) {
events.push(event);
}
return events;
})();
await manager.waitForAgentRunStart(snapshot.id);
@@ -1405,16 +1440,32 @@ describe("AgentManager", () => {
liveEvents.push({ type: "turn_completed", provider: "codex" });
releaseForeground.resolve();
await consumeForeground;
const foregroundEvents = await foregroundResults;
const replaying = manager.getAgent(snapshot.id);
expect(replaying?.lifecycle).toBe("running");
expect(
foregroundEvents.some((event) => event.type === "turn_completed")
).toBe(true);
expect(
foregroundEvents.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("AUTONOMOUS_DURING_FOREGROUND")
)
).toBe(false);
await autonomousTurnStarted;
await secondRunningState;
const settled = await manager.waitForAgentEvent(snapshot.id);
expect(settled.status).toBe("idle");
expect(manager.getTimeline(snapshot.id)).toContainEqual({
type: "assistant_message",
text: "AUTONOMOUS_DURING_FOREGROUND",
});
expect(runningStateEvents).toHaveLength(2);
});
test("restarts live event pump after iterator failure", async () => {
@@ -267,7 +267,7 @@ describe("generateStructuredAgentResponseWithFallback", () => {
providers: [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini" },
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
],
runner: async () => {
throw new Error("failed");
@@ -94,7 +94,7 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
{ provider: "claude", model: "haiku" },
{ provider: "codex", model: "gpt-5.1-codex-mini", thinkingOptionId: "low" },
{ provider: "opencode", model: "opencode/kimi-k2.5-free" },
{ provider: "opencode", model: "opencode/gpt-5-nano" },
] as const;
interface SchemaValidator<T> {
@@ -1322,210 +1322,4 @@ describe("ClaudeAgentSession redesign invariants", () => {
await session.close();
});
test("surfaces autonomous running transitions during true overlap before foreground terminal result", async () => {
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
const readPromptUuid = createPromptUuidReader(prompt);
let step = 0;
return createBaseQueryMock(
vi.fn(async () => {
if (step === 0) {
step += 1;
return {
done: false,
value: {
type: "system",
subtype: "init",
session_id: "redesign-manager-overlap-session",
permissionMode: "default",
model: "opus",
},
};
}
if (step === 1) {
step += 1;
return {
done: false,
value: {
type: "user",
message: { role: "user", content: "foreground prompt replay" },
parent_tool_use_id: null,
uuid: (await readPromptUuid()) ?? "missing-prompt-uuid",
session_id: "redesign-manager-overlap-session",
},
};
}
if (step === 2) {
step += 1;
return {
done: false,
value: {
type: "assistant",
task_id: "fg-1",
message: { content: "FOREGROUND_BEFORE_OVERLAP" },
},
};
}
if (step === 3) {
step += 1;
return {
done: false,
value: {
type: "stream_event",
task_id: "bg-1",
event: {
type: "message_start",
message: { id: "bg-msg-1", role: "assistant", model: "opus" },
},
},
};
}
if (step === 4) {
step += 1;
return {
done: false,
value: {
type: "stream_event",
task_id: "bg-1",
event: {
type: "content_block_delta",
message_id: "bg-msg-1",
delta: { type: "text_delta", text: "AUTONOMOUS_OVERLAP_DONE" },
},
},
};
}
if (step === 5) {
step += 1;
return {
done: false,
value: {
type: "stream_event",
task_id: "bg-1",
event: {
type: "message_stop",
message_id: "bg-msg-1",
},
},
};
}
if (step === 6) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
task_id: "bg-1",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
if (step === 7) {
step += 1;
return {
done: false,
value: {
type: "assistant",
message: { content: "FOREGROUND_DONE" },
},
};
}
if (step === 8) {
step += 1;
return {
done: false,
value: {
type: "result",
subtype: "success",
usage: buildUsage(),
total_cost_usd: 0,
},
};
}
return { done: true, value: undefined };
})
);
});
const logger = createTestLogger();
const claudeClient = new ClaudeAgentClient({ logger });
vi.spyOn(claudeClient, "isAvailable").mockResolvedValue(true);
const manager = new AgentManager({
clients: {
claude: claudeClient,
},
logger,
});
const agent = await manager.createAgent({
provider: "claude",
cwd: process.cwd(),
});
const runningStateEvents: string[] = [];
const turnStartedEvents: AgentStreamEvent[] = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === agent.id) {
if (event.agent.lifecycle === "running") {
runningStateEvents.push(event.agent.lifecycle);
}
return;
}
if (
event.type === "agent_stream" &&
event.agentId === agent.id &&
event.event.type === "turn_started"
) {
turnStartedEvents.push(event.event);
}
},
{ agentId: agent.id, replayState: true }
);
const foregroundEvents = await collectUntilTerminal(
manager.streamAgent(agent.id, "foreground prompt")
);
expect(
foregroundEvents.some((event) => event.type === "turn_completed")
).toBe(true);
expect(
foregroundEvents.some(
(event) =>
event.type === "timeline" &&
event.item.type === "assistant_message" &&
event.item.text.includes("AUTONOMOUS_OVERLAP_DONE")
)
).toBe(false);
await waitForCondition(() => turnStartedEvents.length >= 1, 5_000);
await waitForCondition(() => runningStateEvents.length >= 2, 5_000);
const timeline = manager.getTimeline(agent.id);
expect(
timeline.some(
(item) =>
item.type === "assistant_message" &&
item.text.includes("FOREGROUND_BEFORE_OVERLAP")
)
).toBe(true);
expect(
timeline.some(
(item) =>
item.type === "assistant_message" &&
item.text.includes("FOREGROUND_DONE")
)
).toBe(true);
expect(
timeline.some(
(item) =>
item.type === "assistant_message" &&
item.text.includes("AUTONOMOUS_OVERLAP_DONE")
)
).toBe(true);
unsubscribe();
await manager.closeAgent(agent.id);
});
});
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,17 @@
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
__codexAppServerInternals,
@@ -17,6 +26,7 @@ import type {
AgentPermissionRequest,
AgentPromptContentBlock,
AgentStreamEvent,
AgentRunResult,
AgentTimelineItem,
} from "../agent-sdk-types.js";
@@ -24,6 +34,7 @@ const CODEX_TEST_MODEL = agentConfigs.codex.model;
const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId;
const ONE_BY_ONE_PNG_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII=";
const TEST_FILE_DIR = path.dirname(fileURLToPath(import.meta.url));
function isCodexInstalled(): boolean {
try {
@@ -52,6 +63,42 @@ function useTempCodexSessionDir(): () => void {
};
}
function useTempCodexHome(prefix = "codex-home-"): { codexHome: string; cleanup: () => void } {
const codexHome = tmpCwd(prefix);
const prevCodexHome = process.env.CODEX_HOME;
const sharedCodexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const sharedAuthPath = path.join(sharedCodexHome, "auth.json");
if (!existsSync(sharedAuthPath)) {
throw new Error(`Codex auth file not found at ${sharedAuthPath}`);
}
copyFileSync(sharedAuthPath, path.join(codexHome, "auth.json"));
writeFileSync(
path.join(codexHome, "config.toml"),
[
'model = "gpt-5.2-codex"',
'model_reasoning_effort = "medium"',
`[projects."${process.cwd()}"]`,
'trust_level = "trusted"',
"[features]",
"unified_exec = true",
"shell_snapshot = true",
].join("\n"),
"utf8"
);
process.env.CODEX_HOME = codexHome;
return {
codexHome,
cleanup: () => {
if (prevCodexHome === undefined) {
delete process.env.CODEX_HOME;
} else {
process.env.CODEX_HOME = prevCodexHome;
}
rmSync(codexHome, { recursive: true, force: true });
},
};
}
function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean {
if (item.type !== "tool_call") return false;
if (item.detail.type === "shell") {
@@ -198,6 +245,17 @@ function deferred<T>(): Deferred<T> {
return { promise, resolve, reject };
}
function expectSuccessfulAssistantTurn(
result: Pick<AgentRunResult, "finalText" | "timeline">,
options?: { forbiddenText?: string[] }
): void {
expect(result.finalText.trim().length).toBeGreaterThan(0);
expect(result.timeline.some((item) => item.type === "assistant_message")).toBe(true);
for (const fragment of options?.forbiddenText ?? []) {
expect(result.finalText.toLowerCase()).not.toContain(fragment.toLowerCase());
}
}
describe("Codex app-server provider (integration)", () => {
const logger = createTestLogger();
@@ -375,12 +433,17 @@ describe("Codex app-server provider (integration)", () => {
});
const result = await session.run([
{ type: "text", text: "Reply with exactly: OK." },
{
type: "text",
text: "Confirm in one short sentence that you received the attached image.",
},
{ type: "image", mimeType: "image/png", data: ONE_BY_ONE_PNG_BASE64 },
] satisfies AgentPromptContentBlock[]);
await session.close();
expect(result.finalText).toContain("OK");
expectSuccessfulAssistantTurn(result, {
forbiddenText: ["validation error", "invalid request", "schema"],
});
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
@@ -487,9 +550,10 @@ describe("Codex app-server provider (integration)", () => {
test.runIf(isCodexInstalled())("round-trips a stdio MCP tool call", async () => {
const cleanup = useTempCodexSessionDir();
const { cleanup: cleanupCodexHome } = useTempCodexHome("codex-mcp-home-");
const cwd = tmpCwd("codex-mcp-roundtrip-");
const token = `MCP_ROUNDTRIP_${Date.now()}`;
const mcpScriptPath = path.resolve(process.cwd(), "scripts", "mcp-echo-test-server.mjs");
const mcpScriptPath = path.resolve(TEST_FILE_DIR, "../../../../scripts/mcp-echo-test-server.mjs");
try {
const client = new CodexAppServerAgentClient(logger);
@@ -497,8 +561,8 @@ describe("Codex app-server provider (integration)", () => {
provider: "codex",
cwd,
modeId: "read-only",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
model: "gpt-5.2-codex",
thinkingOptionId: "medium",
extra: {
codex: {
tools: {
@@ -519,6 +583,7 @@ describe("Codex app-server provider (integration)", () => {
const result = await session.run(
[
"Use the MCP tool-calling interface, not shell commands or plain text.",
"You must call the MCP tool named paseo_test.paseo_roundtrip_text exactly once.",
`Call it with text: ${token}`,
"Do not use shell or any non-MCP tools.",
@@ -573,6 +638,7 @@ describe("Codex app-server provider (integration)", () => {
expect(JSON.stringify(mcpDetail?.output ?? {})).toContain(`ECHO:${token}`);
expect(result.finalText).toContain(`ECHO:${token}`);
} finally {
cleanupCodexHome();
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
@@ -582,7 +648,7 @@ describe("Codex app-server provider (integration)", () => {
"listCommands includes custom prompts and run('/prompts:*') expands them",
async () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const { codexHome, cleanup: cleanupCodexHome } = useTempCodexHome("codex-prompts-home-");
const promptsDir = path.join(codexHome, "prompts");
const promptName = `paseo-test-${process.pid}-${Date.now().toString(36)}`;
const promptPath = path.join(promptsDir, `${promptName}.md`);
@@ -648,6 +714,7 @@ describe("Codex app-server provider (integration)", () => {
await session.close();
}
} finally {
cleanupCodexHome();
cleanup();
rmSync(cwd, { recursive: true, force: true });
rmSync(promptPath, { force: true });
@@ -660,7 +727,9 @@ describe("Codex app-server provider (integration)", () => {
"slash prompt run streams live turn events (turn_started/turn_completed)",
async () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const { codexHome, cleanup: cleanupCodexHome } = useTempCodexHome(
"codex-stream-prompts-home-"
);
const promptsDir = path.join(codexHome, "prompts");
const promptName = `paseo-stream-${process.pid}-${Date.now().toString(36)}`;
const promptPath = path.join(promptsDir, `${promptName}.md`);
@@ -706,6 +775,7 @@ describe("Codex app-server provider (integration)", () => {
await session.close();
}
} finally {
cleanupCodexHome();
cleanup();
rmSync(cwd, { recursive: true, force: true });
rmSync(promptPath, { force: true });
@@ -542,8 +542,24 @@ export type OpenCodeEventTranslationState = {
messageRoles: Map<string, OpenCodeMessageRole>;
accumulatedUsage: AgentUsage;
streamedPartKeys: Set<string>;
emittedStructuredMessageIds: Set<string>;
};
function stringifyStructuredAssistantMessage(value: unknown): string | null {
if (value === undefined) {
return null;
}
if (typeof value === "string") {
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
try {
return JSON.stringify(value);
} catch {
return null;
}
}
export function translateOpenCodeEvent(
event: unknown,
state: OpenCodeEventTranslationState
@@ -583,6 +599,23 @@ export function translateOpenCodeEvent(
if (messageId && messageSessionId === state.sessionId && role) {
state.messageRoles.set(messageId, role);
if (
role === "assistant" &&
!state.emittedStructuredMessageIds.has(messageId) &&
typeof info.time === "object" &&
info.time !== null &&
"completed" in info.time
) {
const text = stringifyStructuredAssistantMessage(info.structured);
if (text) {
state.emittedStructuredMessageIds.add(messageId);
events.push({
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
});
}
}
}
break;
}
@@ -759,6 +792,8 @@ class OpenCodeAgentSession implements AgentSession {
private messageRoles = new Map<string, OpenCodeMessageRole>();
/** Tracks streamed textual part IDs to suppress final full-text echoes from OpenCode. */
private streamedPartKeys = new Set<string>();
/** Tracks assistant messages already emitted from structured payloads. */
private emittedStructuredMessageIds = new Set<string>();
constructor(
config: OpenCodeAgentConfig,
@@ -797,8 +832,8 @@ class OpenCodeAgentSession implements AgentSession {
this.config.thinkingOptionId = normalizedThinkingOptionId ?? undefined;
}
async run(prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt);
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
const events = this.stream(prompt, options);
const timeline: AgentTimelineItem[] = [];
let finalText = "";
let usage: AgentUsage | undefined;
@@ -826,7 +861,7 @@ class OpenCodeAgentSession implements AgentSession {
async *stream(
prompt: AgentPromptInput,
_options?: AgentRunOptions
options?: AgentRunOptions
): AsyncGenerator<AgentStreamEvent> {
this.abortController = new AbortController();
await this.ensureMcpServersConfigured();
@@ -842,6 +877,14 @@ class OpenCodeAgentSession implements AgentSession {
sessionID: this.sessionId,
directory: this.config.cwd,
parts,
...(options?.outputSchema
? {
format: {
type: "json_schema" as const,
schema: options.outputSchema as Record<string, unknown>,
},
}
: {}),
...(this.config.systemPrompt ? { system: this.config.systemPrompt } : {}),
...(model ? { model } : {}),
...(effectiveVariant ? { variant: effectiveVariant } : {}),
@@ -927,6 +970,7 @@ class OpenCodeAgentSession implements AgentSession {
};
}
} else if (role === "assistant") {
let emittedAssistantText = false;
// Process each part
for (const part of parts) {
const partType = (part as { type?: string }).type;
@@ -934,6 +978,7 @@ class OpenCodeAgentSession implements AgentSession {
if (partType === "text") {
const text = (part as { text?: string }).text;
if (text) {
emittedAssistantText = true;
yield {
type: "timeline",
provider: "opencode",
@@ -962,6 +1007,19 @@ class OpenCodeAgentSession implements AgentSession {
}
}
}
if (!emittedAssistantText) {
const text = stringifyStructuredAssistantMessage(
(info as { structured?: unknown }).structured
);
if (text) {
yield {
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text },
};
}
}
}
}
}
@@ -1112,6 +1170,7 @@ class OpenCodeAgentSession implements AgentSession {
messageRoles: this.messageRoles,
accumulatedUsage: this.accumulatedUsage,
streamedPartKeys: this.streamedPartKeys,
emittedStructuredMessageIds: this.emittedStructuredMessageIds,
});
for (const translatedEvent of translated) {
@@ -11,6 +11,7 @@ function createState(sessionId = "session-1"): OpenCodeEventTranslationState {
messageRoles: new Map(),
accumulatedUsage: {},
streamedPartKeys: new Set(),
emittedStructuredMessageIds: new Set(),
};
}
@@ -171,4 +172,49 @@ describe("translateOpenCodeEvent", () => {
},
]);
});
it("emits structured assistant output when schema mode completes without text parts", () => {
const state = createState();
const first = translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-structured-1",
sessionID: "session-1",
role: "assistant",
time: { created: 1, completed: 2 },
structured: { summary: "hello" },
},
},
},
state
);
const second = translateOpenCodeEvent(
{
type: "message.updated",
properties: {
info: {
id: "message-structured-1",
sessionID: "session-1",
role: "assistant",
time: { created: 1, completed: 2 },
structured: { summary: "hello" },
},
},
},
state
);
expect(first).toEqual([
{
type: "timeline",
provider: "opencode",
item: { type: "assistant_message", text: '{"summary":"hello"}' },
},
]);
expect(second).toEqual([]);
});
});
@@ -1,9 +1,11 @@
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync, existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir, homedir } from "node:os";
import path from "node:path";
import { execSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import {
createDaemonTestContext,
@@ -22,6 +24,16 @@ const openaiApiKey = process.env.OPENAI_API_KEY ?? null;
const localModelsDir =
process.env.PASEO_LOCAL_MODELS_DIR ??
path.join(homedir(), ".paseo", "models", "local-speech");
const testFileDir = path.dirname(fileURLToPath(import.meta.url));
const appE2eFixturesDir = path.resolve(testFileDir, "../../../app/e2e/fixtures");
function fixturePath(fileName: string): string {
return path.join(appE2eFixturesDir, fileName);
}
async function readFixture(fileName: string): Promise<Buffer> {
return readFile(fixturePath(fileName));
}
function hasSherpaZipformerModels(modelsDir: string): boolean {
return (
@@ -828,15 +840,7 @@ describe("daemon client E2E", () => {
};
});
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const wav = await readFixture("recording.wav");
await ctx.client.sendVoiceAudioChunk(wav.toString("base64"), "audio/wav", true);
await transcriptSeen;
await new Promise((resolve) => setTimeout(resolve, 1500));
@@ -898,15 +902,7 @@ describe("daemon client E2E", () => {
});
try {
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const wav = await readFixture("recording.wav");
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const format = "audio/pcm;rate=16000;bits=16";
@@ -1010,15 +1006,7 @@ describe("daemon client E2E", () => {
});
try {
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const wav = await readFixture("recording.wav");
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
@@ -1067,15 +1055,7 @@ describe("daemon client E2E", () => {
speechTest(
"streams dictation PCM and returns final transcript",
async () => {
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const wav = await readFixture("recording.wav");
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const dictationId = `dict-${Date.now()}`;
@@ -1103,31 +1083,13 @@ describe("daemon client E2E", () => {
speechTest(
"realtime dictation transcript is similar to baseline fixture",
async () => {
const fixturePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.wav"
);
const wav = await import("node:fs/promises").then((fs) => fs.readFile(fixturePath));
const wav = await readFixture("recording.wav");
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
expect(sampleRate).toBe(16000);
const dictationId = `dict-baseline-${Date.now()}`;
const format = "audio/pcm;rate=16000;bits=16";
const baselinePath = path.resolve(
process.cwd(),
"..",
"app",
"e2e",
"fixtures",
"recording.baseline.txt"
);
const baseline = await import("node:fs/promises")
.then((fs) => fs.readFile(baselinePath, "utf-8"))
.then((text) => text.trim());
const baseline = (await readFile(fixturePath("recording.baseline.txt"), "utf-8")).trim();
await ctx.client.startDictationStream(dictationId, format);
@@ -93,31 +93,36 @@ async function waitForTimelineToolCall(
async function waitForPathExists(
options: { targetPath: string; timeoutMs: number; label: string }
): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (existsSync(options.targetPath)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for ${options.label}: ${options.targetPath}`
);
await waitForCondition({
timeoutMs: options.timeoutMs,
label: `${options.label}: ${options.targetPath}`,
predicate: () => existsSync(options.targetPath),
});
}
async function waitForPathRemoved(
options: { targetPath: string; timeoutMs: number; label: string }
): Promise<void> {
await waitForCondition({
timeoutMs: options.timeoutMs,
label: `removal of ${options.label}: ${options.targetPath}`,
predicate: () => !existsSync(options.targetPath),
});
}
async function waitForCondition(options: {
timeoutMs: number;
label: string;
predicate: () => boolean | Promise<boolean>;
}): Promise<void> {
const start = Date.now();
while (Date.now() - start < options.timeoutMs) {
if (!existsSync(options.targetPath)) {
if (await options.predicate()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
throw new Error(
`Timed out after ${options.timeoutMs}ms waiting for removal of ${options.label}: ${options.targetPath}`
);
throw new Error(`Timed out after ${options.timeoutMs}ms waiting for ${options.label}`);
}
async function withShell<T>(shell: string, run: () => Promise<T>): Promise<T> {
@@ -818,10 +823,17 @@ describe("daemon E2E", () => {
label: "createAgent should not block on setup",
});
await waitForPathExists({
targetPath: path.join(agent.cwd, "dev-terminal.txt"),
timeoutMs: 15000,
label: "worktree terminal marker",
await waitForCondition({
timeoutMs: 30000,
label: `worktree terminal bootstrap for ${agent.cwd}`,
predicate: async () => {
const directories = ctx.daemon.daemon.terminalManager.listDirectories();
if (!directories.includes(agent.cwd)) {
return false;
}
const terminals = await ctx.client.listTerminals(agent.cwd);
return terminals.terminals.some((terminal) => terminal.name === "Dev Server");
},
});
const beforeArchiveDirectories = ctx.daemon.daemon.terminalManager.listDirectories();
@@ -846,122 +858,5 @@ describe("daemon E2E", () => {
60000
);
test(
"archives the worktree when the last agent in it is archived",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Archive Last Agent Cleanup Test",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "archive-last-agent",
worktreeSlug: "archive-last-agent",
},
});
expect(existsSync(agent.cwd)).toBe(true);
const result = await ctx.client.archiveAgent(agent.id);
expect(result.archivedAt).toBeTruthy();
await waitForPathRemoved({
targetPath: agent.cwd,
timeoutMs: 10000,
label: "archived worktree",
});
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
test(
"does not archive the worktree until all agents in it are archived",
async () => {
const repoRoot = tmpCwd();
const { execSync } = await import("child_process");
execSync("git init -b main", { cwd: repoRoot, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git config user.name 'Test'", { cwd: repoRoot, stdio: "pipe" });
writeFileSync(path.join(repoRoot, "file.txt"), "hello\n");
execSync("git add .", { cwd: repoRoot, stdio: "pipe" });
execSync("git -c commit.gpgsign=false commit -m 'initial'", {
cwd: repoRoot,
stdio: "pipe",
});
execSync("git branch -M main", { cwd: repoRoot, stdio: "pipe" });
const firstAgent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: repoRoot,
title: "Archive Multi-Agent Test 1",
git: {
createWorktree: true,
createNewBranch: true,
baseBranch: "main",
newBranchName: "archive-multi-agent",
worktreeSlug: "archive-multi-agent",
},
});
const secondAgent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd: firstAgent.cwd,
title: "Archive Multi-Agent Test 2",
});
expect(existsSync(firstAgent.cwd)).toBe(true);
await ctx.client.archiveAgent(firstAgent.id);
await new Promise((resolve) => setTimeout(resolve, 300));
expect(existsSync(firstAgent.cwd)).toBe(true);
await ctx.client.archiveAgent(secondAgent.id);
await waitForPathRemoved({
targetPath: firstAgent.cwd,
timeoutMs: 10000,
label: "worktree after final agent archive",
});
rmSync(repoRoot, { recursive: true, force: true });
},
60000
);
});
});
@@ -35,6 +35,37 @@ async function waitForCondition(
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition`);
}
async function waitForStableNumber(
read: () => number,
input: {
stableMs: number;
timeoutMs: number;
intervalMs?: number;
}
): Promise<number> {
const intervalMs = input.intervalMs ?? 25;
const start = Date.now();
let lastValue = read();
let stableSince = Date.now();
while (Date.now() - start < input.timeoutMs) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
const nextValue = read();
if (nextValue !== lastValue) {
lastValue = nextValue;
stableSince = Date.now();
continue;
}
if (Date.now() - stableSince >= input.stableMs) {
return nextValue;
}
}
throw new Error(
`Timed out after ${input.timeoutMs}ms waiting for value to stabilize`
);
}
function percentile(values: number[], p: number): number {
if (values.length === 0) return 0;
const sorted = [...values].sort((a, b) => a - b);
@@ -444,7 +475,7 @@ const shouldRun = !process.env.CI;
);
test(
"applies stream backpressure window until client ack advances",
"caps terminal stream output within the backpressure window",
async () => {
const cwd = tmpCwd();
const created = await ctx.client.createTerminal(cwd);
@@ -613,24 +644,16 @@ const shouldRun = !process.env.CI;
);
await waitForCondition(() => outputBytes > 0, 10000);
await new Promise((resolve) => setTimeout(resolve, 800));
const beforeAckBytes = outputBytes;
await waitForCondition(() => outputBytes >= 128 * 1024, 10000);
const beforeAckBytes = await waitForStableNumber(() => outputBytes, {
stableMs: 1000,
timeoutMs: 10000,
});
expect(beforeAckBytes).toBeGreaterThan(0);
expect(beforeAckBytes).toBeGreaterThan(128 * 1024);
expect(beforeAckBytes).toBeLessThan(320 * 1024);
expect(latestEndOffset).toBeGreaterThan(0);
ws.send(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.Ack,
streamId: streamId!,
offset: latestEndOffset,
payload: new Uint8Array(0),
})
);
await waitForCondition(() => outputBytes > beforeAckBytes, 10000);
ws.send(
JSON.stringify({
type: "session",
@@ -43,6 +43,10 @@ const RequestedSpeechProvidersSchema = z.object({
voiceTts: OptionalSpeechProviderSchema.default("local"),
});
function resolveOptionalBooleanFlag(value: unknown): boolean {
return OptionalBooleanFlagSchema.parse(value) ?? true;
}
function resolveRequestedSpeechProviders(params: {
env: NodeJS.ProcessEnv;
persisted: PersistedConfig;
@@ -57,54 +61,71 @@ function resolveRequestedSpeechProviders(params: {
enabled,
});
const dictationSttProviderFromConfig =
params.env.PASEO_DICTATION_STT_PROVIDER ??
params.persisted.features?.dictation?.stt?.provider;
const voiceSttProviderFromConfig =
params.env.PASEO_VOICE_STT_PROVIDER ??
params.persisted.features?.voiceMode?.stt?.provider;
const voiceTurnDetectionProviderFromConfig =
params.env.PASEO_VOICE_TURN_DETECTION_PROVIDER ??
params.persisted.features?.voiceMode?.turnDetection?.provider;
const voiceTtsProviderFromConfig =
params.env.PASEO_VOICE_TTS_PROVIDER ??
params.persisted.features?.voiceMode?.tts?.provider;
const dictationEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_DICTATION_ENABLED ?? params.persisted.features?.dictation?.enabled
) ?? true;
const voiceModeEnabled =
OptionalBooleanFlagSchema.parse(
params.env.PASEO_VOICE_MODE_ENABLED ?? params.persisted.features?.voiceMode?.enabled
) ?? true;
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 parsed = RequestedSpeechProvidersSchema.parse({
dictationStt: dictationSttProviderFromConfig ?? "local",
voiceTurnDetection: voiceTurnDetectionProviderFromConfig ?? "local",
voiceStt: voiceSttProviderFromConfig ?? "local",
voiceTts: voiceTtsProviderFromConfig ?? "local",
dictationStt: featureProviders.dictationStt.configuredValue ?? "local",
voiceTurnDetection: featureProviders.voiceTurnDetection.configuredValue ?? "local",
voiceStt: featureProviders.voiceStt.configuredValue ?? "local",
voiceTts: featureProviders.voiceTts.configuredValue ?? "local",
});
return {
dictationStt: resolveFeatureProvider(
dictationSttProviderFromConfig,
featureProviders.dictationStt.configuredValue,
parsed.dictationStt,
dictationEnabled
featureProviders.dictationStt.enabled
),
voiceTurnDetection: resolveFeatureProvider(
voiceTurnDetectionProviderFromConfig,
featureProviders.voiceTurnDetection.configuredValue,
parsed.voiceTurnDetection,
voiceModeEnabled
featureProviders.voiceTurnDetection.enabled
),
voiceStt: resolveFeatureProvider(
voiceSttProviderFromConfig,
featureProviders.voiceStt.configuredValue,
parsed.voiceStt,
voiceModeEnabled
featureProviders.voiceStt.enabled
),
voiceTts: resolveFeatureProvider(
voiceTtsProviderFromConfig,
featureProviders.voiceTts.configuredValue,
parsed.voiceTts,
voiceModeEnabled
featureProviders.voiceTts.enabled
),
};
}
@@ -284,23 +284,9 @@ describe("TerminalManager - Command Execution", () => {
5000
);
// First command
await manager.sendTextToCommand(
execResult.commandId,
"x = 5",
true,
{ lines: 50, maxWait: 2000 }
);
await manager.sendTextToCommand(execResult.commandId, "x = 5", true);
await manager.sendTextToCommand(execResult.commandId, "y = 3", true);
// Second command
await manager.sendTextToCommand(
execResult.commandId,
"y = 3",
true,
{ lines: 50, maxWait: 2000 }
);
// Third command - use variables
const output = await manager.sendTextToCommand(
execResult.commandId,
"print(x + y)",
@@ -449,24 +435,16 @@ describe("TerminalManager - Command Execution", () => {
expect(execResult.output).toContain(">"); // Node prompt
expect(execResult.isDead).toBe(false);
// Execute JavaScript
await manager.sendTextToCommand(
execResult.commandId,
"const x = [1, 2, 3]",
true,
{ lines: 50, maxWait: 2000 }
);
await manager.sendTextToCommand(execResult.commandId, "const x = [1, 2, 3]", true);
const output2 = await manager.sendTextToCommand(
execResult.commandId,
"x.map(n => n * 2)",
"console.log(x.map(n => n * 2).join(','))",
true,
{ lines: 50, maxWait: 2000 }
);
expect(output2).toContain("2");
expect(output2).toContain("4");
expect(output2).toContain("6");
expect(output2).toContain("2,4,6");
// Exit
await manager.sendTextToCommand(execResult.commandId, ".exit", true);
@@ -1,4 +1,3 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, test } from "vitest";
@@ -26,7 +25,9 @@ describe("resolveVoiceMcpBridgeFromRuntime", () => {
});
test("uses explicit script override when provided", () => {
const explicitScriptPath = path.resolve(process.cwd(), "scripts/mcp-stdio-socket-bridge-cli.mjs");
const explicitScriptPath = fileURLToPath(
new URL("../../scripts/mcp-stdio-socket-bridge-cli.mjs", bootstrapModuleUrl)
);
const result = resolveVoiceMcpBridgeFromRuntime({
bootstrapModuleUrl,
@@ -9,6 +9,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import pino from "pino";
import { createVoiceMcpSocketBridgeManager } from "./voice-mcp-bridge.js";
import { resolveVoiceMcpBridgeScriptPath } from "./voice-mcp-bridge-command.js";
describe("voice MCP bridge", () => {
test("proxies stdio MCP bytes through per-agent unix socket bridge", async () => {
@@ -54,12 +55,12 @@ describe("voice MCP bridge", () => {
const socketPath = await bridgeManager.ensureBridgeForCaller(callerAgentId);
const bridgeScript = path.resolve(process.cwd(), "scripts/mcp-stdio-socket-bridge-cli.mjs");
const transport = new StdioClientTransport({
command: process.execPath,
args: [
bridgeScript,
resolveVoiceMcpBridgeScriptPath({
bootstrapModuleUrl: import.meta.url,
}),
"--socket",
socketPath,
],
+70 -40
View File
@@ -14,6 +14,11 @@ export type ToolCallDisplayModel = {
errorText?: string;
};
type DetailDisplay = {
displayName?: string;
summary?: string;
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -56,61 +61,86 @@ function formatErrorText(error: unknown): string | undefined {
}
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
const lowerName = input.name.trim().toLowerCase();
let displayName = humanizeToolName(input.name);
let summary: string | undefined;
function buildFilePathDisplay(
displayName: string,
filePath: string,
cwd: string | undefined,
): DetailDisplay {
return {
displayName,
summary: stripCwdPrefix(filePath, cwd),
};
}
function buildCanonicalDetailDisplay(input: ToolCallDisplayInput): DetailDisplay {
switch (input.detail.type) {
case "shell":
displayName = "Shell";
summary = input.detail.command;
break;
return {
displayName: "Shell",
summary: input.detail.command,
};
case "read":
displayName = "Read";
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
break;
return buildFilePathDisplay("Read", input.detail.filePath, input.cwd);
case "edit":
displayName = "Edit";
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
break;
return buildFilePathDisplay("Edit", input.detail.filePath, input.cwd);
case "write":
displayName = "Write";
summary = stripCwdPrefix(input.detail.filePath, input.cwd);
break;
return buildFilePathDisplay("Write", input.detail.filePath, input.cwd);
case "search":
displayName = "Search";
summary = input.detail.query;
break;
return {
displayName: "Search",
summary: input.detail.query,
};
case "worktree_setup":
displayName = "Worktree Setup";
summary = input.detail.branchName;
break;
return {
displayName: "Worktree Setup",
summary: input.detail.branchName,
};
case "sub_agent":
displayName = readString(input.detail.subAgentType) ?? "Task";
summary = readString(input.detail.description);
break;
return {
displayName: readString(input.detail.subAgentType) ?? "Task",
summary: readString(input.detail.description),
};
case "plain_text":
summary = input.detail.label;
break;
return {
summary: input.detail.label,
};
case "unknown":
break;
return {};
}
}
if (lowerName === "task" && input.detail.type === "unknown") {
displayName = "Task";
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;
function buildUnknownDetailOverride(input: ToolCallDisplayInput): DetailDisplay {
const lowerName = input.name.trim().toLowerCase();
if (input.detail.type === "unknown" && lowerName === "task") {
return {
displayName: "Task",
summary: isRecord(input.metadata)
? readString(input.metadata.subAgentActivity)
: undefined,
};
}
if (input.detail.type === "unknown" && lowerName === "thinking") {
return {
displayName: "Thinking",
};
}
if (lowerName === "terminal") {
return {
displayName: "Interacted with terminal",
summary: input.detail.type === "plain_text" ? readString(input.detail.label) : undefined,
};
}
return {};
}
export function buildToolCallDisplayModel(input: ToolCallDisplayInput): ToolCallDisplayModel {
const canonicalDisplay = buildCanonicalDetailDisplay(input);
const unknownDetailOverride = buildUnknownDetailOverride(input);
const displayName =
unknownDetailOverride.displayName ??
canonicalDisplay.displayName ??
humanizeToolName(input.name);
const summary = unknownDetailOverride.summary ?? canonicalDisplay.summary;
const errorText = input.status === "failed" ? formatErrorText(input.error) : undefined;
return {