mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
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
This commit is contained in:
@@ -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<AgentSessionConfig>): Promise<AgentSession> {
|
||||
async resumeSession(
|
||||
_handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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<AgentSession> {
|
||||
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<AgentSessionConfig> | undefined;
|
||||
lastResumeLaunchContext: AgentLaunchContext | undefined;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
@@ -251,8 +302,10 @@ describe("AgentManager", () => {
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastResumeOverrides = overrides;
|
||||
this.lastResumeLaunchContext = launchContext;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
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<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastCreateLaunchContext = launchContext;
|
||||
return new TestAgentSession(config);
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
this.lastResumeLaunchContext = launchContext;
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
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 () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentLaunchContext,
|
||||
AgentSlashCommand,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
@@ -641,10 +642,8 @@ export class AgentManager {
|
||||
): Promise<ManagedAgent> {
|
||||
// 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<string, string>; agentId?: string },
|
||||
): Promise<AgentSessionConfig> {
|
||||
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) {
|
||||
|
||||
@@ -382,6 +382,10 @@ export type AgentSessionConfig = {
|
||||
internal?: boolean;
|
||||
};
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
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<AgentSession>;
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession>;
|
||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
||||
|
||||
@@ -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<string, string | undefined> | 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<string, string | undefined> } }) => {
|
||||
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<string, string | undefined> | 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<string, string | undefined> } }) => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -8,6 +8,7 @@ type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
@@ -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" }]),
|
||||
|
||||
@@ -9,11 +9,13 @@ type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, 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<void>;
|
||||
return?: () => Promise<void>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>) => 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<AgentStreamEvent>;
|
||||
}
|
||||
).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<never>((_, 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<string, unknown> };
|
||||
};
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(1);
|
||||
expect(assembler.timelineAssembler.messages.size).toBe(0);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -18,11 +18,13 @@ type QueryMock = {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
interrupt: ReturnType<typeof vi.fn>;
|
||||
return: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||
setModel: ReturnType<typeof vi.fn>;
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, AgentDefinition> };
|
||||
runtimeSettings?: ProviderRuntimeSettings;
|
||||
handle?: AgentPersistenceHandle;
|
||||
launchEnv?: Record<string, string>;
|
||||
logger: Logger;
|
||||
queryFactory?: typeof query;
|
||||
};
|
||||
@@ -323,6 +325,7 @@ function resolveClaudeSpawnCommand(
|
||||
function applyRuntimeSettingsToClaudeOptions(
|
||||
options: ClaudeOptions,
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
launchEnv?: Record<string, string>,
|
||||
): 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<AgentSession> {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const metadata = coerceSessionMetadata(handle.metadata);
|
||||
const merged: Partial<AgentSessionConfig> = { ...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<string, string>;
|
||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly logger: Logger;
|
||||
@@ -1222,12 +1235,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private queryPumpPromise: Promise<void> | 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;
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, string>,
|
||||
): Record<string, string | undefined> {
|
||||
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<string, string>): 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<AgentSession> {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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<string, unknown> },
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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;
|
||||
|
||||
@@ -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<AgentSession> {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
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<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
||||
if (!cwd) {
|
||||
|
||||
@@ -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<AgentSession> {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
return new FakeAgentSession(this.provider, { ...config });
|
||||
}
|
||||
|
||||
async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
_launchContext?: AgentLaunchContext,
|
||||
): Promise<AgentSession> {
|
||||
const cfg: AgentSessionConfig = {
|
||||
provider: this.provider,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
{
|
||||
"compilerOptions": {},
|
||||
"extends": "expo/tsconfig.base"
|
||||
"extends": "./tsconfig.base.json"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user