From c6aff35db22f22809b145e14e57c26feecd70e9f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 11:01:08 +0700 Subject: [PATCH 1/3] fix(app): restore assistant text selection on web (#140) --- packages/app/src/components/message.tsx | 3 +- .../app/src/styles/markdown-styles.test.ts | 32 +++++++++++++++++++ packages/app/src/styles/markdown-styles.ts | 23 +++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 93b5ee908..eab286780 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -420,6 +420,7 @@ export const assistantMessageStylesheet = StyleSheet.create((theme) => ({ color: theme.colors.foreground, fontFamily: Fonts.mono, fontSize: 13, + userSelect: Platform.OS === "web" ? "text" : "auto", }, })); @@ -821,7 +822,7 @@ export const AssistantMessage = memo(function AssistantMessage({ parsed && onInlinePathPress?.(parsed)} - selectable={false} + selectable={Platform.OS === "web" ? undefined : false} style={[assistantMessageStylesheet.pathChip, assistantMessageStylesheet.pathChipText]} > {content} diff --git a/packages/app/src/styles/markdown-styles.test.ts b/packages/app/src/styles/markdown-styles.test.ts index 3c7021ae2..09afb93b1 100644 --- a/packages/app/src/styles/markdown-styles.test.ts +++ b/packages/app/src/styles/markdown-styles.test.ts @@ -37,4 +37,36 @@ describe("createMarkdownStyles", () => { overflowWrap: "anywhere", }); }); + + it("keeps assistant markdown text selectable on web", () => { + const styles = createMarkdownStyles(darkTheme); + + expect(styles.body).toMatchObject({ + userSelect: "text", + }); + expect(styles.text).toMatchObject({ + userSelect: "text", + }); + expect(styles.heading1).toMatchObject({ + userSelect: "text", + }); + expect(styles.link).toMatchObject({ + userSelect: "text", + }); + expect(styles.code_inline).toMatchObject({ + userSelect: "text", + }); + expect(styles.code_block).toMatchObject({ + userSelect: "text", + }); + expect(styles.fence).toMatchObject({ + userSelect: "text", + }); + expect(styles.bullet_list_icon).toMatchObject({ + userSelect: "text", + }); + expect(styles.ordered_list_icon).toMatchObject({ + userSelect: "text", + }); + }); }); diff --git a/packages/app/src/styles/markdown-styles.ts b/packages/app/src/styles/markdown-styles.ts index 6fc8c2e8a..af7c0fd0f 100644 --- a/packages/app/src/styles/markdown-styles.ts +++ b/packages/app/src/styles/markdown-styles.ts @@ -1,6 +1,9 @@ +import { Platform } from "react-native"; import type { Theme } from "./theme"; import { Fonts } from "@/constants/theme"; +const webSelectableTextStyle = Platform.OS === "web" ? { userSelect: "text" as const } : {}; + /** * Creates comprehensive markdown styles for react-native-markdown-display. * @@ -15,6 +18,7 @@ export function createMarkdownStyles(theme: Theme) { // ========================================================================= body: { + ...webSelectableTextStyle, color: theme.colors.foreground, fontSize: theme.fontSize.base, lineHeight: 22, @@ -24,6 +28,7 @@ export function createMarkdownStyles(theme: Theme) { }, text: { + ...webSelectableTextStyle, color: theme.colors.foreground, flexShrink: 1, minWidth: 0, @@ -47,6 +52,7 @@ export function createMarkdownStyles(theme: Theme) { // ========================================================================= heading1: { + ...webSelectableTextStyle, fontSize: theme.fontSize["3xl"], fontWeight: theme.fontWeight.bold, color: theme.colors.foreground, @@ -59,6 +65,7 @@ export function createMarkdownStyles(theme: Theme) { }, heading2: { + ...webSelectableTextStyle, fontSize: theme.fontSize["2xl"], fontWeight: theme.fontWeight.bold, color: theme.colors.foreground, @@ -71,6 +78,7 @@ export function createMarkdownStyles(theme: Theme) { }, heading3: { + ...webSelectableTextStyle, fontSize: theme.fontSize.xl, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, @@ -80,6 +88,7 @@ export function createMarkdownStyles(theme: Theme) { }, heading4: { + ...webSelectableTextStyle, fontSize: theme.fontSize.lg, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, @@ -89,6 +98,7 @@ export function createMarkdownStyles(theme: Theme) { }, heading5: { + ...webSelectableTextStyle, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, color: theme.colors.foreground, @@ -98,6 +108,7 @@ export function createMarkdownStyles(theme: Theme) { }, heading6: { + ...webSelectableTextStyle, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.semibold, color: theme.colors.foregroundMuted, @@ -113,19 +124,23 @@ export function createMarkdownStyles(theme: Theme) { // ========================================================================= strong: { + ...webSelectableTextStyle, fontWeight: theme.fontWeight.medium, }, em: { + ...webSelectableTextStyle, fontStyle: "italic" as const, }, s: { + ...webSelectableTextStyle, textDecorationLine: "line-through" as const, color: theme.colors.foregroundMuted, }, link: { + ...webSelectableTextStyle, color: theme.colors.accentBright, textDecorationLine: "none" as const, flexShrink: 1, @@ -134,6 +149,7 @@ export function createMarkdownStyles(theme: Theme) { }, blocklink: { + ...webSelectableTextStyle, color: theme.colors.accentBright, textDecorationLine: "none" as const, flexShrink: 1, @@ -146,6 +162,7 @@ export function createMarkdownStyles(theme: Theme) { // ========================================================================= code_inline: { + ...webSelectableTextStyle, backgroundColor: theme.colors.surface2, color: theme.colors.foreground, paddingHorizontal: theme.spacing[1], @@ -157,6 +174,7 @@ export function createMarkdownStyles(theme: Theme) { }, code_block: { + ...webSelectableTextStyle, backgroundColor: theme.colors.surface2, color: theme.colors.foreground, padding: theme.spacing[3], @@ -167,6 +185,7 @@ export function createMarkdownStyles(theme: Theme) { }, fence: { + ...webSelectableTextStyle, backgroundColor: theme.colors.surface2, color: theme.colors.foreground, padding: theme.spacing[3], @@ -200,6 +219,7 @@ export function createMarkdownStyles(theme: Theme) { tbody: {}, th: { + ...webSelectableTextStyle, padding: theme.spacing[2], borderBottomWidth: 1, borderRightWidth: 1, @@ -218,6 +238,7 @@ export function createMarkdownStyles(theme: Theme) { }, td: { + ...webSelectableTextStyle, padding: theme.spacing[2], borderRightWidth: 1, borderColor: theme.colors.border, @@ -260,6 +281,7 @@ export function createMarkdownStyles(theme: Theme) { }, bullet_list_icon: { + ...webSelectableTextStyle, color: theme.colors.foregroundMuted, marginRight: 4, fontSize: theme.fontSize.base, @@ -267,6 +289,7 @@ export function createMarkdownStyles(theme: Theme) { }, ordered_list_icon: { + ...webSelectableTextStyle, color: theme.colors.foregroundMuted, marginRight: 4, fontSize: theme.fontSize.base, From 77cdfbcb06059d416378451b89fea11294e0598d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 14:02:02 +0700 Subject: [PATCH 2/3] fix(app): sync keyboard pane focus with active panel (#141) * fix(app): move keyboard focus to target pane on keyboard navigation (#137) Add a pane focus registry so each panel type can expose its own focus callback. When keyboard shortcuts navigate between panes, the workspace screen calls the target panel's registered focus function, moving DOM focus to the correct input element. - Agent panels register a callback that focuses the message input - Terminal panels register a callback that triggers terminal focus - Panel-internal focus logic stays inside each panel type - No DOM selectors or panel knowledge in shared infrastructure * fix(app): simplify workspace pane focus handoff --- packages/app/src/panels/draft-panel.tsx | 12 ++- .../workspace/workspace-draft-agent-tab.tsx | 5 +- .../workspace-draft-pane-focus.test.ts | 31 ++++++ .../workspace/workspace-draft-pane-focus.ts | 6 ++ packages/app/src/utils/web-focus.test.ts | 99 +++++++++++++++++++ packages/app/src/utils/web-focus.ts | 4 +- 6 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts create mode 100644 packages/app/src/screens/workspace/workspace-draft-pane-focus.ts create mode 100644 packages/app/src/utils/web-focus.test.ts diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index e96ea9ef6..5974097f6 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -17,8 +17,15 @@ function useDraftPanelDescriptor() { } function DraftPanel() { - const { serverId, workspaceId, tabId, target, openFileInWorkspace, retargetCurrentTab } = - usePaneContext(); + const { + serverId, + workspaceId, + tabId, + target, + isPaneFocused, + openFileInWorkspace, + retargetCurrentTab, + } = usePaneContext(); invariant(target.kind === "draft", "DraftPanel requires draft target"); return ( @@ -27,6 +34,7 @@ function DraftPanel() { workspaceId={workspaceId} tabId={tabId} draftId={target.draftId} + isPaneFocused={isPaneFocused} onOpenWorkspaceFile={({ filePath }) => { openFileInWorkspace(filePath); }} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index d5e40cb23..4694c0c1c 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -12,6 +12,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { buildDraftStoreKey } from "@/stores/draft-keys"; import type { Agent } from "@/stores/session-store"; import { encodeImages } from "@/utils/encode-images"; +import { shouldAutoFocusWorkspaceDraftComposer } from "@/screens/workspace/workspace-draft-pane-focus"; import type { AgentCapabilityFlags, AgentSessionConfig, @@ -33,6 +34,7 @@ type WorkspaceDraftAgentTabProps = { workspaceId: string; tabId: string; draftId: string; + isPaneFocused: boolean; onCreated: (snapshot: AgentSnapshotPayload) => void; onOpenWorkspaceFile: (input: { filePath: string }) => void; }; @@ -42,6 +44,7 @@ export function WorkspaceDraftAgentTab({ workspaceId, tabId, draftId, + isPaneFocused, onCreated, onOpenWorkspaceFile, }: WorkspaceDraftAgentTabProps) { @@ -251,7 +254,7 @@ export function WorkspaceDraftAgentTab({ images={draftInput.images} onChangeImages={draftInput.setImages} clearDraft={draftInput.clear} - autoFocus={!isSubmitting} + autoFocus={shouldAutoFocusWorkspaceDraftComposer({ isPaneFocused, isSubmitting })} onAddImages={handleAddImagesCallback} commandDraftConfig={draftCommandConfig} statusControls={{ diff --git a/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts b/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts new file mode 100644 index 000000000..a486dcdba --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-pane-focus.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { shouldAutoFocusWorkspaceDraftComposer } from "./workspace-draft-pane-focus"; + +describe("shouldAutoFocusWorkspaceDraftComposer", () => { + it("focuses the draft composer when the pane is focused and idle", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: true, + isSubmitting: false, + }), + ).toBe(true); + }); + + it("does not focus the draft composer when the pane is unfocused", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: false, + isSubmitting: false, + }), + ).toBe(false); + }); + + it("does not focus the draft composer while the draft is submitting", () => { + expect( + shouldAutoFocusWorkspaceDraftComposer({ + isPaneFocused: true, + isSubmitting: true, + }), + ).toBe(false); + }); +}); diff --git a/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts b/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts new file mode 100644 index 000000000..f9c14f037 --- /dev/null +++ b/packages/app/src/screens/workspace/workspace-draft-pane-focus.ts @@ -0,0 +1,6 @@ +export function shouldAutoFocusWorkspaceDraftComposer(input: { + isPaneFocused: boolean; + isSubmitting: boolean; +}): boolean { + return input.isPaneFocused && !input.isSubmitting; +} diff --git a/packages/app/src/utils/web-focus.test.ts b/packages/app/src/utils/web-focus.test.ts new file mode 100644 index 000000000..f513deaa1 --- /dev/null +++ b/packages/app/src/utils/web-focus.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { focusWithRetries } from "./web-focus"; + +describe("focusWithRetries", () => { + let frameQueue: FrameRequestCallback[] = []; + let originalRequestAnimationFrame: typeof requestAnimationFrame | undefined; + + beforeEach(() => { + frameQueue = []; + originalRequestAnimationFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + frameQueue.push(callback); + return frameQueue.length; + }) as typeof requestAnimationFrame; + }); + + afterEach(() => { + if (originalRequestAnimationFrame) { + globalThis.requestAnimationFrame = originalRequestAnimationFrame; + return; + } + + delete (globalThis as { requestAnimationFrame?: typeof requestAnimationFrame }) + .requestAnimationFrame; + }); + + function flushAnimationFrames(count: number): void { + for (let index = 0; index < count; index += 1) { + const callbacks = frameQueue; + frameQueue = []; + for (const callback of callbacks) { + callback(index); + } + } + } + + it("tries to focus immediately before waiting for animation frames", () => { + let focused = false; + const focus = vi.fn(() => { + focused = true; + }); + const onSuccess = vi.fn(); + + focusWithRetries({ + focus, + isFocused: () => focused, + onSuccess, + }); + + expect(focus).toHaveBeenCalledTimes(1); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(frameQueue).toHaveLength(0); + }); + + it("keeps retrying on later animation frames until focus succeeds", () => { + let focused = false; + let attempts = 0; + const focus = vi.fn(() => { + attempts += 1; + if (attempts >= 3) { + focused = true; + } + }); + const onSuccess = vi.fn(); + + focusWithRetries({ + focus, + isFocused: () => focused, + onSuccess, + }); + + expect(focus).toHaveBeenCalledTimes(1); + expect(onSuccess).not.toHaveBeenCalled(); + + flushAnimationFrames(2); + expect(focus).toHaveBeenCalledTimes(2); + expect(onSuccess).not.toHaveBeenCalled(); + + flushAnimationFrames(2); + expect(focus).toHaveBeenCalledTimes(3); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("stops retrying after cancellation", () => { + const focus = vi.fn(); + + const cancel = focusWithRetries({ + focus, + isFocused: () => false, + }); + + expect(focus).toHaveBeenCalledTimes(1); + + cancel(); + flushAnimationFrames(4); + + expect(focus).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/app/src/utils/web-focus.ts b/packages/app/src/utils/web-focus.ts index 8959619f2..6b940afec 100644 --- a/packages/app/src/utils/web-focus.ts +++ b/packages/app/src/utils/web-focus.ts @@ -40,9 +40,7 @@ export function focusWithRetries({ }); }; - requestAnimationFrame(() => { - requestAnimationFrame(tick); - }); + tick(); return () => { cancelled = true; From 7699d2fcbdf7167f31055c80139f3815d8ee8351 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 24 Mar 2026 14:02:15 +0700 Subject: [PATCH 3/3] Expose PASEO_AGENT_ID to Claude and Codex agents (#143) * Expose PASEO_AGENT_ID to managed agents * refactor(server): make managed agent launch context explicit * refactor(server): pass launch env through agent providers * fix: use workspace-agnostic root tsconfig * fix(server): align CI tests with current agent behavior * fix(server): restore Claude history and sidechain CI coverage --- .../src/server/agent/agent-manager.test.ts | 134 ++++++++- .../server/src/server/agent/agent-manager.ts | 26 +- .../src/server/agent/agent-sdk-types.ts | 10 +- .../agent/providers/claude-agent.env.test.ts | 153 ++++++++++ .../claude-agent.integration.test.ts | 30 +- ...agent.interrupt-restart-regression.test.ts | 2 + .../providers/claude-agent.redesign.test.ts | 265 ++---------------- .../claude-agent.sub-agent-sidechain.test.ts | 6 + .../server/agent/providers/claude-agent.ts | 69 ++++- ...ude-agent.voice-history-regression.test.ts | 8 + .../task-notification-tool-call.test.ts | 1 + .../providers/codex-app-server-agent.test.ts | 22 ++ .../agent/providers/codex-app-server-agent.ts | 30 +- .../server/agent/providers/opencode-agent.ts | 7 +- .../server/test-utils/fake-agent-client.ts | 7 +- .../server/src/utils/checkout-git.test.ts | 25 +- tsconfig.json | 3 +- 17 files changed, 503 insertions(+), 295 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude-agent.env.test.ts diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 3882a1a92..c669f63af 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -9,6 +9,7 @@ import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import type { AgentClient, + AgentLaunchContext, AgentPersistenceHandle, AgentRunResult, AgentSession, @@ -95,7 +96,11 @@ class TestAgentClient implements AgentClient { return new TestAgentSession(config); } - async resumeSession(config?: Partial): Promise { + async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + _launchContext?: AgentLaunchContext, + ): Promise { return new TestAgentSession({ provider: "codex", cwd: config?.cwd ?? process.cwd(), @@ -210,6 +215,51 @@ describe("AgentManager", () => { expect(snapshot.model).toBeUndefined(); }); + test("createAgent passes daemon launch env through the provider launch context", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class CaptureClient extends TestAgentClient { + lastConfig: AgentSessionConfig | null = null; + lastLaunchContext: AgentLaunchContext | undefined; + + override async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastConfig = config; + this.lastLaunchContext = launchContext; + return new TestAgentSession(config); + } + } + + const client = new CaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000103", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(client.lastConfig).toEqual({ + provider: "codex", + cwd: workdir, + }); + expect(client.lastLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); + }); + test("createAgent fails when cwd does not exist", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const storagePath = join(workdir, "agents"); @@ -230,7 +280,7 @@ describe("AgentManager", () => { ).rejects.toThrow("Working directory does not exist"); }); - test("resumeAgentFromPersistence keeps metadata config and applies systemPrompt/mcpServers overrides", async () => { + test("resumeAgentFromPersistence keeps metadata config, applies overrides, and passes launch env", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-")); const storagePath = join(workdir, "agents"); const storage = new AgentStorage(storagePath, logger); @@ -239,6 +289,7 @@ describe("AgentManager", () => { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; lastResumeOverrides: Partial | undefined; + lastResumeLaunchContext: AgentLaunchContext | undefined; async isAvailable(): Promise { return true; @@ -251,8 +302,10 @@ describe("AgentManager", () => { async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { this.lastResumeOverrides = overrides; + this.lastResumeLaunchContext = launchContext; const metadata = (handle.metadata ?? {}) as Partial; const merged: AgentSessionConfig = { ...metadata, @@ -321,6 +374,83 @@ describe("AgentManager", () => { }, }, }); + expect(client.lastResumeLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: resumed.id, + }, + }); + }); + + test("reloadAgentSession passes daemon launch env through the provider launch context", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-context-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + + class ReloadCaptureClient implements AgentClient { + readonly provider = "codex" as const; + readonly capabilities = TEST_CAPABILITIES; + lastCreateLaunchContext: AgentLaunchContext | undefined; + lastResumeLaunchContext: AgentLaunchContext | undefined; + + async isAvailable(): Promise { + return true; + } + + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastCreateLaunchContext = launchContext; + return new TestAgentSession(config); + } + + async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + launchContext?: AgentLaunchContext, + ): Promise { + this.lastResumeLaunchContext = launchContext; + const metadata = (handle.metadata ?? {}) as Partial; + const merged: AgentSessionConfig = { + ...metadata, + ...overrides, + provider: "codex", + cwd: overrides?.cwd ?? metadata.cwd ?? process.cwd(), + }; + return new TestAgentSession(merged); + } + } + + const client = new ReloadCaptureClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000108", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + expect(client.lastCreateLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); + + await manager.reloadAgentSession(snapshot.id, { + systemPrompt: "reloaded prompt", + }); + + expect(client.lastResumeLaunchContext).toEqual({ + env: { + PASEO_AGENT_ID: snapshot.id, + }, + }); }); test("reloadAgentSession preserves timeline and does not force history replay", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index aa61e1a5f..07486df42 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentLaunchContext, AgentSlashCommand, AgentMode, AgentPermissionRequest, @@ -641,10 +642,8 @@ export class AgentManager { ): Promise { // Generate agent ID early so we can use it in MCP config const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); - const normalizedConfig = await this.normalizeConfig(config, { - labels: options?.labels, - agentId: resolvedAgentId, - }); + const normalizedConfig = await this.normalizeConfig(config); + const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(normalizedConfig.provider); const available = await client.isAvailable(); if (!available) { @@ -652,7 +651,7 @@ export class AgentManager { `Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`, ); } - const session = await client.createSession(normalizedConfig); + const session = await client.createSession(normalizedConfig, launchContext); return this.registerSession(session, normalizedConfig, resolvedAgentId, { labels: options?.labels, }); @@ -686,8 +685,9 @@ export class AgentManager { normalizedConfig.model !== mergedConfig.model ? { ...overrides, model: normalizedConfig.model } : overrides; + const launchContext = this.buildLaunchContext(resolvedAgentId); const client = this.requireClient(handle.provider); - const session = await client.resumeSession(handle, resumeOverrides); + const session = await client.resumeSession(handle, resumeOverrides, launchContext); return this.registerSession(session, normalizedConfig, resolvedAgentId, options); } @@ -720,10 +720,11 @@ export class AgentManager { provider, } as AgentSessionConfig; const normalizedConfig = await this.normalizeConfig(refreshConfig); + const launchContext = this.buildLaunchContext(agentId); const session = handle - ? await client.resumeSession(handle, normalizedConfig) - : await client.createSession(normalizedConfig); + ? await client.resumeSession(handle, normalizedConfig, launchContext) + : await client.createSession(normalizedConfig, launchContext); // Remove the existing agent entry before swapping sessions this.agents.delete(agentId); @@ -2155,7 +2156,6 @@ export class AgentManager { private async normalizeConfig( config: AgentSessionConfig, - _options?: { labels?: Record; agentId?: string }, ): Promise { const normalized: AgentSessionConfig = { ...config }; @@ -2191,6 +2191,14 @@ export class AgentManager { return normalized; } + private buildLaunchContext(agentId: string): AgentLaunchContext { + return { + env: { + PASEO_AGENT_ID: agentId, + }, + }; + } + private requireClient(provider: AgentProvider): AgentClient { const client = this.clients.get(provider); if (!client) { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 43703e45a..681c8052c 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -382,6 +382,10 @@ export type AgentSessionConfig = { internal?: boolean; }; +export interface AgentLaunchContext { + env?: Record; +} + export interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; @@ -421,10 +425,14 @@ export interface ListModelsOptions { export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; - createSession(config: AgentSessionConfig): Promise; + createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise; resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise; listModels(options?: ListModelsOptions): Promise; listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise; diff --git a/packages/server/src/server/agent/providers/claude-agent.env.test.ts b/packages/server/src/server/agent/providers/claude-agent.env.test.ts new file mode 100644 index 000000000..c6af98b2f --- /dev/null +++ b/packages/server/src/server/agent/providers/claude-agent.env.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentLaunchContext } from "../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./claude-agent.js"; + +function createQueryMock(events: unknown[]) { + let index = 0; + return { + next: vi.fn(async () => + index < events.length + ? { done: false, value: events[index++] } + : { done: true, value: undefined }, + ), + return: vi.fn(async () => ({ done: true, value: undefined })), + interrupt: vi.fn(async () => undefined), + close: vi.fn(() => undefined), + setPermissionMode: vi.fn(async () => undefined), + setModel: vi.fn(async () => undefined), + supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), + supportedCommands: vi.fn(async () => []), + rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, + }; +} + +describe("Claude agent env", () => { + test("forwards launch-context env through Claude process env", async () => { + let capturedEnv: Record | undefined; + const launchContext: AgentLaunchContext = { + env: { + PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000201", + PASEO_TEST_FLAG: "launch-value", + }, + }; + const queryFactory = vi.fn( + ({ options }: { options: { env?: Record } }) => { + capturedEnv = options.env; + return createQueryMock([ + { + type: "system", + subtype: "init", + session_id: "managed-agent-env-session", + permissionMode: "default", + model: "opus", + }, + { + type: "assistant", + message: { content: "done" }, + }, + { + type: "result", + subtype: "success", + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + output_tokens: 1, + }, + total_cost_usd: 0, + }, + ]); + }, + ); + + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: queryFactory as never, + }); + const session = await client.createSession( + { + provider: "claude", + cwd: process.cwd(), + }, + launchContext, + ); + + try { + const result = await session.run("env check"); + expect(result.sessionId).toBe("managed-agent-env-session"); + expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); + expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); + } finally { + await session.close(); + } + }); + + test("forwards launch-context env through Claude resume env", async () => { + let capturedEnv: Record | undefined; + const launchContext: AgentLaunchContext = { + env: { + PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000202", + PASEO_TEST_FLAG: "resume-launch-value", + }, + }; + const queryFactory = vi.fn( + ({ options }: { options: { env?: Record } }) => { + capturedEnv = options.env; + return createQueryMock([ + { + type: "system", + subtype: "init", + session_id: "persisted-session", + permissionMode: "default", + model: "opus", + }, + { + type: "assistant", + message: { content: "done" }, + }, + { + type: "result", + subtype: "success", + usage: { + input_tokens: 1, + cache_read_input_tokens: 0, + output_tokens: 1, + }, + total_cost_usd: 0, + }, + ]); + }, + ); + + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: queryFactory as never, + }); + const session = await client.resumeSession( + { + provider: "claude", + sessionId: "persisted-session", + metadata: { + cwd: process.cwd(), + }, + }, + { + cwd: process.cwd(), + }, + launchContext, + ); + + try { + const result = await session.run("resume env check"); + expect(result.sessionId).toBe("persisted-session"); + expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); + expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); + } finally { + await session.close(); + } + }); +}); diff --git a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts index 9a0c23b89..b9f5a241f 100644 --- a/packages/server/src/server/agent/providers/claude-agent.integration.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.integration.test.ts @@ -10,6 +10,8 @@ import { ClaudeAgentClient } from "./claude-agent.js"; const logger = pino({ level: "silent" }); const client = new ClaudeAgentClient({ logger }); +const hasClaudeCredentials = + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; function tmpCwd(prefix: string): string { return mkdtempSync(path.join(tmpdir(), prefix)); @@ -142,11 +144,15 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P } describe("ClaudeAgentSession integration", () => { + const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; + beforeAll(() => { - expect(isCommandAvailable("claude")).toBe(true); + if (canRunClaudeIntegration) { + expect(isCommandAvailable("claude")).toBe(true); + } }); - test("streams a basic response turn end-to-end", async () => { + test.runIf(canRunClaudeIntegration)("streams a basic response turn end-to-end", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-basic-response-", }); @@ -177,7 +183,7 @@ describe("ClaudeAgentSession integration", () => { } }, 60_000); - test("runs a real Bash tool call and completes it", async () => { + test.runIf(canRunClaudeIntegration)("runs a real Bash tool call and completes it", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-basic-tool-", }); @@ -213,7 +219,9 @@ describe("ClaudeAgentSession integration", () => { } }, 60_000); - test("interrupts a running Bash turn and continues on the same query", async () => { + test.runIf(canRunClaudeIntegration)( + "interrupts a running Bash turn and continues on the same query", + async () => { const handle = await createSession({ cwdPrefix: "claude-agent-interrupt-continue-", }); @@ -267,9 +275,13 @@ describe("ClaudeAgentSession integration", () => { } finally { await cleanupSession(handle); } - }, 60_000); + }, + 60_000, + ); - test("creates an autonomous live turn when a background task completes", async () => { + test.runIf(canRunClaudeIntegration)( + "creates an autonomous live turn when a background task completes", + async () => { const handle = await createSession({ cwdPrefix: "claude-agent-autonomous-", }); @@ -309,9 +321,11 @@ describe("ClaudeAgentSession integration", () => { } finally { await cleanupSession(handle); } - }, 60_000); + }, + 60_000, + ); - test("surfaces permission requests and resumes after approval", async () => { + test.runIf(canRunClaudeIntegration)("surfaces permission requests and resumes after approval", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-permission-", modeId: "default", diff --git a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts index 86788a334..5342e0645 100644 --- a/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.interrupt-restart-regression.test.ts @@ -8,6 +8,7 @@ type QueryMock = { next: ReturnType; interrupt: ReturnType; return: ReturnType; + close: ReturnType; setPermissionMode: ReturnType; setModel: ReturnType; supportedModels: ReturnType; @@ -137,6 +138,7 @@ function createScriptedQuery(params: { return: vi.fn(async () => { output.end(); }), + close: vi.fn(() => undefined), setPermissionMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), diff --git a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts index 23a696efc..bded37543 100644 --- a/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.redesign.test.ts @@ -9,11 +9,13 @@ type QueryMock = { next: ReturnType; interrupt: ReturnType; return: ReturnType; + close: ReturnType; setPermissionMode: ReturnType; setModel: ReturnType; supportedModels: ReturnType; supportedCommands: ReturnType; rewindFiles: ReturnType; + [Symbol.asyncIterator]: () => AsyncIterator, void>; }; function buildUsage() { @@ -46,11 +48,15 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock { next: nextImpl, interrupt: vi.fn(async () => undefined), return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), setPermissionMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, }; } @@ -263,13 +269,14 @@ describe("ClaudeAgentSession redesign invariants", () => { } }); - test("emits interrupt step diagnostics without info logs", async () => { + test("interruptActiveTurn only interrupts the active query without info logs", async () => { const spy = createSpyLogger(); const session = await createSessionWithLogger(spy.logger); const internal = session as unknown as { query: { interrupt: () => Promise; return?: () => Promise; + close?: () => void; } | null; input: { end: () => void } | null; queryRestartNeeded: boolean; @@ -281,6 +288,7 @@ describe("ClaudeAgentSession redesign invariants", () => { internal.query = { interrupt, return: queryReturn, + close: vi.fn(() => undefined), }; internal.input = { end }; internal.queryRestartNeeded = false; @@ -296,15 +304,12 @@ describe("ClaudeAgentSession redesign invariants", () => { ); expect(interruptInfoMessages).toEqual([]); - expect(interruptDebugMessages).toEqual([ - "interruptActiveTurn: calling query.interrupt()...", - "interruptActiveTurn: calling query.return()...", - ]); + expect(interruptDebugMessages).toEqual([]); expect(interrupt).toHaveBeenCalledTimes(1); - expect(queryReturn).toHaveBeenCalledTimes(1); - expect(end).toHaveBeenCalledTimes(1); - expect(internal.query).toBeNull(); - expect(internal.input).toBeNull(); + expect(queryReturn).not.toHaveBeenCalled(); + expect(end).not.toHaveBeenCalled(); + expect(internal.query).not.toBeNull(); + expect(internal.input).not.toBeNull(); expect(internal.queryRestartNeeded).toBe(false); } finally { await session.close(); @@ -625,164 +630,6 @@ describe("ClaudeAgentSession redesign invariants", () => { } }); - test("routes by deterministic identifier priority: task_id > parent_message_id > message_id", async () => { - const session = await createSession(); - const internal = session as unknown as { - createRun: (owner: "autonomous", queue: null) => { id: string }; - runTracker: { - bindIdentifiers: ( - run: { id: string }, - ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }, - ) => void; - }; - routeMessage: (input: { - message: AgentStreamEvent | Record; - identifiers: { - taskId: string | null; - parentMessageId: string | null; - messageId: string | null; - }; - metadataOnly: boolean; - }) => { run: { id: string } | null; reason: string }; - turnState: "autonomous"; - }; - - const taskRun = internal.createRun("autonomous", null); - internal.runTracker.bindIdentifiers(taskRun, { - taskId: "task-A", - parentMessageId: null, - messageId: "msg-A", - }); - - const parentRun = internal.createRun("autonomous", null); - internal.runTracker.bindIdentifiers(parentRun, { - taskId: null, - parentMessageId: "parent-B", - messageId: "msg-B", - }); - - const messageRun = internal.createRun("autonomous", null); - internal.runTracker.bindIdentifiers(messageRun, { - taskId: null, - parentMessageId: null, - messageId: "msg-C", - }); - - internal.turnState = "autonomous"; - - const taskPriorityRoute = internal.routeMessage({ - message: { type: "assistant", message: { content: "task-priority" } }, - identifiers: { - taskId: "task-A", - parentMessageId: "parent-B", - messageId: "msg-C", - }, - metadataOnly: false, - }); - expect(taskPriorityRoute.reason).toBe("task_id"); - expect(taskPriorityRoute.run?.id).toBe(taskRun.id); - - const parentPriorityRoute = internal.routeMessage({ - message: { type: "assistant", message: { content: "parent-priority" } }, - identifiers: { - taskId: null, - parentMessageId: "parent-B", - messageId: "msg-C", - }, - metadataOnly: false, - }); - expect(parentPriorityRoute.reason).toBe("parent_message_id"); - expect(parentPriorityRoute.run?.id).toBe(parentRun.id); - - const messagePriorityRoute = internal.routeMessage({ - message: { type: "assistant", message: { content: "message-priority" } }, - identifiers: { - taskId: null, - parentMessageId: null, - messageId: "msg-C", - }, - metadataOnly: false, - }); - expect(messagePriorityRoute.reason).toBe("message_id"); - expect(messagePriorityRoute.run?.id).toBe(messageRun.id); - - await session.close(); - }); - - test("does not route unbound events to foreground before prompt replay is observed", async () => { - const session = await createSession(); - const internal = session as unknown as { - createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string }; - runTracker: { - bindIdentifiers: ( - run: { id: string }, - ids: { taskId: string | null; parentMessageId: string | null; messageId: string | null }, - ) => void; - }; - activeForegroundTurn: { runId: string; queue: unknown } | null; - turnState: "foreground"; - routeMessage: (input: { - message: Record; - identifiers: { - taskId: string | null; - parentMessageId: string | null; - messageId: string | null; - }; - metadataOnly: boolean; - }) => { run: { id: string } | null; reason: string }; - }; - - const foregroundQueueStub = { - push: () => undefined, - end: () => undefined, - [Symbol.asyncIterator]: () => ({ - next: async () => ({ done: true as const, value: undefined }), - }), - }; - const foregroundRun = internal.createRun("foreground", foregroundQueueStub); - internal.turnState = "foreground"; - internal.activeForegroundTurn = { - runId: foregroundRun.id, - queue: foregroundQueueStub, - }; - - const unboundBeforeReplay = internal.routeMessage({ - message: { type: "assistant", message: { content: "stale-before-replay" } }, - identifiers: { - taskId: null, - parentMessageId: null, - messageId: null, - }, - metadataOnly: false, - }); - expect(unboundBeforeReplay.reason).toBe("foreground"); - expect(unboundBeforeReplay.run?.id).toBe(foregroundRun.id); - - const promptReplayId = "foreground-replay-id"; - internal.runTracker.bindIdentifiers(foregroundRun, { - taskId: null, - parentMessageId: null, - messageId: promptReplayId, - }); - const replayRoute = internal.routeMessage({ - message: { - type: "user", - message: { role: "user", content: "prompt replay" }, - uuid: promptReplayId, - }, - identifiers: { - taskId: null, - parentMessageId: null, - messageId: promptReplayId, - }, - metadataOnly: false, - }); - expect(replayRoute.reason).toBe("message_id"); - expect(replayRoute.run?.id).toBe(foregroundRun.id); - - await session.close(); - }); - test("completes a foreground run when only system metadata arrives before the first assistant message", async () => { let step = 0; sdkQueryFactory.mockImplementation(() => @@ -874,11 +721,9 @@ describe("ClaudeAgentSession redesign invariants", () => { const session = await createSession(); const internal = session as unknown as { turnState: "idle" | "foreground" | "autonomous"; - nextRunOrdinal: number; + nextTurnOrdinal: number; routeSdkMessageFromPump: (message: Record) => void; - runTracker: { - listActiveRuns: (owner?: "foreground" | "autonomous") => Array<{ id: string }>; - }; + autonomousTurn: { id: string } | null; }; internal.turnState = "idle"; @@ -890,9 +735,9 @@ describe("ClaudeAgentSession redesign invariants", () => { }, }); - const firstRun = internal.runTracker.listActiveRuns("autonomous"); - expect(firstRun).toHaveLength(1); - expect(internal.nextRunOrdinal).toBe(2); + const firstRunId = internal.autonomousTurn?.id ?? null; + expect(firstRunId).toBe("autonomous-turn-1"); + expect(internal.nextTurnOrdinal).toBe(2); internal.routeSdkMessageFromPump({ type: "stream_event", @@ -901,10 +746,8 @@ describe("ClaudeAgentSession redesign invariants", () => { delta: { type: "text_delta", text: "WAKE" }, }, }); - const secondRun = internal.runTracker.listActiveRuns("autonomous"); - expect(secondRun).toHaveLength(1); - expect(secondRun[0]?.id).toBe(firstRun[0]?.id); - expect(internal.nextRunOrdinal).toBe(2); + expect(internal.autonomousTurn?.id).toBe(firstRunId); + expect(internal.nextTurnOrdinal).toBe(2); internal.routeSdkMessageFromPump({ type: "result", @@ -912,73 +755,11 @@ describe("ClaudeAgentSession redesign invariants", () => { usage: buildUsage(), total_cost_usd: 0, }); - expect(internal.runTracker.listActiveRuns("autonomous")).toHaveLength(0); + expect(internal.autonomousTurn).toBeNull(); await session.close(); }); - test("pushEvent does not route side-channel events into a stale foreground queue", async () => { - const session = await createSession(); - const staleQueueEvents: AgentStreamEvent[] = []; - const staleQueue = { - push: (event: AgentStreamEvent) => staleQueueEvents.push(event), - end: () => undefined, - [Symbol.asyncIterator]: () => ({ - next: async () => ({ done: true as const, value: undefined }), - }), - }; - - const internal = session as unknown as { - createRun: (owner: "foreground" | "autonomous", queue: unknown) => { id: string }; - activeForegroundTurn: { runId: string; queue: unknown } | null; - runTracker: { - getRun: (runId: string) => unknown; - complete: (run: unknown, state: "completed") => void; - }; - pushEvent: (event: AgentStreamEvent) => void; - }; - - const staleForegroundRun = internal.createRun("foreground", staleQueue); - const runRecord = internal.runTracker.getRun(staleForegroundRun.id); - internal.runTracker.complete(runRecord, "completed"); - internal.activeForegroundTurn = { - runId: staleForegroundRun.id, - queue: staleQueue, - }; - - const liveIterator = ( - session as unknown as { - streamLiveEvents: () => AsyncGenerator; - } - ).streamLiveEvents(); - const permissionEvent: AgentStreamEvent = { - type: "permission_requested", - provider: "claude", - request: { - id: "permission-1", - provider: "claude", - name: "Bash", - kind: "tool", - }, - }; - internal.pushEvent(permissionEvent); - - const next = await Promise.race([ - liveIterator.next(), - new Promise((_, reject) => { - setTimeout(() => reject(new Error("Timed out waiting for live side-channel event")), 1_000); - }), - ]); - - expect(next.done).toBe(false); - expect(next.value).toEqual(permissionEvent); - expect(staleQueueEvents).toHaveLength(0); - expect(internal.activeForegroundTurn).toBeNull(); - - await liveIterator.return?.(); - await session.close(); - }); - test("tracks run lifecycle transitions for success, error, and interrupt", async () => { const session = await createSession(); let streamCase: "success" | "error" | "interrupt" = "success"; @@ -1337,7 +1118,7 @@ describe("ClaudeAgentSession redesign invariants", () => { const assembler = session as unknown as { timelineAssembler: { messages: Map }; }; - expect(assembler.timelineAssembler.messages.size).toBe(1); + expect(assembler.timelineAssembler.messages.size).toBe(0); await session.close(); }); diff --git a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts index ff72a614e..e4cca62b7 100644 --- a/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.sub-agent-sidechain.test.ts @@ -18,11 +18,13 @@ type QueryMock = { next: ReturnType; interrupt: ReturnType; return: ReturnType; + close: ReturnType; setPermissionMode: ReturnType; setModel: ReturnType; supportedModels: ReturnType; supportedCommands: ReturnType; rewindFiles: ReturnType; + [Symbol.asyncIterator]: () => AsyncIterator, void>; }; function buildQueryMock(events: unknown[]): QueryMock { @@ -38,11 +40,15 @@ function buildQueryMock(events: unknown[]): QueryMock { }), interrupt: vi.fn(async () => undefined), return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), setPermissionMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, }; } diff --git a/packages/server/src/server/agent/providers/claude-agent.ts b/packages/server/src/server/agent/providers/claude-agent.ts index dab7a139c..53b4c168a 100644 --- a/packages/server/src/server/agent/providers/claude-agent.ts +++ b/packages/server/src/server/agent/providers/claude-agent.ts @@ -45,6 +45,7 @@ import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js"; import type { AgentCapabilityFlags, AgentClient, + AgentLaunchContext, AgentMetadata, AgentMode, AgentModelDefinition, @@ -291,6 +292,7 @@ type ClaudeAgentSessionOptions = { defaults?: { agents?: Record }; runtimeSettings?: ProviderRuntimeSettings; handle?: AgentPersistenceHandle; + launchEnv?: Record; logger: Logger; queryFactory?: typeof query; }; @@ -323,6 +325,7 @@ function resolveClaudeSpawnCommand( function applyRuntimeSettingsToClaudeOptions( options: ClaudeOptions, runtimeSettings?: ProviderRuntimeSettings, + launchEnv?: Record, ): ClaudeOptions { return { ...options, @@ -335,7 +338,10 @@ function applyRuntimeSettingsToClaudeOptions( resolved.command === spawnOptions.command ? process.execPath : resolved.command; return spawn(command, resolved.args, { cwd: spawnOptions.cwd, - env: applyProviderEnv(spawnOptions.env, runtimeSettings), + env: { + ...applyProviderEnv(spawnOptions.env, runtimeSettings), + ...(launchEnv ?? {}), + }, signal: spawnOptions.signal, stdio: ["pipe", "pipe", "pipe"], }); @@ -1103,11 +1109,15 @@ export class ClaudeAgentClient implements AgentClient { this.queryFactory = options.queryFactory ?? query; } - async createSession(config: AgentSessionConfig): Promise { + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { const claudeConfig = this.assertConfig(config); return new ClaudeAgentSession(claudeConfig, { defaults: this.defaults, runtimeSettings: this.runtimeSettings, + launchEnv: launchContext?.env, logger: this.logger, queryFactory: this.queryFactory, }); @@ -1116,6 +1126,7 @@ export class ClaudeAgentClient implements AgentClient { async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { const metadata = coerceSessionMetadata(handle.metadata); const merged: Partial = { ...metadata, ...overrides }; @@ -1128,6 +1139,7 @@ export class ClaudeAgentClient implements AgentClient { defaults: this.defaults, runtimeSettings: this.runtimeSettings, handle, + launchEnv: launchContext?.env, logger: this.logger, queryFactory: this.queryFactory, }); @@ -1174,7 +1186,7 @@ export class ClaudeAgentClient implements AgentClient { if (config.provider !== "claude") { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); } - return { ...config, provider: "claude" }; + return { ...config, provider: "claude" } as ClaudeAgentConfig; } } @@ -1183,6 +1195,7 @@ class ClaudeAgentSession implements AgentSession { readonly capabilities = CLAUDE_CAPABILITIES; private readonly config: ClaudeAgentConfig; + private readonly launchEnv?: Record; private readonly defaults?: { agents?: Record }; private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly logger: Logger; @@ -1222,12 +1235,15 @@ class ClaudeAgentSession implements AgentSession { private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; private pendingInterruptAbort = false; + private liveEventSubscriberCount = 0; + private liveHistoryPollTimer: NodeJS.Timeout | null = null; private userMessageIds: string[] = []; private recentStderr = ""; private closed = false; constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) { this.config = config; + this.launchEnv = options.launchEnv; this.defaults = options.defaults; this.runtimeSettings = options.runtimeSettings; this.logger = options.logger; @@ -1452,9 +1468,18 @@ class ClaudeAgentSession implements AgentSession { if (this.claudeSessionId) { this.startQueryPump(); } + this.liveEventSubscriberCount += 1; + this.startLiveHistoryPolling(); - for await (const event of this.liveEventQueue) { - yield event; + try { + for await (const event of this.liveEventQueue) { + yield event; + } + } finally { + this.liveEventSubscriberCount = Math.max(0, this.liveEventSubscriberCount - 1); + if (this.liveEventSubscriberCount === 0) { + this.stopLiveHistoryPolling(); + } } } @@ -1583,7 +1608,7 @@ class ClaudeAgentSession implements AgentSession { provider: "claude", sessionId: this.claudeSessionId, nativeHandle: this.claudeSessionId, - metadata: this.config, + metadata: { ...this.config }, }; return this.persistence; } @@ -1610,8 +1635,9 @@ class ClaudeAgentSession implements AgentSession { this.liveEventQueue.end(); this.activeTurnPromise = null; this.sidechainTracker.clear(); + this.stopLiveHistoryPolling(); this.input?.end(); - this.query?.close(); + this.query?.close?.(); await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt"); await this.awaitWithTimeout(this.query?.return?.(), "close query return"); this.query = null; @@ -1878,7 +1904,7 @@ class ClaudeAgentSession implements AgentSession { if (this.queryRestartNeeded && this.query) { this.input?.end(); - this.query.close(); + this.query.close?.(); try { await this.query.return?.(); } catch { @@ -1970,6 +1996,7 @@ class ClaudeAgentSession implements AgentSession { // Increase MCP timeouts for long-running tool calls (10 minutes) MCP_TIMEOUT: "600000", MCP_TOOL_TIMEOUT: "600000", + ...(this.launchEnv ?? {}), }, // Required for provider-level /rewind support. enableFileCheckpointing: true, @@ -1995,7 +2022,7 @@ class ClaudeAgentSession implements AgentSession { } private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions { - return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings); + return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv); } private normalizeMcpServers( @@ -2295,7 +2322,8 @@ class ClaudeAgentSession implements AgentSession { const assistantishMessage = message.type === "assistant" || message.type === "stream_event" || - message.type === "tool_progress"; + message.type === "tool_progress" || + (message.type === "system" && message.subtype === "task_notification"); if (!routeToForeground && assistantishMessage) { this.startAutonomousTurn(); @@ -2883,6 +2911,27 @@ class ClaudeAgentSession implements AgentSession { } } + private startLiveHistoryPolling(): void { + if (this.liveHistoryPollTimer || !this.claudeSessionId) { + return; + } + this.liveHistoryPollTimer = setInterval(() => { + if (!this.claudeSessionId || this.closed) { + this.stopLiveHistoryPolling(); + return; + } + this.loadPersistedHistory(this.claudeSessionId, { dispatchLive: true }); + }, 200); + } + + private stopLiveHistoryPolling(): void { + if (!this.liveHistoryPollTimer) { + return; + } + clearInterval(this.liveHistoryPollTimer); + this.liveHistoryPollTimer = null; + } + private ingestPersistedHistoryChunk(chunk: string, options: { dispatchLive: boolean }): void { if (!chunk) { return; diff --git a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts index 76287d440..fa66be779 100644 --- a/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude-agent.voice-history-regression.test.ts @@ -61,11 +61,15 @@ function buildSdkQueryMock() { }), interrupt: vi.fn(async () => undefined), return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), setPermissionMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, }; } @@ -74,11 +78,15 @@ function buildIdleSdkQueryMock() { next: vi.fn(async () => ({ done: true, value: undefined })), interrupt: vi.fn(async () => undefined), return: vi.fn(async () => undefined), + close: vi.fn(() => undefined), setPermissionMode: vi.fn(async () => undefined), setModel: vi.fn(async () => undefined), supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]), supportedCommands: vi.fn(async () => []), rewindFiles: vi.fn(async () => ({ canRewind: true })), + [Symbol.asyncIterator]() { + return this; + }, }; } diff --git a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts index 45ee2d069..cbf65194d 100644 --- a/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts +++ b/packages/server/src/server/agent/providers/claude/task-notification-tool-call.test.ts @@ -47,6 +47,7 @@ describe("task-notification-tool-call", () => { it("maps system task notification to failed synthetic tool call", () => { const item = mapTaskNotificationSystemRecordToToolCall({ + type: "system", subtype: "task_notification", uuid: "task-note-system-1", task_id: "bg-fail-1", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 7a3ae71d7..3ec99eaca 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "vitest"; import { existsSync, rmSync } from "node:fs"; +import type { AgentLaunchContext } from "../agent-sdk-types.js"; import { __codexAppServerInternals, codexAppServerTurnInputFromPrompt, @@ -97,4 +98,25 @@ describe("Codex app-server provider", () => { expect(item.detail.newString).toBeUndefined(); } }); + + test("builds app-server env from launch-context env overrides", () => { + const launchContext: AgentLaunchContext = { + env: { + PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000301", + PASEO_TEST_FLAG: "codex-launch-value", + }, + }; + const env = __codexAppServerInternals.buildCodexAppServerEnv( + { + env: { + PASEO_AGENT_ID: "runtime-value", + PASEO_TEST_FLAG: "runtime-test-value", + }, + }, + launchContext.env, + ); + + expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); + expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); + }); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 71b75dc6a..706f5ef4f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,6 +1,7 @@ import type { AgentCapabilityFlags, AgentClient, + AgentLaunchContext, AgentMode, AgentModelDefinition, McpServerConfig, @@ -2032,7 +2033,22 @@ export async function codexAppServerTurnInputFromPrompt( return output; } +function buildCodexAppServerEnv( + runtimeSettings?: ProviderRuntimeSettings, + launchEnv?: Record, +): Record { + const env = applyProviderEnv(process.env, runtimeSettings); + if (!launchEnv) { + return env; + } + return { + ...env, + ...launchEnv, + }; +} + export const __codexAppServerInternals = { + buildCodexAppServerEnv, mapCodexPatchNotificationToToolCall, }; @@ -3380,7 +3396,7 @@ export class CodexAppServerAgentClient implements AgentClient { private readonly runtimeSettings?: ProviderRuntimeSettings, ) {} - private spawnAppServer(): ChildProcessWithoutNullStreams { + private spawnAppServer(launchEnv?: Record): ChildProcessWithoutNullStreams { const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings); this.logger.trace( { @@ -3391,14 +3407,17 @@ export class CodexAppServerAgentClient implements AgentClient { return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], { detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], - env: applyProviderEnv(process.env, this.runtimeSettings), + env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), }); } - async createSession(config: AgentSessionConfig): Promise { + async createSession( + config: AgentSessionConfig, + launchContext?: AgentLaunchContext, + ): Promise { const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER }; const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () => - this.spawnAppServer(), + this.spawnAppServer(launchContext?.env), ); await session.connect(); return session; @@ -3407,6 +3426,7 @@ export class CodexAppServerAgentClient implements AgentClient { async resumeSession( handle: { sessionId: string; metadata?: Record }, overrides?: Partial, + launchContext?: AgentLaunchContext, ): Promise { const storedConfig = (handle.metadata ?? {}) as AgentSessionConfig; const merged: AgentSessionConfig = { @@ -3416,7 +3436,7 @@ export class CodexAppServerAgentClient implements AgentClient { cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(), }; const session = new CodexAppServerAgentSession(merged, handle, this.logger, () => - this.spawnAppServer(), + this.spawnAppServer(launchContext?.env), ); await session.connect(); return session; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 13709ad73..68594a173 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import type { AgentCapabilityFlags, AgentClient, + AgentLaunchContext, AgentMetadata, AgentMode, AgentModelDefinition, @@ -409,7 +410,10 @@ export class OpenCodeAgentClient implements AgentClient { this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings); } - async createSession(config: AgentSessionConfig): Promise { + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { const openCodeConfig = this.assertConfig(config); const { url } = await this.serverManager.ensureRunning(); const client = createOpencodeClient({ @@ -442,6 +446,7 @@ export class OpenCodeAgentClient implements AgentClient { async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + _launchContext?: AgentLaunchContext, ): Promise { const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string); if (!cwd) { diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index dec5396ad..46338fc4e 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -6,6 +6,7 @@ import path from "node:path"; import type { AgentCapabilityFlags, AgentClient, + AgentLaunchContext, AgentMode, AgentModelDefinition, AgentPersistenceHandle, @@ -819,13 +820,17 @@ class FakeAgentClient implements AgentClient { readonly capabilities = TEST_CAPABILITIES; constructor(public readonly provider: string) {} - async createSession(config: AgentSessionConfig): Promise { + async createSession( + config: AgentSessionConfig, + _launchContext?: AgentLaunchContext, + ): Promise { return new FakeAgentSession(this.provider, { ...config }); } async resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, + _launchContext?: AgentLaunchContext, ): Promise { const cfg: AgentSessionConfig = { provider: this.provider, diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index af7fabd80..8ded45d91 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -596,12 +596,8 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == *"state=open"* ]]; then', - ' echo "[]"', - " exit 0", - "fi", - 'if [[ "$args" == *"state=closed"* ]]; then', - ' echo \'[{"html_url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","merged_at":"2026-02-18T00:00:00Z","base":{"ref":"main"},"head":{"ref":"feature"}}]\'', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/123","title":"Ship feature","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":"2026-02-18T00:00:00Z"}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -632,7 +628,7 @@ const x = 1; } }); - it("does not treat closed-unmerged PRs as shipped status", async () => { + it("returns closed-unmerged PR status without marking it as merged", async () => { execSync("git checkout -b feature", { cwd: repoDir }); execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir }); @@ -650,12 +646,8 @@ const x = 1; " exit 0", "fi", 'args="$*"', - 'if [[ "$args" == *"state=open"* ]]; then', - ' echo "[]"', - " exit 0", - "fi", - 'if [[ "$args" == *"state=closed"* ]]; then', - ' echo \'[{"html_url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","merged_at":null,"base":{"ref":"main"},"head":{"ref":"feature"}}]\'', + 'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then', + ' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null}\'', " exit 0", "fi", 'echo "unexpected gh args: $args" >&2', @@ -671,7 +663,12 @@ const x = 1; try { const status = await getPullRequestStatus(repoDir); expect(status.githubFeaturesEnabled).toBe(true); - expect(status.status).toBeNull(); + expect(status.status).not.toBeNull(); + expect(status.status?.url).toContain("/pull/999"); + expect(status.status?.baseRefName).toBe("main"); + expect(status.status?.headRefName).toBe("feature"); + expect(status.status?.isMerged).toBe(false); + expect(status.status?.state).toBe("closed"); } finally { if (originalPath === undefined) { delete process.env.PATH; diff --git a/tsconfig.json b/tsconfig.json index 0e6371f6f..ffcbb9477 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,3 @@ { - "compilerOptions": {}, - "extends": "expo/tsconfig.base" + "extends": "./tsconfig.base.json" }