mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94a5731b | ||
|
|
f13bb2136c | ||
|
|
2323ee6844 | ||
|
|
1e00f452ea | ||
|
|
a162e1075f | ||
|
|
8f1091da64 | ||
|
|
4dd0e50e78 |
@@ -628,6 +628,11 @@ export interface AgentCreateSessionOptions {
|
||||
* Defaults to true. Providers that cannot honor false should no-op.
|
||||
*/
|
||||
persistSession?: boolean;
|
||||
/**
|
||||
* Model ids added by provider configuration (`models` / `additionalModels`).
|
||||
* Providers that validate against runtime-advertised models must accept these.
|
||||
*/
|
||||
configuredModelIds?: readonly string[];
|
||||
}
|
||||
|
||||
/** What a resumed session is for: driving the agent, or reading what it already did. */
|
||||
@@ -637,6 +642,8 @@ export type AgentResumePurpose = "interactive" | "history";
|
||||
export interface AgentResumeSessionOptions {
|
||||
/** Defaults to interactive. History loading may be read-only for archived native sessions. */
|
||||
purpose?: AgentResumePurpose;
|
||||
/** See AgentCreateSessionOptions.configuredModelIds. */
|
||||
configuredModelIds?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -492,6 +492,7 @@ function wrapClientProvider(
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -509,7 +510,7 @@ function wrapClientProvider(
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
options,
|
||||
{ ...options, configuredModelIds },
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext, options) =>
|
||||
@@ -526,7 +527,7 @@ function wrapClientProvider(
|
||||
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
options,
|
||||
{ ...options, configuredModelIds },
|
||||
),
|
||||
),
|
||||
fetchCatalog: async (options, context) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
ClientSideConnection,
|
||||
@@ -54,8 +54,10 @@ import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
ProviderRefreshContext,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { buildStringCommandShellInvocation } from "../../../utils/string-command-shell.js";
|
||||
import { asInternals } from "../../test-utils/class-mocks.js";
|
||||
@@ -4370,3 +4372,151 @@ describe("ACPAgentSession close() with an unresponsive provider", () => {
|
||||
expect(isProcessAlive(pid)).toBe(false);
|
||||
}, 15_000);
|
||||
});
|
||||
|
||||
// An ACP agent that advertises two models (or none, with ACP_ADVERTISE_MODELS=none) but,
|
||||
// like Cline, runs any model id it is given.
|
||||
const PERMISSIVE_MODEL_ACP_AGENT = `
|
||||
import { Readable, Writable } from "node:stream";
|
||||
const { AgentSideConnection, PROTOCOL_VERSION, ndJsonStream } = await import(process.env.ACP_SDK_URL);
|
||||
let currentModelId = "advertised-default";
|
||||
const modelState = () =>
|
||||
process.env.ACP_ADVERTISE_MODELS === "none"
|
||||
? undefined
|
||||
: {
|
||||
currentModelId,
|
||||
availableModels: [
|
||||
{ modelId: "advertised-default", name: "Advertised default" },
|
||||
{ modelId: "advertised-other", name: "Advertised other" },
|
||||
],
|
||||
};
|
||||
new AgentSideConnection(
|
||||
() => ({
|
||||
async initialize() {
|
||||
return { protocolVersion: PROTOCOL_VERSION, agentCapabilities: { loadSession: true } };
|
||||
},
|
||||
async newSession() {
|
||||
return { sessionId: "permissive-session", models: modelState() };
|
||||
},
|
||||
async loadSession() {
|
||||
return { models: modelState() };
|
||||
},
|
||||
async unstable_setSessionModel({ modelId }) {
|
||||
currentModelId = modelId;
|
||||
return {};
|
||||
},
|
||||
async authenticate() {},
|
||||
async cancel() {},
|
||||
async prompt() {
|
||||
return { stopReason: "end_turn" };
|
||||
},
|
||||
}),
|
||||
ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin)),
|
||||
);
|
||||
`;
|
||||
|
||||
describe("custom ACP provider with configured models", () => {
|
||||
let dir: string;
|
||||
let agentScript: string;
|
||||
let session: AgentSession | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(path.join(tmpdir(), "paseo-acp-configured-models-"));
|
||||
agentScript = path.join(dir, "agent.mjs");
|
||||
await writeFile(agentScript, PERMISSIVE_MODEL_ACP_AGENT);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await session?.close();
|
||||
session = null;
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildRegistry(
|
||||
override: {
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
},
|
||||
agentEnv: Record<string, string> = {},
|
||||
) {
|
||||
return buildProviderRegistry(createTestLogger(), {
|
||||
providerOverrides: {
|
||||
permissive: {
|
||||
extends: "acp",
|
||||
label: "Permissive",
|
||||
command: [process.execPath, agentScript],
|
||||
env: {
|
||||
ACP_SDK_URL: pathToFileURL(
|
||||
createRequire(import.meta.url).resolve("@agentclientprotocol/sdk"),
|
||||
).href,
|
||||
...agentEnv,
|
||||
},
|
||||
...override,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function createSessionOnModel(
|
||||
registry: ReturnType<typeof buildRegistry>,
|
||||
model: string,
|
||||
): Promise<AgentSession> {
|
||||
const client = registry.permissive.createClient(createTestLogger());
|
||||
session = await client.createSession({ provider: "permissive", cwd: dir, model });
|
||||
return session;
|
||||
}
|
||||
|
||||
test("runs on a model added through additionalModels that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("runs on a model from a replacement models list that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
models: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("runs on a model from additionalModels when the agent advertises no models", async () => {
|
||||
const registry = buildRegistry(
|
||||
{ additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }] },
|
||||
{ ACP_ADVERTISE_MODELS: "none" },
|
||||
);
|
||||
|
||||
const created = await createSessionOnModel(registry, "stealth/pixel-canary");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("resumes on a model added through additionalModels that the agent does not advertise", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
const client = registry.permissive.createClient(createTestLogger());
|
||||
|
||||
session = await client.resumeSession(
|
||||
{ provider: "permissive", sessionId: "permissive-session", metadata: { cwd: dir } },
|
||||
{ model: "stealth/pixel-canary" },
|
||||
);
|
||||
|
||||
expect((await session.getRuntimeInfo()).model).toBe("stealth/pixel-canary");
|
||||
});
|
||||
|
||||
test("keeps the agent's model when the requested model is neither advertised nor configured", async () => {
|
||||
const registry = buildRegistry({
|
||||
additionalModels: [{ id: "stealth/pixel-canary", label: "Pixel Canary" }],
|
||||
});
|
||||
|
||||
const created = await createSessionOnModel(registry, "stale/removed-model");
|
||||
|
||||
expect((await created.getRuntimeInfo()).model).toBe("advertised-default");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
type AgentPromptContentBlock,
|
||||
type AgentPromptInput,
|
||||
type AgentResumePurpose,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentRunOptions,
|
||||
type AgentRunResult,
|
||||
@@ -512,6 +513,7 @@ interface ACPAgentSessionOptions {
|
||||
extensionCommandsParser?: ACPExtensionCommandsParser;
|
||||
handle?: AgentPersistenceHandle;
|
||||
resumePurpose?: AgentResumePurpose;
|
||||
configuredModelIds?: readonly string[];
|
||||
agentId?: string;
|
||||
launchEnv?: Record<string, string>;
|
||||
waitForInitialCommands?: boolean;
|
||||
@@ -979,6 +981,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
this.assertProvider(config);
|
||||
const providerOptions = ACPProviderOptionsSchema.parse(config.providerOptions ?? {});
|
||||
@@ -1006,6 +1009,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
supportsMcpServers:
|
||||
providerOptions.supportsMcpServers ?? this.capabilities.supportsMcpServers,
|
||||
},
|
||||
configuredModelIds: options?.configuredModelIds,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1064,6 +1068,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
},
|
||||
handle,
|
||||
resumePurpose: options?.purpose,
|
||||
configuredModelIds: options?.configuredModelIds,
|
||||
agentId: launchContext?.agentId,
|
||||
launchEnv: launchContext?.env,
|
||||
extensionCommandsParser: this.extensionCommandsParser,
|
||||
@@ -1692,6 +1697,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
configOptions: SessionConfigOption[],
|
||||
) => SessionConfigOption[];
|
||||
private readonly configFeatureOptions: ACPConfigFeatureOption[];
|
||||
private readonly configuredModelIds: ReadonlySet<string>;
|
||||
private readonly clientCapabilities?: ACPClientCapabilities;
|
||||
private readonly clientCapabilityMeta?: ACPClientCapabilityMeta;
|
||||
private readonly modeIdTransformer?: (modeId: string) => string | null;
|
||||
@@ -1760,6 +1766,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.sessionResponseTransformer = options.sessionResponseTransformer;
|
||||
this.configOptionsTransformer = options.configOptionsTransformer;
|
||||
this.configFeatureOptions = options.configFeatureOptions ?? [];
|
||||
this.configuredModelIds = new Set(options.configuredModelIds);
|
||||
this.clientCapabilities = options.clientCapabilities;
|
||||
this.clientCapabilityMeta = options.clientCapabilityMeta;
|
||||
this.modeIdTransformer = options.modeIdTransformer;
|
||||
@@ -2194,12 +2201,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setModelWithSelection({ modelId, selection: this.resolveModelSelection(modelId) });
|
||||
}
|
||||
|
||||
private resolveModelSelection(modelId: string): ACPModelSelection {
|
||||
const selection = resolveACPModelSelection({
|
||||
modelId,
|
||||
availableModels: this.availableModels,
|
||||
configOptions: this.configOptions,
|
||||
});
|
||||
await this.setModelWithSelection({ modelId, selection });
|
||||
if (selection.availableModel || !this.configuredModelIds.has(modelId)) {
|
||||
return selection;
|
||||
}
|
||||
// A model the user added in provider config is applied even when the agent does not advertise it.
|
||||
return { ...selection, availableModel: { modelId, name: modelId } };
|
||||
}
|
||||
|
||||
private async setModelWithSelection({
|
||||
@@ -2213,21 +2228,20 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (selection.hasAvailableModels) {
|
||||
if (!selection.availableModel) {
|
||||
this.warnInvalidSelection(
|
||||
modelId,
|
||||
`is not a valid ${this.provider} model. Available options: ${this.availableModels
|
||||
?.map((model) => model.modelId)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof this.connection.unstable_setSessionModel !== "function") {
|
||||
throw new Error(this.modelSelectionUnavailableMessage());
|
||||
}
|
||||
if (selection.hasAvailableModels && !selection.availableModel) {
|
||||
this.warnInvalidSelection(
|
||||
modelId,
|
||||
`is not a valid ${this.provider} model. Available options: ${this.availableModels
|
||||
?.map((model) => model.modelId)
|
||||
.join(", ")}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
selection.availableModel &&
|
||||
typeof this.connection.unstable_setSessionModel === "function"
|
||||
) {
|
||||
try {
|
||||
await this.connection.unstable_setSessionModel({
|
||||
sessionId: this.sessionId,
|
||||
@@ -2922,13 +2936,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
const configuredModelId = this.config.model;
|
||||
let switchedModel = false;
|
||||
if (configuredModelId && configuredModelId !== this.currentModel) {
|
||||
const selection = resolveACPModelSelection({
|
||||
modelId: configuredModelId,
|
||||
availableModels: this.availableModels,
|
||||
configOptions: this.configOptions,
|
||||
});
|
||||
try {
|
||||
await this.setModelWithSelection({ modelId: configuredModelId, selection });
|
||||
await this.setModelWithSelection({
|
||||
modelId: configuredModelId,
|
||||
selection: this.resolveModelSelection(configuredModelId),
|
||||
});
|
||||
switchedModel = true;
|
||||
} catch (error) {
|
||||
if (!this.isModelSelectionUnavailableError(error)) {
|
||||
|
||||
@@ -327,6 +327,57 @@ test("fails an auto mode turn when Claude Code uses Vertex", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("switches an auto mode session to another mode when Claude Code uses Bedrock", async () => {
|
||||
const previousBedrock = process.env.CLAUDE_CODE_USE_BEDROCK;
|
||||
process.env.CLAUDE_CODE_USE_BEDROCK = "1";
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
sdkQueryFactory.mockImplementation(() => queryMock);
|
||||
const client = new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory: sdkQueryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
modeId: "auto",
|
||||
});
|
||||
|
||||
try {
|
||||
await session.setMode("acceptEdits");
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("acceptEdits");
|
||||
expect(sdkQueryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(sdkQueryFactory.mock.calls[0]?.[0]?.options?.permissionMode).toBe("acceptEdits");
|
||||
} finally {
|
||||
restoreEnvValue("CLAUDE_CODE_USE_BEDROCK", previousBedrock);
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not keep a query started in a mode that Claude Code rejected", async () => {
|
||||
const launchedModes: Array<string | undefined> = [];
|
||||
sdkQueryFactory.mockImplementation(({ options }: { options: { permissionMode?: string } }) => {
|
||||
launchedModes.push(options.permissionMode);
|
||||
const queryMock = createBaseQueryMock(vi.fn(async () => ({ done: true, value: undefined })));
|
||||
if (launchedModes.length === 1) {
|
||||
queryMock.setPermissionMode.mockRejectedValue(new Error("bypass refused"));
|
||||
}
|
||||
return queryMock;
|
||||
});
|
||||
const session = await createSession();
|
||||
|
||||
try {
|
||||
await expect(session.setMode("bypassPermissions")).rejects.toThrow("bypass refused");
|
||||
await session.listCommands();
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("default");
|
||||
expect(launchedModes).toEqual(["bypassPermissions", "default"]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("logs redacted query summary and never leaks sentinel secrets", async () => {
|
||||
const envSecret = "PASEO_ENV_SENTINEL_SECRET";
|
||||
const runtimeSecret = "PASEO_RUNTIME_SENTINEL_SECRET";
|
||||
@@ -1037,11 +1088,11 @@ test("preserves bypass capability across query restarts triggered by thinking ch
|
||||
|
||||
expect(capturedOptions).toHaveLength(2);
|
||||
expect(capturedOptions[0]).toMatchObject({
|
||||
permissionMode: "default",
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
});
|
||||
expect(capturedOptions[1]).toMatchObject({
|
||||
permissionMode: "acceptEdits",
|
||||
permissionMode: "bypassPermissions",
|
||||
allowDangerouslySkipPermissions: true,
|
||||
effort: "high",
|
||||
});
|
||||
|
||||
@@ -2441,8 +2441,15 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
|
||||
const previousMode = this.currentMode;
|
||||
const activeQuery = await this.ensureQuery();
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
const launchesQuery = !this.query || this.queryRestartNeeded;
|
||||
const activeQuery = await this.ensureQuery(normalized);
|
||||
try {
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
} catch (error) {
|
||||
// The query was launched in the rejected mode; relaunch in the current mode next time.
|
||||
if (launchesQuery) this.queryRestartNeeded = true;
|
||||
throw error;
|
||||
}
|
||||
if (normalized === "plan") {
|
||||
if (previousMode !== "plan") {
|
||||
this.planResumeMode = previousMode;
|
||||
@@ -3128,7 +3135,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return { kind: "fresh-session" };
|
||||
}
|
||||
|
||||
private async ensureQuery(): Promise<Query> {
|
||||
private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise<Query> {
|
||||
if (this.query && !this.queryRestartNeeded) {
|
||||
return this.query;
|
||||
}
|
||||
@@ -3172,7 +3179,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistence = null;
|
||||
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
const options = await this.buildOptions();
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.query = claudeQuery(
|
||||
@@ -3298,7 +3305,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private async buildOptions(): Promise<ClaudeOptions> {
|
||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
const providerOptions = applyClaudeToolPolicy(
|
||||
@@ -3307,7 +3314,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(this.currentMode, sdkEnv);
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
|
||||
const claudeBinary = await this.resolveBinary();
|
||||
this.logger.debug(
|
||||
@@ -3330,7 +3337,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const base: ClaudeOptions = {
|
||||
cwd: this.config.cwd,
|
||||
includePartialMessages: true,
|
||||
permissionMode: this.currentMode,
|
||||
permissionMode,
|
||||
// Dynamic mode switching can recreate the underlying Claude query. Keep the
|
||||
// bypass launch capability available so later setPermissionMode("bypassPermissions")
|
||||
// calls do not fail after a model/thinking/rewind-driven restart.
|
||||
|
||||
@@ -459,7 +459,9 @@ describe("OMP agent client and session", () => {
|
||||
|
||||
test("fails a turn when the provider idle gate passes its deadline", async () => {
|
||||
const scheduler = new ManualIdleScheduler();
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 1 });
|
||||
// Long enough that the gate's first check, made right after the turn ends, cannot already
|
||||
// be past it on a slow runner.
|
||||
const omp = new OmpHarness({ providerIdleScheduler: scheduler, providerIdleDeadlineMs: 50 });
|
||||
await omp.start();
|
||||
const { completion } = await omp.startPromptUntilProviderIdle("first", "first done", {
|
||||
isStreaming: true,
|
||||
@@ -473,7 +475,7 @@ describe("OMP agent client and session", () => {
|
||||
args: { command: "sleep 30" },
|
||||
});
|
||||
expect(omp.runningToolCallIds()).toEqual(["tool-at-deadline"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
scheduler.retry();
|
||||
await expect(completion).rejects.toThrow(/provider idle/i);
|
||||
expect(omp.runningToolCallIds()).toEqual([]);
|
||||
|
||||
@@ -126,6 +126,7 @@ import { createPaseoWorktree as createRegisteredPaseoWorktree } from "./paseo-wo
|
||||
import { createWorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js";
|
||||
import { createPaseoWorktreeWorkflow } from "./worktree-session.js";
|
||||
import { DownloadTokenStore } from "./file-download/token-store.js";
|
||||
import { formatAttachmentContentDisposition } from "./file-download/content-disposition.js";
|
||||
import type { OpenAiSpeechProviderConfig } from "./speech/providers/openai/config.js";
|
||||
import type { LocalSpeechProviderConfig } from "./speech/providers/local/config.js";
|
||||
import type { RequestedSpeechProviders } from "./speech/speech-types.js";
|
||||
@@ -836,9 +837,8 @@ export async function createPaseoDaemon(
|
||||
return;
|
||||
}
|
||||
|
||||
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
|
||||
res.setHeader("Content-Type", entry.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
|
||||
res.setHeader("Content-Disposition", formatAttachmentContentDisposition(entry.fileName));
|
||||
res.setHeader("Content-Length", fileStats.size.toString());
|
||||
|
||||
const stream = fileHandle.createReadStream();
|
||||
|
||||
@@ -24,42 +24,77 @@ describe("daemon E2E", () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file download tokens", () => {
|
||||
test("issues token over WS and downloads via HTTP", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const filePath = path.join(cwd, "download.txt");
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
test.each([
|
||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "報告書.md",
|
||||
disposition:
|
||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||
},
|
||||
{
|
||||
fileName: "보고서.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
])(
|
||||
"issues token over WS and downloads $fileName via HTTP",
|
||||
async ({ fileName, disposition }) => {
|
||||
const cwd = tmpCwd();
|
||||
try {
|
||||
const filePath = path.join(cwd, fileName);
|
||||
const fileContents = "download test payload";
|
||||
writeFileSync(filePath, fileContents, "utf-8");
|
||||
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
model: CODEX_TEST_MODEL,
|
||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||
cwd,
|
||||
title: "Download Token Test Agent",
|
||||
});
|
||||
|
||||
expect(agent.id).toBeTruthy();
|
||||
expect(agent.id).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt");
|
||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
expect(tokenResponse.token).toBeTruthy();
|
||||
expect(tokenResponse.fileName).toBe("download.txt");
|
||||
expect(tokenResponse.error).toBeNull();
|
||||
expect(tokenResponse.token).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||
);
|
||||
expect(tokenResponse.fileName).toBe(fileName);
|
||||
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||
);
|
||||
const response = await fetch(
|
||||
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
||||
const disposition = response.headers.get("content-disposition") ?? "";
|
||||
expect(disposition).toContain("download.txt");
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
|
||||
expect(response.headers.get("content-disposition")).toBe(disposition);
|
||||
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
const body = await response.text();
|
||||
expect(body).toBe(fileContents);
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60000,
|
||||
);
|
||||
|
||||
test("rejects invalid token", async () => {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir, uptime } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { readDaemonInstance, stopDaemonInstance } from "./daemon-instance.js";
|
||||
import { acquirePidLock, getPidLockInfo, isLocked, type PidLockInfo } from "./pid-lock.js";
|
||||
@@ -94,3 +94,42 @@ describe("daemon instance identity across a reboot", () => {
|
||||
await expect(readFile(join(paseoHome, "paseo.pid"), "utf-8")).rejects.toThrow(/ENOENT/);
|
||||
});
|
||||
});
|
||||
|
||||
// Linux keeps a boot's id while a VM is paused for a host sleep, though the VM's wall clock
|
||||
// runs ahead of its uptime once it resumes. A sleep longer than the time between boot and
|
||||
// the supervisor's start puts the wall-clock boot instant after the lock's startedAt.
|
||||
describe.runIf(process.platform === "linux")("daemon instance identity on Linux", () => {
|
||||
let paseoHome: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
paseoHome = await mkdtemp(join(tmpdir(), "paseo-daemon-instance-linux-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("a supervisor still holds the lock after its paused VM resumes", async () => {
|
||||
await acquirePidLock(paseoHome, null, { ownerPid: process.pid });
|
||||
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(Date.now() + uptime() * 1000 + 12 * 60 * 60_000);
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toMatchObject({ pid: process.pid });
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: true });
|
||||
await expect(
|
||||
acquirePidLock(paseoHome, null, { ownerPid: process.pid + 10_000 }),
|
||||
).rejects.toThrow("Another Paseo daemon is already running");
|
||||
});
|
||||
|
||||
test("a lock written during another boot has no running owner", async () => {
|
||||
await writeLock(paseoHome, {
|
||||
...lockFor(process.pid, new Date()),
|
||||
bootId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
|
||||
expect(await readDaemonInstance(paseoHome)).toBeNull();
|
||||
expect(await isLocked(paseoHome)).toMatchObject({ locked: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { formatAttachmentContentDisposition } from "./content-disposition.js";
|
||||
|
||||
describe("formatAttachmentContentDisposition", () => {
|
||||
test.each([
|
||||
{ fileName: "report.txt", disposition: 'attachment; filename="report.txt"' },
|
||||
{
|
||||
fileName: "café.txt",
|
||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||
},
|
||||
{
|
||||
fileName: "Résumé Ångström.pdf",
|
||||
disposition:
|
||||
"attachment; filename=\"R?sum? ?ngstr?m.pdf\"; filename*=UTF-8''R%C3%A9sum%C3%A9%20%C3%85ngstr%C3%B6m.pdf",
|
||||
},
|
||||
{
|
||||
fileName: "中文报告 (最终版).txt",
|
||||
disposition:
|
||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||
},
|
||||
{
|
||||
fileName: "report-📄.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||
},
|
||||
{
|
||||
fileName: 'say "hi" \\ bye.txt',
|
||||
disposition: 'attachment; filename="say \\"hi\\" \\\\ bye.txt"',
|
||||
},
|
||||
{
|
||||
fileName: "100%25 done.txt",
|
||||
disposition: "attachment; filename=\"100%25 done.txt\"; filename*=UTF-8''100%2525%20done.txt",
|
||||
},
|
||||
{
|
||||
fileName: "l'été (1)*.txt",
|
||||
disposition:
|
||||
"attachment; filename=\"l'?t? (1)*.txt\"; filename*=UTF-8''l%27%C3%A9t%C3%A9%20%281%29%2A.txt",
|
||||
},
|
||||
])("$fileName", ({ fileName, disposition }) => {
|
||||
expect(formatAttachmentContentDisposition(fileName)).toBe(disposition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
const NON_PRINTABLE_ASCII = /[^\x20-\x7e]/g;
|
||||
const PERCENT_ESCAPE = /%[0-9A-Fa-f]{2}/;
|
||||
const QUOTED_STRING_SPECIALS = /[\\"]/g;
|
||||
const NON_ATTR_CHARS_LEFT_BY_URI_ENCODING = /['()*]/g;
|
||||
|
||||
/**
|
||||
* Content-Disposition for a file download (RFC 6266).
|
||||
*
|
||||
* Any name that is not plain ASCII also gets `filename*=UTF-8''…`, because
|
||||
* browsers do not read raw Latin-1 bytes in `filename` and fall back to a name
|
||||
* taken from the URL. A name with a percent escape gets it too, so browsers do
|
||||
* not decode the escape in `filename`.
|
||||
*/
|
||||
export function formatAttachmentContentDisposition(fileName: string): string {
|
||||
const asciiFileName = fileName.replace(NON_PRINTABLE_ASCII, "?");
|
||||
const disposition = `attachment; filename=${quote(asciiFileName)}`;
|
||||
if (asciiFileName === fileName && !PERCENT_ESCAPE.test(fileName)) {
|
||||
return disposition;
|
||||
}
|
||||
return `${disposition}; filename*=UTF-8''${encodeExtendedValue(fileName)}`;
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return `"${value.replace(QUOTED_STRING_SPECIALS, "\\$&")}"`;
|
||||
}
|
||||
|
||||
function encodeExtendedValue(value: string): string {
|
||||
return encodeURIComponent(value).replace(
|
||||
NON_ATTR_CHARS_LEFT_BY_URI_ENCODING,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { open, readFile, stat, unlink, utimes } from "node:fs/promises";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import { ensurePrivateDirectory } from "./private-files.js";
|
||||
@@ -15,6 +16,7 @@ export const pidLockInfoSchema = z.object({
|
||||
serverId: z.string().nullable().optional(),
|
||||
desktopManaged: z.boolean().optional(),
|
||||
heartbeat: z.literal(true).optional(),
|
||||
bootId: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface PidLockInfo extends z.infer<typeof pidLockInfoSchema> {}
|
||||
@@ -62,16 +64,38 @@ function precedesThisBoot(startedAt: string): boolean {
|
||||
return stamped < Date.now() - uptime() * 1000 - BOOT_INSTANT_TOLERANCE_MS;
|
||||
}
|
||||
|
||||
let cachedBootId: string | null | undefined;
|
||||
|
||||
// Linux names each boot. The wall-clock boot instant is not enough there: a VM paused while
|
||||
// its host sleeps keeps its uptime, so after it resumes the instant lands after locks its
|
||||
// running supervisor wrote.
|
||||
function currentBootId(): string | null {
|
||||
if (cachedBootId === undefined) {
|
||||
try {
|
||||
cachedBootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf-8").trim() || null;
|
||||
} catch {
|
||||
cachedBootId = null;
|
||||
}
|
||||
}
|
||||
return cachedBootId;
|
||||
}
|
||||
|
||||
function writtenDuringThisBoot(lock: PidLockInfo): boolean {
|
||||
const thisBoot = currentBootId();
|
||||
if (lock.bootId && thisBoot) return lock.bootId === thisBoot;
|
||||
return !precedesThisBoot(lock.startedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the process that wrote this lock is still running.
|
||||
*
|
||||
* A PID alone does not identify the supervisor: the operating system hands the number to
|
||||
* something else once the supervisor is gone, and a reboot reassigns it freely. A process
|
||||
* cannot predate the boot it runs under, so a lock stamped before this boot is abandoned
|
||||
* cannot predate the boot it runs under, so a lock written during another boot is abandoned
|
||||
* however alive its PID looks.
|
||||
*/
|
||||
export function isPidLockOwnerRunning(lock: PidLockInfo): boolean {
|
||||
if (precedesThisBoot(lock.startedAt)) return false;
|
||||
if (!writtenDuringThisBoot(lock)) return false;
|
||||
return isPidRunning(lock.pid);
|
||||
}
|
||||
|
||||
@@ -214,6 +238,7 @@ export async function acquirePidLock(
|
||||
}
|
||||
|
||||
// Create new lock with exclusive flag
|
||||
const bootId = currentBootId();
|
||||
const lockInfo: PidLockInfo = {
|
||||
pid: lockOwnerPid,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -221,6 +246,7 @@ export async function acquirePidLock(
|
||||
uid: process.getuid?.() ?? 0,
|
||||
listen,
|
||||
heartbeat: true,
|
||||
...(bootId ? { bootId } : {}),
|
||||
...(process.env.PASEO_DESKTOP_MANAGED === "1" ? { desktopManaged: true } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import type pino from "pino";
|
||||
import pino from "pino";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import { exportPublicKey, generateKeyPair } from "@getpaseo/relay";
|
||||
import { startRelayTransport } from "./relay-transport";
|
||||
@@ -146,6 +146,37 @@ describe("relay-transport control lifecycle", () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("releases a stalled encrypted data connection without restarting control", async () => {
|
||||
vi.useFakeTimers();
|
||||
let attachments = 0;
|
||||
controllers.push(
|
||||
startRelayTransport({
|
||||
logger: pino({ level: "silent" }),
|
||||
attachSocket: async () => {
|
||||
attachments += 1;
|
||||
},
|
||||
relayEndpoint: "relay.paseo.sh:443",
|
||||
relayUseTls: true,
|
||||
serverId: "srv_test",
|
||||
daemonKeyPair: generateKeyPair(),
|
||||
createWebSocket: relay.createWebSocket,
|
||||
}),
|
||||
);
|
||||
const control = relay.sockets[0];
|
||||
control.open();
|
||||
const connected = JSON.stringify({ type: "connected", connectionId: "stalled" });
|
||||
control.message(connected);
|
||||
const stalled = relay.sockets[1];
|
||||
stalled.open();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
expect(stalled.terminateCalls).toBe(1);
|
||||
expect(attachments).toBe(0);
|
||||
expect(control.terminateCalls).toBe(0);
|
||||
control.message(connected);
|
||||
expect(relay.sockets).toHaveLength(3);
|
||||
expect(relay.sockets[2].url).toBe(stalled.url);
|
||||
});
|
||||
|
||||
test("logs relay_control_connected only after first valid control message", () => {
|
||||
const logger = createMockLogger();
|
||||
const controller = startRelayTransport({
|
||||
|
||||
@@ -420,6 +420,10 @@ async function attachEncryptedSocket(
|
||||
attachSocket: (ws: RelaySocketLike, metadata?: ExternalSocketMetadata) => Promise<void>,
|
||||
metadata?: ExternalSocketMetadata,
|
||||
): Promise<void> {
|
||||
const handshakeTimeout = setTimeout(() => {
|
||||
logger.warn("relay_e2ee_handshake_timeout_terminating");
|
||||
socket.terminate();
|
||||
}, 15_000);
|
||||
try {
|
||||
const relayTransport = createRelayTransportAdapter(socket, logger);
|
||||
const emitter = new EventEmitter();
|
||||
@@ -440,6 +444,7 @@ async function attachEncryptedSocket(
|
||||
emitter.emit("error", error);
|
||||
},
|
||||
});
|
||||
clearTimeout(handshakeTimeout);
|
||||
const encryptedSocket = createEncryptedRelaySocket({
|
||||
channel,
|
||||
emitter,
|
||||
@@ -459,6 +464,8 @@ async function attachEncryptedSocket(
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(handshakeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1514,197 +1514,6 @@ describe("ScheduleService", () => {
|
||||
expect(storedAgent?.archivedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("keeps a new-agent run open while the agent's background subagent is still running", async () => {
|
||||
class BackgroundChildSession implements AgentSession {
|
||||
readonly provider = "claude";
|
||||
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
|
||||
readonly id = "background-child-session";
|
||||
closed = false;
|
||||
launched = false;
|
||||
private turnCount = 0;
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
|
||||
constructor(private readonly config: AgentSessionConfig) {}
|
||||
|
||||
async run(_prompt: AgentPromptInput, _options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
throw new Error("Scheduled runs start turns");
|
||||
}
|
||||
|
||||
// The shape of a Claude turn that calls Agent with run_in_background: true and ends without
|
||||
// waiting: the child is still running when the parent's turn completes.
|
||||
async startTurn(
|
||||
_prompt: AgentPromptInput,
|
||||
_options?: AgentRunOptions,
|
||||
): Promise<{ turnId: string }> {
|
||||
const turnId = `turn-${++this.turnCount}`;
|
||||
setImmediate(() => {
|
||||
this.emit({ type: "turn_started", provider: this.provider, turnId });
|
||||
this.emit({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: { type: "upsert", id: "probe-child", title: "probe child", status: "running" },
|
||||
});
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
item: { type: "assistant_message", text: "LAUNCHED" },
|
||||
});
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
turnId,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
});
|
||||
this.launched = true;
|
||||
});
|
||||
return { turnId };
|
||||
}
|
||||
|
||||
// Claude marks the child completed, then its notification wakes the parent for a turn of
|
||||
// its own.
|
||||
finishChild(): void {
|
||||
this.emit({
|
||||
type: "provider_subagent",
|
||||
provider: this.provider,
|
||||
event: { type: "upsert", id: "probe-child", status: "completed" },
|
||||
});
|
||||
this.emit({ type: "turn_started", provider: this.provider });
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
item: { type: "assistant_message", text: "CHILD_REPORTED" },
|
||||
});
|
||||
this.emit({
|
||||
type: "turn_completed",
|
||||
provider: this.provider,
|
||||
usage: { inputTokens: 1, outputTokens: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
sessionId: this.id,
|
||||
model: this.config.model ?? null,
|
||||
modeId: this.config.modeId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async getAvailableModes(): Promise<AgentMode[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getCurrentMode(): Promise<string | null> {
|
||||
return this.config.modeId ?? null;
|
||||
}
|
||||
|
||||
async setMode(modeId: string): Promise<void> {
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_requestId: string,
|
||||
_response: AgentPermissionResponse,
|
||||
): Promise<void> {}
|
||||
|
||||
describePersistence(): AgentPersistenceHandle {
|
||||
return { provider: this.provider, sessionId: this.id, metadata: { ...this.config } };
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
private emit(event: AgentStreamEvent): void {
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BackgroundChildClient implements AgentClient {
|
||||
readonly provider = "claude";
|
||||
readonly capabilities = SCHEDULE_TEST_CAPABILITIES;
|
||||
readonly sessions: BackgroundChildSession[] = [];
|
||||
|
||||
async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new BackgroundChildSession(config);
|
||||
this.sessions.push(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
async resumeSession(): Promise<AgentSession> {
|
||||
throw new Error("Scheduled runs create fresh sessions");
|
||||
}
|
||||
|
||||
async fetchCatalog(): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
|
||||
return { models: [], modes: [] };
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new BackgroundChildClient();
|
||||
const manager = new AgentManager({
|
||||
logger: createTestLogger(),
|
||||
clients: { claude: client },
|
||||
registry: agentStorage,
|
||||
});
|
||||
const service = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: manager,
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
});
|
||||
const created = await service.create({
|
||||
prompt: "launch a background child and end your turn",
|
||||
cadence: { type: "cron", expression: "0 3 1 1 *" },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: { provider: "claude", model: "test-model", cwd: tempDir },
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
|
||||
let runSettled = false;
|
||||
const run = service.runOnce(created.id).finally(() => {
|
||||
runSettled = true;
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(client.sessions[0]?.launched).toBe(true);
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
expect(runSettled).toBe(false);
|
||||
expect(client.sessions[0]?.closed).toBe(false);
|
||||
|
||||
client.sessions[0]!.finishChild();
|
||||
const finished = await run;
|
||||
|
||||
expect(finished.runs[0]).toMatchObject({ status: "succeeded", output: "CHILD_REPORTED" });
|
||||
expect(client.sessions[0]?.closed).toBe(true);
|
||||
});
|
||||
|
||||
test("records prompt-start failures as failed and archives the scheduled agent", async () => {
|
||||
class StartFailureScheduleSession implements AgentSession {
|
||||
readonly provider = "claude";
|
||||
|
||||
@@ -113,6 +113,22 @@ test("keeps the directory's ranking window when searching", async ({ page }) =>
|
||||
);
|
||||
});
|
||||
|
||||
test("searches for a term typed before the page finished loading", async ({ page }) => {
|
||||
let loadScripts!: () => void;
|
||||
const scriptsHeld = new Promise<void>((resolve) => (loadScripts = resolve));
|
||||
await page.route(/\.js($|\?)/, async (route) => {
|
||||
await scriptsHeld;
|
||||
await route.continue();
|
||||
});
|
||||
await page.goto("/plugins?window=month", { waitUntil: "domcontentloaded" });
|
||||
await searchPlugins(page, "graphite");
|
||||
loadScripts();
|
||||
await page.waitForLoadState("load");
|
||||
await expect(page.getByRole("button", { name: "Clear search" })).toBeVisible();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/plugins\/all\?q=graphite&window=month$/);
|
||||
});
|
||||
|
||||
test("clears the search with the clear button", async ({ page }) => {
|
||||
await page.goto("/plugins/all");
|
||||
const searchbox = page.getByRole("searchbox", { name: "Search plugins" });
|
||||
|
||||
Reference in New Issue
Block a user