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:
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user