mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0e209e414 | ||
|
|
9917ad61c0 | ||
|
|
9f2b6d16ba | ||
|
|
176085cf1d | ||
|
|
d14bebc154 | ||
|
|
09855f7275 | ||
|
|
e59bbdb7ed | ||
|
|
c14ae3807e | ||
|
|
f832d9c163 | ||
|
|
8f5729d1e8 | ||
|
|
8214ebceaf |
@@ -88,6 +88,8 @@ export interface PluginSessionOpenRequest {
|
||||
readonly background: boolean;
|
||||
readonly labels: Readonly<Record<string, string>>;
|
||||
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||
/** Instructions appended for this open; not saved on the agent. */
|
||||
appendSystemPrompt?: string[];
|
||||
tools: AgentToolDefinition[];
|
||||
env: Record<string, string>;
|
||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||
|
||||
@@ -673,6 +673,48 @@ async function startAndSteerThroughManager(
|
||||
return { manager, agentId: agent.id, workdir };
|
||||
}
|
||||
|
||||
test("OpenCode persistence excludes launch-only instructions from storage and clients", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-plugin-persistence-"));
|
||||
const harness = new V2Harness();
|
||||
harness.info.location.directory = workdir;
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
|
||||
const manager = new AgentManager({
|
||||
clients: { opencode: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
appendSystemPrompt: "Daemon instruction",
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (name, request) =>
|
||||
name === "agent.open.before"
|
||||
? { ...request, appendSystemPrompt: ["Plugin instruction"] }
|
||||
: request,
|
||||
},
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "opencode", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
expect(harness.instructions.get("paseo")).toBe("Daemon instruction\n\nPlugin instruction");
|
||||
await manager.flush();
|
||||
const record = await storage.get(agent.id);
|
||||
expect(record?.persistence?.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
|
||||
expect(record?.persistence?.metadata).not.toHaveProperty("daemonAppendSystemPrompt");
|
||||
const live = manager.getAgent(agent.id)!;
|
||||
expect(toAgentPayload(live).persistence?.metadata).not.toHaveProperty(
|
||||
"pluginAppendSystemPrompt",
|
||||
);
|
||||
expect(toAgentPayload(live).persistence?.metadata).not.toHaveProperty(
|
||||
"daemonAppendSystemPrompt",
|
||||
);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await client.shutdown();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("stops an OpenCode execution already active when its conversation is reopened", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-resume-"));
|
||||
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
|
||||
@@ -14523,3 +14565,69 @@ test("idle cleanup gives a newly restored runtime a fresh idle window while keep
|
||||
expect((await h.sweep({ now: h.later(61) })).collected).toHaveLength(1);
|
||||
expect(h.closes).toBe(closesBeforeRestore + 1);
|
||||
});
|
||||
|
||||
test("plugin instructions are recomputed for each launch without changing saved config", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-plugin-prompt-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new TestAgentClient();
|
||||
let additions = ["Plugin one", "Plugin two"];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
appendSystemPrompt: "Daemon",
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (name, request) =>
|
||||
name === "agent.open.before" ? { ...request, appendSystemPrompt: additions } : request,
|
||||
},
|
||||
});
|
||||
const snapshot = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, systemPrompt: "Saved" },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
expect(client.createdConfigs[0]).toMatchObject({
|
||||
systemPrompt: "Saved",
|
||||
daemonAppendSystemPrompt: "Daemon",
|
||||
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
|
||||
});
|
||||
expect(snapshot.config.systemPrompt).toBe("Saved");
|
||||
expect((await storage.get(snapshot.id))?.config?.systemPrompt).toBe("Saved");
|
||||
const handle: AgentPersistenceHandle = {
|
||||
provider: "codex",
|
||||
sessionId: "prompt-session",
|
||||
metadata: { provider: "codex", cwd: workdir, systemPrompt: "Saved" },
|
||||
};
|
||||
await manager.closeAgent(snapshot.id);
|
||||
await manager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
{ cwd: workdir, systemPrompt: "Saved" },
|
||||
snapshot.id,
|
||||
);
|
||||
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual([
|
||||
"Plugin one",
|
||||
"Plugin two",
|
||||
]);
|
||||
additions = ["Changed"];
|
||||
await manager.closeAgent(snapshot.id);
|
||||
await manager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
{ cwd: workdir, systemPrompt: "Saved" },
|
||||
snapshot.id,
|
||||
);
|
||||
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual(["Changed"]);
|
||||
additions = ["Refreshed"];
|
||||
await manager.reloadAgentSession(snapshot.id);
|
||||
expect(client.resumeOverrides.at(-1)?.pluginAppendSystemPrompt).toEqual(["Refreshed"]);
|
||||
additions = [];
|
||||
await manager.closeAgent(snapshot.id);
|
||||
await manager.resumeAgentFromPersistence(
|
||||
handle,
|
||||
{ cwd: workdir, systemPrompt: "Saved" },
|
||||
snapshot.id,
|
||||
);
|
||||
expect(client.resumeOverrides.at(-1)?.systemPrompt).toBe("Saved");
|
||||
expect(client.resumeOverrides.at(-1)?.daemonAppendSystemPrompt).toBe("Daemon");
|
||||
expect((await storage.get(snapshot.id))?.config?.systemPrompt).toBe("Saved");
|
||||
});
|
||||
|
||||
@@ -634,7 +634,12 @@ function normalizePersistenceForConfig(
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
|
||||
const {
|
||||
mcpServers: _launchMcpServers,
|
||||
pluginAppendSystemPrompt: _pluginAppendSystemPrompt,
|
||||
daemonAppendSystemPrompt: _daemonAppendSystemPrompt,
|
||||
...metadata
|
||||
} = handle.metadata ?? {};
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
@@ -1727,7 +1732,10 @@ export class AgentManager {
|
||||
...(record ? { metadata: record.metadata, attention: extractAttention(record) } : {}),
|
||||
workspaceId,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
persistence:
|
||||
purpose === "interactive" && session.persistenceUpdatedOnResume
|
||||
? (session.describePersistence() ?? handle)
|
||||
: handle,
|
||||
restoring: true,
|
||||
});
|
||||
}
|
||||
@@ -6168,6 +6176,7 @@ export class AgentManager {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
const generation = this.sessionOpenGeneration;
|
||||
this.detachPluginTools(agentId);
|
||||
let appendSystemPrompt: string[] | undefined;
|
||||
let mcpServers: AgentSessionConfig["mcpServers"];
|
||||
let mcpServerOwners: Record<string, string> | undefined;
|
||||
if (this.pluginLifecycle) {
|
||||
@@ -6189,12 +6198,14 @@ export class AgentManager {
|
||||
env = transformed.env;
|
||||
if (request.purpose === "interactive") {
|
||||
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
|
||||
appendSystemPrompt = transformed.appendSystemPrompt;
|
||||
mcpServers = transformed.mcpServers;
|
||||
mcpServerOwners = transformed.mcpServerOwners;
|
||||
}
|
||||
}
|
||||
const context: AgentLaunchContext = {
|
||||
agentId,
|
||||
...pluginPromptContext(appendSystemPrompt),
|
||||
mcpServers,
|
||||
mcpServerOwners,
|
||||
env: {
|
||||
@@ -6241,6 +6252,7 @@ export class AgentManager {
|
||||
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
|
||||
}
|
||||
}
|
||||
launchConfig = { ...launchConfig, pluginAppendSystemPrompt: launchContext.appendSystemPrompt };
|
||||
const merged =
|
||||
Object.keys(launchContext.mcpServers ?? {}).length > 0
|
||||
? {
|
||||
@@ -6378,3 +6390,9 @@ export function commandMayHaveChangedExternalState(command: string): boolean {
|
||||
/\bgit\s+fetch\b/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function pluginPromptContext(
|
||||
appendSystemPrompt: string[] | undefined,
|
||||
): Pick<AgentLaunchContext, "appendSystemPrompt"> {
|
||||
return appendSystemPrompt?.length ? { appendSystemPrompt } : {};
|
||||
}
|
||||
|
||||
@@ -337,6 +337,7 @@ describe("toAgentPayload", () => {
|
||||
nativeHandle: { id: "native" } as unknown,
|
||||
metadata: {
|
||||
restored: new Date("2025-03-01T00:00:00.000Z"),
|
||||
paseoInstructionReceipt: { threadId: "persist-99", digest: "internal-digest" },
|
||||
empty: {},
|
||||
mcpServers: {
|
||||
hub: {
|
||||
@@ -387,6 +388,8 @@ describe("toAgentPayload", () => {
|
||||
sessionId: "persist-stored",
|
||||
metadata: {
|
||||
conversationId: "conversation-stored",
|
||||
pluginAppendSystemPrompt: ["plugin instruction"],
|
||||
daemonAppendSystemPrompt: "daemon instruction",
|
||||
mcpServers: {
|
||||
hub: {
|
||||
type: "http",
|
||||
@@ -402,6 +405,8 @@ describe("toAgentPayload", () => {
|
||||
|
||||
expect(record.persistence?.metadata).toEqual({
|
||||
conversationId: "conversation-stored",
|
||||
pluginAppendSystemPrompt: ["plugin instruction"],
|
||||
daemonAppendSystemPrompt: "daemon instruction",
|
||||
mcpServers: {
|
||||
hub: {
|
||||
type: "http",
|
||||
|
||||
@@ -495,6 +495,9 @@ function projectPersistenceHandleForWire(
|
||||
return projected;
|
||||
}
|
||||
delete projected.metadata.mcpServers;
|
||||
delete projected.metadata.pluginAppendSystemPrompt;
|
||||
delete projected.metadata.daemonAppendSystemPrompt;
|
||||
delete projected.metadata.paseoInstructionReceipt;
|
||||
if (Object.keys(projected.metadata).length === 0) {
|
||||
delete projected.metadata;
|
||||
}
|
||||
|
||||
@@ -621,6 +621,8 @@ export interface AgentSessionConfig {
|
||||
* persisted into agent config so daemon setting changes apply cleanly.
|
||||
*/
|
||||
daemonAppendSystemPrompt?: string;
|
||||
/** Plugin instructions for this launch; never persisted. */
|
||||
pluginAppendSystemPrompt?: string[];
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
@@ -636,6 +638,8 @@ export interface AgentSessionConfig {
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
/** Launch-only instructions contributed by plugins. */
|
||||
appendSystemPrompt?: string[];
|
||||
/** Launch-only plugin MCP servers and their owners; never persisted. */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
mcpServerOwners?: Record<string, string>;
|
||||
@@ -688,6 +692,8 @@ export interface AgentUsageSession {
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
/** Persist the session's current handle immediately after an interactive resume. */
|
||||
readonly persistenceUpdatedOnResume?: boolean;
|
||||
usageSession?(): AgentUsageSession | null;
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
|
||||
@@ -1949,7 +1949,11 @@ function mapSessionConfig(
|
||||
return {
|
||||
cwd: config.cwd,
|
||||
env: createProviderEnv({ baseEnv: {}, overlays: [launchContext?.env] }),
|
||||
systemPrompt: combineSystemPrompts(config.systemPrompt, config.daemonAppendSystemPrompt),
|
||||
systemPrompt: combineSystemPrompts(
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
...(config.pluginAppendSystemPrompt ?? []),
|
||||
),
|
||||
mcpServers: { ...config.mcpServers },
|
||||
toolPolicy: config.toolPolicy
|
||||
? { preapproved: config.toolPolicy.preapproved.map((grant) => ({ ...grant })) }
|
||||
@@ -1968,8 +1972,9 @@ function mapSessionConfig(
|
||||
function combineSystemPrompts(
|
||||
systemPrompt: string | undefined,
|
||||
daemonPrompt: string | undefined,
|
||||
...pluginPrompts: string[]
|
||||
): string | undefined {
|
||||
const parts = [systemPrompt, daemonPrompt].filter(
|
||||
const parts = [systemPrompt, daemonPrompt, ...pluginPrompts].filter(
|
||||
(value): value is string => typeof value === "string" && value.trim().length > 0,
|
||||
);
|
||||
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||
|
||||
@@ -448,6 +448,9 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
get initialTimeline() {
|
||||
return inner.initialTimeline;
|
||||
},
|
||||
get persistenceUpdatedOnResume() {
|
||||
return inner.persistenceUpdatedOnResume;
|
||||
},
|
||||
get controls() {
|
||||
return inner.controls;
|
||||
},
|
||||
|
||||
@@ -153,6 +153,56 @@ describe("Claude SDK env", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("resume uses current plugin instructions and excludes them from persistence", async () => {
|
||||
const prompts: unknown[] = [];
|
||||
const queryFactory = ({ options }: ClaudeQueryInput) => {
|
||||
prompts.push(options.systemPrompt);
|
||||
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,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const first = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
pluginAppendSystemPrompt: ["Plugin instructions"],
|
||||
});
|
||||
await first.run("first");
|
||||
const handle = first.describePersistence()!;
|
||||
expect(handle.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
|
||||
await first.close();
|
||||
const resumed = await client.resumeSession(handle, {
|
||||
cwd: process.cwd(),
|
||||
pluginAppendSystemPrompt: undefined,
|
||||
});
|
||||
try {
|
||||
await resumed.run("after disable");
|
||||
expect(prompts).toEqual([
|
||||
{ type: "preset", preset: "claude_code", append: "Plugin instructions" },
|
||||
{ type: "preset", preset: "claude_code", append: "" },
|
||||
]);
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards launch-context env through Claude resume env", async () => {
|
||||
let capturedEnv: Record<string, string | undefined> | undefined;
|
||||
const launchContext: AgentLaunchContext = {
|
||||
|
||||
@@ -3044,7 +3044,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!this.claudeSessionId) {
|
||||
return null;
|
||||
}
|
||||
const { providerOptions: _providerOptions, ...persistedConfig } = this.config;
|
||||
const {
|
||||
providerOptions: _providerOptions,
|
||||
pluginAppendSystemPrompt: _pluginAppendSystemPrompt,
|
||||
...persistedConfig
|
||||
} = this.config;
|
||||
this.persistence = {
|
||||
provider: "claude",
|
||||
sessionId: this.claudeSessionId,
|
||||
@@ -3712,7 +3716,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
|
||||
private buildAppendedSystemPrompt(): string {
|
||||
return (
|
||||
composeSystemPromptParts(this.config.systemPrompt, this.config.daemonAppendSystemPrompt) ?? ""
|
||||
composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
...(this.config.pluginAppendSystemPrompt ?? []),
|
||||
) ?? ""
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+92
-1
@@ -22,6 +22,18 @@ function isCodexInstalled(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function supportsInstructionSnapshots(): boolean {
|
||||
try {
|
||||
const match = execFileSync("codex", ["--version"], { encoding: "utf8" }).match(
|
||||
/(\d+)\.(\d+)\.(\d+)/,
|
||||
);
|
||||
if (!match) return false;
|
||||
return Number(match[1]) > 0 || Number(match[2]) >= 121;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sse(events: unknown[]): string {
|
||||
return events
|
||||
.map((event) => {
|
||||
@@ -133,7 +145,7 @@ async function startMockResponsesServer(sequence: string[]): Promise<{
|
||||
}> {
|
||||
const requestBodies: string[] = [];
|
||||
let index = 0;
|
||||
const server = createServer((req, res) => {
|
||||
const server = createServer({ maxHeaderSize: 1024 * 1024 }, (req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
req.on("end", () => {
|
||||
@@ -221,6 +233,85 @@ function waitForEvent<TEvent extends AgentStreamEvent>(params: {
|
||||
}
|
||||
|
||||
describe("Codex app-server provider (local e2e)", () => {
|
||||
test.runIf(supportsInstructionSnapshots())(
|
||||
"delivers changed and removed instructions to the model across process restarts",
|
||||
async () => {
|
||||
const artifactDir = path.resolve(process.cwd(), "../../.artifacts");
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
const cwd = mkdtempSync(path.join(artifactDir, "codex-snapshot-cwd-"));
|
||||
const codexHome = mkdtempSync(path.join(artifactDir, "codex-snapshot-home-"));
|
||||
const server = await startMockResponsesServer([]);
|
||||
const client = new CodexAppServerAgentClient(createTestLogger());
|
||||
const base = { provider: "codex" as const, cwd, modeId: "auto", model: "mock-model" };
|
||||
const launch = { env: { CODEX_HOME: codexHome } };
|
||||
const run = async (session: Awaited<ReturnType<typeof client.createSession>>) => {
|
||||
const finished = waitForEvent({
|
||||
session,
|
||||
timeoutMs: 20_000,
|
||||
label: "turn completion",
|
||||
predicate: (
|
||||
event,
|
||||
): event is Extract<
|
||||
AgentStreamEvent,
|
||||
{ type: "turn_completed" | "turn_failed" | "turn_canceled" }
|
||||
> =>
|
||||
event.type === "turn_completed" ||
|
||||
event.type === "turn_failed" ||
|
||||
event.type === "turn_canceled",
|
||||
});
|
||||
await session.startTurn("continue");
|
||||
const result = await finished;
|
||||
if (result.type !== "turn_completed") throw new Error(JSON.stringify(result));
|
||||
const handle = session.describePersistence();
|
||||
if (!handle) throw new Error("Expected persisted Codex thread");
|
||||
await session.close();
|
||||
return handle;
|
||||
};
|
||||
try {
|
||||
writeMockCodexConfig(codexHome, server.url);
|
||||
let handle = await run(
|
||||
await client.createSession(
|
||||
{ ...base, pluginAppendSystemPrompt: ["old snapshot"] },
|
||||
launch,
|
||||
),
|
||||
);
|
||||
expect(server.requestBodies.at(-1)).toContain("old snapshot");
|
||||
expect(server.requestBodies.at(-1)).not.toContain("paseo_instruction_snapshot");
|
||||
handle = await run(
|
||||
await client.resumeSession(
|
||||
handle,
|
||||
{ ...base, pluginAppendSystemPrompt: ["new snapshot"] },
|
||||
launch,
|
||||
),
|
||||
);
|
||||
expect(server.requestBodies.at(-1)).toContain("new snapshot");
|
||||
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(2);
|
||||
handle = await run(
|
||||
await client.resumeSession(
|
||||
handle,
|
||||
{ ...base, pluginAppendSystemPrompt: ["new snapshot"] },
|
||||
launch,
|
||||
),
|
||||
);
|
||||
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(2);
|
||||
await run(
|
||||
await client.resumeSession(
|
||||
handle,
|
||||
{ ...base, pluginAppendSystemPrompt: undefined },
|
||||
launch,
|
||||
),
|
||||
);
|
||||
expect(server.requestBodies.at(-1)).toContain('\\"instructions\\":\\"\\"');
|
||||
expect(server.requestBodies.at(-1)?.match(/paseo_instruction_snapshot/g)).toHaveLength(4);
|
||||
} finally {
|
||||
await server.close();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
rmSync(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test.runIf(isCodexInstalled())(
|
||||
"concurrent import scans initialize successfully in a fresh shared Codex home",
|
||||
async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { CodexInstructionSnapshot } from "./codex/instruction-snapshot.js";
|
||||
|
||||
import type {
|
||||
AgentClient,
|
||||
@@ -100,6 +101,7 @@ import {
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { asInternals as castInternals, createStub } from "../../test-utils/class-mocks.js";
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import { buildProviderRegistry } from "../provider-registry.js";
|
||||
|
||||
interface CollaborationModeRecord {
|
||||
@@ -173,6 +175,9 @@ function createSession(
|
||||
) as CodexTestSession;
|
||||
session.connectionState = "connected";
|
||||
session.currentThreadId = "test-thread";
|
||||
castInternals<{ instructionSnapshot: { recordNewThread(id: string): void } }>(
|
||||
session,
|
||||
).instructionSnapshot.recordNewThread("test-thread");
|
||||
session.activeForegroundTurnId = "test-turn";
|
||||
return session;
|
||||
}
|
||||
@@ -683,7 +688,7 @@ function codexAgentItem(text: string) {
|
||||
|
||||
// An AgentManager over a real Codex provider whose every spawn gets a fresh fake
|
||||
// app-server serving `threads` from thread/read.
|
||||
function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
|
||||
function createReopenableCodexAgent(threads: Record<string, unknown[]>, registry?: AgentStorage) {
|
||||
const appServers: FakeCodexAppServer[] = [];
|
||||
const provider = new CodexAppServerAgentClient(createTestLogger(), undefined, {
|
||||
appServerProcess: createFakeCodexAppServerProcess(() => {
|
||||
@@ -715,7 +720,11 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
|
||||
},
|
||||
resumeSession: (...args) => provider.resumeSession(...args),
|
||||
};
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger: createTestLogger() });
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
const agentId = "00000000-0000-4000-8000-000000000001";
|
||||
const handle = { provider: "codex", sessionId: "saved-thread" };
|
||||
const config = createConfig({ cwd: process.cwd() });
|
||||
@@ -728,6 +737,9 @@ function createReopenableCodexAgent(threads: Record<string, unknown[]>) {
|
||||
return {
|
||||
manager,
|
||||
agentId,
|
||||
handle,
|
||||
config,
|
||||
appServers,
|
||||
latestAppServer: () => appServers[appServers.length - 1],
|
||||
threadMethods: (index: number) =>
|
||||
appServers[index]
|
||||
@@ -1406,6 +1418,216 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("reopening drops disabled plugin instructions and starts with fresh collaboration settings", async () => {
|
||||
const modes = {
|
||||
data: [
|
||||
{
|
||||
name: "Code",
|
||||
mode: "default",
|
||||
model: null,
|
||||
reasoning_effort: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const firstServer = createFakeCodexAppServer({ "collaborationMode/list": () => modes });
|
||||
const first = await createProviderWithFakeAppServer(firstServer).createSession(
|
||||
createConfig({ pluginAppendSystemPrompt: ["Plugin instructions"] }),
|
||||
);
|
||||
await first.startTurn("first");
|
||||
const firstTurn = await firstServer.waitForTurnStart();
|
||||
expect(
|
||||
firstServer.requests().find((request) => request.method === "thread/start")?.params,
|
||||
).toMatchObject({ developerInstructions: "Plugin instructions" });
|
||||
expect(firstTurn).toMatchObject({
|
||||
collaborationMode: {
|
||||
settings: { developer_instructions: null },
|
||||
},
|
||||
});
|
||||
const started = waitForNextEvent(first, "thread_started");
|
||||
firstServer.startsTurn({ threadId: "thread-1" });
|
||||
await started;
|
||||
const handle = first.describePersistence()!;
|
||||
expect(handle.metadata).not.toHaveProperty("pluginAppendSystemPrompt");
|
||||
await first.close();
|
||||
|
||||
const secondServer = createFakeCodexAppServer({ "collaborationMode/list": () => modes });
|
||||
const resumed = await createProviderWithFakeAppServer(secondServer).resumeSession(handle, {
|
||||
pluginAppendSystemPrompt: undefined,
|
||||
});
|
||||
try {
|
||||
const resume = secondServer.requests().find((request) => request.method === "thread/resume");
|
||||
expect(resume?.params).not.toHaveProperty("developerInstructions");
|
||||
await resumed.startTurn("after disable");
|
||||
const secondTurn = await secondServer.waitForTurnStart();
|
||||
expect(secondTurn).not.toHaveProperty("developerInstructions");
|
||||
expect(secondTurn).toMatchObject({
|
||||
collaborationMode: { settings: { developer_instructions: null } },
|
||||
});
|
||||
firstServer.assertNoErrors();
|
||||
secondServer.assertNoErrors();
|
||||
} finally {
|
||||
await resumed.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("reopen injects current instructions once, including an explicit empty snapshot", async () => {
|
||||
const handlers = {
|
||||
initialize: () => ({ userAgent: "codex/0.165.0" }),
|
||||
"thread/inject_items": () => ({}),
|
||||
};
|
||||
const firstServer = createFakeCodexAppServer(handlers);
|
||||
const first = await createProviderWithFakeAppServer(firstServer).createSession(
|
||||
createConfig({ pluginAppendSystemPrompt: ["Old plugin"] }),
|
||||
);
|
||||
await first.startTurn("first");
|
||||
const started = waitForNextEvent(first, "thread_started");
|
||||
firstServer.startsTurn({ threadId: "thread-1" });
|
||||
await started;
|
||||
expect(
|
||||
firstServer.requests().filter((request) => request.method === "thread/inject_items"),
|
||||
).toEqual([]);
|
||||
const original = first.describePersistence()!;
|
||||
expect(original.metadata).toHaveProperty("paseoInstructionReceipt");
|
||||
await first.close();
|
||||
|
||||
const changedServer = createFakeCodexAppServer(handlers);
|
||||
const changed = await createProviderWithFakeAppServer(changedServer).resumeSession(original, {
|
||||
pluginAppendSystemPrompt: ["New plugin"],
|
||||
});
|
||||
const injected = changedServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/inject_items");
|
||||
expect(injected).toHaveLength(1);
|
||||
expect(JSON.stringify(injected[0]?.params)).toContain("New plugin");
|
||||
const changedHandle = changed.describePersistence()!;
|
||||
expect(changedHandle.metadata?.paseoInstructionReceipt).not.toEqual(
|
||||
original.metadata?.paseoInstructionReceipt,
|
||||
);
|
||||
await changed.close();
|
||||
|
||||
const sameServer = createFakeCodexAppServer(handlers);
|
||||
const same = await createProviderWithFakeAppServer(sameServer).resumeSession(changedHandle, {
|
||||
pluginAppendSystemPrompt: ["New plugin"],
|
||||
});
|
||||
expect(
|
||||
sameServer.requests().filter((request) => request.method === "thread/inject_items"),
|
||||
).toEqual([]);
|
||||
await same.close();
|
||||
|
||||
const emptyServer = createFakeCodexAppServer(handlers);
|
||||
const empty = await createProviderWithFakeAppServer(emptyServer).resumeSession(changedHandle, {
|
||||
pluginAppendSystemPrompt: undefined,
|
||||
});
|
||||
const removal = emptyServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/inject_items");
|
||||
expect(removal).toHaveLength(1);
|
||||
expect(JSON.stringify(removal[0]?.params)).toContain('\\"instructions\\":\\"\\"');
|
||||
await empty.close();
|
||||
});
|
||||
|
||||
test("requires a compatible Codex version before creating a thread with plugin instructions", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
initialize: () => ({ userAgent: "codex/0.120.0" }),
|
||||
});
|
||||
await expect(
|
||||
createProviderWithFakeAppServer(appServer).createSession(
|
||||
createConfig({ pluginAppendSystemPrompt: ["Plugin instructions"] }),
|
||||
),
|
||||
).rejects.toThrow("Update Codex to 0.121.0 or newer");
|
||||
});
|
||||
|
||||
test("retries an instruction snapshot when injection fails", async () => {
|
||||
const snapshot = new CodexInstructionSnapshot("current", undefined);
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("connection lost"))
|
||||
.mockResolvedValue({});
|
||||
await expect(snapshot.apply("thread-1", request, true)).rejects.toThrow("connection lost");
|
||||
expect(snapshot.describe("thread-1")).toBeUndefined();
|
||||
await snapshot.apply("thread-1", request, true);
|
||||
expect(snapshot.describe("thread-1")).toEqual({
|
||||
threadId: "thread-1",
|
||||
digest: expect.any(String),
|
||||
});
|
||||
await snapshot.apply("thread-1", request, true);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("keeps built-in Plan instructions while sending plugin instructions to the thread", async () => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({
|
||||
data: [
|
||||
{ name: "Plan", mode: "plan", model: null, reasoning_effort: "medium" },
|
||||
{ name: "Default", mode: "default", model: null, reasoning_effort: null },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const session = await createProviderWithFakeAppServer(appServer).createSession(
|
||||
createConfig({
|
||||
pluginAppendSystemPrompt: ["Plugin instructions"],
|
||||
providerOptions: { plan_mode: true },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await session.startTurn("make a plan");
|
||||
const start = appServer.requests().find((request) => request.method === "thread/start");
|
||||
expect(start?.params).toMatchObject({ developerInstructions: "Plugin instructions" });
|
||||
expect(await appServer.waitForTurnStart()).toMatchObject({
|
||||
collaborationMode: { mode: "plan", settings: { developer_instructions: null } },
|
||||
});
|
||||
expect(
|
||||
appServer.requests().find((request) => request.method === "turn/start")?.params,
|
||||
).not.toHaveProperty("developerInstructions");
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ instructions: undefined, expected: undefined },
|
||||
{ instructions: ["Current plugin"], expected: "Current plugin" },
|
||||
])(
|
||||
"rebuilds restored instructions with an unavailable collaboration catalog: $expected",
|
||||
async ({ instructions, expected }) => {
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"thread/resume": () => ({
|
||||
thread: { id: "saved-thread" },
|
||||
collaborationMode: {
|
||||
mode: "default",
|
||||
settings: {
|
||||
model: "gpt-5.4",
|
||||
reasoning_effort: "medium",
|
||||
developer_instructions: "Old plugin",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const session = await createProviderWithFakeAppServer(appServer).resumeSession(
|
||||
{ sessionId: "saved-thread" },
|
||||
{ pluginAppendSystemPrompt: instructions },
|
||||
);
|
||||
try {
|
||||
const resume = appServer.requests().find((request) => request.method === "thread/resume");
|
||||
if (expected) expect(resume?.params).toMatchObject({ developerInstructions: expected });
|
||||
else expect(resume?.params).not.toHaveProperty("developerInstructions");
|
||||
await session.startTurn("after reopen");
|
||||
const turn = await appServer.waitForTurnStart();
|
||||
expect(turn).toMatchObject({
|
||||
collaborationMode: {
|
||||
mode: "default",
|
||||
settings: { model: "gpt-5.4", developer_instructions: null },
|
||||
},
|
||||
});
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("keeps the default effort when a session without one is resumed after a restart", async () => {
|
||||
const beforeRestart = createFakeCodexAppServer();
|
||||
const firstSession = await createProviderWithFakeAppServer(beforeRestart).createSession(
|
||||
@@ -2285,7 +2507,12 @@ describe("Codex app-server provider", () => {
|
||||
]);
|
||||
// Background terminal observation is independent of conversation history loading.
|
||||
const historyMethods = (index: number) =>
|
||||
codex.threadMethods(index).filter((method) => method !== "thread/backgroundTerminals/list");
|
||||
codex
|
||||
.threadMethods(index)
|
||||
.filter(
|
||||
(method) =>
|
||||
method !== "thread/backgroundTerminals/list" && method !== "thread/inject_items",
|
||||
);
|
||||
expect(historyMethods(0)).toEqual([
|
||||
"thread/loaded/list",
|
||||
"thread/resume",
|
||||
@@ -2299,6 +2526,34 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("persists an acknowledged instruction snapshot before a turn on repeated opens", async () => {
|
||||
const artifactDir = path.resolve(process.cwd(), "../../.artifacts");
|
||||
mkdirSync(artifactDir, { recursive: true });
|
||||
const dir = await mkdtemp(path.join(artifactDir, "codex-receipt-"));
|
||||
const storage = new AgentStorage(dir, createTestLogger());
|
||||
const codex = createReopenableCodexAgent({}, storage);
|
||||
try {
|
||||
let handle = codex.handle;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await codex.manager.resumeAgentFromPersistence(handle, codex.config, codex.agentId);
|
||||
await storage.flush();
|
||||
const saved = await storage.get(codex.agentId);
|
||||
expect(saved?.persistence?.metadata?.paseoInstructionReceipt).toEqual(expect.any(Object));
|
||||
handle = saved!.persistence!;
|
||||
await codex.manager.closeAgent(codex.agentId);
|
||||
}
|
||||
expect(
|
||||
codex.appServers
|
||||
.flatMap((server) => server.requests())
|
||||
.filter((request) => request.method === "thread/inject_items"),
|
||||
).toHaveLength(1);
|
||||
} finally {
|
||||
if (codex.manager.getAgent(codex.agentId)) await codex.manager.closeAgent(codex.agentId);
|
||||
await storage.flush();
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("rewinds an agent reopened without reading its Codex history", async () => {
|
||||
const firstTurn = {
|
||||
id: "turn-1",
|
||||
@@ -2818,6 +3073,9 @@ describe("Codex app-server provider", () => {
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({
|
||||
cwd: "/workspace/project",
|
||||
systemPrompt: "Agent instructions",
|
||||
daemonAppendSystemPrompt: "Daemon instructions",
|
||||
pluginAppendSystemPrompt: ["Plugin instructions"],
|
||||
mcpServers: {
|
||||
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents?callerAgentId=agent-1" },
|
||||
},
|
||||
@@ -2849,6 +3107,24 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
await session.revertConversation({ messageId: "codex-first" });
|
||||
|
||||
const forkRequest = appServer.requests().find((request) => request.method === "thread/fork");
|
||||
expect(forkRequest?.params).toMatchObject({
|
||||
developerInstructions: "Agent instructions\n\nDaemon instructions\n\nPlugin instructions",
|
||||
});
|
||||
const rewindRequests = appServer
|
||||
.requests()
|
||||
.filter((request) =>
|
||||
["thread/fork", "thread/rollback", "thread/inject_items"].includes(
|
||||
String(request.method),
|
||||
),
|
||||
);
|
||||
expect(rewindRequests.map((request) => request.method)).toEqual(
|
||||
historyMode === "legacy"
|
||||
? ["thread/fork", "thread/rollback", "thread/inject_items"]
|
||||
: ["thread/fork", "thread/inject_items"],
|
||||
);
|
||||
expect(rewindRequests.at(-1)?.params).toMatchObject({ threadId: "forked-thread" });
|
||||
expect(JSON.stringify(rewindRequests.at(-1)?.params)).toContain("Plugin instructions");
|
||||
const forkConfigs = appServer
|
||||
.requests()
|
||||
.filter((request) => request.method === "thread/fork")
|
||||
|
||||
@@ -55,7 +55,8 @@ import path from "node:path";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { z } from "zod";
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../system-prompt.js";
|
||||
import { composeAgentSystemPrompt } from "../system-prompt.js";
|
||||
import { CodexInstructionSnapshot } from "./codex/instruction-snapshot.js";
|
||||
import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js";
|
||||
import {
|
||||
fileChangesToPreview,
|
||||
@@ -3591,9 +3592,12 @@ interface ConsumedRootCompaction {
|
||||
export class CodexAppServerAgentSession implements AgentSession {
|
||||
readonly provider = CODEX_PROVIDER;
|
||||
readonly capabilities = CODEX_APP_SERVER_CAPABILITIES;
|
||||
readonly persistenceUpdatedOnResume = true;
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly config: AgentSessionConfig;
|
||||
private readonly instructionSnapshot: CodexInstructionSnapshot;
|
||||
private supportsInstructionSnapshots = false;
|
||||
private readonly asyncQuestions: CodexAsyncQuestions;
|
||||
private readonly codexHome: string;
|
||||
private currentMode: string;
|
||||
@@ -3706,6 +3710,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
reasoning_effort?: string | null;
|
||||
developer_instructions?: string | null;
|
||||
}> = [];
|
||||
private restoredCollaborationMode: {
|
||||
name: string;
|
||||
mode: string;
|
||||
model: string;
|
||||
reasoning_effort: string | null;
|
||||
developer_instructions?: never;
|
||||
} | null = null;
|
||||
private resolvedCollaborationMode: {
|
||||
mode: string;
|
||||
settings: Record<string, unknown>;
|
||||
@@ -3751,6 +3762,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.providerOptions =
|
||||
validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {};
|
||||
this.config = config;
|
||||
this.instructionSnapshot = new CodexInstructionSnapshot(
|
||||
composeAgentSystemPrompt(config),
|
||||
resumeHandle?.metadata?.paseoInstructionReceipt,
|
||||
);
|
||||
this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv();
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
@@ -3837,9 +3852,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.registerRequestHandlers();
|
||||
|
||||
try {
|
||||
this.threadRollbackAvailable = codexServerHasThreadRollback(
|
||||
toObjectRecord(initialized)?.userAgent,
|
||||
);
|
||||
const userAgent = toObjectRecord(initialized)?.userAgent;
|
||||
this.threadRollbackAvailable = codexServerHasThreadRollback(userAgent);
|
||||
this.supportsInstructionSnapshots =
|
||||
typeof userAgent === "string" && codexVersionAtLeast(userAgent, [0, 121, 0]);
|
||||
if (
|
||||
!this.currentThreadId &&
|
||||
this.config.pluginAppendSystemPrompt?.length &&
|
||||
!this.supportsInstructionSnapshots
|
||||
) {
|
||||
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.");
|
||||
}
|
||||
|
||||
this.speedModels =
|
||||
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
||||
@@ -4037,18 +4060,17 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
settings: Record<string, unknown>;
|
||||
name: string;
|
||||
} | null {
|
||||
const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code");
|
||||
const match =
|
||||
this.findCollaborationMode(this.planModeEnabled ? "plan" : "code") ??
|
||||
this.restoredCollaborationMode;
|
||||
if (!match) return null;
|
||||
|
||||
const settings: Record<string, unknown> = {};
|
||||
if (match.model) settings.model = match.model;
|
||||
if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort;
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
match.developer_instructions,
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
if (developerInstructions) settings.developer_instructions = developerInstructions;
|
||||
// Codex applies thread instructions separately. Null asks it to supply the
|
||||
// built-in collaboration mode instructions instead of replacing them.
|
||||
settings.developer_instructions = match.developer_instructions ?? null;
|
||||
if (this.config.model) settings.model = this.config.model;
|
||||
const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
if (thinkingOptionId) settings.reasoning_effort = thinkingOptionId;
|
||||
@@ -4308,10 +4330,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
// the unnamespaced key emitted by earlier Paseo versions.
|
||||
params.modelProvider = this.deps.customCodexConfig.model_provider;
|
||||
}
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const developerInstructions = composeAgentSystemPrompt(this.config);
|
||||
if (developerInstructions) {
|
||||
params.developerInstructions = developerInstructions;
|
||||
}
|
||||
@@ -4322,11 +4341,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
try {
|
||||
const loaded = toObjectRecord(await this.client.request("thread/loaded/list", {}));
|
||||
const ids = Array.isArray(loaded?.data) ? loaded.data : [];
|
||||
if (ids.includes(this.currentThreadId)) {
|
||||
return;
|
||||
if (!ids.includes(this.currentThreadId)) {
|
||||
const response = await this.client.request("thread/resume", params);
|
||||
this.rememberResolvedSandboxPolicy(response);
|
||||
this.rememberRestoredCollaborationMode(response);
|
||||
}
|
||||
const response = await this.client.request("thread/resume", params);
|
||||
this.rememberResolvedSandboxPolicy(response);
|
||||
} catch (error) {
|
||||
const threadId = this.currentThreadId;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -4340,12 +4359,34 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
const response = await this.client.request("thread/resume", params);
|
||||
this.rememberResolvedSandboxPolicy(response);
|
||||
this.rememberRestoredCollaborationMode(response);
|
||||
this.logger.info({ threadId }, "Unarchived Codex thread to restore active Paseo agent");
|
||||
return;
|
||||
} else {
|
||||
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
|
||||
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
|
||||
}
|
||||
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
|
||||
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
|
||||
}
|
||||
await this.instructionSnapshot.apply(
|
||||
this.currentThreadId,
|
||||
this.client.request.bind(this.client),
|
||||
this.supportsInstructionSnapshots,
|
||||
);
|
||||
}
|
||||
|
||||
private rememberRestoredCollaborationMode(response: unknown): void {
|
||||
const mode = toObjectRecord(toObjectRecord(response)?.collaborationMode);
|
||||
const settings = toObjectRecord(mode?.settings);
|
||||
if (typeof mode?.mode !== "string" || typeof settings?.model !== "string") return;
|
||||
// The resumed mode can contain instructions from a previous launch. Keep its
|
||||
// identity and model, and let Codex restore the built-in mode instructions.
|
||||
this.restoredCollaborationMode = {
|
||||
name: mode.mode,
|
||||
mode: mode.mode,
|
||||
model: settings.model,
|
||||
reasoning_effort:
|
||||
typeof settings.reasoning_effort === "string" ? settings.reasoning_effort : null,
|
||||
};
|
||||
this.refreshResolvedCollaborationMode();
|
||||
}
|
||||
|
||||
private parseSlashCommandInput(text: string): { commandName: string; args?: string } | null {
|
||||
@@ -4461,13 +4502,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (options?.outputSchema) {
|
||||
params.outputSchema = normalizeCodexOutputSchema(options.outputSchema);
|
||||
}
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
if (developerInstructions) {
|
||||
params.developerInstructions = developerInstructions;
|
||||
}
|
||||
const developerInstructions = composeAgentSystemPrompt(this.config);
|
||||
const codexConfig = this.buildCodexInnerConfig();
|
||||
if (codexConfig) {
|
||||
params.config = codexConfig;
|
||||
@@ -5226,6 +5261,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
systemPrompt: this.config.systemPrompt,
|
||||
mcpServers: this.config.mcpServers,
|
||||
asyncQuestions: this.asyncQuestions.serialize(),
|
||||
...(this.instructionSnapshot.describe(threadId)
|
||||
? { paseoInstructionReceipt: this.instructionSnapshot.describe(threadId) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5256,9 +5294,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
model: this.config.model ?? null,
|
||||
serviceTier: this.serviceTier,
|
||||
config: this.buildCodexInnerConfig(),
|
||||
developerInstructions: composeAgentSystemPrompt(this.config),
|
||||
userMessageTurns: this.codexUserMessageTurns(),
|
||||
threadRollbackAvailable: this.threadRollbackAvailable,
|
||||
setThreadId: async (threadId) => {
|
||||
await this.instructionSnapshot.apply(
|
||||
threadId,
|
||||
this.client!.request.bind(this.client),
|
||||
this.supportsInstructionSnapshots,
|
||||
);
|
||||
this.currentThreadId = threadId;
|
||||
// Codex writes a forked thread's rollout when it forks it, even before any turn.
|
||||
this.savedThreadId = threadId;
|
||||
@@ -5792,6 +5836,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
this.currentThreadId = threadId;
|
||||
this.instructionSnapshot.recordNewThread(threadId);
|
||||
}
|
||||
|
||||
private buildThreadStartRequest(model: string): {
|
||||
@@ -5803,10 +5848,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const approvalPolicy = this.hasWorkflowModeOverride ? preset.approvalPolicy : undefined;
|
||||
const sandbox = this.hasWorkflowModeOverride ? preset.sandbox : undefined;
|
||||
const innerConfig = this.buildCodexInnerConfig();
|
||||
const developerInstructions = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const developerInstructions = composeAgentSystemPrompt(this.config);
|
||||
const params: Record<string, unknown> = {
|
||||
model,
|
||||
cwd: this.config.cwd ?? null,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { CodexAppServerRpcError } from "./app-server-transport.js";
|
||||
|
||||
/** The receipt records only which snapshot was delivered to a particular native thread. */
|
||||
interface InstructionReceipt {
|
||||
threadId: string;
|
||||
digest: string;
|
||||
}
|
||||
|
||||
function readReceipt(value: unknown): InstructionReceipt | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const receipt = value as Record<string, unknown>;
|
||||
if (typeof receipt.threadId !== "string" || typeof receipt.digest !== "string") return null;
|
||||
if (!/^[a-f0-9]{64}$/.test(receipt.digest)) return null;
|
||||
return { threadId: receipt.threadId, digest: receipt.digest };
|
||||
}
|
||||
|
||||
export class CodexInstructionSnapshot {
|
||||
private receipt: InstructionReceipt | null;
|
||||
private readonly digest: string;
|
||||
|
||||
constructor(
|
||||
private readonly instructions: string | undefined,
|
||||
receipt: unknown,
|
||||
) {
|
||||
this.receipt = readReceipt(receipt);
|
||||
this.digest = createHash("sha256")
|
||||
.update(JSON.stringify({ version: 1, instructions: instructions ?? "" }))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
/** A new thread receives its first snapshot through thread/start. */
|
||||
recordNewThread(threadId: string): void {
|
||||
this.receipt = { threadId, digest: this.digest };
|
||||
}
|
||||
|
||||
describe(threadId: string): InstructionReceipt | undefined {
|
||||
return this.receipt?.threadId === threadId ? { ...this.receipt } : undefined;
|
||||
}
|
||||
|
||||
async apply(
|
||||
threadId: string,
|
||||
request: (method: string, params: unknown) => Promise<unknown>,
|
||||
supported: boolean,
|
||||
): Promise<void> {
|
||||
if (this.receipt?.threadId === threadId && this.receipt.digest === this.digest) return;
|
||||
if (!supported)
|
||||
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.");
|
||||
const snapshot = JSON.stringify({ version: 1, instructions: this.instructions ?? "" });
|
||||
const text =
|
||||
"This is the complete current set of Paseo-supplied instructions. It supersedes all earlier Paseo-supplied instructions in the conversation, including earlier snapshots. An empty instruction string means there are no current Paseo-supplied instructions. Provider and collaboration instructions remain in effect.\n\n<paseo_instruction_snapshot>" +
|
||||
snapshot +
|
||||
"</paseo_instruction_snapshot>";
|
||||
try {
|
||||
await request("thread/inject_items", {
|
||||
threadId,
|
||||
items: [{ type: "message", role: "developer", content: [{ type: "input_text", text }] }],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof CodexAppServerRpcError && error.code === -32601) {
|
||||
throw new Error("Update Codex to 0.121.0 or newer to apply agent instruction updates.", {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.receipt = { threadId, digest: this.digest };
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ export async function revertCodexConversation(input: {
|
||||
model?: string | null;
|
||||
serviceTier?: string | null;
|
||||
config?: Record<string, unknown> | null;
|
||||
developerInstructions?: string;
|
||||
userMessageTurns: CodexUserMessageTurnIndex;
|
||||
threadRollbackAvailable: boolean;
|
||||
setThreadId: (threadId: string) => void | Promise<void>;
|
||||
@@ -76,6 +77,7 @@ export async function revertCodexConversation(input: {
|
||||
model: input.model ?? null,
|
||||
serviceTier: input.serviceTier ?? null,
|
||||
...(input.config ? { config: input.config } : {}),
|
||||
...(input.developerInstructions ? { developerInstructions: input.developerInstructions } : {}),
|
||||
// Paseo re-reads the rewound thread through paged history, not the fork reply.
|
||||
excludeTurns: true,
|
||||
persistExtendedHistory: true,
|
||||
|
||||
@@ -171,7 +171,7 @@ export function createFakeCodexAppServer(
|
||||
const child = createCodexAppServerChildProcess();
|
||||
const recordedRollbacks: JsonObject[] = [];
|
||||
const responseHandlers: Record<string, FakeCodexAppServerHandler> = {
|
||||
initialize: () => ({}),
|
||||
initialize: () => ({ userAgent: "codex/0.150.0" }),
|
||||
"collaborationMode/list": () => ({ data: [] }),
|
||||
"config/read": () => ({ config: {} }),
|
||||
getUserSavedConfig: () => ({ config: {} }),
|
||||
@@ -189,6 +189,7 @@ export function createFakeCodexAppServer(
|
||||
"thread/loaded/list": () => ({ data: [] }),
|
||||
"thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }),
|
||||
"thread/resume": () => ({}),
|
||||
"thread/inject_items": () => ({}),
|
||||
"turn/start": () => ({}),
|
||||
"thread/fork": (params) => ({
|
||||
thread: {
|
||||
|
||||
@@ -466,6 +466,7 @@ function buildResumeStartInput(input: {
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
input.resumeConfig.config.systemPrompt,
|
||||
input.resumeConfig.config.daemonAppendSystemPrompt,
|
||||
...(input.resumeConfig.config.pluginAppendSystemPrompt ?? []),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -2370,7 +2371,11 @@ export class OmpAgentClient implements AgentClient {
|
||||
noSession: config.internal === true,
|
||||
modeId: launchMode.modeId,
|
||||
extraArgs: launchMode.extraArgs,
|
||||
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
...(config.pluginAppendSystemPrompt ?? []),
|
||||
),
|
||||
env: launchContext?.env,
|
||||
};
|
||||
const runtimeSession = await this.resolveRuntime(config.providerOptions).startSession(
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type AgentDraftControls,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
type AgentPermissionAction,
|
||||
@@ -1521,6 +1522,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launchContext?: AgentLaunchContext,
|
||||
_options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
||||
const cwd = overrides?.cwd ?? metadata.cwd;
|
||||
@@ -3692,6 +3694,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const systemPrompt = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
...(this.config.pluginAppendSystemPrompt ?? []),
|
||||
);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
@@ -4029,6 +4032,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const systemPrompt = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
...(this.config.pluginAppendSystemPrompt ?? []),
|
||||
);
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AgentSessionConfig,
|
||||
AgentLaunchContext,
|
||||
AgentCreateSessionOptions,
|
||||
AgentResumeSessionOptions,
|
||||
AgentPersistenceHandle,
|
||||
FetchCatalogOptions,
|
||||
ProviderRefreshContext,
|
||||
@@ -149,9 +150,10 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
launch?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
) {
|
||||
const client = await this.client();
|
||||
const session = await client.resumeSession(handle, config, launch);
|
||||
const session = await client.resumeSession(handle, config, launch, options);
|
||||
return client === this.legacy ? session : withOpenCodeRuntimeNotice(session, 2, handle);
|
||||
}
|
||||
async fetchCatalog(options: FetchCatalogOptions, context?: ProviderRefreshContext) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export class V2Harness {
|
||||
readonly prompts: string[] = [];
|
||||
readonly environments: Array<{ sessionID: string; variables: Record<string, string> }> = [];
|
||||
readonly mcpAdds: string[] = [];
|
||||
readonly instructions = new Map<string, string>();
|
||||
releases = 0;
|
||||
active = false;
|
||||
activeReads = 0;
|
||||
@@ -153,7 +154,17 @@ export class V2Harness {
|
||||
environment: async (input) => {
|
||||
this.environments.push(input);
|
||||
},
|
||||
instructions: { entry: { list: unexpected, put: unexpected, remove: unexpected } },
|
||||
instructions: {
|
||||
entry: {
|
||||
list: async () => [...this.instructions].map(([key, value]) => ({ key, value })),
|
||||
put: async ({ key, value }) => {
|
||||
this.instructions.set(key, String(value));
|
||||
},
|
||||
remove: async ({ key }) => {
|
||||
this.instructions.delete(key);
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: async (input, options) => {
|
||||
const before = this.executions.length;
|
||||
await this.prompt(input, options);
|
||||
|
||||
@@ -17,6 +17,54 @@ function terminalEvents(events: AgentStreamEvent[]) {
|
||||
}
|
||||
|
||||
describe("OpenCode v2 session lifecycle", () => {
|
||||
test("reopening replaces and removes plugin instructions", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const created = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
pluginAppendSystemPrompt: ["first"],
|
||||
});
|
||||
expect(harness.instructions.get("paseo")).toBe("first");
|
||||
const handle = created.describePersistence()!;
|
||||
await created.close();
|
||||
const resumed = await client.resumeSession(handle, {
|
||||
cwd: "/tmp/project",
|
||||
pluginAppendSystemPrompt: ["changed"],
|
||||
});
|
||||
expect(harness.instructions.get("paseo")).toBe("changed");
|
||||
await resumed.close();
|
||||
const cleared = await client.resumeSession(handle, {
|
||||
cwd: "/tmp/project",
|
||||
pluginAppendSystemPrompt: undefined,
|
||||
});
|
||||
expect(harness.instructions.has("paseo")).toBe(false);
|
||||
await cleared.close();
|
||||
});
|
||||
|
||||
test("opening history does not change an existing session instruction", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.instructions.set("paseo", "Existing plugin instruction");
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "session", metadata: { cwd: "/tmp/project" } },
|
||||
{ cwd: "/tmp/project" },
|
||||
undefined,
|
||||
{ purpose: "history" },
|
||||
);
|
||||
try {
|
||||
expect(harness.instructions.get("paseo")).toBe("Existing plugin instruction");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes the existing location before reading a forced catalog", async () => {
|
||||
const harness = new V2Harness();
|
||||
const refreshed: string[] = [];
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentResumeSessionOptions,
|
||||
AgentDraftControls,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
@@ -131,6 +132,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
launch?: AgentLaunchContext,
|
||||
options?: AgentResumeSessionOptions,
|
||||
): Promise<AgentSession> {
|
||||
const cwd = overrides?.cwd ?? handle.metadata?.cwd;
|
||||
if (typeof cwd !== "string")
|
||||
@@ -151,7 +153,14 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
sessionID: handle.nativeHandle ?? handle.sessionId,
|
||||
});
|
||||
await applyResumeOverrides(connection.client, info, overrides);
|
||||
return await this.attach(connection, info, config, launch, true);
|
||||
return await this.attach(
|
||||
connection,
|
||||
info,
|
||||
config,
|
||||
launch,
|
||||
true,
|
||||
options?.purpose === "history",
|
||||
);
|
||||
} catch (error) {
|
||||
await connection.release();
|
||||
throw error;
|
||||
@@ -163,6 +172,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
launch: AgentLaunchContext | undefined,
|
||||
persist: boolean,
|
||||
historyOnly = false,
|
||||
) {
|
||||
const acquire = () =>
|
||||
this.runtime.acquire(
|
||||
@@ -209,6 +219,7 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
bindChild,
|
||||
acquire,
|
||||
moved,
|
||||
historyOnly,
|
||||
);
|
||||
try {
|
||||
await session.initialize(
|
||||
|
||||
@@ -75,6 +75,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
bindChild?: (id: string) => void,
|
||||
private readonly acquire?: () => Promise<V2Connection>,
|
||||
private readonly moved?: (connection: V2Connection) => void,
|
||||
private readonly historyOnly = false,
|
||||
) {
|
||||
this.background = new OpenCodeBackgroundInventory({
|
||||
id: info.id,
|
||||
@@ -193,16 +194,24 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
});
|
||||
await this.awaitMcp(server);
|
||||
}
|
||||
if (this.historyOnly) return;
|
||||
const system = composeSystemPromptParts(
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
...(this.config.pluginAppendSystemPrompt ?? []),
|
||||
);
|
||||
if (system)
|
||||
if (system) {
|
||||
await this.client.session.instructions.entry.put({
|
||||
sessionID: this.id,
|
||||
key: "paseo",
|
||||
value: system,
|
||||
});
|
||||
} else {
|
||||
const entries = await this.client.session.instructions.entry.list({ sessionID: this.id });
|
||||
if (entries.some((entry) => entry.key === "paseo")) {
|
||||
await this.client.session.instructions.entry.remove({ sessionID: this.id, key: "paseo" });
|
||||
}
|
||||
}
|
||||
}
|
||||
private async startStream() {
|
||||
let ready!: () => void;
|
||||
|
||||
@@ -1892,6 +1892,7 @@ describe("PiRpcAgentSession", () => {
|
||||
createConfig({
|
||||
systemPrompt: "Agent prompt",
|
||||
daemonAppendSystemPrompt: "Daemon prompt",
|
||||
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1910,7 +1911,9 @@ describe("PiRpcAgentSession", () => {
|
||||
|
||||
await expect(
|
||||
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
|
||||
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
|
||||
).resolves.toBe(
|
||||
"Pi project prompt\n\nAgent prompt\n\nDaemon prompt\n\nPlugin one\n\nPlugin two",
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
@@ -1933,6 +1936,7 @@ describe("PiRpcAgentSession", () => {
|
||||
},
|
||||
{
|
||||
daemonAppendSystemPrompt: "Daemon prompt",
|
||||
pluginAppendSystemPrompt: ["Plugin one", "Plugin two"],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1956,7 +1960,9 @@ describe("PiRpcAgentSession", () => {
|
||||
]);
|
||||
await expect(
|
||||
applyPaseoExtensionSystemPrompt(actualLaunch.extensionPaths[0]!, "Pi project prompt"),
|
||||
).resolves.toBe("Pi project prompt\n\nAgent prompt\n\nDaemon prompt");
|
||||
).resolves.toBe(
|
||||
"Pi project prompt\n\nAgent prompt\n\nDaemon prompt\n\nPlugin one\n\nPlugin two",
|
||||
);
|
||||
});
|
||||
|
||||
test("updates model and thinking through Pi runtime commands", async () => {
|
||||
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
type ResolvedProviderLaunch,
|
||||
} from "../../provider-launch-config.js";
|
||||
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { composeSystemPromptParts } from "../../system-prompt.js";
|
||||
import { composeAgentSystemPrompt } from "../../system-prompt.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
@@ -2630,7 +2630,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
const mcp = await this.prepareMcpInjection(config.cwd, config.mcpServers, mcpEnv, runtime);
|
||||
const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
|
||||
const paseoExtension = createPiPaseoExtensionFile({
|
||||
systemPrompt: composeSystemPromptParts(config.systemPrompt, config.daemonAppendSystemPrompt),
|
||||
systemPrompt: composeAgentSystemPrompt(config),
|
||||
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
|
||||
});
|
||||
let runtimeSession: PiRuntimeSession;
|
||||
@@ -2699,10 +2699,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
);
|
||||
const mcpConfigFile = mcp?.kind === "adapter" ? mcp.configFile : null;
|
||||
const paseoExtension = createPiPaseoExtensionFile({
|
||||
systemPrompt: composeSystemPromptParts(
|
||||
resumeConfig.config.systemPrompt,
|
||||
resumeConfig.config.daemonAppendSystemPrompt,
|
||||
),
|
||||
systemPrompt: composeAgentSystemPrompt(resumeConfig.config),
|
||||
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
|
||||
});
|
||||
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
|
||||
|
||||
@@ -8,3 +8,15 @@ export function composeSystemPromptParts(
|
||||
|
||||
return prompt.length > 0 ? prompt : undefined;
|
||||
}
|
||||
|
||||
export function composeAgentSystemPrompt(config: {
|
||||
systemPrompt?: string;
|
||||
daemonAppendSystemPrompt?: string;
|
||||
pluginAppendSystemPrompt?: string[];
|
||||
}): string | undefined {
|
||||
return composeSystemPromptParts(
|
||||
config.systemPrompt,
|
||||
config.daemonAppendSystemPrompt,
|
||||
...(config.pluginAppendSystemPrompt ?? []),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -527,13 +527,25 @@ test("agent creation hooks change the provider and environment before the sessio
|
||||
});
|
||||
server.addHook("agent.open.before", async ({ request }) => {
|
||||
console.log(JSON.stringify({ hook: "agent.open.before", request }));
|
||||
return { ...request, env: { ...request.env, HOOK_OPEN: "opened" } };
|
||||
return { ...request, appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Plugin instructions"], env: { ...request.env, HOOK_OPEN: "opened" } };
|
||||
});
|
||||
server.addHook("agent.create.after", async (event) => {
|
||||
console.log(JSON.stringify({ hook: "agent.create.after", event }));
|
||||
});`,
|
||||
reportOpen: true,
|
||||
});
|
||||
const extraPlugin = path.join(directory, "extra-plugin");
|
||||
await mkdir(extraPlugin);
|
||||
await writeFile(
|
||||
path.join(extraPlugin, "paseo-plugin.json"),
|
||||
JSON.stringify({ id: "a-prompt", requirements: { paseo: ">=0.8.0" } }),
|
||||
);
|
||||
const writePrompt = (prompt: string) =>
|
||||
writeFile(
|
||||
path.join(extraPlugin, "index.server.ts"),
|
||||
`export default function contribute(server) { server.addHook("agent.open.before", async ({ request }) => ({ ...request, appendSystemPrompt: [...(request.appendSystemPrompt ?? []), ${JSON.stringify(prompt)}] })); return () => {}; }`,
|
||||
);
|
||||
await writePrompt("First plugin");
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
@@ -541,15 +553,17 @@ test("agent creation hooks change the provider and environment before the sessio
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pluginDirectory);
|
||||
await client.installDirectoryPlugin(extraPlugin);
|
||||
const agent = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: directory,
|
||||
title: "Hook test",
|
||||
systemPrompt: "Saved instructions",
|
||||
});
|
||||
expect(agent.provider).toBe(PROVIDER_ID);
|
||||
expect(await daemon.daemon.agentStorage.get(agent.id)).toMatchObject({
|
||||
provider: PROVIDER_ID,
|
||||
config: { model: "shutdown-model" },
|
||||
config: { model: "shutdown-model", systemPrompt: "Saved instructions" },
|
||||
});
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -569,13 +583,32 @@ test("agent creation hooks change the provider and environment before the sessio
|
||||
},
|
||||
{
|
||||
hook: "provider.open",
|
||||
config: { env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" } },
|
||||
config: {
|
||||
env: { HOOK_CREATE: "created", HOOK_OPEN: "opened" },
|
||||
systemPrompt: "Saved instructions\n\nFirst plugin\n\nPlugin instructions",
|
||||
},
|
||||
},
|
||||
{
|
||||
hook: "agent.create.after",
|
||||
event: { agent: { id: agent.id, provider: PROVIDER_ID } },
|
||||
},
|
||||
]);
|
||||
await writePrompt("Changed plugin");
|
||||
await client.reloadPlugin("a-prompt");
|
||||
await daemon.daemon.agentManager.reloadAgentSession(agent.id);
|
||||
await client.disablePlugin("a-prompt");
|
||||
await daemon.daemon.agentManager.reloadAgentSession(agent.id);
|
||||
const opens = (await client.getPluginLogs("shutdown-provider-plugin"))
|
||||
.filter((entry) => entry.message.startsWith('{"hook":"provider.open"'))
|
||||
.map((entry) => JSON.parse(entry.message).config.systemPrompt);
|
||||
expect(opens).toEqual([
|
||||
"Saved instructions\n\nFirst plugin\n\nPlugin instructions",
|
||||
"Saved instructions\n\nChanged plugin\n\nPlugin instructions",
|
||||
"Saved instructions\n\nPlugin instructions",
|
||||
]);
|
||||
expect((await daemon.daemon.agentStorage.get(agent.id))?.config?.systemPrompt).toBe(
|
||||
"Saved instructions",
|
||||
);
|
||||
await client.archiveAgent(agent.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
|
||||
@@ -228,7 +228,9 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }, { background:
|
||||
},
|
||||
paseo,
|
||||
),
|
||||
).rejects.toThrow("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
).rejects.toThrow(
|
||||
"agent.open.before hooks can only change env, tools, mcpServers, and appendSystemPrompt",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -357,3 +359,42 @@ test("checkout-branch hooks reject a branch-off baseRef", async () => {
|
||||
validateBeforeResult("worktree.create.before", request, { ...request, baseRef: "main" }),
|
||||
).rejects.toThrow(/baseRef/);
|
||||
});
|
||||
|
||||
test("session-open instructions compose, survive old hooks, and are ignored for history", async () => {
|
||||
const hooks = new PluginHookHandlers(() => {});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
...request,
|
||||
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "first"],
|
||||
}));
|
||||
hooks.addHook("agent.open.before", async ({ request }) => {
|
||||
const { appendSystemPrompt: _ignored, ...oldRequest } = request;
|
||||
return oldRequest;
|
||||
});
|
||||
hooks.addHook("agent.open.before", async ({ request }) => ({
|
||||
...request,
|
||||
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "second"],
|
||||
}));
|
||||
const input = {
|
||||
agentId: "agent",
|
||||
workspaceId: null,
|
||||
provider: "codex",
|
||||
cwd: "/project",
|
||||
reason: "resume",
|
||||
purpose: "interactive",
|
||||
env: {},
|
||||
title: null,
|
||||
labels: {},
|
||||
metadata: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
const interactive = await hooks.invoke("interactive", "agent.open.before", input, paseo);
|
||||
expect(interactive).toMatchObject({ appendSystemPrompt: ["first", "second"] });
|
||||
const history = await hooks.invoke(
|
||||
"history",
|
||||
"agent.open.before",
|
||||
{ ...input, purpose: "history" },
|
||||
paseo,
|
||||
);
|
||||
expect(history).not.toHaveProperty("appendSystemPrompt");
|
||||
});
|
||||
|
||||
@@ -108,6 +108,7 @@ const beforeSchemas = {
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
|
||||
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
|
||||
appendSystemPrompt: z.array(z.string()).optional(),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
@@ -248,9 +249,18 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
|
||||
const previous = beforeSchemas["agent.open.before"].parse(input);
|
||||
const next = beforeSchemas["agent.open.before"].parse(result);
|
||||
if (changesSessionOpenTarget(previous, next)) {
|
||||
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
|
||||
throw new Error(
|
||||
"agent.open.before hooks can only change env, tools, mcpServers, and appendSystemPrompt",
|
||||
);
|
||||
}
|
||||
return { ...result, background: previous.background, metadata: previous.metadata };
|
||||
return {
|
||||
...result,
|
||||
background: previous.background,
|
||||
metadata: previous.metadata,
|
||||
...(next.appendSystemPrompt === undefined && previous.appendSystemPrompt !== undefined
|
||||
? { appendSystemPrompt: previous.appendSystemPrompt }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (name === "agent.create.before") {
|
||||
const previous = beforeSchemas["agent.create.before"].parse(input);
|
||||
@@ -376,6 +386,7 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
|
||||
if (request.purpose === "history") {
|
||||
request.tools = [];
|
||||
request.mcpServers = {};
|
||||
delete request.appendSystemPrompt;
|
||||
} else this.agentTools?.attach(request.agentId, tools);
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
return {
|
||||
...request,
|
||||
appendSystemPrompt: [
|
||||
...(request.appendSystemPrompt ?? []),
|
||||
"Follow the team engineering guide.",
|
||||
],
|
||||
mcpServers: {
|
||||
...request.mcpServers,
|
||||
company: {
|
||||
|
||||
@@ -31,7 +31,7 @@ Names follow `subject.operation.position`: the subject, the operation, then `bef
|
||||
|
||||
All handlers must be async. Turn completion includes success, failure, and cancellation; inspect `outcome`. Permission completion includes every resolution; inspect `resolution`.
|
||||
|
||||
Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action.
|
||||
Creating an agent happens once, and `agent.create.before` changes its saved configuration. Opening starts the provider process: the first run, resume after a daemon restart, reading history, importing an outside session, or reload. `agent.open.before` contributes launch-only environment, instructions, tools, and MCP servers each time. Creating opens the agent; opening an existing agent does not create it; closing ends the running instance. Opening a tab in the app is a separate action.
|
||||
|
||||
`worktree.create.after` runs before the workspace is registered. It precedes `workspace.create.after`, and both precede the first agent's `agent.create.before` / `agent.open.before`.
|
||||
|
||||
@@ -49,22 +49,22 @@ Paseo only sees the returned promise, so detached work gets no error reporting a
|
||||
|
||||
## Hooks
|
||||
|
||||
| Hook | Waits | Payload | Handler may change |
|
||||
| ------------------------- | ----- | ----------------------------------------- | ---------------------------------------------- |
|
||||
| `workspace.create.before` | yes | `{ request }` | Entire create request |
|
||||
| `workspace.create.after` | no | `{ workspace }` | |
|
||||
| `workspace.archive.after` | no | `{ workspace }` | |
|
||||
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
|
||||
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
|
||||
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
|
||||
| `agent.create.after` | no | `{ agent }` | |
|
||||
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `tools`, `mcpServers` |
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
| Hook | Waits | Payload | Handler may change |
|
||||
| ------------------------- | ----- | ----------------------------------------- | -------------------------------------------------------------- |
|
||||
| `workspace.create.before` | yes | `{ request }` | Entire create request |
|
||||
| `workspace.create.after` | no | `{ workspace }` | |
|
||||
| `workspace.archive.after` | no | `{ workspace }` | |
|
||||
| `worktree.create.before` | yes | `{ request }` | Creation options and destination |
|
||||
| `worktree.create.after` | yes | `{ worktree }` | Files in the worktree; throw to abort creation |
|
||||
| `agent.create.before` | yes | `{ request: { config, env?, metadata } }` | Saved config except `cwd`; `env`; `metadata` |
|
||||
| `agent.create.after` | no | `{ agent }` | |
|
||||
| `agent.open.before` | yes | `{ request }` | Launch-only `env`, `appendSystemPrompt`, `tools`, `mcpServers` |
|
||||
| `agent.close.after` | no | `{ agent }` | |
|
||||
| `agent.archive.after` | no | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | no | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | no | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | no | `{ agent, request }` | |
|
||||
| `agent.permission.after` | no | `{ agent, requestId, resolution }` | |
|
||||
|
||||
## Shared payloads
|
||||
|
||||
@@ -283,7 +283,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
|
||||
|
||||
## agent.open.before
|
||||
|
||||
Receives `{ request }`; only `env`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open.
|
||||
Receives `{ request }`; only `env`, `appendSystemPrompt`, `tools`, and `mcpServers` may change, for this launch. All other fields are read-only. A throw, invalid result, or timeout fails the open.
|
||||
|
||||
Do not await a write to the agent being opened: `setMetadata`, `setLabels`, or an option, model, thinking or mode change. The write waits for the open to finish, the open waits for your hook, so the hook times out and the open fails. Start the write without awaiting it; it runs right after the open:
|
||||
|
||||
@@ -329,6 +329,19 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
|
||||
| `purpose` | `interactive`, `history` |
|
||||
| `env` | Launch override map; excludes the daemon's inherited environment. Replace the map to add, replace, or remove overrides. |
|
||||
|
||||
### Append instructions on every session opening
|
||||
|
||||
```ts
|
||||
server.addHook("agent.open.before", async ({ request }) => ({
|
||||
...request,
|
||||
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Follow the team engineering guide."],
|
||||
}));
|
||||
```
|
||||
|
||||
Instructions follow the saved agent prompt and daemon instructions, in plugin order. They are not saved in the agent configuration and are ignored for history opens. Reload the plugin after changing its data to apply new instructions on the next reopen. Running turns continue; removing an instruction does not remove conversation history. Internal agents also receive these instructions. The ACP adapter does not send system instructions to its providers. Plugin providers receive the composed prompt, but must apply it themselves.
|
||||
|
||||
Codex instruction updates require Codex 0.121.0 or newer with a recognizable version reported by the app server. Source builds reporting `0.0.0` must use a versioned build. Paseo adds a current instruction snapshot to existing threads; earlier snapshots remain in the conversation. A crash during an update or rewinding a thread can repeat a snapshot.
|
||||
|
||||
### Inject environment variables on every session opening
|
||||
|
||||
```ts
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
所有处理函数都必须为异步函数。轮次完成包括成功、失败和取消,请查看 `outcome`。权限完成包括所有处理结果,请查看 `resolution`。
|
||||
|
||||
创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。
|
||||
创建 Agent 会发生一次,`agent.create.before` 修改已保存的配置。打开 Agent 指启动 Provider 进程,包括首次运行、daemon 重启后恢复、读取历史、导入外部会话和重新加载。`agent.open.before` 为每次启动提供临时的环境变量、指令、工具和 MCP 服务器。创建会打开 Agent;打开已有 Agent 不会创建它;关闭会结束运行中的实例。在应用中打开标签页不属于此操作。
|
||||
|
||||
`worktree.create.after` 在工作区注册前运行,随后是 `workspace.create.after`,两者都早于第一个 Agent 的 `agent.create.before` / `agent.open.before`。
|
||||
|
||||
@@ -47,22 +47,22 @@ Paseo 只观察返回的 Promise,因此独立任务没有错误报告,也没
|
||||
|
||||
## 钩子一览 {#hooks}
|
||||
|
||||
| 钩子 | 等待 | 载荷 | 可修改 |
|
||||
| ------------------------- | ---- | ----------------------------------------- | ------------------------------------------ |
|
||||
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
|
||||
| `workspace.create.after` | 否 | `{ workspace }` | |
|
||||
| `workspace.archive.after` | 否 | `{ workspace }` | |
|
||||
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
|
||||
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
|
||||
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
|
||||
| `agent.create.after` | 否 | `{ agent }` | |
|
||||
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`tools`、`mcpServers` |
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
| 钩子 | 等待 | 载荷 | 可修改 |
|
||||
| ------------------------- | ---- | ----------------------------------------- | --------------------------------------------------------------- |
|
||||
| `workspace.create.before` | 是 | `{ request }` | 整个创建请求 |
|
||||
| `workspace.create.after` | 否 | `{ workspace }` | |
|
||||
| `workspace.archive.after` | 否 | `{ workspace }` | |
|
||||
| `worktree.create.before` | 是 | `{ request }` | 创建选项和目标路径 |
|
||||
| `worktree.create.after` | 是 | `{ worktree }` | worktree 中的文件;抛出错误中止创建 |
|
||||
| `agent.create.before` | 是 | `{ request: { config, env?, metadata } }` | 除 `cwd` 外的已保存配置;`env`;`metadata` |
|
||||
| `agent.create.after` | 否 | `{ agent }` | |
|
||||
| `agent.open.before` | 是 | `{ request }` | 仅用于启动的 `env`、`appendSystemPrompt`、`tools`、`mcpServers` |
|
||||
| `agent.close.after` | 否 | `{ agent }` | |
|
||||
| `agent.archive.after` | 否 | `{ agent, archivedAt }` | |
|
||||
| `agent.turn.before` | 否 | `{ agent, turnId }` | |
|
||||
| `agent.turn.after` | 否 | `{ agent, turnId, outcome }` | |
|
||||
| `agent.permission.before` | 否 | `{ agent, request }` | |
|
||||
| `agent.permission.after` | 否 | `{ agent, requestId, resolution }` | |
|
||||
|
||||
## 共享载荷 {#shared-payloads}
|
||||
|
||||
@@ -279,7 +279,7 @@ server.addHook("agent.create.after", async ({ agent }) => {
|
||||
|
||||
## agent.open.before {#agentopenbefore}
|
||||
|
||||
收到 `{ request }`,只能修改 `env`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。
|
||||
收到 `{ request }`,只能修改 `env`、`appendSystemPrompt`、`tools` 和 `mcpServers`,修改只用于此次启动。其他字段只读。抛出错误、无效返回或超时会让打开操作失败。
|
||||
|
||||
不要 await 对正在打开的 Agent 的写入,例如 `setMetadata`。该写入要等打开完成,而打开在等你的钩子,结果钩子超时,打开失败。启动写入但不要 await,它会在打开完成后立即执行:
|
||||
|
||||
@@ -325,6 +325,19 @@ server.addHook("agent.open.before", async ({ request }, { paseo }) => {
|
||||
| `purpose` | `interactive`、`history` |
|
||||
| `env` | 启动覆盖映射;不包括 daemon 继承的环境变量。替换整个映射即可添加、替换或移除覆盖项。 |
|
||||
|
||||
### 在每次打开会话时追加指令 {#append-instructions-on-every-session-opening}
|
||||
|
||||
```ts
|
||||
server.addHook("agent.open.before", async ({ request }) => ({
|
||||
...request,
|
||||
appendSystemPrompt: [...(request.appendSystemPrompt ?? []), "Follow the team engineering guide."],
|
||||
}));
|
||||
```
|
||||
|
||||
插件指令按插件顺序追加在已保存的 Agent 提示词和 daemon 指令之后。指令不会保存到 Agent 配置中,读取历史时也会忽略这些指令。插件数据更改后,重新加载插件以在下次重新打开会话时应用新指令。正在运行的轮次会继续。移除指令不会删除对话历史。内部 Agent 也会收到这些指令。ACP 适配器不会向 Provider 发送系统指令。插件 Provider 会收到组合后的提示词,但需要自行应用。
|
||||
|
||||
更新 Codex 指令需要 Codex 0.121.0 或更新版本,且 app server 报告的版本必须可识别。版本显示为 `0.0.0` 的源码构建需要换用带版本号的构建。Paseo 会向现有会话添加当前指令快照;之前的快照仍保留在对话中。更新期间发生崩溃或回退会话时,快照可能重复。
|
||||
|
||||
### 在每次打开会话时注入环境变量 {#inject-environment-variables-on-every-session-opening}
|
||||
|
||||
```ts
|
||||
|
||||
Reference in New Issue
Block a user