Merge branch 'main' into plan/structured-agent-self-id

This commit is contained in:
Mohamed Boudra
2026-02-02 13:48:46 +07:00
18 changed files with 2864 additions and 256 deletions
+1 -3
View File
@@ -121,7 +121,6 @@ Run `npx expo-doctor` to diagnose version mismatches and native module issues.
- **Launch agents in the most permissive mode**: Use full access or bypass permissions mode.
- **Set cwd to the repository root** - The agent's working directory should usually be the repo root
**CRITICAL: ALWAYS RUN TYPECHECK AFTER EVERY CHANGE.**
## Agent Authentication
@@ -132,7 +131,6 @@ All agent providers (Claude, Codex, OpenCode) handle their own authentication ou
## NEVER DO THESE THINGS
- **NEVER restart the main Paseo daemon on port 6767** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
- **NEVER kill or restart processes in Tmux** without explicit user permission
- **NEVER restart the main Paseo daemon on port 6767 without permission** - This is the production daemon that launches and manages agents. If you are reading this, you are probably running as an agent under it. Restarting it will kill your own process and all other running agents. The daemon is managed by the user in Tmux.
- **NEVER assume a timeout means the service needs restarting** - Timeouts can be transient network issues, not service failures
- **NEVER add authentication checks to tests** - Agent providers handle their own auth. If tests fail due to auth issues, report it rather than adding conditional skips or env var checks
+1 -1
View File
@@ -137,7 +137,7 @@ async function assertE2EUsesSeededTestDaemon(page: Page): Promise<void> {
export const gotoHome = async (page: Page) => {
await page.goto('/');
await ensureE2EStorageSeeded(page);
await expect(page.getByText('New Agent', { exact: true }).first()).toBeVisible();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Message agent...' })).toBeVisible();
};
@@ -851,7 +851,6 @@ export interface GitOptionsSectionProps {
currentBranch: string | null;
baseBranch: string;
onBaseBranchChange: (value: string) => void;
branches: Array<{ name: string; isCurrent: boolean }>;
status: "idle" | "loading" | "ready" | "error";
repoError: string | null;
gitValidationError: string | null;
@@ -871,7 +870,6 @@ export function GitOptionsSection({
currentBranch,
baseBranch,
onBaseBranchChange,
branches,
status,
repoError,
gitValidationError,
@@ -373,16 +373,14 @@ function AgentScreenContent({
const modelDisplayValue = agentModel ?? "Unknown";
const repoInfoQuery = useQuery({
queryKey: ["gitRepoInfo", serverId, agent?.cwd ?? ""],
queryKey: ["checkoutStatus", serverId, agent?.cwd ?? ""],
queryFn: async () => {
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitRepoInfo({
cwd: agent?.cwd ?? ".",
});
const payload = await client.getCheckoutStatus(agent?.cwd ?? ".");
if (payload.error) {
throw new Error(payload.error);
throw new Error(payload.error.message);
}
return {
cwd: payload.cwd,
@@ -312,29 +312,29 @@ export function DraftAgentScreen({
type RepoInfoState = {
cwd: string;
repoRoot: string;
branches: Array<{ name: string; isCurrent: boolean }>;
currentBranch: string | null;
isDirty: boolean;
};
const repoInfoQuery = useQuery({
queryKey: ["gitRepoInfo", selectedServerId, trimmedWorkingDir],
queryKey: ["checkoutStatus", selectedServerId, trimmedWorkingDir],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
const payload = await client.getGitRepoInfo({
cwd: trimmedWorkingDir || ".",
});
const payload = await client.getCheckoutStatus(trimmedWorkingDir || ".");
if (payload.error) {
throw new Error(payload.error);
throw new Error(payload.error.message);
}
if (!payload.isGit) {
throw new Error("Not a git repository");
}
// After the isGit check, TypeScript knows we have a git repo
return {
cwd: payload.cwd,
repoRoot: payload.repoRoot,
branches: payload.branches ?? [],
currentBranch: payload.currentBranch ?? null,
isDirty: Boolean(payload.isDirty),
currentBranch: payload.currentBranch,
isDirty: payload.isDirty,
};
},
enabled:
@@ -501,6 +501,30 @@ export function DraftAgentScreen({
validateWorktreeName,
]);
// Validate branch exists (checks local first, then remote)
const branchValidationQuery = useQuery({
queryKey: ["validateBranch", selectedServerId, trimmedWorkingDir, baseBranch],
queryFn: async () => {
const client = sessionClient;
if (!client) {
throw new Error("Daemon client unavailable");
}
return client.validateBranch({
cwd: trimmedWorkingDir || ".",
branchName: baseBranch,
});
},
enabled:
isCreateWorktree &&
!isNonGitDirectory &&
Boolean(baseBranch) &&
Boolean(trimmedWorkingDir) &&
Boolean(sessionClient) &&
isConnected,
retry: false,
staleTime: 30_000,
});
const baseBranchError = useMemo(() => {
if (!isCreateWorktree || isNonGitDirectory) {
return null;
@@ -508,16 +532,21 @@ export function DraftAgentScreen({
if (!baseBranch) {
return "Base branch is required";
}
const branches = repoInfo?.branches ?? [];
if (branches.length === 0) {
// While validating, don't show error
if (branchValidationQuery.isPending || branchValidationQuery.isFetching) {
return null;
}
const branchExists = branches.some((b) => b.name === baseBranch);
if (!branchExists) {
// If validation query errored, show generic error
if (branchValidationQuery.isError) {
return "Failed to validate branch";
}
// If validation completed and branch doesn't exist
const validationResult = branchValidationQuery.data;
if (validationResult && !validationResult.exists) {
return `Branch "${baseBranch}" not found in repository`;
}
return null;
}, [isCreateWorktree, isNonGitDirectory, baseBranch, repoInfo?.branches]);
}, [isCreateWorktree, isNonGitDirectory, baseBranch, branchValidationQuery]);
const handleBaseBranchChange = useCallback((value: string) => {
setBaseBranch(value);
@@ -865,7 +894,6 @@ export function DraftAgentScreen({
currentBranch={repoInfo?.currentBranch ?? null}
baseBranch={baseBranch}
onBaseBranchChange={handleBaseBranchChange}
branches={repoInfo?.branches ?? []}
status={repoInfoStatus}
repoError={repoInfoError}
gitValidationError={gitBlockingError}
+19 -79
View File
@@ -19,17 +19,17 @@ import type {
FileExplorerResponse,
GitDiffResponse,
GitSetupOptions,
GitRepoInfoResponse,
HighlightedDiffResponse,
CheckoutStatusResponse,
CheckoutDiffResponse,
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
PaseoWorktreeListResponse,
CheckoutDiffResponse,
CheckoutCommitResponse,
CheckoutMergeResponse,
CheckoutMergeFromBaseResponse,
CheckoutPushResponse,
CheckoutPrCreateResponse,
CheckoutPrStatusResponse,
ValidateBranchResponse,
PaseoWorktreeListResponse,
PaseoWorktreeArchiveResponse,
ProjectIconResponse,
ListCommandsResponse,
@@ -177,7 +177,6 @@ type ListVoiceConversationsPayload = ListVoiceConversationsResponseMessage["payl
type DeleteVoiceConversationPayload = DeleteVoiceConversationResponseMessage["payload"];
type GitDiffPayload = GitDiffResponse["payload"];
type HighlightedDiffPayload = HighlightedDiffResponse["payload"];
type GitRepoInfoPayload = GitRepoInfoResponse["payload"];
type CheckoutStatusPayload = CheckoutStatusResponse["payload"];
type CheckoutDiffPayload = CheckoutDiffResponse["payload"];
type CheckoutCommitPayload = CheckoutCommitResponse["payload"];
@@ -186,6 +185,7 @@ type CheckoutMergeFromBasePayload = CheckoutMergeFromBaseResponse["payload"];
type CheckoutPushPayload = CheckoutPushResponse["payload"];
type CheckoutPrCreatePayload = CheckoutPrCreateResponse["payload"];
type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"];
type ValidateBranchPayload = ValidateBranchResponse["payload"];
type PaseoWorktreeListPayload = PaseoWorktreeListResponse["payload"];
type PaseoWorktreeArchivePayload = PaseoWorktreeArchiveResponse["payload"];
type FileExplorerPayload = FileExplorerResponse["payload"];
@@ -200,9 +200,6 @@ type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type TerminalOutputPayload = TerminalOutput["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type AgentCreateFailedStatusPayload = z.infer<
typeof AgentCreateFailedStatusPayloadSchema
>;
type AgentRefreshedStatusPayload = z.infer<
typeof AgentRefreshedStatusPayloadSchema
>;
@@ -889,43 +886,7 @@ export class DaemonClientV2 {
throw new Error(status.error);
}
return this.waitForAgentUpsert(
status.agentId,
(snapshot) => snapshot.status === "idle",
60000
);
}
async createAgentExpectFail(
options: CreateAgentRequestOptions
): Promise<AgentCreateFailedStatusPayload> {
const requestId = this.createRequestId(options.requestId);
const config = resolveAgentConfig(options);
const message = SessionInboundMessageSchema.parse({
type: "create_agent_request",
requestId,
config,
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "status") {
return null;
}
const failed = AgentCreateFailedStatusPayloadSchema.safeParse(msg.payload);
if (failed.success && failed.data.requestId === requestId) {
return failed.data;
}
return null;
},
10000,
{ skipQueue: true }
);
await this.sendSessionMessageOrThrow(message);
return response;
return status.agent;
}
async deleteAgent(agentId: string): Promise<void> {
@@ -1007,11 +968,7 @@ export class DaemonClientV2 {
await this.sendSessionMessageOrThrow(message);
const status = await statusPromise;
return this.waitForAgentUpsert(
status.agentId,
(snapshot) => snapshot.status === "idle",
60000
);
return status.agent;
}
async refreshAgent(
@@ -1639,37 +1596,20 @@ export class DaemonClientV2 {
return response;
}
async getGitRepoInfo(
input: string | { cwd: string } | { agentId: string },
async validateBranch(
options: { cwd: string; branchName: string },
requestId?: string
): Promise<GitRepoInfoPayload> {
const normalizedInput =
typeof input === "string" ? { agentId: input } : input;
): Promise<ValidateBranchPayload> {
const resolvedRequestId = this.createRequestId(requestId);
const cwd =
"cwd" in normalizedInput
? normalizedInput.cwd
: (await this.fetchAgent(normalizedInput.agentId).catch(() => null))?.cwd;
if (!cwd) {
return {
cwd: "cwd" in normalizedInput ? normalizedInput.cwd : "",
repoRoot: "",
requestId: resolvedRequestId,
error: `Agent not found: ${
"agentId" in normalizedInput ? normalizedInput.agentId : ""
}`,
};
}
const message = SessionInboundMessageSchema.parse({
type: "git_repo_info_request",
cwd,
type: "validate_branch_request",
cwd: options.cwd,
branchName: options.branchName,
requestId: resolvedRequestId,
});
const response = this.waitFor(
(msg) => {
if (msg.type !== "git_repo_info_response") {
if (msg.type !== "validate_branch_response") {
return null;
}
if (msg.payload.requestId !== resolvedRequestId) {
@@ -7,7 +7,7 @@ import type {
import type { Logger } from "pino";
import { ClaudeAgentClient } from "./providers/claude-agent.js";
import { CodexMcpAgentClient } from "./providers/codex-mcp-agent.js";
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
import { OpenCodeAgentClient, OpenCodeServerManager } from "./providers/opencode-agent.js";
import {
@@ -32,7 +32,7 @@ export interface ProviderDefinition extends AgentProviderDefinition {
export function buildProviderRegistry(logger: Logger): Record<AgentProvider, ProviderDefinition> {
const claudeClient = new ClaudeAgentClient({ logger });
const codexClient = new CodexMcpAgentClient(logger);
const codexClient = new CodexAppServerAgentClient(logger);
const opencodeClient = new OpenCodeAgentClient(logger);
return {
@@ -43,7 +43,7 @@ export function buildProviderRegistry(logger: Logger): Record<AgentProvider, Pro
},
codex: {
...AGENT_PROVIDER_DEFINITIONS.find((d) => d.id === "codex")!,
createClient: (logger: Logger) => new CodexMcpAgentClient(logger),
createClient: (logger: Logger) => new CodexAppServerAgentClient(logger),
fetchModels: (options) => codexClient.listModels(options),
},
opencode: {
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { mkdtempSync } from "node:fs";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function isCodexInstalled(): boolean {
try {
const out = execFileSync("which", ["codex"], { encoding: "utf8" }).trim();
return out.length > 0;
} catch {
return false;
}
}
describe("Codex app-server provider (e2e)", () => {
test.runIf(isCodexInstalled())("lists models and runs a simple prompt", async () => {
const client = new CodexAppServerAgentClient(createTestLogger());
const models = await client.listModels();
expect(models.some((m) => m.id.includes("gpt-5.1-codex"))).toBe(true);
const session = await client.createSession({
provider: "codex",
cwd: mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-")),
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const result = await session.run("Say hello in one sentence.");
expect(result.finalText.length).toBeGreaterThan(0);
await session.close();
}, 30000);
});
@@ -0,0 +1,710 @@
import { describe, expect, test } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { CodexAppServerAgentClient } from "./codex-app-server-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type {
AgentPermissionRequest,
AgentPromptContentBlock,
AgentTimelineItem,
} from "../agent-sdk-types.js";
const CODEX_TEST_MODEL = "gpt-5.1-codex-mini";
const CODEX_TEST_REASONING_EFFORT = "low";
function isCodexInstalled(): boolean {
try {
const out = execFileSync("which", ["codex"], { encoding: "utf8" }).trim();
return out.length > 0;
} catch {
return false;
}
}
function tmpCwd(prefix = "codex-app-server-e2e-"): string {
return mkdtempSync(path.join(os.tmpdir(), prefix));
}
function useTempCodexSessionDir(): () => void {
const codexSessionDir = tmpCwd("codex-sessions-");
const prevSessionDir = process.env.CODEX_SESSION_DIR;
process.env.CODEX_SESSION_DIR = codexSessionDir;
return () => {
if (prevSessionDir === undefined) {
delete process.env.CODEX_SESSION_DIR;
} else {
process.env.CODEX_SESSION_DIR = prevSessionDir;
}
rmSync(codexSessionDir, { recursive: true, force: true });
};
}
function hasShellCommand(item: AgentTimelineItem, commandFragment: string): boolean {
if (item.type !== "tool_call" || item.name !== "shell") return false;
const input = item.input as { command?: string } | undefined;
const command = input?.command ?? "";
return command.includes(commandFragment);
}
function hasApplyPatchFile(item: AgentTimelineItem, fileName: string): boolean {
if (item.type !== "tool_call" || item.name !== "apply_patch") return false;
const input = item.input as { files?: Array<{ path?: string }> } | undefined;
const output = item.output as
| { files?: Array<{ path?: string; patch?: string }>; diff?: string }
| undefined;
const inInput = (input?.files ?? []).some((file) => file?.path === fileName);
const inOutput = (output?.files ?? []).some((file) => file?.path === fileName);
const inDiff = typeof output?.diff === "string" && output.diff.includes(fileName);
return inInput || inOutput || inDiff;
}
describe("Codex app-server provider (integration)", () => {
const logger = createTestLogger();
test.runIf(isCodexInstalled())("listModels returns live Codex models", async () => {
const client = new CodexAppServerAgentClient(logger);
const models = await client.listModels();
expect(models.some((model) => model.id.includes("gpt-5.1-codex"))).toBe(true);
}, 30000);
test.runIf(isCodexInstalled())("getRuntimeInfo reflects model + mode", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-runtime-");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
await session.run("Reply with OK.");
const info = await session.getRuntimeInfo();
await session.close();
expect(info.model).toBe(CODEX_TEST_MODEL);
expect(info.modeId).toBe("auto");
expect(info.sessionId?.length).toBeGreaterThan(0);
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 30000);
test.runIf(isCodexInstalled())("listCommands includes custom prompts and executeCommand runs them", async () => {
const cleanup = useTempCodexSessionDir();
const codexHome = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
const promptsDir = path.join(codexHome, "prompts");
const promptPath = path.join(promptsDir, "test.md");
const cwd = tmpCwd("codex-cmd-");
mkdirSync(promptsDir, { recursive: true });
writeFileSync(
promptPath,
"---\ndescription: Test Prompt\n---\nReply with exactly: OK-$ARGUMENTS.",
"utf8"
);
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const commands = await session.listCommands?.();
expect(commands?.some((cmd) => cmd.name === "prompts:test")).toBe(true);
const result = await session.executeCommand?.("prompts:test", "world");
await session.close();
expect(result?.text.trim()).toContain("OK-world");
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
rmSync(promptPath, { force: true });
}
}, 60000);
test.runIf(isCodexInstalled())("command approval flow requests permission and runs command", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-cmd-approval-");
const filePath = path.join(cwd, "permission.txt");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use the shell tool for command execution tasks. Do not answer without running the command.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
let sawPermission = false;
let captured: AgentPermissionRequest | null = null;
let sawPermissionResolved = false;
const timelineItems: AgentTimelineItem[] = [];
const events = session.stream(
[
"You must use your shell tool to run the exact command",
"`printf \"ok\" > permission.txt`.",
"If you need approval before running it, request approval first.",
"After approval, run it and reply DONE.",
].join(" ")
);
let failure: string | null = null;
for await (const event of events) {
if (event.type === "permission_requested" && event.request.name === "CodexBash") {
sawPermission = true;
captured = event.request;
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
event.type === "permission_resolved" &&
captured &&
event.requestId === captured.id &&
event.resolution.behavior === "allow"
) {
sawPermissionResolved = true;
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineItems.push(event.item);
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
if (captured) {
expect(sawPermissionResolved).toBe(true);
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(
timelineItems.some((item) => item.type === "tool_call" && item.name === "shell")
).toBe(true);
expect(existsSync(filePath)).toBe(true);
expect(readFileSync(filePath, "utf8")).toContain("ok");
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test.runIf(isCodexInstalled())(
"streams responses and maps shell + file change tool calls into timeline items",
async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-stream-");
const shellFile = path.join(cwd, "shell.txt");
const patchFile = path.join(cwd, "patch.txt");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use shell for commands and apply_patch for file edits. Do not skip tool usage.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
let sawAssistantMessage = false;
let sawShellTool = false;
let sawPatchTool = false;
let sawShellCompleted = false;
let sawPatchCompleted = false;
const timelineItems: AgentTimelineItem[] = [];
const shellEvents = session.stream(
"Run the exact shell command `printf \"ok\" > shell.txt`. After it completes, reply SHELL_DONE."
);
let failure: string | null = null;
for await (const event of shellEvents) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "timeline") {
timelineItems.push(event.item);
if (event.item.type === "assistant_message") {
sawAssistantMessage = true;
}
if (hasShellCommand(event.item, "printf")) {
sawShellTool = true;
if (event.item.status === "completed") {
sawShellCompleted = true;
}
}
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
if (failure) {
throw new Error(failure);
}
const patch = [
"*** Begin Patch",
"*** Add File: patch.txt",
"+patched",
"*** End Patch",
].join("\\n");
const patchEvents = session.stream(
[
"Use the apply_patch tool and nothing else.",
"Do not use the shell tool for file changes.",
"Do not respond with any message until the apply_patch tool completes.",
"Apply the following patch exactly:",
patch,
"After it completes, reply PATCH_DONE.",
].join("\\n")
);
for await (const event of patchEvents) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (event.type === "timeline") {
timelineItems.push(event.item);
if (event.item.type === "assistant_message") {
sawAssistantMessage = true;
}
if (hasApplyPatchFile(event.item, "patch.txt")) {
sawPatchTool = true;
if (event.item.status === "completed") {
sawPatchCompleted = true;
}
}
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
expect(sawAssistantMessage).toBe(true);
expect(sawShellTool).toBe(true);
expect(sawPatchTool).toBe(true);
expect(sawShellCompleted || existsSync(shellFile)).toBe(true);
expect(sawPatchCompleted || existsSync(patchFile)).toBe(true);
expect(readFileSync(shellFile, "utf8")).toContain("ok");
expect(readFileSync(patchFile, "utf8").trim()).toBe("patched");
const shellItem = timelineItems.find((item) => hasShellCommand(item, "printf"));
const patchItem = timelineItems.find((item) => hasApplyPatchFile(item, "patch.txt"));
expect(shellItem?.type).toBe("tool_call");
expect(patchItem?.type).toBe("tool_call");
if (shellItem?.type === "tool_call") {
expect(shellItem.name).toBe("shell");
expect(shellItem.input).toBeTruthy();
}
if (patchItem?.type === "tool_call") {
expect(patchItem.name).toBe("apply_patch");
expect(patchItem.input || patchItem.output).toBeTruthy();
}
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
120000
);
test.runIf(isCodexInstalled())(
"interrupts long-running commands and emits a canceled turn",
async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-interrupt-");
let session: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null = null;
let followupSession: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null =
null;
let interruptAt: number | null = null;
let stoppedAt: number | null = null;
try {
const client = new CodexAppServerAgentClient(logger);
session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
approvalPolicy: "on-request",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const stream = session.stream(
"Run the exact shell command `sleep 60` using your shell tool and do not respond until it finishes."
);
let sawSleepCommand = false;
for await (const event of stream) {
if (event.type === "permission_requested") {
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "shell" &&
hasShellCommand(event.item, "sleep 60")
) {
sawSleepCommand = true;
if (!interruptAt) {
interruptAt = Date.now();
await session.interrupt();
}
}
if (event.type === "turn_canceled") {
stoppedAt = Date.now();
break;
}
if (event.type === "turn_completed" || event.type === "turn_failed") {
stoppedAt = Date.now();
break;
}
}
if (!interruptAt) {
throw new Error("Did not issue interrupt for long-running command");
}
if (!stoppedAt) {
stoppedAt = Date.now();
}
const latencyMs = stoppedAt - interruptAt;
expect(sawSleepCommand).toBe(true);
expect(latencyMs).toBeGreaterThanOrEqual(0);
expect(latencyMs).toBeLessThan(10_000);
await session.close();
session = null;
followupSession = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const followup = await followupSession.run("Reply OK and stop.");
expect(followup.finalText.toLowerCase()).toContain("ok");
} finally {
await session?.close();
await followupSession?.close();
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
120000
);
test.runIf(isCodexInstalled())(
"persists session metadata and resumes with history",
async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-resume-");
const token = `ALPHA-${Date.now()}`;
let session: Awaited<ReturnType<CodexAppServerAgentClient["createSession"]>> | null = null;
let resumed: Awaited<ReturnType<CodexAppServerAgentClient["resumeSession"]>> | null = null;
try {
const client = new CodexAppServerAgentClient(logger);
session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const first = await session.run(`Remember the word ${token} and reply ACK.`);
expect(first.finalText.toLowerCase()).toContain("ack");
const handle = session.describePersistence();
expect(handle?.sessionId).toBeTruthy();
expect(handle?.metadata?.threadId).toBe(handle?.sessionId);
await session.close();
session = null;
resumed = await client.resumeSession(handle!);
const history: AgentTimelineItem[] = [];
for await (const event of resumed.streamHistory()) {
if (event.type === "timeline") {
history.push(event.item);
}
}
expect(
history.some(
(item) => item.type === "assistant_message" || item.type === "user_message"
)
).toBe(true);
const response = await resumed.run(
`Respond with the exact token ${token} and stop.`
);
expect(response.finalText).toContain(token);
const resumedHandle = resumed.describePersistence();
expect(resumedHandle?.sessionId).toBe(handle?.sessionId);
expect(resumedHandle?.metadata?.threadId).toBe(handle?.sessionId);
} finally {
await session?.close();
await resumed?.close();
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
180000
);
test.runIf(isCodexInstalled())(
"emits plan items and resolves collaboration mode mapping",
async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-plan-");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "read-only",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
const result = await Promise.race([
session.run("Provide a concise 2-step plan and do not execute. Reply PLAN_DONE."),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Plan run timed out")), 60000)
),
]);
const info = await session.getRuntimeInfo();
const sawCollaborationMode = Boolean(info.extra?.collaborationMode);
await session.close();
const sawTodo = result.timeline.some(
(item) => item.type === "todo" && item.items.length > 0
);
expect(sawTodo).toBe(true);
expect(sawCollaborationMode).toBe(true);
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
},
120000
);
test.runIf(isCodexInstalled())("file change approval flow requests permission and applies change", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-file-approval-");
const targetPath = path.join(cwd, "approval-test.txt");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "full-access",
approvalPolicy: "on-request",
paseoPromptInstructions:
"You must use the apply_patch tool for file edits. Do not use the shell tool for file changes.",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
let sawPermission = false;
let captured: AgentPermissionRequest | null = null;
let sawPermissionResolved = false;
const timelineItems: AgentTimelineItem[] = [];
const patch = [
"*** Begin Patch",
"*** Add File: approval-test.txt",
"+ok",
"*** End Patch",
].join("\\n");
const events = session.stream(
[
"Use the apply_patch tool and nothing else.",
"If you need approval before writing files, request approval first.",
"Do not respond with any message until the apply_patch tool completes.",
"Apply the following patch exactly:",
patch,
"After approval, reply FILE_DONE.",
].join("\\n")
);
let failure: string | null = null;
for await (const event of events) {
if (event.type === "permission_requested" && event.request.name === "CodexFileChange") {
sawPermission = true;
captured = event.request;
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
event.type === "permission_resolved" &&
captured &&
event.requestId === captured.id &&
event.resolution.behavior === "allow"
) {
sawPermissionResolved = true;
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineItems.push(event.item);
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
if (captured) {
expect(sawPermissionResolved).toBe(true);
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
expect(
timelineItems.some((item) => item.type === "tool_call" && item.name === "apply_patch")
).toBe(true);
expect(readFileSync(targetPath, "utf8").trim()).toBe("ok");
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test.runIf(isCodexInstalled())("tool approval flow requests user input for app tools", async () => {
const cleanup = useTempCodexSessionDir();
const cwd = tmpCwd("codex-tool-approval-");
try {
const client = new CodexAppServerAgentClient(logger);
const session = await client.createSession({
provider: "codex",
cwd,
modeId: "auto",
model: CODEX_TEST_MODEL,
reasoningEffort: CODEX_TEST_REASONING_EFFORT,
});
await session.connect();
const rawClient = (session as any).client as { request: (method: string, params?: any) => Promise<any> } | null;
if (!rawClient) {
throw new Error("Codex app-server client unavailable for app/list");
}
const appsResult = await rawClient.request("app/list", { cursor: null, limit: 10 });
const apps = Array.isArray(appsResult?.data) ? appsResult.data : [];
const app = apps.find((entry: any) => entry?.isAccessible) ?? apps[0];
if (!app) {
await session.close();
return;
}
const input = [
{ type: "text", text: `$${app.id} Perform a minimal action and wait for approval if required. Reply with TOOL_DONE.` },
{ type: "mention", name: app.name ?? app.id, path: `app://${app.id}` },
] as unknown as AgentPromptContentBlock[];
let sawPermission = false;
let captured: AgentPermissionRequest | null = null;
let sawPermissionResolved = false;
let failure: string | null = null;
const timelineItems: AgentTimelineItem[] = [];
for await (const event of session.stream(input)) {
if (event.type === "permission_requested" && event.request.name === "CodexTool") {
sawPermission = true;
captured = event.request;
await session.respondToPermission(event.request.id, { behavior: "allow" });
}
if (
event.type === "permission_resolved" &&
captured &&
event.requestId === captured.id &&
event.resolution.behavior === "allow"
) {
sawPermissionResolved = true;
}
if (event.type === "timeline" && event.item.type === "tool_call") {
timelineItems.push(event.item);
}
if (event.type === "turn_failed") {
failure = event.error;
break;
}
if (event.type === "turn_completed") {
break;
}
}
await session.close();
if (failure) {
throw new Error(failure);
}
if (captured) {
expect(sawPermissionResolved).toBe(true);
}
expect(sawPermission || timelineItems.length > 0).toBe(true);
if (captured) {
expect(Array.isArray(captured?.metadata?.questions)).toBe(true);
}
} finally {
cleanup();
rmSync(cwd, { recursive: true, force: true });
}
}, 90000);
});
File diff suppressed because it is too large Load Diff
@@ -185,13 +185,14 @@ describe("daemon client v2 E2E", () => {
return unsubscribe;
});
const failResult = await ctx.client.createAgentExpectFail({
...getFullAccessConfig("codex"),
cwd: "/this/path/does/not/exist/12345",
title: "Should Fail",
requestId: failRequestId,
});
expect(failResult.error).toContain("Working directory does not exist");
await expect(
ctx.client.createAgent({
...getFullAccessConfig("codex"),
cwd: "/this/path/does/not/exist/12345",
title: "Should Fail",
requestId: failRequestId,
})
).rejects.toThrow("Working directory does not exist");
await failedStatusPromise;
let sawRefresh = false;
@@ -783,36 +784,11 @@ describe("daemon client v2 E2E", () => {
title: "Git/File Test",
});
const repoInfoRequestId = `repo-info-${Date.now()}`;
const repoInfoMessagePromise = waitForSignal(15000, (resolve) => {
const unsubscribeRepo = ctx.client.on(
"git_repo_info_response",
(message) => {
if (message.type !== "git_repo_info_response") {
return;
}
if (message.payload.cwd !== cwd) {
return;
}
if (message.payload.requestId !== repoInfoRequestId) {
return;
}
resolve(message);
}
);
return unsubscribeRepo;
});
const repoInfo = await ctx.client.getGitRepoInfo(
{ cwd },
repoInfoRequestId
);
const repoInfoMessage = await repoInfoMessagePromise;
expect(repoInfo.error ?? null).toBeNull();
expect(repoInfo.repoRoot).toContain(cwd);
expect(repoInfo.requestId).toBe(repoInfoRequestId);
expect(repoInfoMessage.payload.cwd).toBe(cwd);
expect(repoInfoMessage.payload.requestId).toBe(repoInfoRequestId);
// Test checkout status RPC
const checkoutStatus = await ctx.client.getCheckoutStatus(cwd);
expect(checkoutStatus.error).toBeNull();
expect(checkoutStatus.isGit).toBe(true);
expect(checkoutStatus.repoRoot).toContain(cwd);
const diffRequestId = `diff-${Date.now()}`;
const diffMessagePromise = waitForSignal(15000, (resolve) => {
@@ -93,14 +93,13 @@ describe("daemon E2E", () => {
test("fails to create agent with non-existent cwd", async () => {
const nonExistentCwd = "/this/path/does/not/exist/12345";
const result = await ctx.client.createAgentExpectFail({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: nonExistentCwd,
title: "Should Fail Agent",
});
expect(result.error).toContain("Working directory does not exist");
expect(result.error).toContain(nonExistentCwd);
await expect(
ctx.client.createAgent({
provider: "codex", model: CODEX_TEST_MODEL, reasoningEffort: CODEX_TEST_REASONING_EFFORT,
cwd: nonExistentCwd,
title: "Should Fail Agent",
})
).rejects.toThrow(nonExistentCwd);
});
@@ -234,7 +234,7 @@ describe("daemon E2E", () => {
});
describe("getGitRepoInfo", () => {
describe("getCheckoutStatus", () => {
test(
"returns repo info for git repo with branch and dirty state",
async () => {
@@ -268,16 +268,15 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy();
expect(agent.status).toBe("idle");
// Get git repo info
const result = await ctx.client.getGitRepoInfo(agent.id);
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
// Verify repo info returned without error
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
// macOS symlinks /var to /private/var, so we check containment
expect(result.repoRoot).toContain("daemon-e2e-");
expect(result.currentBranch).toBeTruthy();
expect(result.branches.length).toBeGreaterThan(0);
expect(result.branches.some((b) => b.isCurrent)).toBe(true);
expect(result.isDirty).toBe(true);
// Cleanup
@@ -316,10 +315,11 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy();
// Get git repo info
const result = await ctx.client.getGitRepoInfo(agent.id);
// Get checkout status
const result = await ctx.client.getCheckoutStatus(cwd);
expect(result.error).toBeNull();
expect(result.isGit).toBe(true);
expect(result.isDirty).toBe(false);
expect(result.currentBranch).toBeTruthy();
@@ -331,7 +331,7 @@ describe("daemon E2E", () => {
);
test(
"returns error for non-git directory",
"returns isGit false for non-git directory",
async () => {
const cwd = tmpCwd();
// Don't initialize git - just a regular directory
@@ -345,11 +345,10 @@ describe("daemon E2E", () => {
expect(agent.id).toBeTruthy();
// Get git repo info - should return error
const result = await ctx.client.getGitRepoInfo(agent.id);
// Get checkout status - should return isGit: false
const result = await ctx.client.getCheckoutStatus(cwd);
// Server returns cwd as repoRoot even on error, so we just check for error
expect(result.error).toBeTruthy();
expect(result.isGit).toBe(false);
// Cleanup
await ctx.client.deleteAgent(agent.id);
@@ -0,0 +1,153 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { EventEmitter } from "node:events";
import pino from "pino";
import {
DictationStreamManager,
type RealtimeTranscriptionSession,
type RealtimeTranscriptionSessionFactory,
} from "./dictation-stream-manager.js";
class FakeRealtimeSession extends EventEmitter implements RealtimeTranscriptionSession {
connected = false;
appended: string[] = [];
commitCalls = 0;
clearCalls = 0;
closed = false;
async connect(): Promise<void> {
this.connected = true;
}
appendPcm16Base64(base64Audio: string): void {
this.appended.push(base64Audio);
}
commit(): void {
this.commitCalls += 1;
}
clear(): void {
this.clearCalls += 1;
}
close(): void {
this.closed = true;
}
emitCommitted(itemId: string): void {
this.emit("committed", { itemId, previousItemId: null });
}
emitTranscript(itemId: string, transcript: string, isFinal: boolean): void {
this.emit("transcript", { itemId, transcript, isFinal });
}
emitError(message: string): void {
this.emit("error", new Error(message));
}
}
const buildPcmBase64 = (sampleValue: number, sampleCount: number): string => {
const samples = new Int16Array(sampleCount);
samples.fill(sampleValue);
return Buffer.from(samples.buffer).toString("base64");
};
const tick = async (): Promise<void> => {
await Promise.resolve();
await Promise.resolve();
};
describe("DictationStreamManager (semantic VAD grace fallback)", () => {
const env = {
turnDetection: process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION,
dictationDebug: process.env.PASEO_DICTATION_DEBUG,
};
beforeEach(() => {
vi.useFakeTimers();
process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION = "semantic_vad";
process.env.PASEO_DICTATION_DEBUG = "false";
});
afterEach(() => {
vi.useRealTimers();
process.env.OPENAI_REALTIME_DICTATION_TURN_DETECTION = env.turnDetection;
process.env.PASEO_DICTATION_DEBUG = env.dictationDebug;
});
it("treats buffer-too-small as benign and finalizes with existing transcripts", async () => {
const session = new FakeRealtimeSession();
const factory: RealtimeTranscriptionSessionFactory = () => session;
const emitted: Array<{ type: string; payload: any }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (msg) => emitted.push(msg),
sessionId: "s1",
openaiApiKey: "k",
sessionFactory: factory,
finalTimeoutMs: 5000,
});
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d1",
seq: 0,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitTranscript("i1", "hello world", true);
await manager.handleFinish("d1", 0);
await tick();
vi.advanceTimersByTime(2000);
await tick();
session.emitError(
"Error committing input audio buffer: buffer too small. Expected at least 100ms of audio, but buffer only has 0.00ms of audio."
);
await tick();
const final = emitted.find((msg) => msg.type === "dictation_stream_final");
const error = emitted.find((msg) => msg.type === "dictation_stream_error");
expect(error).toBeUndefined();
expect(final?.payload.text).toBe("hello world");
expect(session.closed).toBe(true);
});
it("does not fallback-commit if committed event arrives during grace window", async () => {
const session = new FakeRealtimeSession();
const factory: RealtimeTranscriptionSessionFactory = () => session;
const emitted: Array<{ type: string; payload: any }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (msg) => emitted.push(msg),
sessionId: "s1",
openaiApiKey: "k",
sessionFactory: factory,
finalTimeoutMs: 5000,
});
await manager.handleStart("d1", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d1",
seq: 0,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
await manager.handleFinish("d1", 0);
session.emitCommitted("i1");
session.emitTranscript("i1", "hi there", true);
vi.advanceTimersByTime(2000);
await tick();
expect(session.commitCalls).toBe(0);
const final = emitted.find((msg) => msg.type === "dictation_stream_final");
expect(final?.payload.text).toBe("hi there");
});
});
@@ -12,7 +12,11 @@ import { OpenAIRealtimeTranscriptionSession } from "../agent/openai-realtime-tra
const PCM_CHANNELS = 1;
const PCM_BITS_PER_SAMPLE = 16;
const DICTATION_PCM_OUTPUT_RATE = 24000;
const DEFAULT_DICTATION_FINAL_TIMEOUT_MS = 30000;
const DEFAULT_DICTATION_FINAL_TIMEOUT_MS = 10000;
const DICTATION_VAD_GRACE_TIMEOUT_MS = Number.parseInt(
process.env.OPENAI_REALTIME_DICTATION_VAD_GRACE_TIMEOUT_MS ?? "2000",
10
);
const DICTATION_SILENCE_PEAK_THRESHOLD = Number.parseInt(
process.env.OPENAI_REALTIME_DICTATION_SILENCE_PEAK_THRESHOLD ?? "300",
10
@@ -101,6 +105,15 @@ export type RealtimeTranscriptionSession = {
on(event: "error", handler: (err: unknown) => void): unknown;
};
export type RealtimeTranscriptionSessionFactory = (params: {
apiKey: string;
logger: pino.Logger;
transcriptionModel: string;
language?: string;
prompt?: string;
turnDetection: OpenAITurnDetection;
}) => RealtimeTranscriptionSession;
function convertPCMToWavBuffer(
pcmBuffer: Buffer,
sampleRate: number,
@@ -149,10 +162,13 @@ type DictationStreamState = {
transcriptsByItemId: Map<string, string>;
finalTranscriptItemIds: Set<string>;
awaitingFinalCommit: boolean;
vadGraceTimeout: ReturnType<typeof setTimeout> | null;
fallbackCommitAttempted: boolean;
finishRequested: boolean;
finishSealed: boolean;
finalSeq: number | null;
finalTimeout: ReturnType<typeof setTimeout> | null;
isSemanticVad: boolean;
};
export type DictationStreamOutboundMessage =
@@ -177,6 +193,7 @@ export class DictationStreamManager {
private readonly sessionId: string;
private readonly openaiApiKey: string | null;
private readonly finalTimeoutMs: number;
private readonly createSession: RealtimeTranscriptionSessionFactory;
private readonly streams = new Map<string, DictationStreamState>();
constructor(params: {
@@ -185,12 +202,17 @@ export class DictationStreamManager {
sessionId: string;
openaiApiKey?: string | null;
finalTimeoutMs?: number;
sessionFactory?: RealtimeTranscriptionSessionFactory;
}) {
this.logger = params.logger.child({ component: "dictation-stream-manager" });
this.emit = params.emit;
this.sessionId = params.sessionId;
this.openaiApiKey = params.openaiApiKey ?? null;
this.finalTimeoutMs = params.finalTimeoutMs ?? DEFAULT_DICTATION_FINAL_TIMEOUT_MS;
this.createSession =
params.sessionFactory ??
((factoryParams) =>
new OpenAIRealtimeTranscriptionSession(factoryParams));
}
public cleanupAll(): void {
@@ -214,13 +236,14 @@ export class DictationStreamManager {
process.env.OPENAI_REALTIME_DICTATION_TRANSCRIPTION_PROMPT ??
"Transcribe only what the speaker says. Do not add words. Preserve punctuation and casing. If the audio is silence or non-speech noise, return an empty transcript.";
const openai = new OpenAIRealtimeTranscriptionSession({
const turnDetection = parseDictationTurnDetection();
const openai = this.createSession({
apiKey,
logger: this.logger.child({ dictationId }),
transcriptionModel,
language: "en",
prompt: transcriptionPrompt,
turnDetection: parseDictationTurnDetection(),
turnDetection,
});
openai.on("committed", ({ itemId }: { itemId: string }) => {
@@ -228,6 +251,7 @@ export class DictationStreamManager {
if (!state) {
return;
}
this.clearVadGraceTimeout(state);
state.committedItemIds.push(itemId);
state.bytesSinceCommit = 0;
state.peakSinceCommit = 0;
@@ -263,6 +287,7 @@ export class DictationStreamManager {
// If we triggered a finish commit but OpenAI doesn't emit committed events (or they arrive late),
// allow final transcripts to unblock finalization.
if (state.finishRequested && state.awaitingFinalCommit && isFinal) {
this.clearVadGraceTimeout(state);
state.awaitingFinalCommit = false;
}
@@ -282,6 +307,15 @@ export class DictationStreamManager {
openai.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err);
const state = this.streams.get(dictationId);
if (state && state.finishRequested && isBufferTooSmallError(message)) {
this.clearVadGraceTimeout(state);
if (state.awaitingFinalCommit) {
state.awaitingFinalCommit = false;
}
this.maybeFinalizeDictationStream(dictationId);
return;
}
void this.failAndCleanupDictationStream(dictationId, message, true);
});
@@ -329,10 +363,13 @@ export class DictationStreamManager {
transcriptsByItemId: new Map(),
finalTranscriptItemIds: new Set(),
awaitingFinalCommit: false,
vadGraceTimeout: null,
fallbackCommitAttempted: false,
finishRequested: false,
finishSealed: false,
finalSeq: null,
finalTimeout: null,
isSemanticVad: turnDetection?.type === "semantic_vad",
});
this.emitDictationAck(dictationId, -1);
@@ -529,6 +566,7 @@ export class DictationStreamManager {
if (!state) {
return;
}
this.clearVadGraceTimeout(state);
if (state.finalTimeout) {
clearTimeout(state.finalTimeout);
}
@@ -585,13 +623,17 @@ export class DictationStreamManager {
state.bytesSinceCommit += silenceBytes;
}
try {
state.awaitingFinalCommit = true;
state.openai.commit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(dictationId, message, true);
return;
state.awaitingFinalCommit = true;
if (state.isSemanticVad) {
this.startVadGraceTimeout(state);
} else {
try {
state.openai.commit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
void this.failAndCleanupDictationStream(dictationId, message, true);
return;
}
}
}
} else {
@@ -690,4 +732,43 @@ export class DictationStreamManager {
this.cleanupDictationStream(dictationId);
})();
}
private startVadGraceTimeout(state: DictationStreamState): void {
if (state.vadGraceTimeout || DICTATION_VAD_GRACE_TIMEOUT_MS <= 0) {
return;
}
state.vadGraceTimeout = setTimeout(() => {
state.vadGraceTimeout = null;
if (!state.finishRequested || !state.awaitingFinalCommit) {
return;
}
if (state.bytesSinceCommit <= 0 || state.fallbackCommitAttempted) {
return;
}
state.fallbackCommitAttempted = true;
try {
state.openai.commit();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isBufferTooSmallError(message)) {
state.awaitingFinalCommit = false;
this.maybeFinalizeDictationStream(state.dictationId);
return;
}
void this.failAndCleanupDictationStream(state.dictationId, message, true);
}
}, DICTATION_VAD_GRACE_TIMEOUT_MS);
}
private clearVadGraceTimeout(state: DictationStreamState): void {
if (!state.vadGraceTimeout) {
return;
}
clearTimeout(state.vadGraceTimeout);
state.vadGraceTimeout = null;
}
}
function isBufferTooSmallError(message: string): boolean {
return /buffer too small/i.test(message);
}
+10 -57
View File
@@ -922,10 +922,6 @@ export class Session {
await this.handleFileDownloadTokenRequest(msg);
break;
case "git_repo_info_request":
await this.handleGitRepoInfoRequest(msg);
break;
case "list_provider_models_request":
await this.handleListProviderModelsRequest(msg);
break;
@@ -1441,12 +1437,17 @@ export class Session {
}
if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id);
if (!agentPayload) {
throw new Error(`Agent ${snapshot.id} not found after creation`);
}
this.emit({
type: "status",
payload: {
status: "agent_created",
agentId: snapshot.id,
requestId,
agent: agentPayload,
},
});
}
@@ -1516,6 +1517,10 @@ export class Session {
await this.forwardAgentUpdate(snapshot);
const timelineSize = this.emitAgentTimelineSnapshot(snapshot);
if (requestId) {
const agentPayload = await this.getAgentPayloadById(snapshot.id);
if (!agentPayload) {
throw new Error(`Agent ${snapshot.id} not found after resume`);
}
this.emit({
type: "status",
payload: {
@@ -1523,6 +1528,7 @@ export class Session {
agentId: snapshot.id,
requestId,
timelineSize,
agent: agentPayload,
},
});
}
@@ -1787,59 +1793,6 @@ export class Session {
}
}
private async handleGitRepoInfoRequest(
msg: Extract<SessionInboundMessage, { type: "git_repo_info_request" }>
): Promise<void> {
const { cwd, requestId } = msg;
const resolvedCwd = expandTilde(cwd);
try {
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome });
if (!status.isGit) {
throw new NotGitRepoError(resolvedCwd);
}
const repoRoot = status.repoRoot ?? resolvedCwd;
const { stdout: branchesRaw } = await execAsync(
"git branch --format='%(refname:short)'",
{ cwd: repoRoot, env: READ_ONLY_GIT_ENV }
);
const currentBranch = status.currentBranch ?? "";
const branches = branchesRaw
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0)
.map((name) => ({
name,
isCurrent: name === currentBranch,
}));
const isDirty = status.isDirty ?? false;
this.emit({
type: "git_repo_info_response",
payload: {
cwd: resolvedCwd,
repoRoot,
requestId,
branches,
currentBranch: currentBranch || null,
isDirty,
error: null,
},
});
} catch (error) {
this.emit({
type: "git_repo_info_response",
payload: {
cwd,
repoRoot: cwd,
requestId,
error: (error as Error)?.message ?? String(error),
},
});
}
}
private async handleListProviderModelsRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>
): Promise<void> {
+24 -21
View File
@@ -623,6 +623,13 @@ export const CheckoutPrStatusRequestSchema = z.object({
requestId: z.string(),
});
export const ValidateBranchRequestSchema = z.object({
type: z.literal("validate_branch_request"),
cwd: z.string(),
branchName: z.string(),
requestId: z.string(),
});
export const PaseoWorktreeListRequestSchema = z.object({
type: z.literal("paseo_worktree_list_request"),
cwd: z.string().optional(),
@@ -842,13 +849,13 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushRequestSchema,
CheckoutPrCreateRequestSchema,
CheckoutPrStatusRequestSchema,
ValidateBranchRequestSchema,
PaseoWorktreeListRequestSchema,
PaseoWorktreeArchiveRequestSchema,
HighlightedDiffRequestSchema,
FileExplorerRequestSchema,
ProjectIconRequestSchema,
FileDownloadTokenRequestSchema,
GitRepoInfoRequestMessageSchema,
ClearAgentAttentionMessageSchema,
ClientHeartbeatMessageSchema,
ListCommandsRequestSchema,
@@ -979,6 +986,7 @@ const AgentStatusWithTimelineSchema = AgentStatusWithRequestSchema.extend({
export const AgentCreatedStatusPayloadSchema = z
.object({
status: z.literal("agent_created"),
agent: AgentSnapshotPayloadSchema,
})
.extend(AgentStatusWithRequestSchema.shape);
@@ -991,6 +999,7 @@ export const AgentCreateFailedStatusPayloadSchema = z.object({
export const AgentResumedStatusPayloadSchema = z
.object({
status: z.literal("agent_resumed"),
agent: AgentSnapshotPayloadSchema,
})
.extend(AgentStatusWithTimelineSchema.shape);
@@ -1333,6 +1342,17 @@ export const CheckoutPrStatusResponseSchema = z.object({
}),
});
export const ValidateBranchResponseSchema = z.object({
type: z.literal("validate_branch_response"),
payload: z.object({
exists: z.boolean(),
resolvedRef: z.string().nullable(),
isRemote: z.boolean(),
error: z.string().nullable(),
requestId: z.string(),
}),
});
const PaseoWorktreeSchema = z.object({
worktreePath: z.string(),
branchName: z.string().nullable().optional(),
@@ -1410,24 +1430,6 @@ export const FileDownloadTokenResponseSchema = z.object({
}),
});
const GitBranchInfoSchema = z.object({
name: z.string(),
isCurrent: z.boolean(),
});
export const GitRepoInfoResponseSchema = z.object({
type: z.literal("git_repo_info_response"),
payload: z.object({
cwd: z.string(),
repoRoot: z.string(),
requestId: z.string(),
branches: z.array(GitBranchInfoSchema).optional(),
currentBranch: z.string().nullable().optional(),
isDirty: z.boolean().optional(),
error: z.string().nullable().optional(),
}),
});
export const ListProviderModelsResponseMessageSchema = z.object({
type: z.literal("list_provider_models_response"),
payload: z.object({
@@ -1581,13 +1583,13 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
CheckoutPushResponseSchema,
CheckoutPrCreateResponseSchema,
CheckoutPrStatusResponseSchema,
ValidateBranchResponseSchema,
PaseoWorktreeListResponseSchema,
PaseoWorktreeArchiveResponseSchema,
HighlightedDiffResponseSchema,
FileExplorerResponseSchema,
ProjectIconResponseSchema,
FileDownloadTokenResponseSchema,
GitRepoInfoResponseSchema,
ListProviderModelsResponseMessageSchema,
ListCommandsResponseSchema,
ExecuteCommandResponseSchema,
@@ -1685,6 +1687,8 @@ export type CheckoutPrCreateRequest = z.infer<typeof CheckoutPrCreateRequestSche
export type CheckoutPrCreateResponse = z.infer<typeof CheckoutPrCreateResponseSchema>;
export type CheckoutPrStatusRequest = z.infer<typeof CheckoutPrStatusRequestSchema>;
export type CheckoutPrStatusResponse = z.infer<typeof CheckoutPrStatusResponseSchema>;
export type ValidateBranchRequest = z.infer<typeof ValidateBranchRequestSchema>;
export type ValidateBranchResponse = z.infer<typeof ValidateBranchResponseSchema>;
export type PaseoWorktreeListRequest = z.infer<typeof PaseoWorktreeListRequestSchema>;
export type PaseoWorktreeListResponse = z.infer<typeof PaseoWorktreeListResponseSchema>;
export type PaseoWorktreeArchiveRequest = z.infer<typeof PaseoWorktreeArchiveRequestSchema>;
@@ -1698,7 +1702,6 @@ export type ProjectIconResponse = z.infer<typeof ProjectIconResponseSchema>;
export type ProjectIcon = z.infer<typeof ProjectIconSchema>;
export type FileDownloadTokenRequest = z.infer<typeof FileDownloadTokenRequestSchema>;
export type FileDownloadTokenResponse = z.infer<typeof FileDownloadTokenResponseSchema>;
export type GitRepoInfoResponse = z.infer<typeof GitRepoInfoResponseSchema>;
export type RestartServerRequestMessage = z.infer<typeof RestartServerRequestMessageSchema>;
export type ClearAgentAttentionMessage = z.infer<typeof ClearAgentAttentionMessageSchema>;
export type ClientHeartbeatMessage = z.infer<typeof ClientHeartbeatMessageSchema>;
+12 -3
View File
@@ -542,10 +542,19 @@ export async function createWorktree({
if (normalizedBaseBranch === "HEAD") {
throw new Error("Base branch cannot be HEAD when creating a Paseo worktree");
}
// Resolve the base branch - try local first, then remote
let resolvedBaseBranch = normalizedBaseBranch;
try {
await execAsync(`git rev-parse --verify ${normalizedBaseBranch}`, { cwd });
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
// Local branch doesn't exist, try remote (origin/{branch})
try {
await execAsync(`git rev-parse --verify origin/${normalizedBaseBranch}`, { cwd });
resolvedBaseBranch = `origin/${normalizedBaseBranch}`;
} catch {
throw new Error(`Base branch not found: ${normalizedBaseBranch}`);
}
}
let worktreePath: string;
@@ -568,8 +577,8 @@ export async function createWorktree({
// Always create a new branch for the worktree
// If branchName already exists, use it as base and create worktree-slug as branch name
// If branchName doesn't exist, create it from baseBranch
const base = branchExists ? branchName : normalizedBaseBranch;
// If branchName doesn't exist, create it from baseBranch (resolved to remote if needed)
const base = branchExists ? branchName : resolvedBaseBranch;
const candidateBranch = branchExists ? desiredSlug : branchName;
// Find unique branch name if collision