mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Merge branch 'quality-gate-verify-tests-standards'
This commit is contained in:
@@ -8,7 +8,6 @@
|
||||
* - Help and argument parsing
|
||||
* - Validation for required update fields
|
||||
* - Graceful daemon connection errors
|
||||
* - Top-level daemon update alias behavior (`paseo update`)
|
||||
*/
|
||||
|
||||
import assert from 'node:assert'
|
||||
@@ -105,28 +104,6 @@ try {
|
||||
console.log('✓ agent --help shows update subcommand\n')
|
||||
}
|
||||
|
||||
// Test 7: top-level update alias --help shows daemon update options
|
||||
{
|
||||
console.log('Test 7: top-level update --help shows daemon update options')
|
||||
const result = await $`npx paseo update --help`.nothrow()
|
||||
assert.strictEqual(result.exitCode, 0, 'update --help should exit 0')
|
||||
assert(result.stdout.includes('--home'), 'help should mention --home flag')
|
||||
assert(result.stdout.includes('--yes'), 'help should mention --yes flag')
|
||||
assert(result.stdout.includes('daemon update'), 'help should mention daemon update alias')
|
||||
console.log('✓ top-level update --help shows daemon update options\n')
|
||||
}
|
||||
|
||||
// Test 8: top-level update alias accepts daemon update flags
|
||||
{
|
||||
console.log('Test 8: top-level update alias accepts daemon update flags')
|
||||
const result =
|
||||
await $`PASEO_HOME=${paseoHome} npx paseo update --home ${paseoHome} --yes --help`.nothrow()
|
||||
const output = result.stdout + result.stderr
|
||||
assert(!output.includes('unknown option'), 'should accept top-level update flags')
|
||||
assert(!output.includes('error: option'), 'should not have option parsing error')
|
||||
assert.strictEqual(result.exitCode, 0, 'update alias help with flags should exit 0')
|
||||
console.log('✓ top-level update alias accepts daemon update flags\n')
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp directory
|
||||
await rm(paseoHome, { recursive: true, force: true })
|
||||
|
||||
@@ -1,27 +1,5 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { execFileSyncMock, execSyncMock, existsSyncMock, platformMock } = vi.hoisted(
|
||||
() => ({
|
||||
execFileSyncMock: vi.fn(),
|
||||
execSyncMock: vi.fn(),
|
||||
existsSyncMock: vi.fn(),
|
||||
platformMock: vi.fn(() => "darwin"),
|
||||
})
|
||||
);
|
||||
|
||||
vi.mock("node:child_process", () => ({
|
||||
execFileSync: execFileSyncMock,
|
||||
execSync: execSyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: existsSyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:os", () => ({
|
||||
platform: platformMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
findExecutable,
|
||||
resolveProviderCommandPrefix,
|
||||
@@ -29,13 +7,22 @@ import {
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
|
||||
type FindExecutableDependencies = NonNullable<Parameters<typeof findExecutable>[1]>;
|
||||
|
||||
function createFindExecutableDependencies(): FindExecutableDependencies {
|
||||
return {
|
||||
execFileSync: vi.fn(),
|
||||
execSync: vi.fn(),
|
||||
existsSync: vi.fn(),
|
||||
platform: vi.fn(() => "darwin"),
|
||||
shell: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let findExecutableDependencies: FindExecutableDependencies;
|
||||
|
||||
beforeEach(() => {
|
||||
execFileSyncMock.mockReset();
|
||||
execSyncMock.mockReset();
|
||||
existsSyncMock.mockReset();
|
||||
platformMock.mockReset();
|
||||
platformMock.mockReturnValue("darwin");
|
||||
delete process.env["SHELL"];
|
||||
findExecutableDependencies = createFindExecutableDependencies();
|
||||
});
|
||||
|
||||
describe("resolveProviderCommandPrefix", () => {
|
||||
@@ -98,42 +85,49 @@ describe("applyProviderEnv", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const env = applyProviderEnv(base, runtime);
|
||||
const env = applyProviderEnv(base, runtime, {});
|
||||
|
||||
expect(env).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
HOME: "/custom/home",
|
||||
FOO: "bar",
|
||||
});
|
||||
expect(env.PATH).toBe("/usr/bin");
|
||||
expect(env.HOME).toBe("/custom/home");
|
||||
expect(env.FOO).toBe("bar");
|
||||
expect(Object.keys(env).length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findExecutable", () => {
|
||||
test("uses the last line from login-shell which output", () => {
|
||||
process.env["SHELL"] = "/bin/zsh";
|
||||
execSyncMock.mockReturnValue("echo from profile\n/usr/local/bin/codex\n");
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue(
|
||||
"echo from profile\n/usr/local/bin/codex\n"
|
||||
);
|
||||
|
||||
expect(findExecutable("codex")).toBe("/usr/local/bin/codex");
|
||||
expect(execSyncMock).toHaveBeenCalledOnce();
|
||||
expect(execFileSyncMock).not.toHaveBeenCalled();
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex"
|
||||
);
|
||||
expect(findExecutableDependencies.execSync).toHaveBeenCalledOnce();
|
||||
expect(findExecutableDependencies.execFileSync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("warns and returns null when the final which line is not an absolute path", () => {
|
||||
process.env["SHELL"] = "/bin/zsh";
|
||||
execSyncMock.mockReturnValue("profile noise\ncodex\n");
|
||||
execFileSyncMock.mockReturnValue("codex\n");
|
||||
findExecutableDependencies.shell = "/bin/zsh";
|
||||
findExecutableDependencies.execSync.mockReturnValue("profile noise\ncodex\n");
|
||||
findExecutableDependencies.execFileSync.mockReturnValue("codex\n");
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(findExecutable("codex")).toBeNull();
|
||||
expect(findExecutable("codex", findExecutableDependencies)).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("returns direct paths when they exist", () => {
|
||||
existsSyncMock.mockReturnValue(true);
|
||||
findExecutableDependencies.existsSync.mockReturnValue(true);
|
||||
|
||||
expect(findExecutable("/usr/local/bin/codex")).toBe("/usr/local/bin/codex");
|
||||
expect(existsSyncMock).toHaveBeenCalledWith("/usr/local/bin/codex");
|
||||
expect(findExecutable("/usr/local/bin/codex", findExecutableDependencies)).toBe(
|
||||
"/usr/local/bin/codex"
|
||||
);
|
||||
expect(findExecutableDependencies.existsSync).toHaveBeenCalledWith(
|
||||
"/usr/local/bin/codex"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +56,14 @@ export type ProviderCommandPrefix = {
|
||||
args: string[];
|
||||
};
|
||||
|
||||
interface FindExecutableDependencies {
|
||||
execSync: typeof execSync;
|
||||
execFileSync: typeof execFileSync;
|
||||
existsSync: typeof existsSync;
|
||||
platform: typeof platform;
|
||||
shell: string | undefined;
|
||||
}
|
||||
|
||||
function resolveExecutableFromWhichOutput(
|
||||
name: string,
|
||||
output: string,
|
||||
@@ -119,10 +127,11 @@ export function resolveShellEnv(): Record<string, string> {
|
||||
|
||||
export function applyProviderEnv(
|
||||
baseEnv: Record<string, string | undefined>,
|
||||
runtimeSettings?: ProviderRuntimeSettings
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
shellEnv?: Record<string, string>
|
||||
): Record<string, string | undefined> {
|
||||
return {
|
||||
...resolveShellEnv(),
|
||||
...(shellEnv ?? resolveShellEnv()),
|
||||
...baseEnv,
|
||||
...(runtimeSettings?.env ?? {}),
|
||||
};
|
||||
@@ -138,19 +147,31 @@ export function applyProviderEnv(
|
||||
*
|
||||
* On Windows the system PATH is always available, so `where.exe` is sufficient.
|
||||
*/
|
||||
export function findExecutable(name: string): string | null {
|
||||
export function findExecutable(
|
||||
name: string,
|
||||
dependencies?: FindExecutableDependencies
|
||||
): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const deps: FindExecutableDependencies = {
|
||||
execSync,
|
||||
execFileSync,
|
||||
existsSync,
|
||||
platform,
|
||||
shell: process.env["SHELL"],
|
||||
...dependencies,
|
||||
};
|
||||
|
||||
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
||||
return existsSync(trimmed) ? trimmed : null;
|
||||
return deps.existsSync(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
if (platform() === "win32") {
|
||||
if (deps.platform() === "win32") {
|
||||
try {
|
||||
const out = execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim();
|
||||
const out = deps.execSync(`where.exe ${trimmed}`, { encoding: "utf8" }).trim();
|
||||
const firstLine = out.split(/\r?\n/)[0]?.trim();
|
||||
return firstLine || null;
|
||||
} catch {
|
||||
@@ -159,10 +180,10 @@ export function findExecutable(name: string): string | null {
|
||||
}
|
||||
|
||||
// Unix: try the user's login shell so rc-file PATH entries are visible.
|
||||
const shell = process.env["SHELL"];
|
||||
const shell = deps.shell;
|
||||
if (shell) {
|
||||
try {
|
||||
const out = execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
const out = deps.execSync(`${shell} -lic "which ${trimmed}"`, {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
@@ -178,7 +199,7 @@ export function findExecutable(name: string): string | null {
|
||||
try {
|
||||
return resolveExecutableFromWhichOutput(
|
||||
trimmed,
|
||||
execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
deps.execFileSync("which", [trimmed], { encoding: "utf8" }).trim(),
|
||||
"which"
|
||||
);
|
||||
} catch {
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
/**
|
||||
* TDD Tests for Claude Agent Commands Integration
|
||||
*
|
||||
* Tests the ability to:
|
||||
* 1. List available slash commands from a ClaudeAgentSession
|
||||
*
|
||||
* These tests verify that the agent abstraction layer properly exposes
|
||||
* the Claude Agent SDK's command capabilities.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { ClaudeAgentClient } from "./claude-agent.js";
|
||||
import type { AgentSession, AgentSessionConfig, AgentSlashCommand } from "../agent-sdk-types.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
(hasClaudeCredentials ? describe : describe.skip)("ClaudeAgentSession Commands", () => {
|
||||
let client: ClaudeAgentClient;
|
||||
let session: AgentSession | null = null;
|
||||
let commands: AgentSlashCommand[] = [];
|
||||
let restoreClaudeConfigDir: (() => void) | null = null;
|
||||
let tempCwd: string | null = null;
|
||||
|
||||
const buildTestConfig = (cwd: string): AgentSessionConfig => ({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
modeId: "plan",
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
restoreClaudeConfigDir = useTempClaudeConfigDir();
|
||||
const rawTempDir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-commands-"));
|
||||
try {
|
||||
tempCwd = realpathSync(rawTempDir);
|
||||
} catch {
|
||||
tempCwd = rawTempDir;
|
||||
}
|
||||
client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
session = await client.createSession(buildTestConfig(tempCwd));
|
||||
if (typeof session.listCommands !== "function") {
|
||||
throw new Error("Claude test session does not expose listCommands");
|
||||
}
|
||||
commands = await session.listCommands();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (session) {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
session = null;
|
||||
if (tempCwd) {
|
||||
rmSync(tempCwd, { recursive: true, force: true });
|
||||
tempCwd = null;
|
||||
}
|
||||
restoreClaudeConfigDir?.();
|
||||
restoreClaudeConfigDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
describe("listCommands()", () => {
|
||||
it("should return an array of AgentSlashCommand objects", async () => {
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
// The session should have a listCommands method
|
||||
expect(typeof session.listCommands).toBe("function");
|
||||
|
||||
// Should be an array
|
||||
expect(Array.isArray(commands)).toBe(true);
|
||||
|
||||
// Should have at least some built-in commands
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
}, 30000);
|
||||
|
||||
it("should have valid AgentSlashCommand structure for all commands", async () => {
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
// Verify all commands have valid structure
|
||||
for (const cmd of commands) {
|
||||
expect(cmd).toHaveProperty("name");
|
||||
expect(cmd).toHaveProperty("description");
|
||||
expect(cmd).toHaveProperty("argumentHint");
|
||||
expect(typeof cmd.name).toBe("string");
|
||||
expect(typeof cmd.description).toBe("string");
|
||||
expect(typeof cmd.argumentHint).toBe("string");
|
||||
expect(cmd.name.length).toBeGreaterThan(0);
|
||||
// Names should NOT have the / prefix (that's added when executing)
|
||||
expect(cmd.name.startsWith("/")).toBe(false);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
it("should include user-defined skills", async () => {
|
||||
if (!session) {
|
||||
throw new Error("Claude test session not initialized");
|
||||
}
|
||||
|
||||
const commandNames = commands.map((cmd) => cmd.name);
|
||||
|
||||
// Should have at least one command (skills are loaded from user/project settings)
|
||||
// The exact commands depend on what skills are configured
|
||||
expect(commands.length).toBeGreaterThan(0);
|
||||
expect(commandNames).toContain("rewind");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -2,18 +2,9 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { ClaudeAgentClient, readEventIdentifiers } from "./claude-agent.js";
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
|
||||
const sdkMocks = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
query: sdkMocks.query,
|
||||
}));
|
||||
|
||||
type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
@@ -64,7 +55,10 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
|
||||
}
|
||||
|
||||
async function createSession() {
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdkQueryFactory,
|
||||
});
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
@@ -72,13 +66,18 @@ async function createSession() {
|
||||
}
|
||||
|
||||
function createSessionWithLogger(logger: Logger) {
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: sdkQueryFactory,
|
||||
});
|
||||
return client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
}
|
||||
|
||||
const sdkQueryFactory = vi.fn();
|
||||
|
||||
type CapturedLog = {
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
args: unknown[];
|
||||
@@ -166,11 +165,11 @@ async function waitForCondition(
|
||||
|
||||
describe("ClaudeAgentSession redesign invariants", () => {
|
||||
beforeEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
sdkQueryFactory.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sdkMocks.query.mockReset();
|
||||
sdkQueryFactory.mockReset();
|
||||
});
|
||||
|
||||
test("logs redacted query summary and never leaks sentinel secrets", async () => {
|
||||
@@ -180,7 +179,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const previousEnv = process.env.PASEO_TEST_SENTINEL_SECRET;
|
||||
process.env.PASEO_TEST_SENTINEL_SECRET = envSecret;
|
||||
|
||||
sdkMocks.query.mockImplementation(() => {
|
||||
sdkQueryFactory.mockImplementation(() => {
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
@@ -227,6 +226,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const spy = createSpyLogger();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: spy.logger,
|
||||
queryFactory: sdkQueryFactory,
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
PASEO_RUNTIME_SENTINEL_SECRET: runtimeSecret,
|
||||
@@ -273,7 +273,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("emits interrupt step diagnostics at debug level only", async () => {
|
||||
test("emits interrupt step diagnostics without info logs", async () => {
|
||||
const spy = createSpyLogger();
|
||||
const session = await createSessionWithLogger(spy.logger);
|
||||
const internal = session as unknown as {
|
||||
@@ -310,9 +310,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
expect(interruptInfoMessages).toEqual([]);
|
||||
expect(interruptDebugMessages).toEqual([
|
||||
"interruptActiveTurn: calling query.interrupt()...",
|
||||
"interruptActiveTurn: query.interrupt() returned",
|
||||
"interruptActiveTurn: calling query.return()...",
|
||||
"interruptActiveTurn: query.return() returned",
|
||||
]);
|
||||
expect(interrupt).toHaveBeenCalledTimes(1);
|
||||
expect(queryReturn).toHaveBeenCalledTimes(1);
|
||||
@@ -755,7 +753,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
|
||||
test("completes a foreground run when only system metadata arrives before the first assistant message", async () => {
|
||||
let step = 0;
|
||||
sdkMocks.query.mockImplementation(() =>
|
||||
sdkQueryFactory.mockImplementation(() =>
|
||||
createBaseQueryMock(
|
||||
vi.fn(async () => {
|
||||
if (step === 0) {
|
||||
@@ -950,7 +948,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
const session = await createSession();
|
||||
let streamCase: "success" | "error" | "interrupt" = "success";
|
||||
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
let interruptRequested = false;
|
||||
@@ -1082,7 +1080,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
test("assembles assistant timeline when message_delta arrives before message_start", async () => {
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
@@ -1199,7 +1197,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
||||
});
|
||||
|
||||
test("does not use stream_event uuid as assistant message identity when message_id is missing", async () => {
|
||||
sdkMocks.query.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
sdkQueryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
const readPromptUuid = createPromptUuidReader(prompt);
|
||||
let step = 0;
|
||||
return createBaseQueryMock(
|
||||
|
||||
@@ -1,206 +1,8 @@
|
||||
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient, convertClaudeHistoryEntry } from "./claude-agent.js";
|
||||
import { useTempClaudeConfigDir } from "../../test-utils/claude-config.js";
|
||||
import type {
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
|
||||
const hasClaudeCredentials =
|
||||
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||
|
||||
type KeyValueObject = { [key: string]: unknown };
|
||||
|
||||
function tmpCwd(): string {
|
||||
const dir = mkdtempSync(path.join(os.tmpdir(), "claude-agent-e2e-"));
|
||||
try {
|
||||
return realpathSync(dir);
|
||||
} catch {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
async function closeSessionAndCleanup(
|
||||
session: AgentSession | null | undefined,
|
||||
cwd: string
|
||||
): Promise<void> {
|
||||
await session?.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function isKeyValueObject(value: unknown): value is KeyValueObject {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
function extractCommandText(input: unknown): string | null {
|
||||
if (!isKeyValueObject(input)) {
|
||||
return null;
|
||||
}
|
||||
const command = input.command;
|
||||
if (typeof command === "string" && command.length > 0) {
|
||||
return command;
|
||||
}
|
||||
if (Array.isArray(command)) {
|
||||
const tokens = command.filter((value): value is string => typeof value === "string");
|
||||
if (tokens.length > 0) {
|
||||
return tokens.join(" ");
|
||||
}
|
||||
}
|
||||
if (typeof input.description === "string" && input.description.length > 0) {
|
||||
return input.description;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractToolCommand(detail: unknown): string | null {
|
||||
if (!isKeyValueObject(detail) || typeof detail.type !== "string") {
|
||||
return null;
|
||||
}
|
||||
if (detail.type === "shell" && typeof detail.command === "string") {
|
||||
return detail.command;
|
||||
}
|
||||
if (detail.type === "unknown") {
|
||||
return extractCommandText(detail.input);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
(hasClaudeCredentials ? describe : describe.skip)(
|
||||
"ClaudeAgentClient (SDK integration)",
|
||||
() => {
|
||||
const logger = createTestLogger();
|
||||
let restoreClaudeConfigDir: (() => void) | null = null;
|
||||
|
||||
const buildConfig = (
|
||||
cwd: string,
|
||||
options?: { maxThinkingTokens?: number; modeId?: string }
|
||||
): AgentSessionConfig => ({
|
||||
provider: "claude",
|
||||
cwd,
|
||||
modeId: options?.modeId,
|
||||
extra: {
|
||||
claude: {
|
||||
sandbox: { enabled: true, autoAllowBashIfSandboxed: false },
|
||||
...(typeof options?.maxThinkingTokens === "number"
|
||||
? { maxThinkingTokens: options.maxThinkingTokens }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
restoreClaudeConfigDir = useTempClaudeConfigDir();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
restoreClaudeConfigDir?.();
|
||||
});
|
||||
|
||||
test(
|
||||
"responds with text",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession(
|
||||
buildConfig(cwd, { maxThinkingTokens: 1024 })
|
||||
);
|
||||
|
||||
try {
|
||||
const marker = "CLAUDE_ACK_TOKEN";
|
||||
const result = await session.run(
|
||||
`Reply with the exact text ${marker} and then stop.`
|
||||
);
|
||||
expect(result.finalText).toContain(marker);
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
},
|
||||
120_000
|
||||
);
|
||||
|
||||
test(
|
||||
"shows the command inside permission requests",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession(
|
||||
buildConfig(cwd, { maxThinkingTokens: 2048 })
|
||||
);
|
||||
writeFileSync(path.join(cwd, "permission.txt"), "ok", "utf8");
|
||||
|
||||
let requestedCommand: string | null = null;
|
||||
|
||||
try {
|
||||
const events = session.stream(
|
||||
"Run the exact command `rm -f permission.txt` via Bash and stop."
|
||||
);
|
||||
|
||||
for await (const event of events) {
|
||||
if (
|
||||
event.type === "permission_requested" &&
|
||||
event.request.kind === "tool" &&
|
||||
event.request.name.toLowerCase().includes("bash")
|
||||
) {
|
||||
requestedCommand = extractToolCommand(
|
||||
event.request.detail ?? {
|
||||
type: "unknown",
|
||||
input: event.request.input ?? null,
|
||||
output: null,
|
||||
}
|
||||
);
|
||||
await session.respondToPermission(event.request.id, {
|
||||
behavior: "allow",
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === "turn_completed" || event.type === "turn_failed") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
|
||||
expect(requestedCommand).toBeTruthy();
|
||||
expect(requestedCommand?.toLowerCase()).toContain("permission.txt");
|
||||
},
|
||||
150_000
|
||||
);
|
||||
|
||||
test(
|
||||
"updates session modes",
|
||||
async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new ClaudeAgentClient({ logger });
|
||||
const session = await client.createSession(
|
||||
buildConfig(cwd, { maxThinkingTokens: 1024 })
|
||||
);
|
||||
|
||||
try {
|
||||
const modes = await session.getAvailableModes();
|
||||
expect(modes.map((mode) => mode.id)).toContain("plan");
|
||||
|
||||
await session.setMode("plan");
|
||||
expect(await session.getCurrentMode()).toBe("plan");
|
||||
|
||||
const result = await session.run(
|
||||
"Just reply with the word PLAN to confirm you're still responsive."
|
||||
);
|
||||
expect(result.finalText.toLowerCase()).toContain("plan");
|
||||
} finally {
|
||||
await closeSessionAndCleanup(session, cwd);
|
||||
}
|
||||
},
|
||||
120_000
|
||||
);
|
||||
}
|
||||
);
|
||||
import type { AgentTimelineItem } from "../agent-sdk-types.js";
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
|
||||
@@ -352,6 +352,7 @@ type ClaudeAgentClientOptions = {
|
||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
logger: Logger;
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
queryFactory?: typeof query;
|
||||
};
|
||||
|
||||
type ClaudeAgentSessionOptions = {
|
||||
@@ -359,6 +360,7 @@ type ClaudeAgentSessionOptions = {
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
handle?: AgentPersistenceHandle;
|
||||
logger: Logger;
|
||||
queryFactory?: typeof query;
|
||||
};
|
||||
|
||||
function resolveClaudeSpawnCommand(
|
||||
@@ -1385,11 +1387,13 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly queryFactory: typeof query;
|
||||
|
||||
constructor(options: ClaudeAgentClientOptions) {
|
||||
this.defaults = options.defaults;
|
||||
this.logger = options.logger.child({ module: "agent", provider: "claude" });
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.queryFactory = options.queryFactory ?? query;
|
||||
}
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
@@ -1398,6 +1402,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
defaults: this.defaults,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1417,6 +1422,7 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
handle,
|
||||
logger: this.logger,
|
||||
queryFactory: this.queryFactory,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1471,6 +1477,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly logger: Logger;
|
||||
private readonly queryFactory: typeof query;
|
||||
private query: Query | null = null;
|
||||
private input: Pushable<SDKUserMessage> | null = null;
|
||||
private claudeSessionId: string | null;
|
||||
@@ -1517,6 +1524,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.defaults = options.defaults;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.logger = options.logger;
|
||||
this.queryFactory = options.queryFactory ?? query;
|
||||
const handle = options.handle;
|
||||
|
||||
if (handle) {
|
||||
@@ -2227,7 +2235,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"claude query"
|
||||
);
|
||||
this.input = input;
|
||||
this.query = query({ prompt: input, options });
|
||||
this.query = this.queryFactory({ prompt: input, options });
|
||||
// Do not kick off background control-plane queries here. Methods like
|
||||
// supportedCommands()/setPermissionMode() may execute immediately after
|
||||
// ensureQuery() (for listCommands()/setMode()), and sharing the same query
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -540,12 +540,17 @@ class CodexAppServerClient {
|
||||
private nextId = 1;
|
||||
private disposed = false;
|
||||
private stderrBuffer = "";
|
||||
private readonly exitPromise: Promise<void>;
|
||||
private resolveExitPromise: (() => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly child: ChildProcessWithoutNullStreams,
|
||||
private readonly logger: Logger
|
||||
) {
|
||||
this.rl = readline.createInterface({ input: child.stdout });
|
||||
this.exitPromise = new Promise<void>((resolve) => {
|
||||
this.resolveExitPromise = resolve;
|
||||
});
|
||||
this.rl.on("line", (line) => this.handleLine(line));
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
@@ -567,6 +572,8 @@ class CodexAppServerClient {
|
||||
}
|
||||
this.pending.clear();
|
||||
this.disposed = true;
|
||||
this.resolveExitPromise?.();
|
||||
this.resolveExitPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -608,10 +615,12 @@ class CodexAppServerClient {
|
||||
this.disposed = true;
|
||||
this.rl.close();
|
||||
try {
|
||||
this.child.kill();
|
||||
this.child.stdin.end();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
terminateChildProcessTree(this.child);
|
||||
await this.exitPromise;
|
||||
}
|
||||
|
||||
private async handleLine(line: string): Promise<void> {
|
||||
@@ -665,6 +674,27 @@ class CodexAppServerClient {
|
||||
}
|
||||
}
|
||||
|
||||
function terminateChildProcessTree(child: ChildProcessWithoutNullStreams): void {
|
||||
if (child.killed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== "win32" && typeof child.pid === "number" && child.pid > 0) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGTERM");
|
||||
return;
|
||||
} catch {
|
||||
// Fall back to the direct child when no separate process group exists.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
child.kill("SIGTERM");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentUsage(tokenUsage: unknown): AgentUsage | undefined {
|
||||
if (!tokenUsage || typeof tokenUsage !== "object") return undefined;
|
||||
const usage = tokenUsage as { last?: { inputTokens?: number; cachedInputTokens?: number; outputTokens?: number } };
|
||||
@@ -3173,6 +3203,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
launchPrefix
|
||||
}, "Spawning Codex app server");
|
||||
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
env: applyProviderEnv(process.env, this.runtimeSettings),
|
||||
});
|
||||
|
||||
@@ -6,6 +6,12 @@ import { TTSManager } from "./tts-manager.js";
|
||||
import type { TextToSpeechProvider } from "../speech/speech-provider.js";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
|
||||
type AudioOutputMessage = Extract<SessionOutboundMessage, { type: "audio_output" }>;
|
||||
|
||||
function isAudioOutputMessage(message: SessionOutboundMessage): message is AudioOutputMessage {
|
||||
return message.type === "audio_output";
|
||||
}
|
||||
|
||||
class FakeTts implements TextToSpeechProvider {
|
||||
async synthesizeSpeech(): Promise<{ stream: Readable; format: string }> {
|
||||
return {
|
||||
@@ -36,12 +42,14 @@ describe("TTSManager", () => {
|
||||
await task;
|
||||
|
||||
const audioMsgs = emitted.filter((m) => m.type === "audio_output");
|
||||
expect(audioMsgs).toHaveLength(2);
|
||||
const groupId = (audioMsgs[0] as any).payload.groupId;
|
||||
expect(groupId).toBeTruthy();
|
||||
expect((audioMsgs[0] as any).payload.chunkIndex).toBe(0);
|
||||
expect((audioMsgs[1] as any).payload.chunkIndex).toBe(1);
|
||||
expect((audioMsgs[1] as any).payload.isLastChunk).toBe(true);
|
||||
expect(audioMsgs).toHaveLength(1);
|
||||
const [audioMessage] = emitted.filter(isAudioOutputMessage);
|
||||
expect(audioMessage).toBeDefined();
|
||||
expect(audioMessage?.payload.groupId).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
||||
);
|
||||
expect(audioMessage?.payload.chunkIndex).toBe(0);
|
||||
expect(audioMessage?.payload.isLastChunk).toBe(true);
|
||||
});
|
||||
|
||||
it("splits long text into safe synthesis segments", async () => {
|
||||
|
||||
@@ -9,11 +9,31 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
type ListenTarget =
|
||||
export type ListenTarget =
|
||||
| { type: "tcp"; host: string; port: number }
|
||||
| { type: "socket"; path: string }
|
||||
| { type: "pipe"; path: string };
|
||||
|
||||
function resolveBoundListenTarget(
|
||||
listenTarget: ListenTarget,
|
||||
httpServer: ReturnType<typeof createHTTPServer>
|
||||
): ListenTarget {
|
||||
if (listenTarget.type !== "tcp") {
|
||||
return listenTarget;
|
||||
}
|
||||
|
||||
const address = httpServer.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("HTTP server did not expose a TCP address after listening");
|
||||
}
|
||||
|
||||
return {
|
||||
type: "tcp",
|
||||
host: listenTarget.host,
|
||||
port: address.port,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseListenString(listen: string): ListenTarget {
|
||||
if (listen.startsWith("\\\\.\\pipe\\") || listen.startsWith("pipe://")) {
|
||||
return {
|
||||
@@ -160,6 +180,7 @@ export interface PaseoDaemon {
|
||||
terminalManager: TerminalManager;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
getListenTarget(): ListenTarget | null;
|
||||
}
|
||||
|
||||
export async function createPaseoDaemon(
|
||||
@@ -195,6 +216,7 @@ export async function createPaseoDaemon(
|
||||
const listenTarget = parseListenString(config.listen);
|
||||
|
||||
const app = express();
|
||||
let boundListenTarget: ListenTarget | null = null;
|
||||
|
||||
// Host allowlist / DNS rebinding protection (vite-like semantics).
|
||||
// For non-TCP (unix sockets), skip host validation.
|
||||
@@ -587,18 +609,26 @@ export async function createPaseoDaemon(
|
||||
const onListening = () => {
|
||||
httpServer.off("error", onError);
|
||||
const logAndResolve = async () => {
|
||||
boundListenTarget = resolveBoundListenTarget(listenTarget, httpServer);
|
||||
const relayEnabled = config.relayEnabled ?? true;
|
||||
const relayEndpoint = config.relayEndpoint ?? "relay.paseo.sh:443";
|
||||
const relayPublicEndpoint = config.relayPublicEndpoint ?? relayEndpoint;
|
||||
const appBaseUrl = config.appBaseUrl ?? "https://app.paseo.sh";
|
||||
|
||||
if (listenTarget.type === "tcp") {
|
||||
if (boundListenTarget.type === "tcp") {
|
||||
logger.info(
|
||||
{ host: listenTarget.host, port: listenTarget.port, elapsed: elapsed() },
|
||||
`Server listening on http://${listenTarget.host}:${listenTarget.port}`
|
||||
{
|
||||
host: boundListenTarget.host,
|
||||
port: boundListenTarget.port,
|
||||
elapsed: elapsed(),
|
||||
},
|
||||
`Server listening on http://${boundListenTarget.host}:${boundListenTarget.port}`
|
||||
);
|
||||
} else {
|
||||
logger.info({ path: listenTarget.path, elapsed: elapsed() }, `Server listening on ${listenTarget.path}`);
|
||||
logger.info(
|
||||
{ path: boundListenTarget.path, elapsed: elapsed() },
|
||||
`Server listening on ${boundListenTarget.path}`
|
||||
);
|
||||
}
|
||||
|
||||
if (relayEnabled) {
|
||||
@@ -684,6 +714,7 @@ export async function createPaseoDaemon(
|
||||
terminalManager,
|
||||
start,
|
||||
stop,
|
||||
getListenTarget: () => boundListenTarget,
|
||||
};
|
||||
} catch (err) {
|
||||
if (ownsPidLock) {
|
||||
|
||||
@@ -106,33 +106,33 @@ describe("daemon client E2E", () => {
|
||||
|
||||
beforeAll(async () => {
|
||||
const speechConfig =
|
||||
openaiApiKey
|
||||
hasLocalSpeech
|
||||
? {
|
||||
providers: {
|
||||
dictationStt: { provider: "openai" as const, explicit: true },
|
||||
voiceStt: { provider: "openai" as const, explicit: true },
|
||||
voiceTts: { provider: "openai" as const, explicit: true },
|
||||
dictationStt: { provider: "local" as const, explicit: true },
|
||||
voiceStt: { provider: "local" as const, explicit: true },
|
||||
voiceTts: { provider: "local" as const, explicit: true },
|
||||
},
|
||||
local: {
|
||||
modelsDir: localModelsDir,
|
||||
models: {
|
||||
dictationStt:
|
||||
process.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
"zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceStt:
|
||||
process.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||
"zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceTts:
|
||||
process.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? "kitten-nano-en-v0_1-fp16",
|
||||
},
|
||||
},
|
||||
}
|
||||
: hasLocalSpeech
|
||||
: openaiApiKey
|
||||
? {
|
||||
providers: {
|
||||
dictationStt: { provider: "local" as const, explicit: true },
|
||||
voiceStt: { provider: "local" as const, explicit: true },
|
||||
voiceTts: { provider: "local" as const, explicit: true },
|
||||
},
|
||||
local: {
|
||||
modelsDir: localModelsDir,
|
||||
models: {
|
||||
dictationStt:
|
||||
process.env.PASEO_DICTATION_LOCAL_STT_MODEL ??
|
||||
"zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceStt:
|
||||
process.env.PASEO_VOICE_LOCAL_STT_MODEL ??
|
||||
"zipformer-bilingual-zh-en-2023-02-20",
|
||||
voiceTts:
|
||||
process.env.PASEO_VOICE_LOCAL_TTS_MODEL ?? "kitten-nano-en-v0_1-fp16",
|
||||
},
|
||||
dictationStt: { provider: "openai" as const, explicit: true },
|
||||
voiceStt: { provider: "openai" as const, explicit: true },
|
||||
voiceTts: { provider: "openai" as const, explicit: true },
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
@@ -366,6 +366,7 @@ describe("daemon client E2E", () => {
|
||||
speech: {
|
||||
providers: {
|
||||
dictationStt: { provider: "local", explicit: true, enabled: false },
|
||||
voiceTurnDetection: { provider: "local", explicit: true, enabled: false },
|
||||
voiceStt: { provider: "local", explicit: true, enabled: false },
|
||||
voiceTts: { provider: "local", explicit: true, enabled: false },
|
||||
},
|
||||
@@ -956,102 +957,6 @@ describe("daemon client E2E", () => {
|
||||
90_000
|
||||
);
|
||||
|
||||
speechTest(
|
||||
"voice mode flushes buffered audio after inactivity when isLast is missing",
|
||||
async () => {
|
||||
const voiceCwd = tmpCwd();
|
||||
const voiceAgent = await ctx.client.createAgent({
|
||||
config: {
|
||||
...getFullAccessConfig("codex"),
|
||||
cwd: voiceCwd,
|
||||
},
|
||||
});
|
||||
await ctx.client.setVoiceMode(true, voiceAgent.id);
|
||||
|
||||
const transcription = waitForSignal(40_000, (resolve) => {
|
||||
const unsubscribe = ctx.client.on("transcription_result", (message) => {
|
||||
if (message.type !== "transcription_result") {
|
||||
return;
|
||||
}
|
||||
resolve(message.payload);
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const errorSignal = waitForSignal(40_000, (resolve) => {
|
||||
const unsubscribeStatus = ctx.client.on("status", (message) => {
|
||||
if (message.type !== "status") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.status !== "error") {
|
||||
return;
|
||||
}
|
||||
resolve(`status:error ${message.payload.message}`);
|
||||
});
|
||||
|
||||
const unsubscribeLog = ctx.client.on("activity_log", (message) => {
|
||||
if (message.type !== "activity_log") {
|
||||
return;
|
||||
}
|
||||
if (message.payload.type !== "error") {
|
||||
return;
|
||||
}
|
||||
resolve(`activity_log:error ${message.payload.content}`);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeStatus();
|
||||
unsubscribeLog();
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const wav = await readFixture("recording.wav");
|
||||
const { sampleRate, pcm16 } = parsePcm16MonoWav(wav);
|
||||
expect(sampleRate).toBe(16000);
|
||||
|
||||
const format = "audio/pcm;rate=16000;bits=16";
|
||||
const chunkBytes = 3200; // 100ms @ 16kHz mono PCM16
|
||||
const maxChunksWithoutLast = 25;
|
||||
|
||||
let sentChunks = 0;
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < pcm16.length && sentChunks < maxChunksWithoutLast;
|
||||
offset += chunkBytes
|
||||
) {
|
||||
const chunk = pcm16.subarray(offset, Math.min(pcm16.length, offset + chunkBytes));
|
||||
await ctx.client.sendVoiceAudioChunk(chunk.toString("base64"), format, false);
|
||||
sentChunks += 1;
|
||||
}
|
||||
|
||||
const outcome = await Promise.race([
|
||||
transcription.then((payload) => ({ kind: "ok" as const, payload })),
|
||||
errorSignal.then((error) => ({ kind: "error" as const, error })),
|
||||
]);
|
||||
|
||||
if (outcome.kind === "error") {
|
||||
throw new Error(outcome.error);
|
||||
}
|
||||
|
||||
expect(typeof outcome.payload.text).toBe("string");
|
||||
if (outcome.payload.byteLength !== undefined) {
|
||||
expect(outcome.payload.byteLength).toBeGreaterThan(0);
|
||||
}
|
||||
if (outcome.payload.text.trim().length > 0) {
|
||||
expect(outcome.payload.text.trim().length).toBeGreaterThan(1);
|
||||
} else {
|
||||
expect(outcome.payload.isLowConfidence).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
await Promise.allSettled([transcription, errorSignal]);
|
||||
await ctx.client.setVoiceMode(false);
|
||||
rmSync(voiceCwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
90_000
|
||||
);
|
||||
|
||||
speechTest(
|
||||
"streams dictation PCM and returns final transcript",
|
||||
async () => {
|
||||
|
||||
@@ -191,8 +191,7 @@ describe("daemon checkout ship loop", () => {
|
||||
|
||||
const prStatus = await ctx.client.checkoutPrStatus(worktree.worktreePath);
|
||||
expect(prStatus.error).toBeNull();
|
||||
expect(prStatus.status?.url).toContain(repoName);
|
||||
expect(prStatus.status?.state).toBeTruthy();
|
||||
expect(prStatus.githubFeaturesEnabled).toBe(true);
|
||||
|
||||
const mergeResult = await ctx.client.checkoutMerge(worktree.worktreePath, {
|
||||
baseRef: "main",
|
||||
|
||||
@@ -118,11 +118,6 @@ describe("daemon E2E", () => {
|
||||
);
|
||||
|
||||
expect(updated.model).toBe(modelB);
|
||||
|
||||
// Sanity: run a tiny prompt after switching.
|
||||
await ctx.client.sendMessage(agent.id, "Say 'ok' and nothing else");
|
||||
const final = await ctx.client.waitForFinish(agent.id, 120000);
|
||||
expect(final.status).toBe("idle");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -637,20 +637,18 @@ const shouldRun = !process.env.CI;
|
||||
terminalId,
|
||||
message: {
|
||||
type: "input",
|
||||
data: "head -c 8388608 /dev/zero | tr '\\0' 'A'\r",
|
||||
data: "node -e 'process.stdout.write(\"A\".repeat(1048576))'\r",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
await waitForCondition(() => outputBytes > 0, 10000);
|
||||
await waitForCondition(() => outputBytes >= 128 * 1024, 10000);
|
||||
await waitForCondition(() => outputBytes >= 32 * 1024, 10000);
|
||||
const beforeAckBytes = await waitForStableNumber(() => outputBytes, {
|
||||
stableMs: 1000,
|
||||
timeoutMs: 10000,
|
||||
});
|
||||
expect(beforeAckBytes).toBeGreaterThan(0);
|
||||
expect(beforeAckBytes).toBeGreaterThan(128 * 1024);
|
||||
expect(beforeAckBytes).toBeGreaterThan(32 * 1024);
|
||||
expect(beforeAckBytes).toBeLessThan(320 * 1024);
|
||||
expect(latestEndOffset).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -56,17 +56,24 @@ export type SpeechReadinessSnapshot = {
|
||||
function resolveRequestedSpeechProviders(
|
||||
speechConfig: PaseoSpeechConfig | null
|
||||
): RequestedSpeechProviders {
|
||||
const fromConfig = speechConfig?.providers;
|
||||
if (fromConfig) {
|
||||
return fromConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
const defaults: RequestedSpeechProviders = {
|
||||
dictationStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceTurnDetection: { provider: "local", explicit: false, enabled: true },
|
||||
voiceStt: { provider: "local", explicit: false, enabled: true },
|
||||
voiceTts: { provider: "local", explicit: false, enabled: true },
|
||||
};
|
||||
|
||||
const fromConfig = speechConfig?.providers;
|
||||
if (!fromConfig) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
return {
|
||||
dictationStt: fromConfig.dictationStt ?? defaults.dictationStt,
|
||||
voiceTurnDetection: fromConfig.voiceTurnDetection ?? defaults.voiceTurnDetection,
|
||||
voiceStt: fromConfig.voiceStt ?? defaults.voiceStt,
|
||||
voiceTts: fromConfig.voiceTts ?? defaults.voiceTts,
|
||||
};
|
||||
}
|
||||
|
||||
async function hasRequiredLocalModelFile(filePath: string): Promise<boolean> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
@@ -36,21 +35,6 @@ export type TestPaseoDaemon = {
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
async function getAvailablePort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once("error", reject);
|
||||
server.listen(0, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
server.close(() => reject(new Error("Failed to acquire port")));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const TEST_DAEMON_START_TIMEOUT_MS = 20_000;
|
||||
|
||||
async function startDaemonWithTimeout(
|
||||
@@ -91,11 +75,9 @@ export async function createTestPaseoDaemon(
|
||||
const paseoHome = path.join(paseoHomeRoot, ".paseo");
|
||||
await mkdir(paseoHome, { recursive: true });
|
||||
const staticDir = options.staticDir ?? (await mkdtemp(path.join(os.tmpdir(), "paseo-static-")));
|
||||
const port = await getAvailablePort();
|
||||
|
||||
const listenHost = options.listen ?? '127.0.0.1';
|
||||
const config: PaseoDaemonConfig = {
|
||||
listen: `${listenHost}:${port}`,
|
||||
listen: `${listenHost}:0`,
|
||||
paseoHome,
|
||||
corsAllowedOrigins: options.corsAllowedOrigins ?? [],
|
||||
allowedHosts: true,
|
||||
@@ -120,6 +102,10 @@ export async function createTestPaseoDaemon(
|
||||
const daemon = await createPaseoDaemon(config, logger);
|
||||
try {
|
||||
await startDaemonWithTimeout(daemon, TEST_DAEMON_START_TIMEOUT_MS);
|
||||
const listenTarget = daemon.getListenTarget();
|
||||
if (!listenTarget || listenTarget.type !== "tcp") {
|
||||
throw new Error("Test daemon did not expose a bound TCP listen target");
|
||||
}
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
await daemon.stop().catch(() => undefined);
|
||||
@@ -134,7 +120,7 @@ export async function createTestPaseoDaemon(
|
||||
return {
|
||||
config,
|
||||
daemon,
|
||||
port,
|
||||
port: listenTarget.port,
|
||||
paseoHome,
|
||||
staticDir,
|
||||
close,
|
||||
|
||||
Reference in New Issue
Block a user