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" }