mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +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 { AgentStorage } from "./agent-storage.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentRunResult,
|
AgentRunResult,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
@@ -95,7 +96,11 @@ class TestAgentClient implements AgentClient {
|
|||||||
return new TestAgentSession(config);
|
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({
|
return new TestAgentSession({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: config?.cwd ?? process.cwd(),
|
cwd: config?.cwd ?? process.cwd(),
|
||||||
@@ -210,6 +215,51 @@ describe("AgentManager", () => {
|
|||||||
expect(snapshot.model).toBeUndefined();
|
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 () => {
|
test("createAgent fails when cwd does not exist", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -230,7 +280,7 @@ describe("AgentManager", () => {
|
|||||||
).rejects.toThrow("Working directory does not exist");
|
).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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-resume-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -239,6 +289,7 @@ describe("AgentManager", () => {
|
|||||||
readonly provider = "codex" as const;
|
readonly provider = "codex" as const;
|
||||||
readonly capabilities = TEST_CAPABILITIES;
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
lastResumeOverrides: Partial<AgentSessionConfig> | undefined;
|
lastResumeOverrides: Partial<AgentSessionConfig> | undefined;
|
||||||
|
lastResumeLaunchContext: AgentLaunchContext | undefined;
|
||||||
|
|
||||||
async isAvailable(): Promise<boolean> {
|
async isAvailable(): Promise<boolean> {
|
||||||
return true;
|
return true;
|
||||||
@@ -251,8 +302,10 @@ describe("AgentManager", () => {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
this.lastResumeOverrides = overrides;
|
this.lastResumeOverrides = overrides;
|
||||||
|
this.lastResumeLaunchContext = launchContext;
|
||||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||||
const merged: AgentSessionConfig = {
|
const merged: AgentSessionConfig = {
|
||||||
...metadata,
|
...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 () => {
|
test("reloadAgentSession preserves timeline and does not force history replay", async () => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentSlashCommand,
|
AgentSlashCommand,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -641,10 +642,8 @@ export class AgentManager {
|
|||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
// Generate agent ID early so we can use it in MCP config
|
// Generate agent ID early so we can use it in MCP config
|
||||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
const normalizedConfig = await this.normalizeConfig(config, {
|
const normalizedConfig = await this.normalizeConfig(config);
|
||||||
labels: options?.labels,
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||||
agentId: resolvedAgentId,
|
|
||||||
});
|
|
||||||
const client = this.requireClient(normalizedConfig.provider);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
@@ -652,7 +651,7 @@ export class AgentManager {
|
|||||||
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
`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, {
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, {
|
||||||
labels: options?.labels,
|
labels: options?.labels,
|
||||||
});
|
});
|
||||||
@@ -686,8 +685,9 @@ export class AgentManager {
|
|||||||
normalizedConfig.model !== mergedConfig.model
|
normalizedConfig.model !== mergedConfig.model
|
||||||
? { ...overrides, model: normalizedConfig.model }
|
? { ...overrides, model: normalizedConfig.model }
|
||||||
: overrides;
|
: overrides;
|
||||||
|
const launchContext = this.buildLaunchContext(resolvedAgentId);
|
||||||
const client = this.requireClient(handle.provider);
|
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);
|
return this.registerSession(session, normalizedConfig, resolvedAgentId, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,10 +720,11 @@ export class AgentManager {
|
|||||||
provider,
|
provider,
|
||||||
} as AgentSessionConfig;
|
} as AgentSessionConfig;
|
||||||
const normalizedConfig = await this.normalizeConfig(refreshConfig);
|
const normalizedConfig = await this.normalizeConfig(refreshConfig);
|
||||||
|
const launchContext = this.buildLaunchContext(agentId);
|
||||||
|
|
||||||
const session = handle
|
const session = handle
|
||||||
? await client.resumeSession(handle, normalizedConfig)
|
? await client.resumeSession(handle, normalizedConfig, launchContext)
|
||||||
: await client.createSession(normalizedConfig);
|
: await client.createSession(normalizedConfig, launchContext);
|
||||||
|
|
||||||
// Remove the existing agent entry before swapping sessions
|
// Remove the existing agent entry before swapping sessions
|
||||||
this.agents.delete(agentId);
|
this.agents.delete(agentId);
|
||||||
@@ -2155,7 +2156,6 @@ export class AgentManager {
|
|||||||
|
|
||||||
private async normalizeConfig(
|
private async normalizeConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
_options?: { labels?: Record<string, string>; agentId?: string },
|
|
||||||
): Promise<AgentSessionConfig> {
|
): Promise<AgentSessionConfig> {
|
||||||
const normalized: AgentSessionConfig = { ...config };
|
const normalized: AgentSessionConfig = { ...config };
|
||||||
|
|
||||||
@@ -2191,6 +2191,14 @@ export class AgentManager {
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildLaunchContext(agentId: string): AgentLaunchContext {
|
||||||
|
return {
|
||||||
|
env: {
|
||||||
|
PASEO_AGENT_ID: agentId,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private requireClient(provider: AgentProvider): AgentClient {
|
private requireClient(provider: AgentProvider): AgentClient {
|
||||||
const client = this.clients.get(provider);
|
const client = this.clients.get(provider);
|
||||||
if (!client) {
|
if (!client) {
|
||||||
|
|||||||
@@ -382,6 +382,10 @@ export type AgentSessionConfig = {
|
|||||||
internal?: boolean;
|
internal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface AgentLaunchContext {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentSession {
|
export interface AgentSession {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
@@ -421,10 +425,14 @@ export interface ListModelsOptions {
|
|||||||
export interface AgentClient {
|
export interface AgentClient {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
createSession(config: AgentSessionConfig): Promise<AgentSession>;
|
createSession(
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession>;
|
||||||
resumeSession(
|
resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
listModels(options?: ListModelsOptions): Promise<AgentModelDefinition[]>;
|
||||||
listPersistedAgents?(options?: ListPersistedAgentsOptions): Promise<PersistedAgentDescriptor[]>;
|
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 logger = pino({ level: "silent" });
|
||||||
const client = new ClaudeAgentClient({ logger });
|
const client = new ClaudeAgentClient({ logger });
|
||||||
|
const hasClaudeCredentials =
|
||||||
|
!!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY;
|
||||||
|
|
||||||
function tmpCwd(prefix: string): string {
|
function tmpCwd(prefix: string): string {
|
||||||
return mkdtempSync(path.join(tmpdir(), prefix));
|
return mkdtempSync(path.join(tmpdir(), prefix));
|
||||||
@@ -142,11 +144,15 @@ async function cleanupSession(handle: { cwd: string; session: AgentSession }): P
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("ClaudeAgentSession integration", () => {
|
describe("ClaudeAgentSession integration", () => {
|
||||||
|
const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials;
|
||||||
|
|
||||||
beforeAll(() => {
|
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({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-basic-response-",
|
cwdPrefix: "claude-agent-basic-response-",
|
||||||
});
|
});
|
||||||
@@ -177,7 +183,7 @@ describe("ClaudeAgentSession integration", () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-basic-tool-",
|
cwdPrefix: "claude-agent-basic-tool-",
|
||||||
});
|
});
|
||||||
@@ -213,7 +219,9 @@ describe("ClaudeAgentSession integration", () => {
|
|||||||
}
|
}
|
||||||
}, 60_000);
|
}, 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({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-interrupt-continue-",
|
cwdPrefix: "claude-agent-interrupt-continue-",
|
||||||
});
|
});
|
||||||
@@ -267,9 +275,13 @@ describe("ClaudeAgentSession integration", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await cleanupSession(handle);
|
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({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-autonomous-",
|
cwdPrefix: "claude-agent-autonomous-",
|
||||||
});
|
});
|
||||||
@@ -309,9 +321,11 @@ describe("ClaudeAgentSession integration", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await cleanupSession(handle);
|
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({
|
const handle = await createSession({
|
||||||
cwdPrefix: "claude-agent-permission-",
|
cwdPrefix: "claude-agent-permission-",
|
||||||
modeId: "default",
|
modeId: "default",
|
||||||
|
|||||||
+2
@@ -8,6 +8,7 @@ type QueryMock = {
|
|||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
interrupt: ReturnType<typeof vi.fn>;
|
interrupt: ReturnType<typeof vi.fn>;
|
||||||
return: ReturnType<typeof vi.fn>;
|
return: ReturnType<typeof vi.fn>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||||
setModel: ReturnType<typeof vi.fn>;
|
setModel: ReturnType<typeof vi.fn>;
|
||||||
supportedModels: ReturnType<typeof vi.fn>;
|
supportedModels: ReturnType<typeof vi.fn>;
|
||||||
@@ -137,6 +138,7 @@ function createScriptedQuery(params: {
|
|||||||
return: vi.fn(async () => {
|
return: vi.fn(async () => {
|
||||||
output.end();
|
output.end();
|
||||||
}),
|
}),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
setPermissionMode: vi.fn(async () => undefined),
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ type QueryMock = {
|
|||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
interrupt: ReturnType<typeof vi.fn>;
|
interrupt: ReturnType<typeof vi.fn>;
|
||||||
return: ReturnType<typeof vi.fn>;
|
return: ReturnType<typeof vi.fn>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||||
setModel: ReturnType<typeof vi.fn>;
|
setModel: ReturnType<typeof vi.fn>;
|
||||||
supportedModels: ReturnType<typeof vi.fn>;
|
supportedModels: ReturnType<typeof vi.fn>;
|
||||||
supportedCommands: ReturnType<typeof vi.fn>;
|
supportedCommands: ReturnType<typeof vi.fn>;
|
||||||
rewindFiles: ReturnType<typeof vi.fn>;
|
rewindFiles: ReturnType<typeof vi.fn>;
|
||||||
|
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildUsage() {
|
function buildUsage() {
|
||||||
@@ -46,11 +48,15 @@ function createBaseQueryMock(nextImpl: QueryMock["next"]): QueryMock {
|
|||||||
next: nextImpl,
|
next: nextImpl,
|
||||||
interrupt: vi.fn(async () => undefined),
|
interrupt: vi.fn(async () => undefined),
|
||||||
return: vi.fn(async () => undefined),
|
return: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
setPermissionMode: vi.fn(async () => undefined),
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
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 spy = createSpyLogger();
|
||||||
const session = await createSessionWithLogger(spy.logger);
|
const session = await createSessionWithLogger(spy.logger);
|
||||||
const internal = session as unknown as {
|
const internal = session as unknown as {
|
||||||
query: {
|
query: {
|
||||||
interrupt: () => Promise<void>;
|
interrupt: () => Promise<void>;
|
||||||
return?: () => Promise<void>;
|
return?: () => Promise<void>;
|
||||||
|
close?: () => void;
|
||||||
} | null;
|
} | null;
|
||||||
input: { end: () => void } | null;
|
input: { end: () => void } | null;
|
||||||
queryRestartNeeded: boolean;
|
queryRestartNeeded: boolean;
|
||||||
@@ -281,6 +288,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
internal.query = {
|
internal.query = {
|
||||||
interrupt,
|
interrupt,
|
||||||
return: queryReturn,
|
return: queryReturn,
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
};
|
};
|
||||||
internal.input = { end };
|
internal.input = { end };
|
||||||
internal.queryRestartNeeded = false;
|
internal.queryRestartNeeded = false;
|
||||||
@@ -296,15 +304,12 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(interruptInfoMessages).toEqual([]);
|
expect(interruptInfoMessages).toEqual([]);
|
||||||
expect(interruptDebugMessages).toEqual([
|
expect(interruptDebugMessages).toEqual([]);
|
||||||
"interruptActiveTurn: calling query.interrupt()...",
|
|
||||||
"interruptActiveTurn: calling query.return()...",
|
|
||||||
]);
|
|
||||||
expect(interrupt).toHaveBeenCalledTimes(1);
|
expect(interrupt).toHaveBeenCalledTimes(1);
|
||||||
expect(queryReturn).toHaveBeenCalledTimes(1);
|
expect(queryReturn).not.toHaveBeenCalled();
|
||||||
expect(end).toHaveBeenCalledTimes(1);
|
expect(end).not.toHaveBeenCalled();
|
||||||
expect(internal.query).toBeNull();
|
expect(internal.query).not.toBeNull();
|
||||||
expect(internal.input).toBeNull();
|
expect(internal.input).not.toBeNull();
|
||||||
expect(internal.queryRestartNeeded).toBe(false);
|
expect(internal.queryRestartNeeded).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
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 () => {
|
test("completes a foreground run when only system metadata arrives before the first assistant message", async () => {
|
||||||
let step = 0;
|
let step = 0;
|
||||||
sdkQueryFactory.mockImplementation(() =>
|
sdkQueryFactory.mockImplementation(() =>
|
||||||
@@ -874,11 +721,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
const session = await createSession();
|
const session = await createSession();
|
||||||
const internal = session as unknown as {
|
const internal = session as unknown as {
|
||||||
turnState: "idle" | "foreground" | "autonomous";
|
turnState: "idle" | "foreground" | "autonomous";
|
||||||
nextRunOrdinal: number;
|
nextTurnOrdinal: number;
|
||||||
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
|
routeSdkMessageFromPump: (message: Record<string, unknown>) => void;
|
||||||
runTracker: {
|
autonomousTurn: { id: string } | null;
|
||||||
listActiveRuns: (owner?: "foreground" | "autonomous") => Array<{ id: string }>;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
internal.turnState = "idle";
|
internal.turnState = "idle";
|
||||||
@@ -890,9 +735,9 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const firstRun = internal.runTracker.listActiveRuns("autonomous");
|
const firstRunId = internal.autonomousTurn?.id ?? null;
|
||||||
expect(firstRun).toHaveLength(1);
|
expect(firstRunId).toBe("autonomous-turn-1");
|
||||||
expect(internal.nextRunOrdinal).toBe(2);
|
expect(internal.nextTurnOrdinal).toBe(2);
|
||||||
|
|
||||||
internal.routeSdkMessageFromPump({
|
internal.routeSdkMessageFromPump({
|
||||||
type: "stream_event",
|
type: "stream_event",
|
||||||
@@ -901,10 +746,8 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
delta: { type: "text_delta", text: "WAKE" },
|
delta: { type: "text_delta", text: "WAKE" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const secondRun = internal.runTracker.listActiveRuns("autonomous");
|
expect(internal.autonomousTurn?.id).toBe(firstRunId);
|
||||||
expect(secondRun).toHaveLength(1);
|
expect(internal.nextTurnOrdinal).toBe(2);
|
||||||
expect(secondRun[0]?.id).toBe(firstRun[0]?.id);
|
|
||||||
expect(internal.nextRunOrdinal).toBe(2);
|
|
||||||
|
|
||||||
internal.routeSdkMessageFromPump({
|
internal.routeSdkMessageFromPump({
|
||||||
type: "result",
|
type: "result",
|
||||||
@@ -912,73 +755,11 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
usage: buildUsage(),
|
usage: buildUsage(),
|
||||||
total_cost_usd: 0,
|
total_cost_usd: 0,
|
||||||
});
|
});
|
||||||
expect(internal.runTracker.listActiveRuns("autonomous")).toHaveLength(0);
|
expect(internal.autonomousTurn).toBeNull();
|
||||||
|
|
||||||
await session.close();
|
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 () => {
|
test("tracks run lifecycle transitions for success, error, and interrupt", async () => {
|
||||||
const session = await createSession();
|
const session = await createSession();
|
||||||
let streamCase: "success" | "error" | "interrupt" = "success";
|
let streamCase: "success" | "error" | "interrupt" = "success";
|
||||||
@@ -1337,7 +1118,7 @@ describe("ClaudeAgentSession redesign invariants", () => {
|
|||||||
const assembler = session as unknown as {
|
const assembler = session as unknown as {
|
||||||
timelineAssembler: { messages: Map<string, unknown> };
|
timelineAssembler: { messages: Map<string, unknown> };
|
||||||
};
|
};
|
||||||
expect(assembler.timelineAssembler.messages.size).toBe(1);
|
expect(assembler.timelineAssembler.messages.size).toBe(0);
|
||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ type QueryMock = {
|
|||||||
next: ReturnType<typeof vi.fn>;
|
next: ReturnType<typeof vi.fn>;
|
||||||
interrupt: ReturnType<typeof vi.fn>;
|
interrupt: ReturnType<typeof vi.fn>;
|
||||||
return: ReturnType<typeof vi.fn>;
|
return: ReturnType<typeof vi.fn>;
|
||||||
|
close: ReturnType<typeof vi.fn>;
|
||||||
setPermissionMode: ReturnType<typeof vi.fn>;
|
setPermissionMode: ReturnType<typeof vi.fn>;
|
||||||
setModel: ReturnType<typeof vi.fn>;
|
setModel: ReturnType<typeof vi.fn>;
|
||||||
supportedModels: ReturnType<typeof vi.fn>;
|
supportedModels: ReturnType<typeof vi.fn>;
|
||||||
supportedCommands: ReturnType<typeof vi.fn>;
|
supportedCommands: ReturnType<typeof vi.fn>;
|
||||||
rewindFiles: ReturnType<typeof vi.fn>;
|
rewindFiles: ReturnType<typeof vi.fn>;
|
||||||
|
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildQueryMock(events: unknown[]): QueryMock {
|
function buildQueryMock(events: unknown[]): QueryMock {
|
||||||
@@ -38,11 +40,15 @@ function buildQueryMock(events: unknown[]): QueryMock {
|
|||||||
}),
|
}),
|
||||||
interrupt: vi.fn(async () => undefined),
|
interrupt: vi.fn(async () => undefined),
|
||||||
return: vi.fn(async () => undefined),
|
return: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
setPermissionMode: vi.fn(async () => undefined),
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { ClaudeSidechainTracker } from "./claude/sidechain-tracker.js";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentMetadata,
|
AgentMetadata,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -291,6 +292,7 @@ type ClaudeAgentSessionOptions = {
|
|||||||
defaults?: { agents?: Record<string, AgentDefinition> };
|
defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
runtimeSettings?: ProviderRuntimeSettings;
|
runtimeSettings?: ProviderRuntimeSettings;
|
||||||
handle?: AgentPersistenceHandle;
|
handle?: AgentPersistenceHandle;
|
||||||
|
launchEnv?: Record<string, string>;
|
||||||
logger: Logger;
|
logger: Logger;
|
||||||
queryFactory?: typeof query;
|
queryFactory?: typeof query;
|
||||||
};
|
};
|
||||||
@@ -323,6 +325,7 @@ function resolveClaudeSpawnCommand(
|
|||||||
function applyRuntimeSettingsToClaudeOptions(
|
function applyRuntimeSettingsToClaudeOptions(
|
||||||
options: ClaudeOptions,
|
options: ClaudeOptions,
|
||||||
runtimeSettings?: ProviderRuntimeSettings,
|
runtimeSettings?: ProviderRuntimeSettings,
|
||||||
|
launchEnv?: Record<string, string>,
|
||||||
): ClaudeOptions {
|
): ClaudeOptions {
|
||||||
return {
|
return {
|
||||||
...options,
|
...options,
|
||||||
@@ -335,7 +338,10 @@ function applyRuntimeSettingsToClaudeOptions(
|
|||||||
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
|
resolved.command === spawnOptions.command ? process.execPath : resolved.command;
|
||||||
return spawn(command, resolved.args, {
|
return spawn(command, resolved.args, {
|
||||||
cwd: spawnOptions.cwd,
|
cwd: spawnOptions.cwd,
|
||||||
env: applyProviderEnv(spawnOptions.env, runtimeSettings),
|
env: {
|
||||||
|
...applyProviderEnv(spawnOptions.env, runtimeSettings),
|
||||||
|
...(launchEnv ?? {}),
|
||||||
|
},
|
||||||
signal: spawnOptions.signal,
|
signal: spawnOptions.signal,
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
stdio: ["pipe", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
@@ -1103,11 +1109,15 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
this.queryFactory = options.queryFactory ?? query;
|
this.queryFactory = options.queryFactory ?? query;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
async createSession(
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
|
): Promise<AgentSession> {
|
||||||
const claudeConfig = this.assertConfig(config);
|
const claudeConfig = this.assertConfig(config);
|
||||||
return new ClaudeAgentSession(claudeConfig, {
|
return new ClaudeAgentSession(claudeConfig, {
|
||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
|
launchEnv: launchContext?.env,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
queryFactory: this.queryFactory,
|
queryFactory: this.queryFactory,
|
||||||
});
|
});
|
||||||
@@ -1116,6 +1126,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const metadata = coerceSessionMetadata(handle.metadata);
|
const metadata = coerceSessionMetadata(handle.metadata);
|
||||||
const merged: Partial<AgentSessionConfig> = { ...metadata, ...overrides };
|
const merged: Partial<AgentSessionConfig> = { ...metadata, ...overrides };
|
||||||
@@ -1128,6 +1139,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
defaults: this.defaults,
|
defaults: this.defaults,
|
||||||
runtimeSettings: this.runtimeSettings,
|
runtimeSettings: this.runtimeSettings,
|
||||||
handle,
|
handle,
|
||||||
|
launchEnv: launchContext?.env,
|
||||||
logger: this.logger,
|
logger: this.logger,
|
||||||
queryFactory: this.queryFactory,
|
queryFactory: this.queryFactory,
|
||||||
});
|
});
|
||||||
@@ -1174,7 +1186,7 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
if (config.provider !== "claude") {
|
if (config.provider !== "claude") {
|
||||||
throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`);
|
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;
|
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||||
|
|
||||||
private readonly config: ClaudeAgentConfig;
|
private readonly config: ClaudeAgentConfig;
|
||||||
|
private readonly launchEnv?: Record<string, string>;
|
||||||
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
private readonly defaults?: { agents?: Record<string, AgentDefinition> };
|
||||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||||
private readonly logger: Logger;
|
private readonly logger: Logger;
|
||||||
@@ -1222,12 +1235,15 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private queryPumpPromise: Promise<void> | null = null;
|
private queryPumpPromise: Promise<void> | null = null;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
private pendingInterruptAbort = false;
|
private pendingInterruptAbort = false;
|
||||||
|
private liveEventSubscriberCount = 0;
|
||||||
|
private liveHistoryPollTimer: NodeJS.Timeout | null = null;
|
||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
private recentStderr = "";
|
private recentStderr = "";
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
|
||||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
|
this.launchEnv = options.launchEnv;
|
||||||
this.defaults = options.defaults;
|
this.defaults = options.defaults;
|
||||||
this.runtimeSettings = options.runtimeSettings;
|
this.runtimeSettings = options.runtimeSettings;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
@@ -1452,9 +1468,18 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.claudeSessionId) {
|
if (this.claudeSessionId) {
|
||||||
this.startQueryPump();
|
this.startQueryPump();
|
||||||
}
|
}
|
||||||
|
this.liveEventSubscriberCount += 1;
|
||||||
|
this.startLiveHistoryPolling();
|
||||||
|
|
||||||
for await (const event of this.liveEventQueue) {
|
try {
|
||||||
yield event;
|
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",
|
provider: "claude",
|
||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
nativeHandle: this.claudeSessionId,
|
nativeHandle: this.claudeSessionId,
|
||||||
metadata: this.config,
|
metadata: { ...this.config },
|
||||||
};
|
};
|
||||||
return this.persistence;
|
return this.persistence;
|
||||||
}
|
}
|
||||||
@@ -1610,8 +1635,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.liveEventQueue.end();
|
this.liveEventQueue.end();
|
||||||
this.activeTurnPromise = null;
|
this.activeTurnPromise = null;
|
||||||
this.sidechainTracker.clear();
|
this.sidechainTracker.clear();
|
||||||
|
this.stopLiveHistoryPolling();
|
||||||
this.input?.end();
|
this.input?.end();
|
||||||
this.query?.close();
|
this.query?.close?.();
|
||||||
await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt");
|
await this.awaitWithTimeout(this.query?.interrupt?.(), "close query interrupt");
|
||||||
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
|
await this.awaitWithTimeout(this.query?.return?.(), "close query return");
|
||||||
this.query = null;
|
this.query = null;
|
||||||
@@ -1878,7 +1904,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
|
|
||||||
if (this.queryRestartNeeded && this.query) {
|
if (this.queryRestartNeeded && this.query) {
|
||||||
this.input?.end();
|
this.input?.end();
|
||||||
this.query.close();
|
this.query.close?.();
|
||||||
try {
|
try {
|
||||||
await this.query.return?.();
|
await this.query.return?.();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1970,6 +1996,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// Increase MCP timeouts for long-running tool calls (10 minutes)
|
// Increase MCP timeouts for long-running tool calls (10 minutes)
|
||||||
MCP_TIMEOUT: "600000",
|
MCP_TIMEOUT: "600000",
|
||||||
MCP_TOOL_TIMEOUT: "600000",
|
MCP_TOOL_TIMEOUT: "600000",
|
||||||
|
...(this.launchEnv ?? {}),
|
||||||
},
|
},
|
||||||
// Required for provider-level /rewind support.
|
// Required for provider-level /rewind support.
|
||||||
enableFileCheckpointing: true,
|
enableFileCheckpointing: true,
|
||||||
@@ -1995,7 +2022,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
|
private applyRuntimeSettings(options: ClaudeOptions): ClaudeOptions {
|
||||||
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings);
|
return applyRuntimeSettingsToClaudeOptions(options, this.runtimeSettings, this.launchEnv);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMcpServers(
|
private normalizeMcpServers(
|
||||||
@@ -2295,7 +2322,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const assistantishMessage =
|
const assistantishMessage =
|
||||||
message.type === "assistant" ||
|
message.type === "assistant" ||
|
||||||
message.type === "stream_event" ||
|
message.type === "stream_event" ||
|
||||||
message.type === "tool_progress";
|
message.type === "tool_progress" ||
|
||||||
|
(message.type === "system" && message.subtype === "task_notification");
|
||||||
|
|
||||||
if (!routeToForeground && assistantishMessage) {
|
if (!routeToForeground && assistantishMessage) {
|
||||||
this.startAutonomousTurn();
|
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 {
|
private ingestPersistedHistoryChunk(chunk: string, options: { dispatchLive: boolean }): void {
|
||||||
if (!chunk) {
|
if (!chunk) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
+8
@@ -61,11 +61,15 @@ function buildSdkQueryMock() {
|
|||||||
}),
|
}),
|
||||||
interrupt: vi.fn(async () => undefined),
|
interrupt: vi.fn(async () => undefined),
|
||||||
return: vi.fn(async () => undefined),
|
return: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
setPermissionMode: vi.fn(async () => undefined),
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||||
|
[Symbol.asyncIterator]() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,11 +78,15 @@ function buildIdleSdkQueryMock() {
|
|||||||
next: vi.fn(async () => ({ done: true, value: undefined })),
|
next: vi.fn(async () => ({ done: true, value: undefined })),
|
||||||
interrupt: vi.fn(async () => undefined),
|
interrupt: vi.fn(async () => undefined),
|
||||||
return: vi.fn(async () => undefined),
|
return: vi.fn(async () => undefined),
|
||||||
|
close: vi.fn(() => undefined),
|
||||||
setPermissionMode: vi.fn(async () => undefined),
|
setPermissionMode: vi.fn(async () => undefined),
|
||||||
setModel: vi.fn(async () => undefined),
|
setModel: vi.fn(async () => undefined),
|
||||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||||
supportedCommands: vi.fn(async () => []),
|
supportedCommands: vi.fn(async () => []),
|
||||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
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", () => {
|
it("maps system task notification to failed synthetic tool call", () => {
|
||||||
const item = mapTaskNotificationSystemRecordToToolCall({
|
const item = mapTaskNotificationSystemRecordToToolCall({
|
||||||
|
type: "system",
|
||||||
subtype: "task_notification",
|
subtype: "task_notification",
|
||||||
uuid: "task-note-system-1",
|
uuid: "task-note-system-1",
|
||||||
task_id: "bg-fail-1",
|
task_id: "bg-fail-1",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { existsSync, rmSync } from "node:fs";
|
import { existsSync, rmSync } from "node:fs";
|
||||||
|
|
||||||
|
import type { AgentLaunchContext } from "../agent-sdk-types.js";
|
||||||
import {
|
import {
|
||||||
__codexAppServerInternals,
|
__codexAppServerInternals,
|
||||||
codexAppServerTurnInputFromPrompt,
|
codexAppServerTurnInputFromPrompt,
|
||||||
@@ -97,4 +98,25 @@ describe("Codex app-server provider", () => {
|
|||||||
expect(item.detail.newString).toBeUndefined();
|
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 {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
@@ -2032,7 +2033,22 @@ export async function codexAppServerTurnInputFromPrompt(
|
|||||||
return output;
|
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 = {
|
export const __codexAppServerInternals = {
|
||||||
|
buildCodexAppServerEnv,
|
||||||
mapCodexPatchNotificationToToolCall,
|
mapCodexPatchNotificationToToolCall,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3380,7 +3396,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
private readonly runtimeSettings?: ProviderRuntimeSettings,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private spawnAppServer(): ChildProcessWithoutNullStreams {
|
private spawnAppServer(launchEnv?: Record<string, string>): ChildProcessWithoutNullStreams {
|
||||||
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
const launchPrefix = resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
@@ -3391,14 +3407,17 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], {
|
||||||
detached: process.platform !== "win32",
|
detached: process.platform !== "win32",
|
||||||
stdio: ["pipe", "pipe", "pipe"],
|
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 sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||||
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () =>
|
const session = new CodexAppServerAgentSession(sessionConfig, null, this.logger, () =>
|
||||||
this.spawnAppServer(),
|
this.spawnAppServer(launchContext?.env),
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
@@ -3407,6 +3426,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: { sessionId: string; metadata?: Record<string, unknown> },
|
handle: { sessionId: string; metadata?: Record<string, unknown> },
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const storedConfig = (handle.metadata ?? {}) as AgentSessionConfig;
|
const storedConfig = (handle.metadata ?? {}) as AgentSessionConfig;
|
||||||
const merged: AgentSessionConfig = {
|
const merged: AgentSessionConfig = {
|
||||||
@@ -3416,7 +3436,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
|||||||
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
cwd: overrides?.cwd ?? storedConfig.cwd ?? process.cwd(),
|
||||||
};
|
};
|
||||||
const session = new CodexAppServerAgentSession(merged, handle, this.logger, () =>
|
const session = new CodexAppServerAgentSession(merged, handle, this.logger, () =>
|
||||||
this.spawnAppServer(),
|
this.spawnAppServer(launchContext?.env),
|
||||||
);
|
);
|
||||||
await session.connect();
|
await session.connect();
|
||||||
return session;
|
return session;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { z } from "zod";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentMetadata,
|
AgentMetadata,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -409,7 +410,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
|
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 openCodeConfig = this.assertConfig(config);
|
||||||
const { url } = await this.serverManager.ensureRunning();
|
const { url } = await this.serverManager.ensureRunning();
|
||||||
const client = createOpencodeClient({
|
const client = createOpencodeClient({
|
||||||
@@ -442,6 +446,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
_launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
const cwd = overrides?.cwd ?? (handle.metadata?.cwd as string);
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
@@ -819,13 +820,17 @@ class FakeAgentClient implements AgentClient {
|
|||||||
readonly capabilities = TEST_CAPABILITIES;
|
readonly capabilities = TEST_CAPABILITIES;
|
||||||
constructor(public readonly provider: string) {}
|
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 });
|
return new FakeAgentSession(this.provider, { ...config });
|
||||||
}
|
}
|
||||||
|
|
||||||
async resumeSession(
|
async resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
|
_launchContext?: AgentLaunchContext,
|
||||||
): Promise<AgentSession> {
|
): Promise<AgentSession> {
|
||||||
const cfg: AgentSessionConfig = {
|
const cfg: AgentSessionConfig = {
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
|
|||||||
@@ -596,12 +596,8 @@ const x = 1;
|
|||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
'args="$*"',
|
'args="$*"',
|
||||||
'if [[ "$args" == *"state=open"* ]]; then',
|
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
|
||||||
' echo "[]"',
|
' 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",
|
|
||||||
'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"}}]\'',
|
|
||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
'echo "unexpected gh args: $args" >&2',
|
'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 checkout -b feature", { cwd: repoDir });
|
||||||
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
execSync("git remote add origin https://github.com/getpaseo/paseo.git", { cwd: repoDir });
|
||||||
|
|
||||||
@@ -650,12 +646,8 @@ const x = 1;
|
|||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
'args="$*"',
|
'args="$*"',
|
||||||
'if [[ "$args" == *"state=open"* ]]; then',
|
'if [[ "$args" == "pr view --json url,title,state,baseRefName,headRefName,mergedAt" ]]; then',
|
||||||
' echo "[]"',
|
' echo \'{"url":"https://github.com/getpaseo/paseo/pull/999","title":"Closed without merge","state":"closed","baseRefName":"main","headRefName":"feature","mergedAt":null}\'',
|
||||||
" 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"}}]\'',
|
|
||||||
" exit 0",
|
" exit 0",
|
||||||
"fi",
|
"fi",
|
||||||
'echo "unexpected gh args: $args" >&2',
|
'echo "unexpected gh args: $args" >&2',
|
||||||
@@ -671,7 +663,12 @@ const x = 1;
|
|||||||
try {
|
try {
|
||||||
const status = await getPullRequestStatus(repoDir);
|
const status = await getPullRequestStatus(repoDir);
|
||||||
expect(status.githubFeaturesEnabled).toBe(true);
|
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 {
|
} finally {
|
||||||
if (originalPath === undefined) {
|
if (originalPath === undefined) {
|
||||||
delete process.env.PATH;
|
delete process.env.PATH;
|
||||||
|
|||||||
+1
-2
@@ -1,4 +1,3 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {},
|
"extends": "./tsconfig.base.json"
|
||||||
"extends": "expo/tsconfig.base"
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user