mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 00:14:54 +00:00
perf(plugins): integrate client bundle minification with current main
Preserve dependency notices while reducing client payloads after eager host interop. Exercise real worker output for registration, byte reduction and immutable host snapshots.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@getpaseo/server",
|
||||
"version": "0.13.2",
|
||||
"version": "0.14.0-beta.1",
|
||||
"description": "Paseo backend server",
|
||||
"files": [
|
||||
"dist/server",
|
||||
@@ -107,11 +107,11 @@
|
||||
"@agentclientprotocol/sdk": "^0.17.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.246",
|
||||
"@anthropic-ai/sdk": "^0.104.2",
|
||||
"@getpaseo/client": "0.13.2",
|
||||
"@getpaseo/highlight": "0.13.2",
|
||||
"@getpaseo/plugin": "0.13.2",
|
||||
"@getpaseo/protocol": "0.13.2",
|
||||
"@getpaseo/relay": "0.13.2",
|
||||
"@getpaseo/client": "0.14.0-beta.1",
|
||||
"@getpaseo/highlight": "0.14.0-beta.1",
|
||||
"@getpaseo/plugin": "0.14.0-beta.1",
|
||||
"@getpaseo/protocol": "0.14.0-beta.1",
|
||||
"@getpaseo/relay": "0.14.0-beta.1",
|
||||
"@isaacs/ttlcache": "^2.1.4",
|
||||
"@modelcontextprotocol/sdk": "1.29.0",
|
||||
"@opencode-ai/sdk": "1.14.46",
|
||||
|
||||
@@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("notification batches share the run they start or join", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new (class extends TestAgentClient {
|
||||
override async createSession(): Promise<AgentSession> {
|
||||
return session;
|
||||
}
|
||||
})(),
|
||||
},
|
||||
logger,
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const batch = (prefix: string) =>
|
||||
["one", "two"]
|
||||
.map((id) =>
|
||||
formatAgentMessage({
|
||||
id: `${prefix}-${id}`,
|
||||
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
|
||||
text: "Worker finished",
|
||||
}),
|
||||
)
|
||||
.join("\n\n");
|
||||
const consume = async (prompt: string, clientMessageId: string) => {
|
||||
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
|
||||
}
|
||||
};
|
||||
try {
|
||||
const first = consume("human", "human");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
await expect(
|
||||
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
|
||||
).resolves.toEqual({ status: "accepted" });
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
|
||||
await first;
|
||||
const second = consume(batch("idle"), "idle-one");
|
||||
await manager.waitForAgentRunStart(agent.id);
|
||||
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
|
||||
await second;
|
||||
expect(
|
||||
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
|
||||
turnId: row.turnId,
|
||||
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
|
||||
})),
|
||||
).toEqual([
|
||||
{ turnId: "active-turn-1", id: "user_message" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
|
||||
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
|
||||
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
|
||||
]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("unavailable steer interrupts once and starts one replacement turn", async () => {
|
||||
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
|
||||
session.steerResult = "unavailable";
|
||||
@@ -11341,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin
|
||||
]);
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
|
||||
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11370,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
|
||||
await manager.runAgent(snapshot.id, { text: "do something" });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
expect(userMessages[0].text).toBe("plain user message");
|
||||
});
|
||||
|
||||
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
|
||||
test("system-injected prompts appear as trigger tools during history replay", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -11411,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
|
||||
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
|
||||
|
||||
const timeline = manager.getTimeline(snapshot.id);
|
||||
expect(timeline).toContainEqual(
|
||||
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
|
||||
);
|
||||
const userMessages = timeline.filter((item) => item.type === "user_message");
|
||||
|
||||
expect(userMessages).toHaveLength(1);
|
||||
|
||||
@@ -85,7 +85,11 @@ import {
|
||||
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
||||
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
||||
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
|
||||
import {
|
||||
PASEO_MCP_SERVER_NAME,
|
||||
stripInternalPaseoMcpServer,
|
||||
withRuntimePaseoMcpServer,
|
||||
} from "./runtime-mcp-config.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
import type { PaseoToolCatalogFactory } from "./tools/types.js";
|
||||
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
|
||||
@@ -559,18 +563,20 @@ interface AgentMetadataPatch {
|
||||
|
||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||
|
||||
function attachPersistenceCwd(
|
||||
function normalizePersistenceForConfig(
|
||||
handle: AgentPersistenceHandle | null,
|
||||
cwd: string,
|
||||
config: AgentSessionConfig,
|
||||
): AgentPersistenceHandle | null {
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
|
||||
return {
|
||||
...handle,
|
||||
metadata: {
|
||||
...handle.metadata,
|
||||
cwd,
|
||||
...metadata,
|
||||
cwd: config.cwd,
|
||||
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1387,7 +1393,7 @@ export class AgentManager {
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
@@ -1515,7 +1521,7 @@ export class AgentManager {
|
||||
launchContext,
|
||||
currentResumeOptions,
|
||||
);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
persistence: handle,
|
||||
@@ -1578,6 +1584,7 @@ export class AgentManager {
|
||||
},
|
||||
{ config: providerLaunchConfig, storedConfig, launchContext },
|
||||
);
|
||||
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
|
||||
let handedToRegistration = false;
|
||||
try {
|
||||
const importedConfig = await this.normalizeConfig(
|
||||
@@ -1673,10 +1680,10 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
if (
|
||||
Object.keys(storedConfig.mcpServers ?? {}).length > 0 &&
|
||||
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
||||
existing.session.capabilities.supportsMcpServers !== true
|
||||
) {
|
||||
throw new Error(`Provider '${provider}' does not support MCP servers`);
|
||||
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
||||
}
|
||||
|
||||
let session: AgentSession | undefined;
|
||||
@@ -1695,7 +1702,7 @@ export class AgentManager {
|
||||
session = handle
|
||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||
: await client.createSession(providerLaunchConfig, launchContext);
|
||||
await this.requireExternalMcpSupport(session, storedConfig);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
|
||||
if (rehydrateFromDisk) {
|
||||
@@ -2921,7 +2928,10 @@ export class AgentManager {
|
||||
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
||||
: null);
|
||||
if (persistenceHandle) {
|
||||
mutableAgent.persistence = attachPersistenceCwd(persistenceHandle, mutableAgent.cwd);
|
||||
mutableAgent.persistence = normalizePersistenceForConfig(
|
||||
persistenceHandle,
|
||||
mutableAgent.config,
|
||||
);
|
||||
}
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -3865,16 +3875,23 @@ export class AgentManager {
|
||||
|
||||
private async requireExternalMcpSupport(
|
||||
session: AgentSession,
|
||||
storedConfig: AgentSessionConfig,
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): Promise<void> {
|
||||
if (
|
||||
Object.keys(storedConfig.mcpServers ?? {}).length === 0 ||
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
||||
session.capabilities.supportsMcpServers === true
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await this.closeUnregisteredSession(session);
|
||||
throw new Error(`Provider '${storedConfig.provider}' does not support MCP servers`);
|
||||
throw this.unsupportedMcpError(launchConfig, launchContext);
|
||||
}
|
||||
|
||||
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
|
||||
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
|
||||
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
|
||||
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
|
||||
}
|
||||
|
||||
private async initializeAgentTimelineForRegister(params: {
|
||||
@@ -3960,9 +3977,9 @@ export class AgentManager {
|
||||
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
||||
finalizedForegroundTurnIds: new Set<string>(),
|
||||
unsubscribeSession: null,
|
||||
persistence: attachPersistenceCwd(
|
||||
persistence: normalizePersistenceForConfig(
|
||||
options?.persistence ?? session.describePersistence(),
|
||||
config.cwd,
|
||||
config,
|
||||
),
|
||||
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
||||
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
||||
@@ -4266,9 +4283,9 @@ export class AgentManager {
|
||||
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
||||
agent.runtimeInfo = newInfo;
|
||||
if (!agent.persistence && newInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
agent.persistence = normalizePersistenceForConfig(
|
||||
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
||||
agent.cwd,
|
||||
agent.config,
|
||||
);
|
||||
}
|
||||
// Emit state if runtimeInfo changed so clients get the updated model
|
||||
@@ -4626,9 +4643,9 @@ export class AgentManager {
|
||||
case "model_changed":
|
||||
agent.runtimeInfo = event.runtimeInfo;
|
||||
if (!agent.persistence && event.runtimeInfo.sessionId) {
|
||||
agent.persistence = attachPersistenceCwd(
|
||||
agent.persistence = normalizePersistenceForConfig(
|
||||
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
|
||||
agent.cwd,
|
||||
agent.config,
|
||||
);
|
||||
}
|
||||
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
|
||||
@@ -4702,7 +4719,7 @@ export class AgentManager {
|
||||
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
||||
const handle = agent.session.describePersistence();
|
||||
if (handle) {
|
||||
agent.persistence = attachPersistenceCwd(handle, agent.cwd);
|
||||
agent.persistence = normalizePersistenceForConfig(handle, agent.config);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5519,6 +5536,8 @@ export class AgentManager {
|
||||
// A plugin changing during this open must still make the resulting session stale.
|
||||
const generation = this.sessionOpenGeneration;
|
||||
this.detachPluginTools(agentId);
|
||||
let mcpServers: AgentSessionConfig["mcpServers"];
|
||||
let mcpServerOwners: Record<string, string> | undefined;
|
||||
if (this.pluginLifecycle) {
|
||||
const request = {
|
||||
agentId,
|
||||
@@ -5531,14 +5550,20 @@ export class AgentManager {
|
||||
title: opening?.title ?? null,
|
||||
labels: opening?.labels ?? {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
};
|
||||
const transformed = await this.pluginLifecycle.before("agent.session_open", request);
|
||||
env = transformed.env;
|
||||
if (request.purpose === "interactive")
|
||||
if (request.purpose === "interactive") {
|
||||
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
|
||||
mcpServers = transformed.mcpServers;
|
||||
mcpServerOwners = transformed.mcpServerOwners;
|
||||
}
|
||||
}
|
||||
const context: AgentLaunchContext = {
|
||||
agentId,
|
||||
mcpServers,
|
||||
mcpServerOwners,
|
||||
env: {
|
||||
...env,
|
||||
PASEO_AGENT_ID: agentId,
|
||||
@@ -5572,7 +5597,26 @@ export class AgentManager {
|
||||
launchConfig: AgentSessionConfig,
|
||||
launchContext: AgentLaunchContext,
|
||||
): AgentSessionConfig {
|
||||
return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig;
|
||||
const savedServerNames = new Set(
|
||||
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
|
||||
);
|
||||
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
|
||||
const owner = launchContext.mcpServerOwners?.[name];
|
||||
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
|
||||
const existingOwner = savedServerNames.has(name)
|
||||
? "agent saved configuration"
|
||||
: "Paseo internal MCP server";
|
||||
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
|
||||
}
|
||||
}
|
||||
const merged =
|
||||
Object.keys(launchContext.mcpServers ?? {}).length > 0
|
||||
? {
|
||||
...launchConfig,
|
||||
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
|
||||
}
|
||||
: launchConfig;
|
||||
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
|
||||
}
|
||||
|
||||
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
@@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null
|
||||
if (item.type !== "user_message") return item;
|
||||
const message = parseAgentMessage(item.text);
|
||||
if (!message) {
|
||||
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
|
||||
// legacy notification history is retired. Schedules still use this envelope.
|
||||
return isSystemInjectedEnvelope(item.text) ? null : item;
|
||||
// Schedule bodies include their run ID, keeping identity stable across acceptance
|
||||
// and provider replay even when the provider assigns its own message ID.
|
||||
if (!isSystemInjectedEnvelope(item.text)) return item;
|
||||
const text = item.text.slice("<paseo-system>\n".length, -"\n</paseo-system>".length);
|
||||
return {
|
||||
type: "tool_call",
|
||||
callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`,
|
||||
name: "heartbeat",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "plain_text", text, icon: "sparkles" },
|
||||
};
|
||||
}
|
||||
const { source } = message;
|
||||
if (!source) {
|
||||
|
||||
@@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r
|
||||
clientMessageId: "human-submission",
|
||||
});
|
||||
});
|
||||
|
||||
test("schedule prompts remain visible as completed tool calls on replay", () => {
|
||||
const text = formatSystemNotificationPrompt("Heartbeat: check progress");
|
||||
expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "tool_call",
|
||||
status: "completed",
|
||||
detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("schedule trigger identity survives the provider assigning a message ID", () => {
|
||||
const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1).");
|
||||
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
|
||||
projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -616,6 +616,9 @@ export interface AgentSessionConfig {
|
||||
}
|
||||
|
||||
export interface AgentLaunchContext {
|
||||
/** Launch-only plugin MCP servers and their owners; never persisted. */
|
||||
mcpServers?: Record<string, McpServerConfig>;
|
||||
mcpServerOwners?: Record<string, string>;
|
||||
agentId?: string;
|
||||
env?: Record<string, string>;
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface AgentTimelineCursor {
|
||||
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface AgentTimelineFetchOptions {
|
||||
wholeTurns?: boolean;
|
||||
direction?: AgentTimelineFetchDirection;
|
||||
cursor?: AgentTimelineCursor;
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,43 @@
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { selectReferenceProjectedTimelinePage } from "./test-utils/timeline-page-reference.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
|
||||
describe("InMemoryAgentTimelineStore", () => {
|
||||
it("resets stale forward cursors to complete available turns", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent", { epoch: "current" });
|
||||
for (let index = 0; index < 60; index++)
|
||||
store.append(
|
||||
"agent",
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: `call-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
{ turnId: "run" },
|
||||
);
|
||||
const page = store.fetch("agent", {
|
||||
direction: "after",
|
||||
cursor: { epoch: "old", seq: 10 },
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.rows).toHaveLength(60);
|
||||
expect(page).toMatchObject({
|
||||
reset: true,
|
||||
staleCursor: true,
|
||||
startSeq: 1,
|
||||
endSeq: 60,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps an overshooting before cursor into the bounded tail window", () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("agent-1", {
|
||||
@@ -217,31 +251,29 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
|
||||
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
|
||||
});
|
||||
|
||||
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
// Generation and its GC are outside the measured request window.
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const started = performance.now();
|
||||
const probe = new Promise<number>((resolve) =>
|
||||
setImmediate(() => resolve(performance.now() - started)),
|
||||
);
|
||||
let page = store.fetch("large", { limit: 200 });
|
||||
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
|
||||
const duration = performance.now() - started;
|
||||
const loopGap = await probe;
|
||||
console.info({ duration, loopGap });
|
||||
expect(page.rows).toHaveLength(200);
|
||||
expect(page.startSeq).toBe(99_801);
|
||||
expect(page.endSeq).toBe(100_000);
|
||||
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
|
||||
// is measured separately. Eight requests amplify the old full-history work.
|
||||
expect(duration).toBeLessThan(50);
|
||||
expect(loopGap).toBeLessThan(50);
|
||||
});
|
||||
it.each([false, true])(
|
||||
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
|
||||
async (wholeTurns) => {
|
||||
const store = new InMemoryAgentTimelineStore();
|
||||
store.initialize("large");
|
||||
for (let index = 0; index < 100_000; index++) {
|
||||
store.append("large", { type: "user_message", text: String(index) });
|
||||
}
|
||||
const { result: tail, blockingMs } = await measureMainThreadWork(
|
||||
`timeline tail main-thread work (wholeTurns=${wholeTurns})`,
|
||||
() => {
|
||||
let page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
for (let repeat = 1; repeat < 8; repeat++)
|
||||
page = store.fetch("large", { limit: 200, wholeTurns });
|
||||
return page;
|
||||
},
|
||||
);
|
||||
expect(tail.rows).toHaveLength(200);
|
||||
expect(tail.startSeq).toBe(99_801);
|
||||
expect(tail.endSeq).toBe(100_000);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
|
||||
let seed = 0x76aabbcc;
|
||||
|
||||
@@ -107,6 +107,7 @@ export class InMemoryAgentTimelineStore {
|
||||
direction: reset ? "tail" : direction,
|
||||
cursorSeq: cursor?.seq,
|
||||
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
|
||||
wholeTurns: options?.wholeTurns,
|
||||
});
|
||||
return {
|
||||
epoch: state.epoch,
|
||||
|
||||
@@ -1321,3 +1321,28 @@ test("plugin without a runtime safety capability uses baseline collection withou
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin provider import launches with runtime MCP servers and returns only saved configuration", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const storedConfig = { provider: harness.registration.id, cwd: "/project" };
|
||||
const company = { type: "http", url: "https://tools.example.com/mcp" } as const;
|
||||
try {
|
||||
const imported = await client.importSession!(
|
||||
{ providerHandleId: 'plugin:{"version":1,"data":{"token":"root"}}', cwd: "/project" },
|
||||
{ config: { ...storedConfig, mcpServers: { company } }, storedConfig },
|
||||
);
|
||||
expect(harness.inputs).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "session.open",
|
||||
config: expect.objectContaining({ mcpServers: { company } }),
|
||||
}),
|
||||
);
|
||||
expect(imported.config).toEqual(storedConfig);
|
||||
await imported.session.close();
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1052,7 +1052,7 @@ class PluginAgentClient implements AgentClient {
|
||||
if (!persistence) throw new Error(`Plugin provider '${this.provider}' did not persist import`);
|
||||
return {
|
||||
session,
|
||||
config: { ...context.config, provider: this.provider, cwd: input.cwd },
|
||||
config: { ...context.storedConfig, provider: this.provider, cwd: input.cwd },
|
||||
persistence,
|
||||
timeline: session.timelineHistory(),
|
||||
providerSubagentEvents: session.subagentHistory(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import fs, { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
@@ -117,13 +118,6 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
await file.close();
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
let lastTick = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - lastTick);
|
||||
lastTick = now;
|
||||
}, 2);
|
||||
let loading = true;
|
||||
let answeredDuringLoad = false;
|
||||
const health = fetch(`http://127.0.0.1:${daemon.port}/api/health`).then(async (response) => {
|
||||
@@ -134,33 +128,39 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
});
|
||||
let session: AgentSession | undefined;
|
||||
try {
|
||||
session = await resume(replyFactory("live after large resume"));
|
||||
let index = 0;
|
||||
for await (const event of session.streamHistory()) {
|
||||
expect(event).toEqual({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
timestamp: undefined,
|
||||
item: { type: "assistant_message", text: `${index}:${text}` },
|
||||
});
|
||||
index++;
|
||||
}
|
||||
loading = false;
|
||||
await health;
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const {
|
||||
result: { resumed: loadedSession, historyCount },
|
||||
blockingMs,
|
||||
} = await measureMainThreadWork("Claude history main-thread work", async () => {
|
||||
const resumed = await resume(replyFactory("live after large resume"));
|
||||
session = resumed;
|
||||
let index = 0;
|
||||
for await (const event of resumed.streamHistory()) {
|
||||
expect(event).toEqual({
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
timestamp: undefined,
|
||||
item: { type: "assistant_message", text: `${index}:${text}` },
|
||||
});
|
||||
index++;
|
||||
}
|
||||
loading = false;
|
||||
await health;
|
||||
return { resumed, historyCount: index };
|
||||
});
|
||||
console.info(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
longestGap,
|
||||
blockingMs,
|
||||
answeredDuringLoad,
|
||||
elapsedMs: performance.now() - startedAt,
|
||||
}),
|
||||
);
|
||||
expect(index).toBe(count);
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
expect(historyCount).toBe(count);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(answeredDuringLoad).toBe(true);
|
||||
const live = await collectSessionTurnEvents(session, "follow up");
|
||||
const live = await collectSessionTurnEvents(loadedSession, "follow up");
|
||||
expect(
|
||||
live
|
||||
.filter((event) => event.type === "timeline" && event.item.type === "assistant_message")
|
||||
@@ -169,7 +169,6 @@ describe("ClaudeAgentSession persisted history load", () => {
|
||||
{ type: "assistant_message", messageId: "live-reply", text: "live after large resume" },
|
||||
]);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
await session?.close();
|
||||
await daemon.close();
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const MATERIALIZED_PNG_PATH_PATTERN =
|
||||
/paseo-attachments(?:-[^\\/]+)?[\\/](?:[^\\/]+[\\/])?[0-9a-f]{64}\.png$/;
|
||||
|
||||
interface ClaudeImageTestSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
convertHistoryEntry(entry: unknown): AgentTimelineItem[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
convertHistoryEntry(entry: unknown): Promise<AgentTimelineItem[]>;
|
||||
}
|
||||
|
||||
async function createSession(): Promise<ClaudeImageTestSession> {
|
||||
@@ -157,7 +157,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("emits the image as assistant markdown and keeps base64 out of the live tool output", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(imageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(imageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
@@ -181,7 +181,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("replays the image as assistant markdown through history conversion", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const items = session.convertHistoryEntry(imageToolResultHistoryEntry());
|
||||
const items = await session.convertHistoryEntry(imageToolResultHistoryEntry());
|
||||
|
||||
const [imageMessage, ...extraImages] = imageMessages(items);
|
||||
expect(extraImages).toEqual([]);
|
||||
@@ -202,7 +202,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("keeps base64 out of an errored tool_result that carries an image", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(erroredImageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(erroredImageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
@@ -226,7 +226,7 @@ describe("Claude tool_result image rendering", () => {
|
||||
test("emits one image message per image block in a multi-image tool_result", async () => {
|
||||
const session = await createSession();
|
||||
|
||||
const events = session.translateMessageToEvents(multiImageToolResultUserMessage());
|
||||
const events = await session.translateMessageToEvents(multiImageToolResultUserMessage());
|
||||
|
||||
const timelineItems = events
|
||||
.filter((event) => event.type === "timeline")
|
||||
|
||||
@@ -32,7 +32,7 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("convertClaudeHistoryEntry", () => {
|
||||
test("maps user tool results to timeline items", () => {
|
||||
test("maps user tool results to timeline items", async () => {
|
||||
const toolUseId = "toolu_test";
|
||||
const entry = {
|
||||
type: "user",
|
||||
@@ -79,14 +79,14 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
];
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue(stubTimeline);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual(stubTimeline);
|
||||
expect(mapBlocks).toHaveBeenCalledTimes(1);
|
||||
expect(Array.isArray(mapBlocks.mock.calls[0][0])).toBe(true);
|
||||
});
|
||||
|
||||
test("replays persisted Claude tool results as completed tool calls", () => {
|
||||
test("replays persisted Claude tool results as completed tool calls", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -124,12 +124,12 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
|
||||
const mapPersistedToolResultBlocks = (): AgentTimelineItem[] => completedToolCall;
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
|
||||
expect(await convertClaudeHistoryEntry(entry, mapPersistedToolResultBlocks)).toEqual(
|
||||
completedToolCall,
|
||||
);
|
||||
});
|
||||
|
||||
test("returns user messages when no tool blocks exist", () => {
|
||||
test("returns user messages when no tool blocks exist", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -138,7 +138,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
},
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "Run npm test",
|
||||
@@ -146,7 +146,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("converts compact boundary metadata variants", () => {
|
||||
test("converts compact boundary metadata variants", async () => {
|
||||
const fixtures = [
|
||||
{
|
||||
entry: {
|
||||
@@ -175,7 +175,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
] as const;
|
||||
|
||||
for (const fixture of fixtures) {
|
||||
expect(convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(fixture.entry, () => [])).toEqual([
|
||||
{
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
@@ -186,7 +186,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("skips synthetic user entries", () => {
|
||||
test("skips synthetic user entries", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isSynthetic: true,
|
||||
@@ -197,13 +197,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips meta user entries from Claude skill loading", () => {
|
||||
test("skips meta user entries from Claude skill loading", async () => {
|
||||
const entry = {
|
||||
type: "user",
|
||||
isMeta: true,
|
||||
@@ -219,13 +219,13 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
};
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
const result = convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
const result = await convertClaudeHistoryEntry(entry, mapBlocks);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mapBlocks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips interrupt placeholder transcript noise", () => {
|
||||
test("skips interrupt placeholder transcript noise", async () => {
|
||||
const interruptEntry = {
|
||||
type: "user",
|
||||
message: {
|
||||
@@ -246,11 +246,11 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
.fn()
|
||||
.mockReturnValue([{ type: "assistant_message", text: "No response requested." }]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(interruptEntry, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(assistantNoiseEntry, mapBlocks)).toEqual([]);
|
||||
});
|
||||
|
||||
test("skips <local-command-stdout> messages (model switch, /context, etc.)", () => {
|
||||
test("skips <local-command-stdout> messages (model switch, /context, etc.)", async () => {
|
||||
// Real entries from Claude Code JSONL history files
|
||||
const modelSwitch = {
|
||||
type: "user",
|
||||
@@ -305,24 +305,24 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
|
||||
const mapBlocks = vi.fn().mockReturnValue([]);
|
||||
|
||||
expect(convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
|
||||
expect(convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(modelSwitch, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(modelSwitchWithAnsi, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(contextDump, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(planMode, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(goodbye, mapBlocks)).toEqual([]);
|
||||
expect(await convertClaudeHistoryEntry(empty, mapBlocks)).toEqual([]);
|
||||
|
||||
// Real user messages must NOT be filtered
|
||||
const realMessage = {
|
||||
type: "user",
|
||||
message: { role: "user", content: "fix the bug in auth.ts" },
|
||||
};
|
||||
expect(convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(realMessage, mapBlocks)).toEqual([
|
||||
{ type: "user_message", text: "fix the bug in auth.ts" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps task notifications to synthetic tool calls", () => {
|
||||
test("maps task notifications to synthetic tool calls", async () => {
|
||||
const entry = {
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
@@ -333,7 +333,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
output_file: "/tmp/bg-fail-1.txt",
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "task_notification_task-note-system-1",
|
||||
@@ -357,7 +357,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("maps queue-operation task notifications to synthetic tool calls", () => {
|
||||
test("maps queue-operation task notifications to synthetic tool calls", async () => {
|
||||
const entry = {
|
||||
type: "queue-operation",
|
||||
operation: "enqueue",
|
||||
@@ -372,7 +372,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
expect(await convertClaudeHistoryEntry(entry, () => [])).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
callId: "task_notification_task-note-queue-1",
|
||||
@@ -396,7 +396,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("passes assistant content blocks through to the mapper", () => {
|
||||
test("passes assistant content blocks through to the mapper", async () => {
|
||||
const entry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
@@ -414,7 +414,7 @@ describe("convertClaudeHistoryEntry", () => {
|
||||
];
|
||||
const mapBlocks = vi.fn().mockReturnValue(mappedTimeline);
|
||||
|
||||
expect(convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
|
||||
expect(await convertClaudeHistoryEntry(entry, mapBlocks)).toEqual(mappedTimeline);
|
||||
expect(mapBlocks).toHaveBeenCalledWith(entry.message.content);
|
||||
});
|
||||
});
|
||||
@@ -1208,7 +1208,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
input: Record<string, unknown>,
|
||||
options: Record<string, unknown>,
|
||||
): Promise<PermissionResult>;
|
||||
translateMessageToEvents(message: SDKMessage): AgentStreamEvent[];
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1248,7 +1248,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
),
|
||||
).resolves.toMatchObject({ behavior: "deny", interrupt: undefined });
|
||||
|
||||
internal.translateMessageToEvents({
|
||||
await internal.translateMessageToEvents({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: steerUuid,
|
||||
state: "started",
|
||||
@@ -2395,7 +2395,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("emits canonical task snapshots from Claude TaskCreate results", async () => {
|
||||
const session = await createSessionForTest();
|
||||
try {
|
||||
session.translateMessageToEvents({
|
||||
await session.translateMessageToEvents({
|
||||
type: "assistant",
|
||||
message: {
|
||||
content: [
|
||||
@@ -2409,7 +2409,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
},
|
||||
} as unknown as SDKMessage);
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "user",
|
||||
message: {
|
||||
content: [{ type: "tool_result", tool_use_id: "task-create-1", content: "created" }],
|
||||
@@ -3611,9 +3611,9 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
]);
|
||||
|
||||
try {
|
||||
const compactEvents = (session as unknown as TestClaudeSession).translateMessageToEvents(
|
||||
createCompactBoundary(),
|
||||
);
|
||||
const compactEvents = await (
|
||||
session as unknown as TestClaudeSession
|
||||
).translateMessageToEvents(createCompactBoundary());
|
||||
expect(compactEvents).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage_updated",
|
||||
@@ -3799,7 +3799,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("result.result is surfaced as an assistant message when no model output was produced", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Unknown command: /foo-doesnt-exist",
|
||||
@@ -3834,7 +3834,7 @@ describe("ClaudeAgentSession context window usage", () => {
|
||||
test("result.result is not duplicated when the model produced output during the turn", async () => {
|
||||
const session = await createSessionForTest();
|
||||
|
||||
const events = session.translateMessageToEvents({
|
||||
const events = await session.translateMessageToEvents({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "Here is the answer.",
|
||||
|
||||
@@ -2546,7 +2546,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.input.push(sdkMessage);
|
||||
setTimeout(() => {
|
||||
if (this.activeForegroundTurnId === turnId) {
|
||||
this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId);
|
||||
void this.emitSubmittedUserMessage(sdkMessage, turnId, options?.clientMessageId).catch(
|
||||
(error: unknown) => {
|
||||
this.logger.warn({ err: error }, "Failed to emit submitted Claude message");
|
||||
},
|
||||
);
|
||||
}
|
||||
}, 0);
|
||||
} catch (error) {
|
||||
@@ -4134,7 +4138,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
|
||||
if (!this.ownsQuery(activeQuery)) return true;
|
||||
this.routeSdkMessageFromPump(message);
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
@@ -4258,7 +4262,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return !this.closed && this.query === query;
|
||||
}
|
||||
|
||||
private routeSdkMessageFromPump(message: SDKMessage): void {
|
||||
private async routeSdkMessageFromPump(message: SDKMessage): Promise<void> {
|
||||
if (this.consumeSessionStateChange(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -4292,7 +4296,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
"provider.claude.parsed_event",
|
||||
);
|
||||
|
||||
const events = this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
const events = await this.buildPumpedMessageEvents(message, identifiers.messageId, turnId);
|
||||
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
@@ -4327,15 +4331,22 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.dispatchEvents(events);
|
||||
}
|
||||
|
||||
private buildPumpedMessageEvents(
|
||||
private async buildPumpedMessageEvents(
|
||||
message: SDKMessage,
|
||||
messageIdHint: string | null,
|
||||
turnId: string | null,
|
||||
): AgentStreamEvent[] {
|
||||
const messageEvents = this.translateMessageToEvents(message, {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const query = this.query;
|
||||
const messageEvents = await this.translateMessageToEvents(message, {
|
||||
suppressAssistantText: true,
|
||||
suppressReasoning: true,
|
||||
});
|
||||
if (
|
||||
this.closed ||
|
||||
this.query !== query ||
|
||||
(this.activeForegroundTurnId ?? this.autonomousTurn?.id ?? null) !== turnId
|
||||
)
|
||||
return [];
|
||||
const assistantTimelineEvents = readClaudeParentToolUseId(message)
|
||||
? []
|
||||
: this.timelineAssembler
|
||||
@@ -4480,13 +4491,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return withdrewAll;
|
||||
}
|
||||
|
||||
private translateMessageToEvents(
|
||||
private async translateMessageToEvents(
|
||||
message: SDKMessage,
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentStreamEvent[] {
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const parentToolUseId = readClaudeParentToolUseId(message);
|
||||
if (parentToolUseId) {
|
||||
return this.translateSidechainFrameToEvents(message, parentToolUseId);
|
||||
@@ -4534,11 +4545,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.appendSystemMessageEvents(message, events);
|
||||
break;
|
||||
case "user":
|
||||
this.appendUserMessageEvents(message, events);
|
||||
await this.appendUserMessageEvents(message, events);
|
||||
this.appendSidechainResultEvents(message, events);
|
||||
break;
|
||||
case "assistant": {
|
||||
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
|
||||
const timelineItems = await this.mapBlocksToTimeline(message.message.content, {
|
||||
suppressAssistantText: options?.suppressAssistantText ?? false,
|
||||
suppressReasoning: options?.suppressReasoning ?? false,
|
||||
});
|
||||
@@ -4549,7 +4560,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
break;
|
||||
}
|
||||
case "stream_event":
|
||||
this.appendStreamEventEvents(message, events, options);
|
||||
await this.appendStreamEventEvents(message, events, options);
|
||||
break;
|
||||
case "result":
|
||||
this.appendResultEvents(message, events);
|
||||
@@ -4651,13 +4662,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private emitSubmittedUserMessage(
|
||||
private async emitSubmittedUserMessage(
|
||||
message: Extract<SDKMessage, { type: "user" }>,
|
||||
turnId: string,
|
||||
clientMessageId?: string,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
this.appendUserMessageEvents(message, events);
|
||||
await this.appendUserMessageEvents(message, events);
|
||||
if (this.activeForegroundTurnId !== turnId) return;
|
||||
if (events.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -4787,10 +4799,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private appendUserMessageEvents(
|
||||
private async appendUserMessageEvents(
|
||||
message: Extract<SDKMessage, { type: "user" }>,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (isSyntheticUserEntry(message)) {
|
||||
return;
|
||||
}
|
||||
@@ -4829,7 +4841,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
this.appendUserContentArrayEvents(content, messageId, events);
|
||||
await this.appendUserContentArrayEvents(content, messageId, events);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4855,12 +4867,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
events.push({ type: "timeline", item, provider: "claude" });
|
||||
}
|
||||
|
||||
private appendUserContentArrayEvents(
|
||||
private async appendUserContentArrayEvents(
|
||||
content: ReadonlyArray<unknown>,
|
||||
messageId: string | undefined,
|
||||
events: AgentStreamEvent[],
|
||||
): void {
|
||||
const timelineItems = this.mapBlocksToTimeline(content, {
|
||||
): Promise<void> {
|
||||
const timelineItems = await this.mapBlocksToTimeline(content, {
|
||||
textMessageType: "user_message",
|
||||
});
|
||||
for (const item of timelineItems) {
|
||||
@@ -4876,16 +4888,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private appendStreamEventEvents(
|
||||
private async appendStreamEventEvents(
|
||||
message: Extract<SDKMessage, { type: "stream_event" }>,
|
||||
events: AgentStreamEvent[],
|
||||
options: { suppressAssistantText?: boolean; suppressReasoning?: boolean } | undefined,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const usageUpdatedEvent = this.contextUsage.buildStreamUsageEvent(message.event);
|
||||
if (usageUpdatedEvent) {
|
||||
events.push(usageUpdatedEvent);
|
||||
}
|
||||
const timelineItems = this.mapPartialEvent(message.event, {
|
||||
const timelineItems = await this.mapPartialEvent(message.event, {
|
||||
suppressAssistantText: options?.suppressAssistantText ?? false,
|
||||
suppressReasoning: options?.suppressReasoning ?? false,
|
||||
});
|
||||
@@ -5351,7 +5363,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
): Promise<void> {
|
||||
const timeline: PersistedTimelineEntry[] = [];
|
||||
for await (const entry of entries) {
|
||||
this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
await this.ingestPersistedHistoryEntry(entry, timeline, replay);
|
||||
}
|
||||
|
||||
if (timeline.length > 0) {
|
||||
@@ -5411,11 +5423,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return replay;
|
||||
}
|
||||
|
||||
private ingestPersistedHistoryEntry(
|
||||
private async ingestPersistedHistoryEntry(
|
||||
entry: ClaudeHistoryEntry,
|
||||
timeline: PersistedTimelineEntry[],
|
||||
replay: ClaudeReplayOwnership,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (entry.isSidechain) {
|
||||
return;
|
||||
}
|
||||
@@ -5429,7 +5441,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
? replay.toolOwners.get(notificationToolUseId)
|
||||
: undefined;
|
||||
if (notificationOwner) {
|
||||
for (const item of this.convertHistoryEntry(entry)) {
|
||||
for (const item of await this.convertHistoryEntry(entry)) {
|
||||
this.persistedProviderSubagentEvents.push({
|
||||
type: "provider_subagent",
|
||||
provider: "claude",
|
||||
@@ -5446,7 +5458,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const taskSnapshot = this.taskState.observe(entry);
|
||||
const items = [
|
||||
...(taskSnapshot ? [taskSnapshot] : []),
|
||||
...this.convertHistoryEntry(entry).map((item) =>
|
||||
...(await this.convertHistoryEntry(entry)).map((item) =>
|
||||
labelReplayedSubagentCard(item, replay.subagentToolCalls),
|
||||
),
|
||||
];
|
||||
@@ -5483,10 +5495,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
|
||||
private convertHistoryEntry(entry: ClaudeHistoryEntry): AgentTimelineItem[] {
|
||||
return convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content)).map(
|
||||
limitAgentTimelineItemContent,
|
||||
);
|
||||
private async convertHistoryEntry(entry: ClaudeHistoryEntry): Promise<AgentTimelineItem[]> {
|
||||
return (
|
||||
await convertClaudeHistoryEntry(entry, (content) => this.mapBlocksToTimeline(content))
|
||||
).map(limitAgentTimelineItemContent);
|
||||
}
|
||||
|
||||
// Maps Claude content blocks into AgentTimelineItems.
|
||||
@@ -5501,14 +5513,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
//
|
||||
// NOTE: convertClaudeHistoryEntry uses extractUserMessageText directly instead of this function
|
||||
// for user entries. Both paths must produce equivalent user_message items.
|
||||
private mapBlocksToTimeline(
|
||||
private async mapBlocksToTimeline(
|
||||
content: string | ReadonlyArray<unknown>,
|
||||
options?: {
|
||||
textMessageType?: "assistant_message" | "user_message";
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentTimelineItem[] {
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const textMessageType = options?.textMessageType ?? "assistant_message";
|
||||
const suppressText =
|
||||
textMessageType === "assistant_message" && (options?.suppressAssistantText ?? false);
|
||||
@@ -5535,7 +5547,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!isClaudeContentChunk(block) || this.mapSubagentHandbackBlock(block, items)) {
|
||||
continue;
|
||||
}
|
||||
this.mapBlockToTimeline(block, {
|
||||
await this.mapBlockToTimeline(block, {
|
||||
items,
|
||||
userTextParts,
|
||||
textMessageType,
|
||||
@@ -5580,7 +5592,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockToTimeline(
|
||||
private async mapBlockToTimeline(
|
||||
block: ClaudeContentChunk,
|
||||
context: {
|
||||
items: AgentTimelineItem[];
|
||||
@@ -5589,7 +5601,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
suppressText: boolean;
|
||||
suppressReasoning: boolean;
|
||||
},
|
||||
): void {
|
||||
): Promise<void> {
|
||||
switch (block.type) {
|
||||
case "text":
|
||||
case "text_delta":
|
||||
@@ -5613,7 +5625,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
this.handleToolResult(block, context.items);
|
||||
await this.handleToolResult(block, context.items);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -5659,7 +5671,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private handleToolResult(block: ClaudeContentChunk, items: AgentTimelineItem[]): void {
|
||||
private async handleToolResult(
|
||||
block: ClaudeContentChunk,
|
||||
items: AgentTimelineItem[],
|
||||
): Promise<void> {
|
||||
const entry =
|
||||
typeof block.tool_use_id === "string" ? this.toolUseCache.get(block.tool_use_id) : undefined;
|
||||
const blockToolName = typeof block.tool_name === "string" ? block.tool_name : undefined;
|
||||
@@ -5692,7 +5707,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.rememberSettledSubagentToolCall(settledToolCall, entry);
|
||||
|
||||
for (const image of images) {
|
||||
const imageItem = renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (imageItem) {
|
||||
@@ -5916,13 +5931,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return false;
|
||||
}
|
||||
|
||||
private mapPartialEvent(
|
||||
private async mapPartialEvent(
|
||||
event: SDKPartialAssistantMessage["event"],
|
||||
options?: {
|
||||
suppressAssistantText?: boolean;
|
||||
suppressReasoning?: boolean;
|
||||
},
|
||||
): AgentTimelineItem[] {
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
if (this.updatePartialEventToolState(event)) {
|
||||
return [];
|
||||
}
|
||||
@@ -5930,14 +5945,14 @@ class ClaudeAgentSession implements AgentSession {
|
||||
switch (event.type) {
|
||||
case "content_block_start":
|
||||
return isClaudeContentChunk(event.content_block)
|
||||
? this.mapBlocksToTimeline([event.content_block], {
|
||||
? await this.mapBlocksToTimeline([event.content_block], {
|
||||
suppressAssistantText: options?.suppressAssistantText,
|
||||
suppressReasoning: options?.suppressReasoning,
|
||||
})
|
||||
: [];
|
||||
case "content_block_delta":
|
||||
return isClaudeContentChunk(event.delta)
|
||||
? this.mapBlocksToTimeline([event.delta], {
|
||||
? await this.mapBlocksToTimeline([event.delta], {
|
||||
suppressAssistantText: options?.suppressAssistantText,
|
||||
suppressReasoning: options?.suppressReasoning,
|
||||
})
|
||||
@@ -6300,12 +6315,14 @@ interface ClaudeHistoryEntry {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function mapAssistantHistoryBlocksWithMessageId(
|
||||
async function mapAssistantHistoryBlocksWithMessageId(
|
||||
entry: ClaudeHistoryEntry,
|
||||
content: string | ClaudeContentChunk[],
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
|
||||
): AgentTimelineItem[] {
|
||||
const items = mapBlocks(content);
|
||||
mapBlocks: (
|
||||
content: string | ClaudeContentChunk[],
|
||||
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const items = await mapBlocks(content);
|
||||
const assistantMessageId =
|
||||
typeof entry.uuid === "string" && entry.uuid.length > 0 ? entry.uuid : null;
|
||||
if (!assistantMessageId) {
|
||||
@@ -6373,10 +6390,12 @@ function isProviderImageMessage(item: AgentTimelineItem): boolean {
|
||||
return item.type === "assistant_message" && isProviderImageMarkdown(item.text);
|
||||
}
|
||||
|
||||
export function convertClaudeHistoryEntry(
|
||||
export async function convertClaudeHistoryEntry(
|
||||
entry: ClaudeHistoryEntry,
|
||||
mapBlocks: (content: string | ClaudeContentChunk[]) => AgentTimelineItem[],
|
||||
): AgentTimelineItem[] {
|
||||
mapBlocks: (
|
||||
content: string | ClaudeContentChunk[],
|
||||
) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const preamble = convertClaudeHistoryEntryPreamble(entry);
|
||||
if ("shortCircuit" in preamble) {
|
||||
return preamble.shortCircuit;
|
||||
@@ -6415,7 +6434,7 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
if (hasToolBlock && normalizedBlocks) {
|
||||
const mapped = mapBlocks(normalizedBlocks);
|
||||
const mapped = await mapBlocks(normalizedBlocks);
|
||||
if (entry.type === "user") {
|
||||
// tool_result handling (handleToolResult) emits image markdown as an assistant_message
|
||||
// alongside the tool_call. User-entry text blocks also map to assistant_message in this path
|
||||
@@ -6429,7 +6448,7 @@ export function convertClaudeHistoryEntry(
|
||||
}
|
||||
|
||||
if (entry.type === "assistant" && contentValue) {
|
||||
return mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||
return await mapAssistantHistoryBlocksWithMessageId(entry, contentValue, mapBlocks);
|
||||
}
|
||||
|
||||
return timeline;
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "../../provider-image-output.js";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -660,3 +666,34 @@ describe("live and replay agree", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("awaits image materialization before publishing replay observations", async () => {
|
||||
const bytes = Buffer.from("async replay image fixture");
|
||||
const convertEntry = async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
return item ? [item] : [];
|
||||
};
|
||||
const { observations } = await observeReplaySubagents({
|
||||
parent: parentWithTaskCall(),
|
||||
subagents: [
|
||||
{
|
||||
agentId: AGENT_ID,
|
||||
meta: { toolUseId: TOOL_USE_ID },
|
||||
entries: [{ type: "assistant", message: { content: [] } }],
|
||||
},
|
||||
],
|
||||
convertEntry,
|
||||
});
|
||||
const image = observations.find(
|
||||
(observation) =>
|
||||
observation.kind === "timeline" && observation.item.type === "assistant_message",
|
||||
);
|
||||
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
|
||||
throw new Error("Expected replay image");
|
||||
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
|
||||
expect(source).toBeTruthy();
|
||||
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
|
||||
});
|
||||
|
||||
@@ -253,7 +253,7 @@ function observeSubtitle(
|
||||
async function observeSubagent(
|
||||
subagent: ClaudeReplaySubagentInput,
|
||||
parent: ClaudeReplayParentFacts,
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>,
|
||||
toolOwners: Map<string, string>,
|
||||
parentSubagentId?: string,
|
||||
): Promise<SubagentObservation[]> {
|
||||
@@ -280,7 +280,7 @@ async function observeSubagent(
|
||||
childTerminalStatus = entry.message?.stop_reason === "end_turn" ? "completed" : null;
|
||||
}
|
||||
recordReplayToolOwners(toolOwners, entry, link.id);
|
||||
for (const item of convertEntry(entry)) {
|
||||
for (const item of await convertEntry(entry)) {
|
||||
timeline.push({ kind: "timeline", id: link.id, item, ...(timestamp ? { timestamp } : {}) });
|
||||
}
|
||||
}
|
||||
@@ -323,7 +323,7 @@ function recordReplayToolOwners(
|
||||
export async function observeReplaySubagents(input: {
|
||||
subagents: readonly ClaudeReplaySubagentInput[];
|
||||
parent: ClaudeReplayParentFacts;
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
}): Promise<{ observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> }> {
|
||||
const observations: SubagentObservation[] = [];
|
||||
const toolOwners = new Map<string, string>();
|
||||
|
||||
+32
@@ -1,3 +1,9 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
} from "../../provider-image-output.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ProviderSubagentStore } from "../../../provider-subagents/store.js";
|
||||
@@ -195,3 +201,29 @@ describe("Claude workflow replay", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("awaits image materialization before publishing replay observations", async () => {
|
||||
const bytes = Buffer.from("async replay image fixture");
|
||||
const convertEntry = async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
return item ? [item] : [];
|
||||
};
|
||||
const observations = await observeReplayWorkflows({
|
||||
workflows: [{ runId: RUN_ID, status: "completed" }],
|
||||
parentEntries: parentEntries(),
|
||||
entriesByRunId: new Map([[RUN_ID, [{ type: "assistant", message: { content: [] } }]]]),
|
||||
convertEntry,
|
||||
});
|
||||
const image = observations.find(
|
||||
(observation) =>
|
||||
observation.kind === "timeline" && observation.item.type === "assistant_message",
|
||||
);
|
||||
if (!image || image.kind !== "timeline" || image.item.type !== "assistant_message")
|
||||
throw new Error("Expected replay image");
|
||||
const source = image.item.text.match(/!\[Image\]\(([^)]+)\)/)?.[1];
|
||||
expect(source).toBeTruthy();
|
||||
expect(await readFile(fileURLToPath(source!))).toEqual(bytes);
|
||||
});
|
||||
|
||||
+3
-3
@@ -53,7 +53,7 @@ export async function observeReplayWorkflows(input: {
|
||||
string,
|
||||
Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>
|
||||
>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const toolCallIdByRunId = await readWorkflowLinks(input.parentEntries);
|
||||
const observations: SubagentObservation[] = [];
|
||||
@@ -122,7 +122,7 @@ async function observeWorkflowTimeline(input: {
|
||||
id: string;
|
||||
result: unknown;
|
||||
entries: Iterable<ClaudeReplayEntry> | AsyncIterable<ClaudeReplayEntry>;
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
|
||||
convertEntry?: (entry: ClaudeReplayEntry) => AgentTimelineItem[] | Promise<AgentTimelineItem[]>;
|
||||
finishedAt?: string;
|
||||
}): Promise<SubagentObservation[]> {
|
||||
const observations: SubagentObservation[] = [];
|
||||
@@ -130,7 +130,7 @@ async function observeWorkflowTimeline(input: {
|
||||
|
||||
for await (const entry of input.entries) {
|
||||
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
|
||||
const items = input.convertEntry?.(entry) ?? [];
|
||||
const items = (await input.convertEntry?.(entry)) ?? [];
|
||||
for (const item of items) {
|
||||
if (item.type === "assistant_message") replayedAssistantText.add(item.text.trim());
|
||||
observations.push({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1939,12 +1939,12 @@ function codexImageOutputFromResult(result: unknown): ProviderImageOutput | null
|
||||
};
|
||||
}
|
||||
|
||||
function mapCodexThreadImageItem(
|
||||
async function mapCodexThreadImageItem(
|
||||
normalizedType: string,
|
||||
normalizedItem: Record<string, unknown>,
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
if (normalizedType === "imageView") {
|
||||
return renderProviderImageOutputAsAssistantMarkdown({
|
||||
return await renderProviderImageOutputAsAssistantMarkdown({
|
||||
path: firstStringField(normalizedItem, ["path"]),
|
||||
});
|
||||
}
|
||||
@@ -1960,7 +1960,7 @@ function mapCodexThreadImageItem(
|
||||
|
||||
const savedPath = firstStringField(normalizedItem, ["savedPath", "saved_path"]);
|
||||
const result = codexImageOutputFromResult(normalizedItem.result);
|
||||
return renderProviderImageOutputAsAssistantMarkdown(
|
||||
return await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{
|
||||
path: savedPath ?? result?.path ?? null,
|
||||
url: result?.url ?? null,
|
||||
@@ -1982,10 +1982,10 @@ function mapCodexAgentMessage(item: Record<string, unknown>): AgentTimelineItem
|
||||
};
|
||||
}
|
||||
|
||||
export function threadItemToTimeline(
|
||||
export async function threadItemToTimeline(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
const itemRecord = toObjectRecord(item);
|
||||
if (!itemRecord) return null;
|
||||
const includeUserMessage = options?.includeUserMessage ?? true;
|
||||
@@ -1999,7 +1999,7 @@ export function threadItemToTimeline(
|
||||
: itemRecord;
|
||||
|
||||
if (normalizedType === "imageView" || normalizedType === "imageGeneration") {
|
||||
return mapCodexThreadImageItem(normalizedType, normalizedItem);
|
||||
return await mapCodexThreadImageItem(normalizedType, normalizedItem);
|
||||
}
|
||||
if (normalizedType && CODEX_TOOL_THREAD_ITEM_TYPES.has(normalizedType)) {
|
||||
return mapCodexToolCallFromThreadItem(normalizedItem, { cwd });
|
||||
@@ -2024,7 +2024,7 @@ export function threadItemToTimeline(
|
||||
}
|
||||
}
|
||||
|
||||
function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
|
||||
async function mcpToolResultImagesToTimeline(item: unknown): Promise<AgentTimelineItem[]> {
|
||||
const itemRecord = toObjectRecord(item);
|
||||
if (!itemRecord) {
|
||||
return [];
|
||||
@@ -2037,24 +2037,25 @@ function mcpToolResultImagesToTimeline(item: unknown): AgentTimelineItem[] {
|
||||
}
|
||||
|
||||
const { images } = splitCodexMcpToolResultImages(itemRecord.result);
|
||||
return images
|
||||
.map((image) =>
|
||||
renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
}),
|
||||
)
|
||||
.filter((timelineItem): timelineItem is AgentTimelineItem => timelineItem !== null);
|
||||
const items: AgentTimelineItem[] = [];
|
||||
for (const image of images) {
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(image, {
|
||||
materialize: materializeProviderImage,
|
||||
});
|
||||
if (imageItem) items.push(imageItem);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function threadItemToTimelineEntries(
|
||||
async function threadItemToTimelineEntries(
|
||||
item: unknown,
|
||||
options?: { includeUserMessage?: boolean; cwd?: string | null },
|
||||
): AgentTimelineItem[] {
|
||||
const timelineItem = threadItemToTimeline(item, options);
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
const timelineItem = await threadItemToTimeline(item, options);
|
||||
if (!timelineItem) {
|
||||
return [];
|
||||
}
|
||||
return [timelineItem, ...mcpToolResultImagesToTimeline(item)];
|
||||
return [timelineItem, ...(await mcpToolResultImagesToTimeline(item))];
|
||||
}
|
||||
|
||||
const CodexThreadReadResponseSchema = z
|
||||
@@ -2103,7 +2104,7 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
historicalSubAgentActivity.agentThreadId,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||
const activityTimelineItem = await threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
@@ -2116,7 +2117,7 @@ async function loadCodexThreadHistoryTimeline(params: {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
for (const timelineItem of threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
for (const timelineItem of await threadItemToTimelineEntries(item, { cwd: params.cwd })) {
|
||||
const timestamp =
|
||||
readCodexHistoryTimestamp(item) ?? readCodexTurnHistoryTimestamp(turn, timelineItem);
|
||||
const settledTimelineItem =
|
||||
@@ -3290,10 +3291,12 @@ export async function codexAppServerTurnInputFromPrompt(
|
||||
}
|
||||
if (block.type === "image") {
|
||||
try {
|
||||
const filePath = materializeProviderImage({
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
}).path;
|
||||
const filePath = (
|
||||
await materializeProviderImage({
|
||||
data: block.data,
|
||||
mimeType: block.mimeType,
|
||||
})
|
||||
).path;
|
||||
output.push({ type: "localImage", path: filePath });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -4370,6 +4373,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.dismissPendingPlanApprovals("Dismissed by a new prompt");
|
||||
|
||||
try {
|
||||
await this.pendingNotifications;
|
||||
await this.connect();
|
||||
if (!this.client) {
|
||||
throw new Error("Codex client not initialized");
|
||||
@@ -5437,7 +5441,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return `codex-turn-${this.nextTurnOrdinal++}`;
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: unknown): void {
|
||||
private pendingNotifications: Promise<void> = Promise.resolve();
|
||||
|
||||
private handleNotification(method: string, params: unknown): Promise<void> {
|
||||
// An image must exist before its event, and completion must follow that event.
|
||||
// Transport callbacks cannot await: the session owns their ordered drain.
|
||||
const next = this.pendingNotifications.then(() => {
|
||||
return this.closed ? undefined : this.processNotification(method, params);
|
||||
});
|
||||
this.pendingNotifications = next.catch((error: unknown) => {
|
||||
this.handleUnexpectedTermination(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
return this.pendingNotifications;
|
||||
}
|
||||
|
||||
private async processNotification(method: string, params: unknown): Promise<void> {
|
||||
const notificationParams = toObjectRecord(params);
|
||||
if (method === "serverRequest/resolved" && typeof notificationParams?.requestId === "number") {
|
||||
const requestId = this.mcpElicitationPermissionIds.get(notificationParams.requestId);
|
||||
@@ -5467,13 +5485,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
if (route.kind === "sub_agent") {
|
||||
this.dispatchSubAgentNotification(parsed, route.callId);
|
||||
await this.dispatchSubAgentNotification(parsed, route.callId);
|
||||
return;
|
||||
}
|
||||
this.dispatchParsedNotification(parsed);
|
||||
await this.dispatchParsedNotification(parsed);
|
||||
}
|
||||
|
||||
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
||||
private async dispatchSubAgentNotification(
|
||||
parsed: ParsedCodexNotification,
|
||||
callId: string,
|
||||
): Promise<void> {
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
|
||||
@@ -5484,7 +5505,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
case "reasoning_delta":
|
||||
case "item_started":
|
||||
case "item_completed":
|
||||
this.dispatchParsedNotification(parsed);
|
||||
await this.dispatchParsedNotification(parsed);
|
||||
return;
|
||||
case "exec_command_output_delta":
|
||||
case "file_change_output_delta":
|
||||
@@ -5510,7 +5531,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchParsedNotification(parsed: ParsedCodexNotification): void {
|
||||
private async dispatchParsedNotification(parsed: ParsedCodexNotification): Promise<void> {
|
||||
if (isCodexDeltaNotification(parsed)) {
|
||||
this.handleCodexDeltaNotification(parsed);
|
||||
return;
|
||||
@@ -5555,10 +5576,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.handlePatchApplyCompletedNotification(parsed);
|
||||
return;
|
||||
case "item_completed":
|
||||
this.handleItemCompletedNotification(parsed);
|
||||
await this.handleItemCompletedNotification(parsed);
|
||||
return;
|
||||
case "item_started":
|
||||
this.handleItemStartedNotification(parsed);
|
||||
await this.handleItemStartedNotification(parsed);
|
||||
return;
|
||||
case "invalid_payload":
|
||||
this.warnInvalidNotificationPayload(parsed.method, parsed.params);
|
||||
@@ -5600,7 +5621,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
pending.push(parsed);
|
||||
}
|
||||
|
||||
private replayPendingSubAgentNotifications(threadIds: readonly string[]): void {
|
||||
private async replayPendingSubAgentNotifications(threadIds: readonly string[]): Promise<void> {
|
||||
for (const threadId of threadIds) {
|
||||
const pending = this.pendingSubAgentNotificationsByThreadId.get(threadId);
|
||||
if (!pending) {
|
||||
@@ -5612,7 +5633,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
continue;
|
||||
}
|
||||
for (const parsed of pending) {
|
||||
this.dispatchSubAgentNotification(parsed, callId);
|
||||
await this.dispatchSubAgentNotification(parsed, callId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6578,9 +6599,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemCompletedNotification(
|
||||
private ownsNotificationTurn(turnId: string | null): boolean {
|
||||
return !this.closed && this.activeForegroundTurnId === turnId;
|
||||
}
|
||||
|
||||
private async handleItemCompletedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||
// channel, but accept a legacy-only child announcement so it can establish
|
||||
@@ -6590,20 +6615,23 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
this.receiveAsyncQuestion(parsed.threadId, parsed.item);
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
await this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (this.handleCompletedSpecialItem(parsed, childSubAgentCallId)) {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const foregroundTurnId = this.activeForegroundTurnId;
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
if (!timelineItem) {
|
||||
return;
|
||||
}
|
||||
const imageItems = await mcpToolResultImagesToTimeline(parsed.item);
|
||||
if (!this.ownsNotificationTurn(foregroundTurnId)) return;
|
||||
const registeredChildThreadIds =
|
||||
timelineItem.type === "tool_call"
|
||||
? this.registerSubAgentToolCall({
|
||||
@@ -6613,12 +6641,11 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
|
||||
})
|
||||
: [];
|
||||
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
|
||||
if (childSubAgentCallId) {
|
||||
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
|
||||
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
const normalizedItemType = normalizeCodexThreadItemType(
|
||||
@@ -6626,7 +6653,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
);
|
||||
const itemId = parsed.item.id;
|
||||
if (this.shouldSkipCompletedThreadItem(timelineItem, normalizedItemType, itemId)) {
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
if (this.consumeStreamedTextCompletion(timelineItem, itemId)) {
|
||||
@@ -6637,7 +6664,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.emittedItemCompletedIds.add(itemId);
|
||||
this.emittedItemStartedIds.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
this.applyBufferedDeltaTextToTimelineItem(timelineItem, itemId);
|
||||
@@ -6666,7 +6693,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
}
|
||||
|
||||
private consumeStreamedTextCompletion(
|
||||
@@ -6738,14 +6765,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private handleItemStartedNotification(
|
||||
private async handleItemStartedNotification(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||
return;
|
||||
}
|
||||
if (this.isUserMessageItem(parsed.item)) {
|
||||
this.handleUserMessageItem(parsed);
|
||||
await this.handleUserMessageItem(parsed);
|
||||
return;
|
||||
}
|
||||
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
@@ -6767,7 +6794,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (this.handleRegisteredSubAgentActivity(parsed.item)) {
|
||||
return;
|
||||
}
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: false,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
@@ -6786,7 +6813,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(childSubAgentCallId, "running");
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
return;
|
||||
}
|
||||
const normalizedItemType = normalizeCodexThreadItemType(
|
||||
@@ -6810,14 +6837,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
await this.replayPendingSubAgentNotifications(registeredChildThreadIds);
|
||||
}
|
||||
|
||||
private handleUserMessageItem(
|
||||
private async handleUserMessageItem(
|
||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
||||
): void {
|
||||
): Promise<void> {
|
||||
const itemId = parsed.item.id;
|
||||
const timelineItem = threadItemToTimeline(parsed.item, {
|
||||
const timelineItem = await threadItemToTimeline(parsed.item, {
|
||||
includeUserMessage: true,
|
||||
cwd: this.config.cwd ?? null,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { JsonlFrameDecoder, type JsonlFrameProblem } from "./jsonl-frame-decoder.js";
|
||||
|
||||
@@ -72,26 +71,15 @@ describe("provider line admission", () => {
|
||||
it("keeps the loop responsive while accumulating a fragmented provider response", async () => {
|
||||
const { decoder, frames, problems } = decoderHarness();
|
||||
const part = "x".repeat(512);
|
||||
let last = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - last);
|
||||
last = now;
|
||||
}, 1);
|
||||
try {
|
||||
await delay(5);
|
||||
const { blockingMs } = await measureMainThreadWork("fragmented JSONL main-thread work", () => {
|
||||
decoder.write('{"type":"notice","text":"');
|
||||
// 1 MiB is enough to expose the former rescan of every accumulated prefix.
|
||||
// 1 MiB exposes the former rescan of every accumulated prefix.
|
||||
for (let index = 0; index < 2048; index++) decoder.write(part);
|
||||
decoder.write('"}\n');
|
||||
await delay(5);
|
||||
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
|
||||
expect(problems).toEqual([]);
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
});
|
||||
expect(frames).toEqual([{ type: "notice", text: part.repeat(2048) }]);
|
||||
expect(problems).toEqual([]);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
});
|
||||
|
||||
it("rejects an unterminated over-cap UTF-8 line and stops accepting frames", () => {
|
||||
|
||||
@@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
|
||||
}
|
||||
|
||||
interface AgentStreamStressRequest {
|
||||
tools: boolean;
|
||||
count: number;
|
||||
coalesced: boolean;
|
||||
}
|
||||
@@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt(
|
||||
|
||||
function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null {
|
||||
const text = promptToText(prompt);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text);
|
||||
const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
|
||||
}
|
||||
return {
|
||||
count: Math.min(count, 5_000),
|
||||
coalesced: Boolean(match[2]),
|
||||
coalesced: match[2]?.trim() === "coalesced",
|
||||
tools: match[2]?.trim() === "tool",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.emitTurnStarted(turn);
|
||||
|
||||
for (let index = 0; index < stress.count; index += 1) {
|
||||
this.emitTimeline(
|
||||
turn.turnId,
|
||||
stress.coalesced
|
||||
? {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
}
|
||||
: {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
},
|
||||
);
|
||||
let item: AgentTimelineItem;
|
||||
if (stress.tools) {
|
||||
item = {
|
||||
type: "tool_call",
|
||||
callId: `${turn.turnId}:stress:${index}`,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` },
|
||||
};
|
||||
} else if (stress.coalesced) {
|
||||
item = {
|
||||
type: "assistant_message",
|
||||
text: `stress-update-${index}`,
|
||||
messageId: turn.assistantMessageId,
|
||||
};
|
||||
} else {
|
||||
item = {
|
||||
type: "todo",
|
||||
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
|
||||
};
|
||||
}
|
||||
this.emitTimeline(turn.turnId, item);
|
||||
}
|
||||
|
||||
if (stress.tools)
|
||||
this.emitTimeline(turn.turnId, {
|
||||
type: "assistant_message",
|
||||
text: `Completed ${stress.count} commands`,
|
||||
messageId: turn.assistantMessageId,
|
||||
});
|
||||
this.activeTurn = null;
|
||||
const usage = {
|
||||
inputTokens: 1,
|
||||
|
||||
@@ -1343,13 +1343,14 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.runtimeLaunches()).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("closes a replacement process if the session closes during relaunch", async () => {
|
||||
test("rejects a pending recovery start and closes its processes when the session closes", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
omp.processExit("OMP RPC process exited with code 1 and signal null");
|
||||
const turn = omp.startTurnDetached("continue");
|
||||
const rejected = expect(turn).rejects.toThrow("closed");
|
||||
await omp.close();
|
||||
await turn;
|
||||
await rejected;
|
||||
await waitForImmediate();
|
||||
expect(omp.runtimeSessions().every((session) => session.closed)).toBe(true);
|
||||
});
|
||||
@@ -1638,3 +1639,93 @@ test.each([
|
||||
expect(await session.canStopRuntime!()).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([false, true])(
|
||||
"image prompt keeps OMP's vision capability contract (vision: %s)",
|
||||
async (vision) => {
|
||||
const runtime = new FakeOmp();
|
||||
runtime.queueSessionSetup((session) => {
|
||||
session.state = {
|
||||
...session.state,
|
||||
model: {
|
||||
provider: "test",
|
||||
id: "image-model",
|
||||
input: vision ? ["text", "image"] : ["text"],
|
||||
},
|
||||
};
|
||||
});
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
onTestFinished(() => session.close());
|
||||
const bytes = Buffer.from("OMP attachment fixture");
|
||||
const first = session.startTurn([
|
||||
{ type: "text", text: "Describe this image." },
|
||||
{ type: "image", data: bytes.toString("base64"), mimeType: "image/png" },
|
||||
]);
|
||||
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
|
||||
await first;
|
||||
const [prompt] = runtime.latestSession().prompts;
|
||||
expect(prompt.imageCount).toBe(vision ? 1 : 0);
|
||||
if (vision) expect(prompt.message).toBe("Describe this image.");
|
||||
else {
|
||||
const imagePath = prompt.message.match(/\[Image available at: (.+)\]/)?.[1];
|
||||
expect(imagePath).toBeTruthy();
|
||||
const { readFile, rm } = await import("node:fs/promises");
|
||||
expect(await readFile(imagePath!)).toEqual(bytes);
|
||||
await rm(imagePath!);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("interrupting OMP during image conversion cancels the admitted turn and permits a follow-up", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
onTestFinished(() => session.close());
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
let admitted = false;
|
||||
const first = session
|
||||
.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 53).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
])
|
||||
.then((result) => {
|
||||
admitted = true;
|
||||
return result;
|
||||
});
|
||||
await waitForImmediate();
|
||||
expect(admitted).toBe(false);
|
||||
expect(runtime.latestSession().prompts).toHaveLength(0);
|
||||
await session.interrupt();
|
||||
const followup = await session.startTurn("follow-up after image interrupt");
|
||||
const original = await first;
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
|
||||
);
|
||||
expect(followup.turnId).not.toBe(original.turnId);
|
||||
expect(runtime.latestSession().prompts).toEqual([
|
||||
{ message: "follow-up after image interrupt", imageCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("closing OMP during image conversion rejects its pending start", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const client = new OmpAgentClient({ logger: createTestLogger(), runtime });
|
||||
const session = await client.createSession({ provider: "omp", cwd: process.cwd() });
|
||||
const first = session.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 54).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
]);
|
||||
const rejected = expect(first).rejects.toThrow("closed");
|
||||
await waitForImmediate();
|
||||
expect(runtime.latestSession().prompts).toHaveLength(0);
|
||||
await session.close();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
@@ -317,9 +317,9 @@ function ompModelSupportsImageInput(model: OmpModel | null | undefined): boolean
|
||||
return model?.input?.includes("image") === true;
|
||||
}
|
||||
|
||||
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
|
||||
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
|
||||
try {
|
||||
const materialized = materializeProviderImage({
|
||||
const materialized = await materializeProviderImage({
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
});
|
||||
@@ -329,10 +329,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
|
||||
}
|
||||
}
|
||||
|
||||
function convertPromptInput(
|
||||
async function convertPromptInput(
|
||||
prompt: AgentPromptInput,
|
||||
options: { model: OmpModel | null | undefined },
|
||||
): OmpPromptPayload {
|
||||
): Promise<OmpPromptPayload> {
|
||||
if (typeof prompt === "string") {
|
||||
return { text: prompt };
|
||||
}
|
||||
@@ -355,7 +355,7 @@ function convertPromptInput(
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
} else {
|
||||
textParts.push(renderTextOnlyImageHint(block));
|
||||
textParts.push(await renderTextOnlyImageHint(block));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -868,11 +868,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
throw new Error("An OMP turn is already active");
|
||||
}
|
||||
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
this.live = true;
|
||||
this.activeTurnId = turnId;
|
||||
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeTurnTerminalAssistantMessage = null;
|
||||
this.activeTurnStarted = false;
|
||||
@@ -880,6 +878,10 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.activePromptRequestId = null;
|
||||
this.clearNoTurnBuffers();
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed) throw new Error("OMP session is closed");
|
||||
if (this.activeTurnId !== turnId) return { turnId };
|
||||
this.rememberClientMessage(options?.clientMessageId ?? null, payload.text);
|
||||
this.activeNoTurnPromptText = payload.text;
|
||||
this.usagePoller.startTurn();
|
||||
if (announceTurn) {
|
||||
@@ -946,7 +948,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
): Promise<SteerResult> {
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
if (this.parseSlashCommandInput(payload.text)) return { status: "unavailable" };
|
||||
const submission = this.rememberClientMessage(options.clientMessageId ?? null, payload.text);
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { measureMainThreadWork } from "../../../../test-utils/main-thread-work.js";
|
||||
import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
|
||||
import { mkdtemp, open, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -50,51 +51,45 @@ test("replays selected OMP history without blocking the event loop", async () =>
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
let last = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - last);
|
||||
last = now;
|
||||
}, 5);
|
||||
try {
|
||||
const replayState = { finished: false };
|
||||
let repliesDuringReplay = 0;
|
||||
const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => {
|
||||
replayState.finished = true;
|
||||
});
|
||||
// A real WebSocket RPC served by the isolated daemon on the same event loop.
|
||||
const requests = (async () => {
|
||||
while (!replayState.finished) {
|
||||
await client.getDaemonStatus();
|
||||
if (!replayState.finished) repliesDuringReplay++;
|
||||
await delay(10);
|
||||
}
|
||||
})();
|
||||
const [entries] = await Promise.all([replay, requests]);
|
||||
await delay(10);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
longestGap,
|
||||
repliesDuringReplay,
|
||||
generationAndLoadMs: performance.now() - started,
|
||||
}),
|
||||
);
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "row-0",
|
||||
parentId: null,
|
||||
message: { role: "user", content: text },
|
||||
},
|
||||
]);
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
expect(repliesDuringReplay).toBeGreaterThan(0);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
let repliesDuringReplay = 0;
|
||||
const { result: replayedEntries, blockingMs } = await measureMainThreadWork(
|
||||
"OMP history main-thread work",
|
||||
async () => {
|
||||
const replayState = { finished: false };
|
||||
const replay = readActiveOmpEntryChain(sessionFile, "row-0").finally(() => {
|
||||
replayState.finished = true;
|
||||
});
|
||||
// A real WebSocket RPC served by the isolated daemon on the same event loop.
|
||||
const requests = (async () => {
|
||||
while (!replayState.finished) {
|
||||
await client.getDaemonStatus();
|
||||
if (!replayState.finished) repliesDuringReplay++;
|
||||
await delay(10);
|
||||
}
|
||||
})();
|
||||
const [entries] = await Promise.all([replay, requests]);
|
||||
return entries;
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
bytes,
|
||||
count,
|
||||
blockingMs,
|
||||
repliesDuringReplay,
|
||||
generationAndLoadMs: performance.now() - started,
|
||||
}),
|
||||
);
|
||||
expect(replayedEntries).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
id: "row-0",
|
||||
parentId: null,
|
||||
message: { role: "user", content: text },
|
||||
},
|
||||
]);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(repliesDuringReplay).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await client.close().finally(() => daemon.close());
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "./test-utils/v2-timeline.js";
|
||||
import type { SessionMessageAssistant, SessionMessageUser } from "@opencode/client";
|
||||
import { V2Timeline } from "./v2/timeline.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -1560,7 +1561,7 @@ describe("translateOpenCodeEvent", () => {
|
||||
});
|
||||
|
||||
describe("OpenCode v2 history reconciliation", () => {
|
||||
it("keeps client message identity on live echoes but omits it from history", () => {
|
||||
it("keeps client message identity on live echoes but omits it from history", async () => {
|
||||
const user: SessionMessageUser = {
|
||||
id: "native-message",
|
||||
type: "user",
|
||||
@@ -1568,13 +1569,13 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
time: { created: 1 },
|
||||
metadata: { paseoClientMessageId: "client-message" },
|
||||
};
|
||||
expect(new V2Timeline().messages([user])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(), [user])).toMatchObject([
|
||||
{ item: { clientMessageId: "client-message", messageId: "native-message" } },
|
||||
]);
|
||||
expect(new V2Timeline(false).messages([user])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(false), [user])).toMatchObject([
|
||||
{ item: { type: "user_message", messageId: "native-message" } },
|
||||
]);
|
||||
const [event] = new V2Timeline(false).messages([user]);
|
||||
const [event] = await collectTimelineSnapshot(new V2Timeline(false), [user]);
|
||||
expect(event && "item" in event && event.item).not.toHaveProperty("clientMessageId");
|
||||
});
|
||||
function assistant(text: string): SessionMessageAssistant {
|
||||
@@ -1587,31 +1588,35 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
content: [{ type: "text", text }],
|
||||
};
|
||||
}
|
||||
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", () => {
|
||||
it("emits only the missing suffix after reconnect, even if an intermediate snapshot lags", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const text = (messages: ReturnType<V2Timeline["messages"]>) =>
|
||||
const text = (messages: Awaited<ReturnType<typeof collectTimelineSnapshot>>) =>
|
||||
messages.flatMap((event) =>
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
? [event.item.text]
|
||||
: [],
|
||||
);
|
||||
expect(text(timeline.messages([assistant("hello")]))).toEqual(["hello"]);
|
||||
expect(timeline.messages([assistant("hel")])).toEqual([]);
|
||||
expect(text(timeline.messages([assistant("hello world")]))).toEqual([" world"]);
|
||||
expect(timeline.messages([assistant("hello world")])).toEqual([]);
|
||||
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello")]))).toEqual(["hello"]);
|
||||
expect(await collectTimelineSnapshot(timeline, [assistant("hel")])).toEqual([]);
|
||||
expect(text(await collectTimelineSnapshot(timeline, [assistant("hello world")]))).toEqual([
|
||||
" world",
|
||||
]);
|
||||
expect(await collectTimelineSnapshot(timeline, [assistant("hello world")])).toEqual([]);
|
||||
});
|
||||
it("keeps reasoning separate from assistant output", () => {
|
||||
it("keeps reasoning separate from assistant output", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const message = assistant("answer");
|
||||
message.content.unshift({ type: "reasoning", text: "thinking" });
|
||||
expect(
|
||||
timeline.messages([message]).map((event) => (event.type === "timeline" ? event.item : null)),
|
||||
(await collectTimelineSnapshot(timeline, [message])).map((event) =>
|
||||
event.type === "timeline" ? event.item : null,
|
||||
),
|
||||
).toEqual([
|
||||
{ type: "reasoning", text: "thinking" },
|
||||
{ type: "assistant_message", text: "answer", messageId: "message" },
|
||||
]);
|
||||
});
|
||||
it("emits tool state transitions once and keeps their call identity", () => {
|
||||
it("emits tool state transitions once and keeps their call identity", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const message = assistant("");
|
||||
message.content = [
|
||||
@@ -1623,7 +1628,7 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
state: { status: "running", input: { command: "pwd" }, metadata: {} },
|
||||
},
|
||||
];
|
||||
expect(timeline.messages([message])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
|
||||
{ item: { type: "tool_call", callId: "call", status: "running" } },
|
||||
]);
|
||||
message.content = [
|
||||
@@ -1639,9 +1644,9 @@ describe("OpenCode v2 history reconciliation", () => {
|
||||
},
|
||||
},
|
||||
];
|
||||
expect(timeline.messages([message])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toMatchObject([
|
||||
{ item: { type: "tool_call", callId: "call", status: "completed" } },
|
||||
]);
|
||||
expect(timeline.messages([message])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [message])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
+13
-10
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "./v2-timeline.js";
|
||||
import { TimelineProjection } from "../../../timeline-projection.js";
|
||||
import { V2Timeline } from "../v2/timeline.js";
|
||||
|
||||
@@ -21,16 +22,18 @@ for (let index = 0; index < chunks; index++) {
|
||||
const event =
|
||||
mode === "delta"
|
||||
? timeline.delta({ ...part, delta })
|
||||
: timeline.messages([
|
||||
{
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content: [{ type: "reasoning", text }],
|
||||
},
|
||||
])[0];
|
||||
: (
|
||||
await collectTimelineSnapshot(timeline, [
|
||||
{
|
||||
id: "answer",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { providerID: "test", id: "model" },
|
||||
time: { created: 2 },
|
||||
content: [{ type: "reasoning", text }],
|
||||
},
|
||||
])
|
||||
)[0];
|
||||
if (event?.type !== "timeline") throw new Error("Expected a timeline event");
|
||||
projection.append({
|
||||
seq: index + 1,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SessionMessageInfo } from "@opencode/client";
|
||||
import type { AgentStreamEvent } from "../../../agent-sdk-types.js";
|
||||
import type { V2Timeline } from "../v2/timeline.js";
|
||||
|
||||
// Snapshot assertions without a live stream can collect the publisher's events.
|
||||
export async function collectTimelineSnapshot(
|
||||
timeline: V2Timeline,
|
||||
messages: SessionMessageInfo[],
|
||||
): Promise<AgentStreamEvent[]> {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
await timeline.messages(messages, (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
return events;
|
||||
}
|
||||
@@ -92,13 +92,13 @@ export class SessionChildren {
|
||||
this.childStates.set(info.id, signature);
|
||||
this.options.emit({ type: "provider_subagent", provider: "opencode", event: presentation });
|
||||
}
|
||||
for (const event of timeline.messages(await messages(this.options.client(), info.id))) {
|
||||
await timeline.messages(await messages(this.options.client(), info.id), (event) => {
|
||||
if (event.type === "timeline")
|
||||
this.options.emit({
|
||||
type: "provider_subagent",
|
||||
provider: "opencode",
|
||||
event: { type: "timeline", id: info.id, item: event.item, timestamp: event.timestamp },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await this.configureConnection();
|
||||
const location = { directory: this.config.cwd };
|
||||
this.modes = modesFromV2((await this.client.agent.list({ location })).data);
|
||||
this.timeline.messages(await messages(this.client, this.id));
|
||||
await this.timeline.messages(await messages(this.client, this.id), () => {});
|
||||
await this.startStream();
|
||||
await this.children.reconcile(this.id);
|
||||
}
|
||||
@@ -261,7 +261,11 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
async *streamHistory() {
|
||||
await this.reconnectIfExited();
|
||||
const history = new V2Timeline(false);
|
||||
yield* history.messages(await messages(this.client, this.id));
|
||||
const events: AgentStreamEvent[] = [];
|
||||
await history.messages(await messages(this.client, this.id), (event) => {
|
||||
events.push(event);
|
||||
});
|
||||
yield* events;
|
||||
}
|
||||
async getRuntimeInfo() {
|
||||
await this.reconnectIfExited();
|
||||
@@ -417,7 +421,7 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
]);
|
||||
this.info = info;
|
||||
this.history = history;
|
||||
for (const event of this.timeline.messages(history)) this.emitTimeline(event);
|
||||
await this.timeline.messages(history, (event) => this.emitTimeline(event));
|
||||
await this.permissions.reconcile(this.id);
|
||||
}
|
||||
private async reconcile() {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
import type { SessionMessageAssistant } from "@opencode/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
@@ -80,7 +81,7 @@ describe("OpenCode v2 token streaming", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("deduplicates snapshots before and after deltas using per-type ordinals", () => {
|
||||
test("deduplicates snapshots before and after deltas using per-type ordinals", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const text = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
|
||||
const reasoning = { ...text, type: "reasoning" } as const;
|
||||
@@ -99,24 +100,28 @@ describe("OpenCode v2 token streaming", () => {
|
||||
},
|
||||
{ type: "text", text: "Hello" },
|
||||
]);
|
||||
expect(timeline.messages([snapshot])).toMatchObject([{ item: { text: "lo" } }]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toMatchObject([
|
||||
{ item: { text: "lo" } },
|
||||
]);
|
||||
expect(timeline.delta({ ...text, delta: "lo" })).toBeNull();
|
||||
expect(timeline.messages([snapshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
|
||||
expect(timeline.delta({ ...text, delta: "!" })).toMatchObject({ item: { text: "!" } });
|
||||
expect(timeline.messages([snapshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [snapshot])).toEqual([]);
|
||||
});
|
||||
|
||||
test("recovers missed fragments from snapshots after reconnect", () => {
|
||||
test("recovers missed fragments from snapshots after reconnect", async () => {
|
||||
const timeline = new V2Timeline();
|
||||
const part = { assistantMessageID: "answer", type: "text", ordinal: 0 } as const;
|
||||
timeline.startPart(part);
|
||||
expect(timeline.delta({ ...part, delta: "Hel" })).toMatchObject({ item: { text: "Hel" } });
|
||||
timeline.resetStreams();
|
||||
expect(timeline.delta({ ...part, delta: "!" })).toBeNull();
|
||||
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toMatchObject([
|
||||
{ item: { text: "lo!" } },
|
||||
]);
|
||||
expect(timeline.messages([assistant([{ type: "text", text: "Hello!" }])])).toEqual([]);
|
||||
expect(
|
||||
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
|
||||
).toMatchObject([{ item: { text: "lo!" } }]);
|
||||
expect(
|
||||
await collectTimelineSnapshot(timeline, [assistant([{ type: "text", text: "Hello!" }])]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("withholds streamed prose during a structured-output turn", async () => {
|
||||
@@ -203,3 +208,62 @@ describe("OpenCode v2 token streaming", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.each(["text", "reasoning"] as const)(
|
||||
"publishes snapshot %s before a live suffix during image materialization",
|
||||
async (type) => {
|
||||
const timeline = new V2Timeline();
|
||||
const part = { assistantMessageID: "answer", type, ordinal: 0 };
|
||||
timeline.startPart(part);
|
||||
const emitted: AgentStreamEvent[] = [];
|
||||
let settled = false;
|
||||
const snapshot = timeline
|
||||
.messages(
|
||||
[
|
||||
assistant([
|
||||
{ type, text: "Hello" },
|
||||
{
|
||||
type: "tool",
|
||||
id: "screenshot",
|
||||
name: "paseo_browser_screenshot",
|
||||
executed: false,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: {},
|
||||
content: [
|
||||
{ type: "file", uri: "data:image/png;base64,aGVsbG8=", mime: "image/png" },
|
||||
],
|
||||
metadata: {},
|
||||
},
|
||||
time: { created: 2, ran: 2, completed: 2 },
|
||||
},
|
||||
]),
|
||||
],
|
||||
(event) => {
|
||||
emitted.push(event);
|
||||
},
|
||||
)
|
||||
.then(() => {
|
||||
settled = true;
|
||||
return undefined;
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
expect(settled).toBe(false);
|
||||
const delta = timeline.delta({ ...part, delta: "Hello world" });
|
||||
if (delta) emitted.push(delta);
|
||||
await snapshot;
|
||||
const text = emitted.flatMap((event) => {
|
||||
if (event.type !== "timeline") return [];
|
||||
if (
|
||||
type === "text" &&
|
||||
event.item.type === "assistant_message" &&
|
||||
!event.item.text.includes("![Image]")
|
||||
)
|
||||
return [event.item.text];
|
||||
if (type === "reasoning" && event.item.type === "reasoning") return [event.item.text];
|
||||
return [];
|
||||
});
|
||||
expect(text).toEqual(["Hello", " world"]);
|
||||
expect(text.join("")).toBe("Hello world");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { collectTimelineSnapshot } from "../test-utils/v2-timeline.js";
|
||||
import type { SessionMessageAssistant } from "@opencode/client";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -15,7 +16,7 @@ function assistant(content: SessionMessageAssistant["content"]): SessionMessageA
|
||||
};
|
||||
}
|
||||
|
||||
test("a completed edit carries the replaced and replacement text", () => {
|
||||
test("a completed edit carries the replaced and replacement text", async () => {
|
||||
// Captured from OpenCode 2.0.18, whose edit tool takes `path` instead of `filePath`.
|
||||
const edit = assistant([
|
||||
{
|
||||
@@ -45,7 +46,7 @@ test("a completed edit carries the replaced and replacement text", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
expect(new V2Timeline().messages([edit])).toMatchObject([
|
||||
expect(await collectTimelineSnapshot(new V2Timeline(), [edit])).toMatchObject([
|
||||
{
|
||||
item: {
|
||||
type: "tool_call",
|
||||
@@ -57,7 +58,7 @@ test("a completed edit carries the replaced and replacement text", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("a tool's image output is shown as an image, not as base64 in the tool output", () => {
|
||||
test("a tool's image output is shown as an image, not as base64 in the tool output", async () => {
|
||||
const png =
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg==";
|
||||
const screenshot = assistant([
|
||||
@@ -80,7 +81,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
|
||||
]);
|
||||
const timeline = new V2Timeline();
|
||||
|
||||
const events = timeline.messages([screenshot]);
|
||||
const events = await collectTimelineSnapshot(timeline, [screenshot]);
|
||||
|
||||
expect(JSON.stringify(events)).not.toContain(png);
|
||||
expect(events.map((event) => event.type === "timeline" && event.item)).toMatchObject([
|
||||
@@ -95,7 +96,7 @@ test("a tool's image output is shown as an image, not as base64 in the tool outp
|
||||
text: expect.stringMatching(/^\n\n!\[Image\]\(file:\/\/.*paseo-attachments.*\.png\)\n\n$/),
|
||||
},
|
||||
]);
|
||||
expect(timeline.messages([screenshot])).toEqual([]);
|
||||
expect(await collectTimelineSnapshot(timeline, [screenshot])).toEqual([]);
|
||||
});
|
||||
|
||||
test("a surrogate pair split across deltas streams back intact", () => {
|
||||
|
||||
@@ -55,18 +55,32 @@ export class V2Timeline {
|
||||
return { type: "timeline", provider: "opencode", item, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
messages(messages: SessionMessageInfo[]): AgentStreamEvent[] {
|
||||
const events: AgentStreamEvent[] = [];
|
||||
private pendingMessages: Promise<unknown> = Promise.resolve();
|
||||
|
||||
// Publish each cursor advance in the same synchronous step. Buffering snapshot
|
||||
// events until image IO finishes lets live deltas overtake already committed text.
|
||||
messages(
|
||||
messages: SessionMessageInfo[],
|
||||
publish: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
const result = this.pendingMessages.then(() => this.publishMessages(messages, publish));
|
||||
this.pendingMessages = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
private async publishMessages(
|
||||
messages: SessionMessageInfo[],
|
||||
publish: (event: AgentStreamEvent) => void,
|
||||
): Promise<void> {
|
||||
const state: TimelineState = { structured: false, accepted: false };
|
||||
for (const message of messages) {
|
||||
const timestamp = new Date(message.time.created).toISOString();
|
||||
const push = (item: AgentTimelineItem) =>
|
||||
events.push({ type: "timeline", provider: "opencode", item, timestamp });
|
||||
publish({ type: "timeline", provider: "opencode", item, timestamp });
|
||||
if (message.type === "user") this.userMessage(message, state, push);
|
||||
else if (message.type === "assistant") this.assistantMessage(message, state, push);
|
||||
else if (message.type === "assistant") await this.assistantMessage(message, state, push);
|
||||
else if (message.type === "compaction") this.compactionMessage(message, push);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
private userMessage(
|
||||
@@ -89,7 +103,7 @@ export class V2Timeline {
|
||||
});
|
||||
}
|
||||
|
||||
private assistantMessage(
|
||||
private async assistantMessage(
|
||||
message: Extract<SessionMessageInfo, { type: "assistant" }>,
|
||||
state: TimelineState,
|
||||
push: (item: AgentTimelineItem) => void,
|
||||
@@ -99,16 +113,16 @@ export class V2Timeline {
|
||||
// paths address the same cursor entry.
|
||||
let textOrdinal = 0;
|
||||
let reasoningOrdinal = 0;
|
||||
message.content.forEach((part, partIndex) => {
|
||||
for (const [partIndex, part] of message.content.entries()) {
|
||||
if (part.type === "tool" && part.name === STRUCTURED_OUTPUT_TOOL) {
|
||||
this.structuredOutputPart(message.id, partIndex, part, state, push);
|
||||
} else if (part.type === "text" || part.type === "reasoning") {
|
||||
const ordinal = part.type === "text" ? textOrdinal++ : reasoningOrdinal++;
|
||||
this.textPart(message.id, part, ordinal, state, push);
|
||||
} else {
|
||||
this.toolPart(message.id, partIndex, part, push);
|
||||
await this.toolPart(message.id, partIndex, part, push);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private structuredOutputPart(
|
||||
@@ -156,7 +170,7 @@ export class V2Timeline {
|
||||
else push({ type: "reasoning", text: suffix });
|
||||
}
|
||||
|
||||
private toolPart(
|
||||
private async toolPart(
|
||||
messageID: string,
|
||||
partIndex: number,
|
||||
part: Extract<
|
||||
@@ -174,16 +188,16 @@ export class V2Timeline {
|
||||
if (item) push(item);
|
||||
// Images follow their tool call as markdown, as Claude and Codex emit them,
|
||||
// so base64 never reaches the tool output.
|
||||
images.forEach((image, index) => {
|
||||
for (const [index, image] of images.entries()) {
|
||||
const imageKey = `${key}:image:${index}`;
|
||||
if (this.content.has(imageKey)) return;
|
||||
if (this.content.has(imageKey)) continue;
|
||||
this.content.set(imageKey, "");
|
||||
const imageItem = renderProviderImageOutputAsAssistantMarkdown(
|
||||
const imageItem = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ url: image.uri, mimeType: image.mime },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
if (imageItem) push(imageItem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private compactionMessage(
|
||||
|
||||
@@ -3583,3 +3583,63 @@ test("pi runtime safety protects queued replies and runtime-owned activity", asy
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
test("reserves the Pi turn while an image prompt is materialized", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const first = session.startTurn([
|
||||
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
|
||||
]);
|
||||
await expect(session.startTurn("overlapping prompt")).rejects.toThrow("already active");
|
||||
await first;
|
||||
expect(pi.latestSession().prompts).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("interrupting Pi during image conversion cancels the admitted turn and permits a follow-up", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
let admitted = false;
|
||||
const first = session
|
||||
.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 51).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
])
|
||||
.then((result) => {
|
||||
admitted = true;
|
||||
return result;
|
||||
});
|
||||
await waitForImmediate();
|
||||
expect(admitted).toBe(false);
|
||||
expect(pi.latestSession().prompts).toHaveLength(0);
|
||||
await session.interrupt();
|
||||
const followup = await session.startTurn("follow-up after image interrupt");
|
||||
const original = await first;
|
||||
expect(events).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled", turnId: original.turnId }),
|
||||
);
|
||||
expect(followup.turnId).not.toBe(original.turnId);
|
||||
expect(pi.latestSession().prompts).toEqual([
|
||||
{ message: "follow-up after image interrupt", imageCount: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("closing Pi during image conversion rejects its pending start", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const first = session.startTurn([
|
||||
{
|
||||
type: "image",
|
||||
data: Buffer.alloc(16 * 1024 * 1024, 52).toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
]);
|
||||
const rejected = expect(first).rejects.toThrow("closed");
|
||||
await waitForImmediate();
|
||||
expect(pi.latestSession().prompts).toHaveLength(0);
|
||||
await session.close();
|
||||
await rejected;
|
||||
});
|
||||
|
||||
@@ -367,9 +367,9 @@ function piModelSupportsImageInput(model: PiModel | null | undefined): boolean {
|
||||
return model?.input?.includes("image") === true;
|
||||
}
|
||||
|
||||
function renderTextOnlyImageHint(image: { data: string; mimeType: string }): string {
|
||||
async function renderTextOnlyImageHint(image: { data: string; mimeType: string }): Promise<string> {
|
||||
try {
|
||||
const materialized = materializeProviderImage({
|
||||
const materialized = await materializeProviderImage({
|
||||
data: image.data,
|
||||
mimeType: image.mimeType,
|
||||
});
|
||||
@@ -379,10 +379,10 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
|
||||
}
|
||||
}
|
||||
|
||||
function convertPromptInput(
|
||||
async function convertPromptInput(
|
||||
prompt: AgentPromptInput,
|
||||
options: { model: PiModel | null | undefined },
|
||||
): PiPromptPayload {
|
||||
): Promise<PiPromptPayload> {
|
||||
if (typeof prompt === "string") {
|
||||
return { text: prompt };
|
||||
}
|
||||
@@ -405,7 +405,7 @@ function convertPromptInput(
|
||||
mimeType: block.mimeType,
|
||||
});
|
||||
} else {
|
||||
textParts.push(renderTextOnlyImageHint(block));
|
||||
textParts.push(await renderTextOnlyImageHint(block));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1280,7 +1280,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
throw new Error("A Pi turn is already active");
|
||||
}
|
||||
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
this.activeTurnId = turnId;
|
||||
this.usagePoller.startTurn();
|
||||
@@ -1292,6 +1291,9 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activePromptRequestId = null;
|
||||
this.pendingSteerSubmissions.length = 0;
|
||||
this.clearNoTurnBuffers();
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed) throw new Error("Pi session is closed");
|
||||
if (this.activeTurnId !== turnId) return { turnId };
|
||||
this.activeNoTurnPromptText = payload.text;
|
||||
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
|
||||
|
||||
@@ -1363,7 +1365,9 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const payload = await convertPromptInput(prompt, { model: this.state.model });
|
||||
if (this.closed || this.activeTurnId !== options.expectedTurnId)
|
||||
return { status: "unavailable" };
|
||||
// Pi rejects steer RPCs that are extension commands, so slash inputs keep the
|
||||
// interrupt-and-replace fallback where they can run directly.
|
||||
if (this.parseSlashCommandInput(payload.text)) {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { describe, expect, test } from "vitest";
|
||||
import { materializeProviderImage } from "./provider-image-output.js";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("materializeProviderImage", () => {
|
||||
test("writes image attachments under a private temp directory", () => {
|
||||
const materialized = materializeProviderImage({
|
||||
test("writes image attachments under a private temp directory", async () => {
|
||||
const materialized = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { existsSync, rmSync, statSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { projectTimelineRows } from "../timeline-projection.js";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
MAX_PROVIDER_IMAGE_BYTES,
|
||||
isProviderImageMarkdown,
|
||||
materializeProviderImage,
|
||||
renderProviderImageOutputAsAssistantMarkdown,
|
||||
@@ -12,8 +13,8 @@ import {
|
||||
|
||||
const HASH = "a".repeat(64);
|
||||
|
||||
function renderImageMarkdown(imagePath: string): string {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
async function renderImageMarkdown(imagePath: string): Promise<string> {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown({ path: imagePath });
|
||||
if (!item || item.type !== "assistant_message") {
|
||||
throw new Error("Expected provider image output to render as assistant markdown.");
|
||||
}
|
||||
@@ -40,8 +41,8 @@ describe("isProviderImageMarkdown", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("emits Windows file paths as file URIs", () => {
|
||||
const markdown = renderImageMarkdown(
|
||||
test("emits Windows file paths as file URIs", async () => {
|
||||
const markdown = await renderImageMarkdown(
|
||||
`C:\\Users\\me\\AppData\\Local\\Temp\\paseo-attachments\\${HASH}.png`,
|
||||
);
|
||||
|
||||
@@ -51,22 +52,24 @@ describe("isProviderImageMarkdown", () => {
|
||||
expect(isProviderImageMarkdown(markdown)).toBe(true);
|
||||
});
|
||||
|
||||
test("emits POSIX file paths with spaces as valid file URI markdown", () => {
|
||||
const markdown = renderImageMarkdown("/home/user/Projects/Project With Spaces/screenshot.png");
|
||||
test("emits POSIX file paths with spaces as valid file URI markdown", async () => {
|
||||
const markdown = await renderImageMarkdown(
|
||||
"/home/user/Projects/Project With Spaces/screenshot.png",
|
||||
);
|
||||
|
||||
expect(markdown).toBe(
|
||||
"\n\n\n\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("encodes URI-significant characters in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||
test("encodes URI-significant characters in POSIX file paths", async () => {
|
||||
const markdown = await renderImageMarkdown("/tmp/screenshot#1?draft.png");
|
||||
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
|
||||
test("preserves double-leading slashes in POSIX file paths", () => {
|
||||
const markdown = renderImageMarkdown("//tmp/screenshot#1.png");
|
||||
test("preserves double-leading slashes in POSIX file paths", async () => {
|
||||
const markdown = await renderImageMarkdown("//tmp/screenshot#1.png");
|
||||
|
||||
expect(markdown).toBe("\n\n\n\n");
|
||||
});
|
||||
@@ -78,8 +81,8 @@ describe("isProviderImageMarkdown", () => {
|
||||
"\\\\?\\UNC\\server\\share\\shot?draft.png",
|
||||
"file://server/share/shot%3Fdraft.png",
|
||||
],
|
||||
])("encodes %s image paths as file URIs", (_label, imagePath, expectedSource) => {
|
||||
expect(renderImageMarkdown(imagePath)).toBe(`\n\n\n\n`);
|
||||
])("encodes %s image paths as file URIs", async (_label, imagePath, expectedSource) => {
|
||||
expect(await renderImageMarkdown(imagePath)).toBe(`\n\n\n\n`);
|
||||
});
|
||||
|
||||
test("rejects user-authored markdown that is not a materialized attachment", () => {
|
||||
@@ -92,8 +95,8 @@ describe("isProviderImageMarkdown", () => {
|
||||
});
|
||||
|
||||
describe("materializeProviderImage", () => {
|
||||
test("recreates the private temp directory if the cached directory is removed", () => {
|
||||
const first = materializeProviderImage({
|
||||
test("recreates the private temp directory if the cached directory is removed", async () => {
|
||||
const first = await materializeProviderImage({
|
||||
data: "YWJjMTIz",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
@@ -102,7 +105,7 @@ describe("materializeProviderImage", () => {
|
||||
|
||||
rmSync(firstDir, { recursive: true, force: true });
|
||||
|
||||
const second = materializeProviderImage({
|
||||
const second = await materializeProviderImage({
|
||||
data: "ZGVmNDU2",
|
||||
mimeType: "image/png",
|
||||
});
|
||||
@@ -117,8 +120,8 @@ describe("materializeProviderImage", () => {
|
||||
});
|
||||
|
||||
describe("provider image paragraph boundaries", () => {
|
||||
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", (ending) => {
|
||||
const image = renderProviderImageOutputAsAssistantMarkdown({
|
||||
test.each(["", "\n"])("keeps image outputs separate from prose ending in %j", async (ending) => {
|
||||
const image = await renderProviderImageOutputAsAssistantMarkdown({
|
||||
url: "https://example.com/chart.png",
|
||||
});
|
||||
if (!image) throw new Error("Expected image output");
|
||||
@@ -152,10 +155,10 @@ describe("provider image paragraph boundaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("materialized image output retains paragraph boundaries and attachment recognition", () => {
|
||||
const item = renderProviderImageOutputAsAssistantMarkdown(
|
||||
test("materialized image output retains paragraph boundaries and attachment recognition", async () => {
|
||||
const item = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data: "YWJjMTIz", mimeType: "image/png" },
|
||||
{ materialize: () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
|
||||
{ materialize: async () => ({ path: `/tmp/paseo-attachments/${HASH}.png` }) },
|
||||
);
|
||||
expect(item).toEqual({
|
||||
type: "assistant_message",
|
||||
@@ -164,3 +167,76 @@ test("materialized image output retains paragraph boundaries and attachment reco
|
||||
if (!item || item.type !== "assistant_message") throw new Error("Expected image output");
|
||||
expect(isProviderImageMarkdown(item.text)).toBe(true);
|
||||
});
|
||||
|
||||
test("repeated content is reused without rewriting the attachment", async () => {
|
||||
const image = {
|
||||
data: Buffer.from("dedupe regression").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const first = await materializeProviderImage(image);
|
||||
const before = statSync(first.path, { bigint: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const second = await materializeProviderImage(image);
|
||||
expect(second.path).toBe(first.path);
|
||||
expect(statSync(second.path, { bigint: true }).mtimeNs).toBe(before.mtimeNs);
|
||||
});
|
||||
|
||||
test("64 MiB materialization leaves the event loop responsive and preserves bytes", async () => {
|
||||
const bytes = Buffer.alloc(64 * 1024 * 1024, 37);
|
||||
const data = bytes.toString("base64");
|
||||
let previous = performance.now();
|
||||
let longest = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longest = Math.max(longest, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
try {
|
||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
console.log(`64 MiB attachment longest loop gap: ${longest.toFixed(2)} ms`);
|
||||
expect(longest).toBeLessThan(50);
|
||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||
rmSync(result.path, { force: true });
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
});
|
||||
|
||||
test("preserves base64 decoding across whitespace and chunk boundaries", async () => {
|
||||
const bytes = Buffer.alloc(512 * 1024, 123);
|
||||
const base64 = bytes.toString("base64");
|
||||
const data = base64.match(/.{1,77}/g)!.join("\n");
|
||||
const result = await materializeProviderImage({ data, mimeType: "image/png" });
|
||||
expect(readFileSync(result.path).equals(bytes)).toBe(true);
|
||||
});
|
||||
|
||||
test("rejects oversized materialization before work and leaves the queue usable", async () => {
|
||||
const data = "A".repeat(Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4 + 260);
|
||||
await expect(materializeProviderImage({ data, mimeType: "image/png" })).rejects.toThrow(
|
||||
"exceeds",
|
||||
);
|
||||
const omitted = await renderProviderImageOutputAsAssistantMarkdown(
|
||||
{ data, mimeType: "image/png" },
|
||||
{ materialize: materializeProviderImage },
|
||||
);
|
||||
expect(omitted).toEqual({
|
||||
type: "assistant_message",
|
||||
text: "Image output was omitted because it was not available as a file path or URL.",
|
||||
});
|
||||
const image = await materializeProviderImage({ data: "YWJjMTIz", mimeType: "image/png" });
|
||||
expect(readFileSync(image.path).toString()).toBe("abc123");
|
||||
});
|
||||
|
||||
test("simultaneous requests for the same content share the published file", async () => {
|
||||
const image = {
|
||||
data: Buffer.from("concurrent dedupe fixture").toString("base64"),
|
||||
mimeType: "image/png",
|
||||
};
|
||||
const results = await Promise.all([
|
||||
materializeProviderImage(image),
|
||||
materializeProviderImage(image),
|
||||
]);
|
||||
expect(results[0]).toEqual(results[1]);
|
||||
expect(readFileSync(results[0].path).toString()).toBe("concurrent dedupe fixture");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import * as fsSync from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -23,32 +24,26 @@ const PRIVATE_ATTACHMENT_DIR_MODE = 0o700;
|
||||
const MATERIALIZED_IMAGE_FILE_MODE = 0o600;
|
||||
|
||||
let materializedImageAttachmentDir: string | null = null;
|
||||
// Serialize materializations: one decoder and one file operation at a time, including
|
||||
// concurrent history replay and live turns. A rejected operation must not poison the queue.
|
||||
let pendingMaterialization: Promise<unknown> = Promise.resolve();
|
||||
|
||||
function canReuseMaterializedImageAttachmentDir(dir: string): boolean {
|
||||
try {
|
||||
const stats = fsSync.lstatSync(dir);
|
||||
if (!stats.isDirectory()) {
|
||||
return false;
|
||||
async function getMaterializedImageAttachmentDir(): Promise<string> {
|
||||
if (materializedImageAttachmentDir) {
|
||||
try {
|
||||
const stats = await fs.lstat(materializedImageAttachmentDir);
|
||||
if (stats.isDirectory()) {
|
||||
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
} catch {
|
||||
/* Temp storage may have been removed between turns. */
|
||||
}
|
||||
fsSync.chmodSync(dir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getMaterializedImageAttachmentDir(): string {
|
||||
if (
|
||||
materializedImageAttachmentDir &&
|
||||
canReuseMaterializedImageAttachmentDir(materializedImageAttachmentDir)
|
||||
) {
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
materializedImageAttachmentDir = fsSync.mkdtempSync(
|
||||
materializedImageAttachmentDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), PROVIDER_IMAGE_ATTACHMENT_DIR_PREFIX),
|
||||
);
|
||||
fsSync.chmodSync(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
await fs.chmod(materializedImageAttachmentDir, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
return materializedImageAttachmentDir;
|
||||
}
|
||||
|
||||
@@ -71,32 +66,92 @@ function getImageExtension(mimeType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageData(mimeType: string, data: string): { mimeType: string; data: string } {
|
||||
if (data.startsWith("data:")) {
|
||||
const match = data.match(/^data:([^;]+);base64,(.*)$/);
|
||||
if (match) {
|
||||
return { mimeType: match[1], data: match[2] };
|
||||
}
|
||||
}
|
||||
return { mimeType, data };
|
||||
}
|
||||
// 128 MiB admits the measured 64 MiB fixture with headroom while bounding disk
|
||||
// and decoding per image. This is materialization admission, not a wire schema limit.
|
||||
export const MAX_PROVIDER_IMAGE_BYTES = 128 * 1024 * 1024;
|
||||
const BASE64_CHUNK_CHARACTERS = 256 * 1024; // Multiple of four; decode units stay aligned.
|
||||
|
||||
// Filenames are a content hash of the bytes so re-materializing the same image
|
||||
// within a process reuses the existing temp file instead of leaking a fresh one
|
||||
// for repeated image blocks or history replay.
|
||||
export function materializeProviderImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}): MaterializedProviderImage {
|
||||
const attachmentsDir = getMaterializedImageAttachmentDir();
|
||||
const normalized = normalizeImageData(image.mimeType ?? "image/png", image.data);
|
||||
const bytes = Buffer.from(normalized.data, "base64");
|
||||
const extension = getImageExtension(normalized.mimeType);
|
||||
const hash = createHash("sha256").update(bytes).digest("hex");
|
||||
const filePath = path.join(attachmentsDir, `${hash}.${extension}`);
|
||||
fsSync.writeFileSync(filePath, bytes, { mode: MATERIALIZED_IMAGE_FILE_MODE });
|
||||
fsSync.chmodSync(filePath, MATERIALIZED_IMAGE_FILE_MODE);
|
||||
return { path: filePath };
|
||||
}): Promise<MaterializedProviderImage> {
|
||||
const result = pendingMaterialization.then(() => materializeImage(image));
|
||||
pendingMaterialization = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function materializeImage(image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}): Promise<MaterializedProviderImage> {
|
||||
const maxEncodedCharacters = Math.ceil(MAX_PROVIDER_IMAGE_BYTES / 3) * 4;
|
||||
// Check length before inspecting/copying the payload, including a bounded data URI header.
|
||||
if (image.data.length > maxEncodedCharacters + 256) {
|
||||
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
let data = image.data;
|
||||
let mimeType = image.mimeType ?? "image/png";
|
||||
if (data.startsWith("data:")) {
|
||||
const separator = data.indexOf(";base64,");
|
||||
if (separator >= 0 && separator < 256) {
|
||||
mimeType = data.slice(5, separator);
|
||||
data = data.slice(separator + 8);
|
||||
}
|
||||
}
|
||||
if (data.length > maxEncodedCharacters) {
|
||||
throw new Error(`Image attachment exceeds ${MAX_PROVIDER_IMAGE_BYTES} bytes`);
|
||||
}
|
||||
const hash = createHash("sha256");
|
||||
for await (const bytes of decodeImage(data)) hash.update(bytes);
|
||||
const dir = await getMaterializedImageAttachmentDir();
|
||||
const filePath = path.join(dir, `${hash.digest("hex")}.${getImageExtension(mimeType)}`);
|
||||
try {
|
||||
await fs.stat(filePath);
|
||||
return { path: filePath };
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
|
||||
const staging = await fs.mkdtemp(path.join(dir, ".pending-"));
|
||||
const stagingPath = path.join(staging, "image");
|
||||
try {
|
||||
await fs.chmod(staging, PRIVATE_ATTACHMENT_DIR_MODE);
|
||||
const file = await fs.open(stagingPath, "wx", MATERIALIZED_IMAGE_FILE_MODE);
|
||||
try {
|
||||
await file.chmod(MATERIALIZED_IMAGE_FILE_MODE);
|
||||
// Decode again only for a new hash. This bounds retained memory to one chunk
|
||||
// and performs no writes at all for repeated content, including history replay.
|
||||
for await (const bytes of decodeImage(data)) await file.writeFile(bytes);
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
await fs.rename(stagingPath, filePath);
|
||||
return { path: filePath };
|
||||
} finally {
|
||||
await fs.rm(staging, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function* decodeImage(data: string): AsyncGenerator<Buffer> {
|
||||
let carry = "";
|
||||
let decodedBytes = 0;
|
||||
for (let offset = 0; offset < data.length; offset += BASE64_CHUNK_CHARACTERS) {
|
||||
// Buffer.from ignores non-base64 characters. Carry incomplete quartets so
|
||||
// line-wrapped payloads decode identically across chunk boundaries.
|
||||
const chunk =
|
||||
carry +
|
||||
data.slice(offset, offset + BASE64_CHUNK_CHARACTERS).replace(/[^A-Za-z0-9+/_=-]/g, "");
|
||||
const finalChunk = offset + BASE64_CHUNK_CHARACTERS >= data.length || chunk.includes("=");
|
||||
const end = finalChunk ? chunk.length : chunk.length - (chunk.length % 4);
|
||||
const bytes = Buffer.from(chunk.slice(0, end), "base64");
|
||||
carry = chunk.slice(end);
|
||||
decodedBytes += bytes.length;
|
||||
if (decodedBytes > MAX_PROVIDER_IMAGE_BYTES)
|
||||
throw new Error("Image attachment exceeds size limit");
|
||||
yield bytes;
|
||||
await setImmediate();
|
||||
if (finalChunk) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Recognizes markdown rendered for a materialized provider image: its source is a content-hashed
|
||||
@@ -112,7 +167,10 @@ export function isProviderImageMarkdown(text: string): boolean {
|
||||
}
|
||||
|
||||
interface RenderProviderImageOutputOptions {
|
||||
materialize?: (image: { data: string; mimeType: string | null }) => MaterializedProviderImage;
|
||||
materialize?: (image: {
|
||||
data: string;
|
||||
mimeType: string | null;
|
||||
}) => Promise<MaterializedProviderImage>;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: string | null | undefined): string | null {
|
||||
@@ -169,10 +227,10 @@ function escapeMarkdownImageSource(value: string): string {
|
||||
return markdownImageSource(value).replace(/\\/g, "\\\\").replace(/\)/g, "\\)");
|
||||
}
|
||||
|
||||
export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
export async function renderProviderImageOutputAsAssistantMarkdown(
|
||||
image: ProviderImageOutput,
|
||||
options: RenderProviderImageOutputOptions = {},
|
||||
): AgentTimelineItem | null {
|
||||
): Promise<AgentTimelineItem | null> {
|
||||
const source = nonEmptyString(image.path) ?? nonEmptyString(image.url);
|
||||
if (source && !isDataImageSource(source)) {
|
||||
const altText = escapeMarkdownImageAlt(nonEmptyString(image.altText) ?? "Image");
|
||||
@@ -191,7 +249,7 @@ export function renderProviderImageOutputAsAssistantMarkdown(
|
||||
let materialized: MaterializedProviderImage | null = null;
|
||||
try {
|
||||
materialized = options.materialize
|
||||
? options.materialize({
|
||||
? await options.materialize({
|
||||
data,
|
||||
mimeType: nonEmptyString(image.mimeType),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentSessionConfig, McpServerConfig } from "./agent-sdk-types.js";
|
||||
|
||||
const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
export const PASEO_MCP_SERVER_NAME = "paseo";
|
||||
const PASEO_MCP_PATHNAME = "/mcp/agents";
|
||||
|
||||
export function stripInternalPaseoMcpServer(config: AgentSessionConfig): AgentSessionConfig {
|
||||
|
||||
@@ -234,7 +234,7 @@ export function selectReferenceProjectedTimelinePage(input: {
|
||||
|
||||
// Existing selection fixtures exercise the retained owner through its public API.
|
||||
export function selectProjectedTimelinePage(
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
|
||||
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0] & { wholeTurns?: boolean },
|
||||
) {
|
||||
const projection = new TimelineProjection(input.rows);
|
||||
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
|
||||
|
||||
@@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => {
|
||||
};
|
||||
}
|
||||
|
||||
test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)],
|
||||
item: {
|
||||
type: "tool_call",
|
||||
callId: `tool-${index}`,
|
||||
name: "shell",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: null, output: null },
|
||||
},
|
||||
}));
|
||||
const tail = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(tail.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3).map((row) => row.seq),
|
||||
);
|
||||
expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false });
|
||||
const before = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 124,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(before.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(before.endSeq).toBe(123);
|
||||
const older = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 4,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]);
|
||||
expect(older.hasOlder).toBe(false);
|
||||
const jump = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 70,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(jump.entries.map((entry) => entry.seqStart)).toEqual(
|
||||
rows.slice(3, 123).map((row) => row.seq),
|
||||
);
|
||||
expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true });
|
||||
const nearPreviousPrompt = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "before",
|
||||
cursorSeq: 10,
|
||||
limit: 40,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 });
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries,
|
||||
).toHaveLength(40);
|
||||
});
|
||||
|
||||
test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => {
|
||||
const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: new Date(1000 + index).toISOString(),
|
||||
item:
|
||||
index % 2 === 0
|
||||
? { type: "user_message", text: `prompt-${index}` }
|
||||
: { type: "assistant_message", text: `reply-${index}` },
|
||||
}));
|
||||
const page = selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 7,
|
||||
wholeTurns: true,
|
||||
});
|
||||
expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]);
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries,
|
||||
).toHaveLength(24);
|
||||
rows.push({
|
||||
...toolRow(25, "completed"),
|
||||
item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"],
|
||||
});
|
||||
rows.push({
|
||||
seq: 26,
|
||||
timestamp: new Date().toISOString(),
|
||||
item: { type: "assistant_message", text: "awake" },
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}).entries.map((entry) => entry.seqStart),
|
||||
).toEqual([25, 26]);
|
||||
});
|
||||
|
||||
test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: new Date(1).toISOString(),
|
||||
turnId: "first",
|
||||
item: { type: "user_message", text: "go" },
|
||||
},
|
||||
{ ...toolRow(2, "running"), turnId: "first" },
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: new Date(3).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "user_message", text: "next" },
|
||||
},
|
||||
{
|
||||
seq: 4,
|
||||
timestamp: new Date(4).toISOString(),
|
||||
turnId: "second",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
{ ...toolRow(5, "completed"), turnId: "first" },
|
||||
];
|
||||
expect(
|
||||
selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }),
|
||||
).toMatchObject({
|
||||
startSeq: 1,
|
||||
endSeq: 5,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
});
|
||||
expect(
|
||||
selectProjectedTimelinePage({
|
||||
rows,
|
||||
direction: "after",
|
||||
cursorSeq: 2,
|
||||
limit: 1,
|
||||
wholeTurns: true,
|
||||
}),
|
||||
).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 }));
|
||||
});
|
||||
|
||||
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
|
||||
|
||||
@@ -296,6 +297,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
|
||||
|
||||
interface ProjectedTimelinePageOptions {
|
||||
wholeTurns?: boolean;
|
||||
bounds: { minSeq: number; maxSeq: number };
|
||||
direction: TimelineLimitDirection;
|
||||
cursorSeq?: number;
|
||||
@@ -423,7 +425,8 @@ export class TimelineProjection {
|
||||
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
|
||||
const start = this.spans[this.spanIndex(requestedStart)].start;
|
||||
const coveredStart = this.spans[this.spanIndex(requestedStart)].start;
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart;
|
||||
return {
|
||||
entries: this.rows.slice(start),
|
||||
startSeq: this.rows[start].seqStart,
|
||||
@@ -433,6 +436,17 @@ export class TimelineProjection {
|
||||
};
|
||||
}
|
||||
|
||||
private wholeTurnWindowStart(requestedStart: number): number {
|
||||
let start = requestedStart;
|
||||
for (;;) {
|
||||
while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--;
|
||||
// Source coverage can pull in an earlier tool and therefore its entire run.
|
||||
const coveredStart = this.spans[this.spanIndex(start)].start;
|
||||
if (coveredStart === start) return start;
|
||||
start = coveredStart;
|
||||
}
|
||||
}
|
||||
|
||||
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
|
||||
const startSeq = Math.max(
|
||||
input.bounds.minSeq,
|
||||
@@ -480,13 +494,23 @@ export class TimelineProjection {
|
||||
else high = middle;
|
||||
}
|
||||
const limit = Math.max(0, Math.floor(input.limit ?? 0));
|
||||
const start = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit);
|
||||
const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart;
|
||||
// Choose the window before extending the right edge, so a prompt jump
|
||||
// cannot lose its target when the next turn is arbitrarily large.
|
||||
if (input.wholeTurns) {
|
||||
while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low]))
|
||||
low++;
|
||||
}
|
||||
let selectedEnd = endSeq;
|
||||
if (input.wholeTurns)
|
||||
selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1;
|
||||
return {
|
||||
entries: this.rows.slice(start, low),
|
||||
startSeq: this.rows[start]?.seqStart ?? null,
|
||||
endSeq,
|
||||
endSeq: selectedEnd,
|
||||
hasOlder: start > 0,
|
||||
hasNewer: endSeq < input.bounds.maxSeq,
|
||||
hasNewer: selectedEnd < input.bounds.maxSeq,
|
||||
};
|
||||
}
|
||||
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
|
||||
@@ -675,6 +699,13 @@ function selectProjectedEntriesAfter(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function continuesProjectedTurn(
|
||||
previous: TimelineProjectionEntry,
|
||||
next: TimelineProjectionEntry,
|
||||
): boolean {
|
||||
return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
|
||||
* Used by callers that only have items in hand (e.g. MCP tools reading
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -25,22 +26,18 @@ describe("server config", () => {
|
||||
const env = { PASEO_PASSWORD: "synthetic-audit-password" };
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
const startup = resolveConfigFromPersisted(paseoHome, persisted, { env });
|
||||
const started = performance.now();
|
||||
const timer = new Promise<number>((resolve) =>
|
||||
setTimeout(() => resolve(performance.now() - started), 0),
|
||||
const { result: reloaded, blockingMs } = await measureMainThreadWork(
|
||||
"config resolve main-thread work",
|
||||
() =>
|
||||
resolveReloadableConfig(
|
||||
{ ...persisted, daemon: { ...persisted.daemon, browserTools: { enabled: true } } },
|
||||
{ env },
|
||||
),
|
||||
);
|
||||
const reloaded = resolveReloadableConfig(
|
||||
{
|
||||
...persisted,
|
||||
daemon: { ...persisted.daemon, browserTools: { enabled: true } },
|
||||
},
|
||||
{ env },
|
||||
);
|
||||
const gap = await timer;
|
||||
expect(reloaded).not.toHaveProperty("auth");
|
||||
expect(reloaded.browserToolsEnabled).toBe(true);
|
||||
expect(startup.auth?.password).toBeTypeOf("string");
|
||||
expect(gap).toBeLessThan(50);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
});
|
||||
|
||||
test("records when the daemon is managed by Paseo Desktop", async () => {
|
||||
|
||||
@@ -4,17 +4,29 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, test } from "vitest";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import type { AgentSessionConfig } from "../agent/agent-sdk-types.js";
|
||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
|
||||
test("the configuration example adds MCP servers and overrides Codex options while preserving other configuration", async () => {
|
||||
test("the configuration example saves Codex options and injects launch-only MCP servers while preserving other configuration", async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-config-example-"));
|
||||
const launches: AgentSessionConfig[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.8.0",
|
||||
agentClients: { codex: createTestAgentClient("codex", { supportsMcpServers: true }) },
|
||||
daemonVersion: "0.13.2",
|
||||
agentClients: {
|
||||
codex: createTestAgentClient("codex", {
|
||||
supportsMcpServers: true,
|
||||
beforeCreateSession: async (config) => {
|
||||
launches.push(config);
|
||||
},
|
||||
}),
|
||||
},
|
||||
mcpEnabled: false,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.13.2",
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
@@ -44,9 +56,15 @@ test("the configuration example adds MCP servers and overrides Codex options whi
|
||||
},
|
||||
mcpServers: {
|
||||
existing: { type: "http", url: "https://existing.example.com/mcp" },
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
},
|
||||
});
|
||||
expect(daemon.daemon.agentManager.getAgent(agent.id)!.config.mcpServers).toEqual({
|
||||
existing: { type: "http", url: "https://existing.example.com/mcp" },
|
||||
});
|
||||
expect(launches[0].mcpServers).toEqual({
|
||||
existing: { type: "http", url: "https://existing.example.com/mcp" },
|
||||
company: { type: "http", url: "https://tools.example.com/mcp" },
|
||||
});
|
||||
await client.archiveAgent(agent.id);
|
||||
} finally {
|
||||
await client.close();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type TestAgentClientOptions,
|
||||
} from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { importSessionFromPersistence } from "../agent/provider-session-import.js";
|
||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||
import { serializePaseoToolInputParameters } from "../agent/tools/paseo-tool-serialization.js";
|
||||
import type {
|
||||
@@ -27,7 +28,7 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions =
|
||||
const events = { interrupts: 0 };
|
||||
const inner = createTestAgentClient("codex", {
|
||||
...callbacks,
|
||||
supportsMcpServers: true,
|
||||
supportsMcpServers: callbacks.supportsMcpServers ?? true,
|
||||
onStartTurn: (prompt, config) => {
|
||||
turns.push({ prompt, launchCount: launches.length });
|
||||
callbacks.onStartTurn?.(prompt, config);
|
||||
@@ -52,6 +53,13 @@ function recordingProvider(native: boolean, callbacks: TestAgentClientOptions =
|
||||
launches.push({ config: config as AgentSessionConfig, context });
|
||||
return inner.resumeSession(handle, config, context, options);
|
||||
},
|
||||
importSession: (input, context) =>
|
||||
importSessionFromPersistence({
|
||||
provider: "codex",
|
||||
request: input,
|
||||
context,
|
||||
resumeSession: provider.resumeSession.bind(provider),
|
||||
}),
|
||||
isAvailable: () => inner.isAvailable(),
|
||||
fetchCatalog: (options) => inner.fetchCatalog(options),
|
||||
};
|
||||
@@ -750,3 +758,285 @@ test("the agent-tools example appends notes only for labeled agents", async () =
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
const companyServer = { type: "http", url: "https://tools.example.com/mcp" } as const;
|
||||
const mcpSource = `
|
||||
export default function contribute(server) {
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.provider !== "codex") return;
|
||||
return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} },
|
||||
env: { ...request.env, OPEN_REASON: request.reason } };
|
||||
});
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
test.each([
|
||||
{ native: false, serverNames: ["paseo", "company"] },
|
||||
{ native: true, serverNames: ["company"] },
|
||||
])(
|
||||
"session-open MCP servers reach launch but never saved configuration (native=$native)",
|
||||
async ({ native, serverNames }) => {
|
||||
const h = await setup(native, mcpSource);
|
||||
try {
|
||||
const a = await h.create();
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
|
||||
expect(a.config.mcpServers?.company).toBeUndefined();
|
||||
const snapshot = (await h.client.fetchAgent(a.id))!.agent;
|
||||
expect(snapshot.id).toBe(a.id);
|
||||
expect(JSON.stringify(snapshot)).not.toContain("tools.example.com");
|
||||
expect(h.launches.at(-1)!.context!.env!.OPEN_REASON).toBe("create");
|
||||
expect(Object.keys(h.launches.at(-1)!.config.mcpServers!)).toEqual(serverNames);
|
||||
await h.daemon.daemon.agentManager.closeAgent(a.id);
|
||||
const agentFiles = await readdir(path.join(h.daemon.paseoHome, "agents"), {
|
||||
recursive: true,
|
||||
});
|
||||
const recordFile = agentFiles.find((file) => file.endsWith(a.id + ".json"))!;
|
||||
const record = await readFile(path.join(h.daemon.paseoHome, "agents", recordFile), "utf8");
|
||||
expect(record).not.toContain("tools.example.com");
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("session-open MCP servers are rederived on resume, SDK reload, and provider import", async () => {
|
||||
const source = reloadRpcSource
|
||||
.replace(' if (request.labels.x !== "y") return;', "")
|
||||
.replace(
|
||||
"return { ...request, env:",
|
||||
`return { ...request, mcpServers: { ...request.mcpServers, company: ${JSON.stringify(companyServer)} }, env:`,
|
||||
);
|
||||
const h = await setup(true, source);
|
||||
try {
|
||||
const a = await h.create({ x: "y" });
|
||||
const manager = h.daemon.daemon.agentManager;
|
||||
await manager.closeAgent(a.id);
|
||||
await manager.resumeAgentFromPersistence(a.persistence!, a.config, a.id);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("resume");
|
||||
await h.client.invokePluginRpc("tools-test", "reload_agent", { id: a.id });
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("refresh");
|
||||
const imported = await manager.importProviderSession({
|
||||
provider: "codex",
|
||||
providerHandleId: "imported-session",
|
||||
cwd: h.directory,
|
||||
workspaceId: "import-workspace",
|
||||
labels: { x: "y" },
|
||||
});
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
|
||||
expect(JSON.parse(h.launches.at(-1)!.context!.env!.OPEN_INFO).reason).toBe("import");
|
||||
expect(imported.config.mcpServers?.company).toBeUndefined();
|
||||
expect(JSON.stringify(imported.persistence)).not.toContain("tools.example.com");
|
||||
expect(manager.getAgent(a.id)!.config.mcpServers?.company).toBeUndefined();
|
||||
expect(JSON.stringify(manager.getAgent(a.id)!.persistence)).not.toContain("tools.example.com");
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("installing and disabling an MCP plugin reopens idle sessions before their next prompt", async () => {
|
||||
const h = await setup(true, "export default function contribute() { return () => {}; }");
|
||||
try {
|
||||
const a = await h.create();
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
await h.install("company-mcp", mcpSource);
|
||||
await sendAndFinish(h, a.id, "after install");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toEqual(companyServer);
|
||||
await h.client.disablePlugin("company-mcp");
|
||||
await sendAndFinish(h, a.id, "after disable");
|
||||
expect(h.launches).toHaveLength(3);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
await sendAndFinish(h, a.id, "stable");
|
||||
expect(h.launches).toHaveLength(3);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("installing and disabling an MCP plugin never interrupts a running turn", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const h = await setup(
|
||||
true,
|
||||
"export default function contribute() { return () => {}; }",
|
||||
undefined,
|
||||
{ beforeTurnComplete: async () => blocked },
|
||||
);
|
||||
try {
|
||||
const a = await h.create();
|
||||
await h.client.sendMessage(a.id, "busy", { activeTurnBehavior: "steer" });
|
||||
const manager = h.daemon.daemon.agentManager;
|
||||
await expect.poll(() => manager.getAgent(a.id)!.activeTurnId).not.toBeNull();
|
||||
const turn = manager.getAgent(a.id)!.activeTurnId;
|
||||
await h.install("company-mcp", mcpSource);
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn);
|
||||
await h.client.disablePlugin("company-mcp");
|
||||
expect(h.launches).toHaveLength(1);
|
||||
expect(manager.getAgent(a.id)!.activeTurnId).toBe(turn);
|
||||
expect(h.events.interrupts).toBe(0);
|
||||
release();
|
||||
await expect.poll(() => manager.hasInFlightRun(a.id)).toBe(false);
|
||||
await sendAndFinish(h, a.id, "idle");
|
||||
expect(h.launches).toHaveLength(2);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
} finally {
|
||||
release();
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test.each([
|
||||
{
|
||||
target: "saved configuration",
|
||||
saved: true,
|
||||
name: "company",
|
||||
other: false,
|
||||
error: /company.*plugin tools-test.*agent saved configuration/,
|
||||
},
|
||||
{
|
||||
target: "saved Paseo name",
|
||||
saved: true,
|
||||
name: "paseo",
|
||||
other: false,
|
||||
error: /paseo.*plugin tools-test.*agent saved configuration/,
|
||||
},
|
||||
{
|
||||
target: "Paseo internal server",
|
||||
saved: false,
|
||||
name: "paseo",
|
||||
other: false,
|
||||
error: /paseo.*plugin tools-test.*Paseo internal MCP server/,
|
||||
},
|
||||
{
|
||||
target: "another plugin with identical config",
|
||||
saved: false,
|
||||
name: "company",
|
||||
other: true,
|
||||
error: /Plugin tools-test.*company.*plugin other-mcp/,
|
||||
},
|
||||
])(
|
||||
"session-open MCP collisions with $target name both owners",
|
||||
async ({ saved, name, other, error }) => {
|
||||
const h = await setup(true, mcpSource.replace("company:", name + ":"));
|
||||
try {
|
||||
if (other) await h.install("other-mcp", mcpSource);
|
||||
await expect(
|
||||
h.daemon.daemon.agentManager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: h.directory,
|
||||
...(saved
|
||||
? { mcpServers: { [name]: { type: "http", url: "https://saved.example.com" } } }
|
||||
: {}),
|
||||
},
|
||||
undefined,
|
||||
{},
|
||||
),
|
||||
).rejects.toThrow(error);
|
||||
expect(h.launches).toHaveLength(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
|
||||
test("session-open MCP servers fail on unsupported providers and leave saved config untouched", async () => {
|
||||
const h = await setup(false, mcpSource, undefined, { supportsMcpServers: false });
|
||||
try {
|
||||
await h.client.disablePlugin("tools-test");
|
||||
const a = await h.create();
|
||||
await h.client.enablePlugin("tools-test");
|
||||
await expect(h.daemon.daemon.agentManager.reloadAgentSession(a.id)).rejects.toThrow(
|
||||
/does not support MCP servers.*plugin tools-test/,
|
||||
);
|
||||
expect(a.config.mcpServers?.company).toBeUndefined();
|
||||
await expect(h.create()).rejects.toThrow(/does not support MCP servers.*plugin tools-test/);
|
||||
const imported = h.daemon.daemon.agentManager.importProviderSession({
|
||||
provider: "codex",
|
||||
providerHandleId: "unsupported-import",
|
||||
cwd: h.directory,
|
||||
workspaceId: "import-workspace",
|
||||
});
|
||||
await expect(imported).rejects.toThrow(/does not support MCP servers.*plugin tools-test/);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("history opens ignore session-open MCP servers", async () => {
|
||||
const h = await setup(true, mcpSource);
|
||||
try {
|
||||
const a = await h.create();
|
||||
await h.client.archiveAgent(a.id);
|
||||
await h.install("other-mcp", mcpSource);
|
||||
await h.daemon.daemon.agentManager.resumeAgentFromPersistence(a.persistence!, a.config, a.id);
|
||||
expect(h.launches.at(-1)!.config.mcpServers?.company).toBeUndefined();
|
||||
expect(h.launches.at(-1)!.context!.mcpServers).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("session-open MCP maps are plugin-local and accumulate only across their own callbacks", async () => {
|
||||
const h = await setup(true, mcpSource);
|
||||
try {
|
||||
await h.install(
|
||||
"z-extend",
|
||||
`
|
||||
export default function contribute(server) {
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if ("mcpServerOwners" in request) throw new Error("Daemon provenance leaked");
|
||||
if (Object.keys(request.mcpServers).length !== 0) throw new Error("Saw another plugin servers");
|
||||
return { ...request, mcpServers: { ...request.mcpServers, extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } } } };
|
||||
});
|
||||
server.before("agent.session_open", ({ request }) => {
|
||||
if (request.mcpServers.extra.command !== "company-tools") throw new Error("Lost own earlier server");
|
||||
return { ...request, mcpServers: { ...request.mcpServers, additional: { type: "http", url: "https://additional.example.com/mcp" } } };
|
||||
});
|
||||
server.before("agent.session_open", () => undefined);
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const a = await h.create();
|
||||
expect(h.launches.at(-1)!.config.mcpServers).toEqual({
|
||||
company: companyServer,
|
||||
extra: { type: "stdio", command: "company-tools", args: ["--mcp"], env: { MODE: "agent" } },
|
||||
additional: { type: "http", url: "https://additional.example.com/mcp" },
|
||||
});
|
||||
const contributedServers = h.launches.at(-1)!.config.mcpServers;
|
||||
await h.install(
|
||||
"zz-remove",
|
||||
`export default function contribute(server) {
|
||||
server.before("agent.session_open", ({ request }) => ({ ...request, mcpServers: {} }));
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
await sendAndFinish(h, a.id, "after removal");
|
||||
expect(h.launches.at(-1)!.config.mcpServers).toEqual(contributedServers);
|
||||
expect(a.config.mcpServers).toBeUndefined();
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("session-open MCP configs are validated before a provider launches", async () => {
|
||||
const h = await setup(
|
||||
true,
|
||||
mcpSource.replace(JSON.stringify(companyServer), '{ type: "http", url: 42 }'),
|
||||
);
|
||||
try {
|
||||
await expect(h.create()).rejects.toThrow(/Plugin tools-test.*before agent.session_open/s);
|
||||
expect(h.launches).toHaveLength(0);
|
||||
} finally {
|
||||
await h.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -78,6 +78,45 @@ test("listed client bundle is published while plugins are disabled; unlisted dir
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("a repeated catalog fetch leaves out the client bundle the client already holds", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-catalog-cache-"));
|
||||
roots.push(root);
|
||||
const directory = await fixture(root, "large", true);
|
||||
// About the size of a real plugin client bundle.
|
||||
await writeFile(
|
||||
path.join(directory, "index.client.tsx"),
|
||||
`const payload = ${JSON.stringify("x".repeat(1_000_000))};
|
||||
export default function contribute() { void payload; return () => {}; }`,
|
||||
);
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
daemonVersion: "0.9.2",
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: new BuiltinPluginLoader(root, ["large"]),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
|
||||
const responseBytes: number[] = [];
|
||||
const unsubscribe = client.subscribeRawMessages((message) => {
|
||||
if (message.type === "plugin.catalog.get.response") {
|
||||
responseBytes.push(JSON.stringify(message).length);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const first = await client.getPluginCatalog();
|
||||
const second = await client.getPluginCatalog();
|
||||
|
||||
expect(second).toEqual(first);
|
||||
expect(second[0]?.clientBundle).toContain("x".repeat(1_000_000));
|
||||
expect(responseBytes).toHaveLength(2);
|
||||
expect(responseBytes[0]).toBeGreaterThan(1_000_000);
|
||||
expect(responseBytes[1]).toBeLessThan(500);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("directory, Git, and npm installs reject a built-in ID", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-builtin-install-"));
|
||||
roots.push(root);
|
||||
|
||||
@@ -268,7 +268,7 @@ async function minifyClientBundle(code: string): Promise<string> {
|
||||
target: "es2020",
|
||||
supported: { "async-await": false },
|
||||
charset: "ascii",
|
||||
legalComments: "none",
|
||||
legalComments: "inline",
|
||||
});
|
||||
return result.code;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import * as zod from "zod";
|
||||
import { ZodError } from "zod";
|
||||
import {
|
||||
compilePlugin,
|
||||
@@ -569,40 +570,77 @@ export default function contribute(client) {
|
||||
expect(clientBundle).toContain("__async");
|
||||
});
|
||||
|
||||
it("minifies the client bundle after Hermes interop getters are eager", async () => {
|
||||
it("reduces client bytes while preserving registration and leaving server output readable", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
await writeFile(
|
||||
entries.client,
|
||||
`import { useState } from "react";
|
||||
const label = "\\u2013";
|
||||
const source = `import { z } from "zod";
|
||||
const commandSchema = z.object({ id: z.string(), title: z.string() });
|
||||
export default function contribute(client) {
|
||||
void useState;
|
||||
void label;
|
||||
client.addCommandCenterItem({ id: "open", title: "Open", icon: "Scan", context: "global", onSelect() {} });
|
||||
const command = commandSchema.parse({ id: "open", title: "Open – plugin" });
|
||||
client.addCommandCenterItem({
|
||||
...command,
|
||||
icon: "Scan",
|
||||
context: "global",
|
||||
onSelect() { return Promise.resolve(command.title); },
|
||||
});
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
await writeFile(
|
||||
entries.server,
|
||||
`import { z } from "zod";
|
||||
export default function contribute() {
|
||||
void z.object({});
|
||||
return () => undefined;
|
||||
}`,
|
||||
);
|
||||
|
||||
}`;
|
||||
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entries);
|
||||
if (clientBundle === null || serverBundle === null) {
|
||||
throw new Error("compilePlugin returned no bundle");
|
||||
}
|
||||
|
||||
expect(clientBundle).not.toContain("get: () => from[key]");
|
||||
expect(clientBundle).not.toContain("get:()=>from[key]");
|
||||
expect(clientBundle).toContain("value:from[key]");
|
||||
for (const bundle of [clientBundle, serverBundle]) {
|
||||
const registered: { id: string; title: string; onSelect: () => Promise<string> }[] = [];
|
||||
const exports = new Function(`return ${bundle}`)()((specifier: string) => {
|
||||
expect(specifier).toBe("zod");
|
||||
return zod;
|
||||
});
|
||||
const cleanup = exports.default({
|
||||
addCommandCenterItem: (command: (typeof registered)[number]) => registered.push(command),
|
||||
});
|
||||
expect(registered).toMatchObject([{ id: "open", title: "Open – plugin" }]);
|
||||
await expect(registered[0]!.onSelect()).resolves.toBe("Open – plugin");
|
||||
expect(cleanup()).toBeUndefined();
|
||||
}
|
||||
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
|
||||
expect(clientBundle.split("").every((char) => char.charCodeAt(0) <= 0x7f)).toBe(true);
|
||||
expect(serverBundle).toContain("value: from[key]");
|
||||
expect(serverBundle).not.toContain("value:from[key]");
|
||||
expect(clientBundle.split("\n").length).toBeLessThan(serverBundle.split("\n").length);
|
||||
expect(serverBundle).toContain('title: "Open');
|
||||
});
|
||||
|
||||
it("retains dependency license notices while minifying executable client code", async () => {
|
||||
const entries = await createSplitPlugin();
|
||||
const dependency = path.join(entries.directory, "node_modules/licensed-dependency");
|
||||
const notice = "Copyright Example Authors. SPDX-License-Identifier: MIT";
|
||||
await mkdir(dependency, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dependency, "package.json"),
|
||||
JSON.stringify({ name: "licensed-dependency", main: "index.js" }),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(dependency, "index.js"),
|
||||
`/*! ${notice} */
|
||||
exports.title = function title(input) {
|
||||
const trimmed = input.trim();
|
||||
return "Licensed " + trimmed;
|
||||
};`,
|
||||
);
|
||||
const source = `import { title } from "licensed-dependency";
|
||||
export default function contribute() { return title(" plugin "); }`;
|
||||
await Promise.all([writeFile(entries.client, source), writeFile(entries.server, source)]);
|
||||
const { clientBundle, serverBundle } = await compilePlugin(entries);
|
||||
if (clientBundle === null || serverBundle === null) {
|
||||
throw new Error("compilePlugin returned no bundle");
|
||||
}
|
||||
|
||||
for (const bundle of [clientBundle, serverBundle]) {
|
||||
expect(bundle).toContain(notice);
|
||||
const exports = new Function(`return ${bundle}`)()(() => {
|
||||
throw new Error("The dependency must be bundled");
|
||||
});
|
||||
expect(exports.default()).toBe("Licensed plugin");
|
||||
}
|
||||
expect(Buffer.byteLength(clientBundle)).toBeLessThan(Buffer.byteLength(serverBundle) * 0.9);
|
||||
});
|
||||
|
||||
it("keeps distinct host exports eager when minifying in the worker", async () => {
|
||||
|
||||
@@ -96,6 +96,7 @@ test("session-open hooks reject changes to session identity instead of silently
|
||||
title: null,
|
||||
labels: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
},
|
||||
paseo,
|
||||
),
|
||||
@@ -120,12 +121,13 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
|
||||
title: "Original",
|
||||
labels: {},
|
||||
tools: [],
|
||||
mcpServers: {},
|
||||
env: {},
|
||||
reason: "create",
|
||||
purpose: "interactive",
|
||||
},
|
||||
paseo,
|
||||
),
|
||||
).rejects.toThrow("agent.session_open hooks can only change env and tools");
|
||||
).rejects.toThrow("agent.session_open hooks can only change env, tools, and mcpServers");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,11 @@ export type PluginWireBeforeRequests = Omit<PluginBeforeRequests, "agent.session
|
||||
"agent.session_open": SessionOpenWireRequest;
|
||||
};
|
||||
|
||||
/** Daemon-only results; ownership never crosses the plugin process boundary. */
|
||||
export type PluginBeforeResults = PluginWireBeforeRequests & {
|
||||
"agent.session_open": SessionOpenWireRequest & { mcpServerOwners?: Record<string, string> };
|
||||
};
|
||||
|
||||
export const lifecycleEventNames = [
|
||||
"agent.created",
|
||||
"agent.closed",
|
||||
@@ -54,6 +59,7 @@ const beforeSchemas = {
|
||||
labels: z.record(z.string(), z.string()),
|
||||
tools: z.array(agentToolMetadataSchema),
|
||||
env: z.record(z.string(), z.string()),
|
||||
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
|
||||
})
|
||||
.strict(),
|
||||
"workspace.create": WorkspaceCreateRequestSchema.omit({ type: true, requestId: true }).strict(),
|
||||
@@ -80,7 +86,7 @@ export interface PluginLifecycle {
|
||||
before<Name extends keyof PluginWireBeforeRequests>(
|
||||
name: Name,
|
||||
request: PluginWireBeforeRequests[Name],
|
||||
): Promise<PluginWireBeforeRequests[Name]>;
|
||||
): Promise<PluginBeforeResults[Name]>;
|
||||
}
|
||||
|
||||
export function validateBeforeRequest<Name extends keyof PluginWireBeforeRequests>(
|
||||
@@ -176,7 +182,7 @@ export function validateBeforeResult<Name extends keyof PluginWireBeforeRequests
|
||||
previous.title !== next.title ||
|
||||
!isDeepStrictEqual(previous.labels, next.labels)
|
||||
) {
|
||||
throw new Error("agent.session_open hooks can only change env and tools");
|
||||
throw new Error("agent.session_open hooks can only change env, tools, and mcpServers");
|
||||
}
|
||||
}
|
||||
if (name === "agent.create") {
|
||||
@@ -290,8 +296,10 @@ export class PluginHookHandlers implements PluginLifecycleRegistration {
|
||||
tools = local.tools;
|
||||
}
|
||||
context.signal.throwIfAborted();
|
||||
if (request.purpose === "history") request.tools = [];
|
||||
else this.agentTools?.attach(request.agentId, tools);
|
||||
if (request.purpose === "history") {
|
||||
request.tools = [];
|
||||
request.mcpServers = {};
|
||||
} else this.agentTools?.attach(request.agentId, tools);
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import pino from "pino";
|
||||
import { createPaseoDaemon } from "../bootstrap.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
@@ -231,21 +231,12 @@ test("password-protected config reload preserves startup auth and session constr
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const configPath = path.join(home, "config.json");
|
||||
await writeFile(configPath, JSON.stringify({ daemon: { browserTools: { enabled: true } } }));
|
||||
let longestGap = 0;
|
||||
let previous = performance.now();
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
try {
|
||||
const reloaded = await client.reloadDaemonConfig();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled");
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
const { result: reloaded, blockingMs } = await measureMainThreadWork(
|
||||
"daemon config reload main-thread work",
|
||||
() => client.reloadDaemonConfig(),
|
||||
);
|
||||
expect(reloaded.appliedPaths).toContain("daemon.browserTools.enabled");
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect((await client.getDaemonConfig()).config.browserTools.enabled).toBe(true);
|
||||
// A new physical session uses ready daemon state; corrupt config is reported only by an explicit read.
|
||||
await writeFile(configPath, "invalid JSON");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
@@ -68,9 +69,18 @@ it("rejects incompatible installs without persisting them, then accepts the corr
|
||||
await expect(service.installDirectory({ path: root })).resolves.toMatchObject({
|
||||
status: "running",
|
||||
});
|
||||
expect(service.catalog()).toEqual([
|
||||
{ id: "example", requirements: { paseo: "^0.8.0" }, clientBundle: expect.any(String) },
|
||||
const catalog = service.catalog();
|
||||
expect(catalog).toEqual([
|
||||
{
|
||||
id: "example",
|
||||
requirements: { paseo: "^0.8.0" },
|
||||
clientBundle: expect.any(String),
|
||||
clientBundleHash: expect.any(String),
|
||||
},
|
||||
]);
|
||||
expect(catalog[0]!.clientBundleHash).toBe(
|
||||
createHash("sha256").update(catalog[0]!.clientBundle).digest("hex"),
|
||||
);
|
||||
});
|
||||
|
||||
it("marks pre-0.8 plugins failed on startup and recovers after migration and reload", async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { fork } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PassThrough } from "node:stream";
|
||||
import path from "node:path";
|
||||
@@ -106,6 +107,12 @@ function createReloadChild(
|
||||
};
|
||||
}
|
||||
|
||||
function sha256(text: string | undefined): string {
|
||||
return createHash("sha256")
|
||||
.update(text ?? "")
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function createTestRuntime(
|
||||
dependencies: NonNullable<ConstructorParameters<typeof PluginRuntime>[2]> = {},
|
||||
logger = pino({ level: "silent" }),
|
||||
@@ -651,7 +658,8 @@ lines.on("line", (line) => {
|
||||
type: "timeline.item",
|
||||
item: expect.objectContaining({
|
||||
type: "assistant_message",
|
||||
id: "assistant-1",
|
||||
id: JSON.stringify(["agent_message_chunk", "assistant-1"]),
|
||||
messageId: "assistant-1",
|
||||
text: "ACP says hi",
|
||||
}),
|
||||
}),
|
||||
@@ -1535,6 +1543,7 @@ export default function contribute(server: { registerProvider(provider: Provider
|
||||
|
||||
expect(spawnChild).not.toHaveBeenCalled();
|
||||
expect(runtime.catalog()[0]?.clientBundle).toContain("addTheme");
|
||||
expect(runtime.catalog()[0]?.clientBundleHash).toBe(sha256(runtime.catalog()[0]?.clientBundle));
|
||||
await runtime.stopAll();
|
||||
});
|
||||
|
||||
@@ -1697,6 +1706,7 @@ export function inspectHost(_input: z.input<typeof inspectRpc.input>) {
|
||||
const plugin = runtime.catalog()[0];
|
||||
expect(plugin?.clientBundle).toContain("Client-only surface");
|
||||
expect(plugin?.clientBundle).not.toContain("node:os");
|
||||
expect(plugin?.clientBundleHash).toBe(sha256(plugin?.clientBundle));
|
||||
await expect(runtime.invoke("split-runtime", "inspect", {})).resolves.toMatchObject({
|
||||
platform: expect.any(String),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PluginWireBeforeRequests } from "./lifecycle/index.js";
|
||||
import type { PluginWireBeforeRequests, PluginBeforeResults } from "./lifecycle/index.js";
|
||||
import type { SessionOpenWireRequest, AttachedAgentTool } from "./agent-tools.js";
|
||||
import type { UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import { validateBeforeRequest, validateBeforeResult } from "./lifecycle/index.j
|
||||
import { fork } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type pino from "pino";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -74,6 +74,8 @@ interface LoadedPlugin {
|
||||
instanceId: string;
|
||||
requirements: PluginRequirements | undefined;
|
||||
clientBundle: string;
|
||||
/** Lets a client that already holds this bundle skip downloading it again. */
|
||||
clientBundleHash: string;
|
||||
methods: ReadonlySet<string>;
|
||||
hooks: { events: string[]; before: string[] };
|
||||
providers: readonly PluginProviderMetadata[];
|
||||
@@ -246,6 +248,10 @@ function terminatePluginChild(child: PluginChild): void {
|
||||
if (!child.killed) child.kill();
|
||||
}
|
||||
|
||||
function hashClientBundle(clientBundle: string): string {
|
||||
return createHash("sha256").update(clientBundle).digest("hex");
|
||||
}
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -369,9 +375,19 @@ export class PluginRuntime {
|
||||
return true;
|
||||
}
|
||||
|
||||
catalog(): Array<{ id: string; clientBundle: string; requirements?: PluginRequirements }> {
|
||||
catalog(): Array<{
|
||||
id: string;
|
||||
clientBundle: string;
|
||||
clientBundleHash: string;
|
||||
requirements?: PluginRequirements;
|
||||
}> {
|
||||
return [...this.plugins.values()]
|
||||
.map(({ id, clientBundle, requirements }) => ({ id, clientBundle, requirements }))
|
||||
.map(({ id, clientBundle, clientBundleHash, requirements }) => ({
|
||||
id,
|
||||
clientBundle,
|
||||
clientBundleHash,
|
||||
requirements,
|
||||
}))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
@@ -535,12 +551,14 @@ export class PluginRuntime {
|
||||
async before<Name extends keyof PluginWireBeforeRequests>(
|
||||
name: Name,
|
||||
input: PluginWireBeforeRequests[Name],
|
||||
): Promise<PluginWireBeforeRequests[Name]> {
|
||||
): Promise<PluginBeforeResults[Name]> {
|
||||
let request = validateBeforeRequest(name, input);
|
||||
const plugins = [...this.plugins.values()].sort((left, right) => {
|
||||
return left.id.localeCompare(right.id);
|
||||
});
|
||||
const attached: AttachedAgentTool[] = [];
|
||||
const mcpServers: SessionOpenWireRequest["mcpServers"] = {};
|
||||
const mcpServerOwners: Record<string, string> = {};
|
||||
for (const loaded of plugins) {
|
||||
if (!loaded.hooks.before.includes(name)) {
|
||||
continue;
|
||||
@@ -551,11 +569,14 @@ export class PluginRuntime {
|
||||
requestId: randomUUID(),
|
||||
kind: "before",
|
||||
name,
|
||||
input: name === "agent.session_open" ? { ...request, tools: [] } : request,
|
||||
input:
|
||||
name === "agent.session_open" ? { ...request, tools: [], mcpServers: {} } : request,
|
||||
});
|
||||
request = validateBeforeResult(name, request, output);
|
||||
if (name === "agent.session_open") {
|
||||
this.collectAgentTools(attached, loaded, (request as SessionOpenWireRequest).tools);
|
||||
const opened = request as SessionOpenWireRequest;
|
||||
this.collectMcpServers(mcpServers, mcpServerOwners, loaded, opened.mcpServers);
|
||||
this.collectAgentTools(attached, loaded, opened.tools);
|
||||
}
|
||||
} catch (error) {
|
||||
if (name === "agent.session_open")
|
||||
@@ -565,7 +586,14 @@ export class PluginRuntime {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (name === "agent.session_open") (request as SessionOpenWireRequest).tools = attached;
|
||||
if (name === "agent.session_open") {
|
||||
return {
|
||||
...request,
|
||||
tools: attached,
|
||||
mcpServers,
|
||||
mcpServerOwners,
|
||||
} as PluginBeforeResults[Name];
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
@@ -587,6 +615,23 @@ export class PluginRuntime {
|
||||
return output;
|
||||
}
|
||||
|
||||
private collectMcpServers(
|
||||
servers: SessionOpenWireRequest["mcpServers"],
|
||||
owners: Record<string, string>,
|
||||
loaded: LoadedPlugin,
|
||||
contributions: SessionOpenWireRequest["mcpServers"],
|
||||
): void {
|
||||
for (const [name, server] of Object.entries(contributions)) {
|
||||
if (Object.hasOwn(servers, name)) {
|
||||
throw new Error(
|
||||
`MCP server ${name} from plugin ${loaded.id} collides with plugin ${owners[name]}`,
|
||||
);
|
||||
}
|
||||
servers[name] = server;
|
||||
owners[name] = loaded.id;
|
||||
}
|
||||
}
|
||||
|
||||
private collectAgentTools(
|
||||
attached: AttachedAgentTool[],
|
||||
loaded: LoadedPlugin,
|
||||
@@ -710,10 +755,12 @@ export class PluginRuntime {
|
||||
if (!canPublish()) throw new Error(`Plugin start cancelled: ${pluginId}`);
|
||||
const serverBundle = bundles.serverBundle;
|
||||
if (!serverBundle) {
|
||||
const clientBundle = bundles.clientBundle ?? "";
|
||||
return {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle: bundles.clientBundle ?? "",
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements: manifest.requirements,
|
||||
methods: new Set(),
|
||||
hooks: { events: [], before: [] },
|
||||
@@ -843,6 +890,7 @@ export class PluginRuntime {
|
||||
id: pluginId,
|
||||
instanceId: randomUUID(),
|
||||
clientBundle,
|
||||
clientBundleHash: hashClientBundle(clientBundle),
|
||||
requirements,
|
||||
methods: new Set(ready.methods),
|
||||
hooks: ready.hooks ?? { events: [], before: [] },
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { measureMainThreadWork } from "../../../test-utils/main-thread-work.js";
|
||||
import assert from "node:assert/strict";
|
||||
import { createHook, executionAsyncResource } from "node:async_hooks";
|
||||
import { open as openFile, writeFile } from "node:fs/promises";
|
||||
import { performance, PerformanceObserver } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import { MessagePort } from "node:worker_threads";
|
||||
import pino from "pino";
|
||||
import { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import { PluginService } from "../index.js";
|
||||
@@ -53,125 +51,9 @@ try {
|
||||
() => service.enablePlugin("responsive-build"),
|
||||
() => service.reloadPlugin("responsive-build"),
|
||||
]) {
|
||||
// Node 22.19+ exposes per-thread CPU accounting; the repository's older
|
||||
// Node declarations do not include it yet. .tool-versions pins Node 22.20.
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
const startedAt = performance.now();
|
||||
let previous = startedAt;
|
||||
let cpu = threadCpuUsage();
|
||||
let longestWall = 0;
|
||||
let longestBlocking = 0;
|
||||
let longestWallCpu = 0;
|
||||
let workerStarts = 0;
|
||||
let portCallbacks = 0;
|
||||
let longestWallEvents = { workerStarts: 0, portCallbacks: 0 };
|
||||
let longestBlockingWindow = { wallMs: 0, cpuMs: 0, resource: "" };
|
||||
let probe: ReturnType<typeof setInterval>;
|
||||
const callbacks = new Map<number, { wall: number; cpu: NodeJS.CpuUsage; resource: string }>();
|
||||
const recordBlocking = (
|
||||
blockingMs: number,
|
||||
window: { wallMs: number; cpuMs: number; resource: string },
|
||||
) => {
|
||||
if (blockingMs > longestBlocking) {
|
||||
longestBlocking = blockingMs;
|
||||
longestBlockingWindow = window;
|
||||
}
|
||||
};
|
||||
let longestGc = 0;
|
||||
const observeGc = (entries: readonly import("node:perf_hooks").PerformanceEntry[]) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.startTime >= startedAt) longestGc = Math.max(longestGc, entry.duration);
|
||||
}
|
||||
};
|
||||
const gc = new PerformanceObserver((list) => observeGc(list.getEntries()));
|
||||
gc.observe({ entryTypes: ["gc"] });
|
||||
// Measure work within callbacks, not CPU-counter increments attributed to
|
||||
// the idle interval between them. Exclude this probe's own timer callback.
|
||||
const activity = createHook({
|
||||
init(_id, type) {
|
||||
if (type === "WORKER") workerStarts++;
|
||||
},
|
||||
before(id) {
|
||||
const resource = executionAsyncResource();
|
||||
if (resource instanceof MessagePort) portCallbacks++;
|
||||
if (resource === probe) return;
|
||||
callbacks.set(id, {
|
||||
wall: performance.now(),
|
||||
cpu: threadCpuUsage(),
|
||||
resource: Object.prototype.toString.call(resource),
|
||||
});
|
||||
},
|
||||
after(id) {
|
||||
const start = callbacks.get(id);
|
||||
if (!start) return;
|
||||
callbacks.delete(id);
|
||||
const wallMs = performance.now() - start.wall;
|
||||
const { user, system } = threadCpuUsage(start.cpu);
|
||||
const cpuMs = (user + system) / 1_000;
|
||||
// A callback prevents the loop from serving IO for its entire span.
|
||||
// Use the high-resolution clock, not Windows' coarse CPU increments.
|
||||
recordBlocking(wallMs, { wallMs, cpuMs, resource: start.resource });
|
||||
},
|
||||
}).enable();
|
||||
const sample = () => {
|
||||
const now = performance.now();
|
||||
const currentCpu = threadCpuUsage();
|
||||
const wallMs = now - previous;
|
||||
const cpuMs = (currentCpu.user - cpu.user + currentCpu.system - cpu.system) / 1_000;
|
||||
// Native worker construction and message deserialization can precede
|
||||
// async_hooks.before. Include their interval when those events occurred.
|
||||
if (workerStarts || portCallbacks) {
|
||||
recordBlocking(Math.min(wallMs, cpuMs), {
|
||||
wallMs,
|
||||
cpuMs,
|
||||
resource: "worker/port interval",
|
||||
});
|
||||
}
|
||||
if (wallMs > longestWall) {
|
||||
longestWall = wallMs;
|
||||
longestWallCpu = cpuMs;
|
||||
longestWallEvents = { workerStarts, portCallbacks };
|
||||
}
|
||||
workerStarts = 0;
|
||||
portCallbacks = 0;
|
||||
previous = now;
|
||||
cpu = currentCpu;
|
||||
};
|
||||
// Sleep between probes: a continuously runnable setImmediate loop consumes
|
||||
// a core while the worker compiles. Raw gaps remain diagnostics; the
|
||||
// assertion measures elapsed daemon-thread callback spans and GC pauses.
|
||||
probe = setInterval(sample, 1);
|
||||
try {
|
||||
// The caller's current callback began before the hook was enabled.
|
||||
// Include synchronous work before the action's first await explicitly.
|
||||
const actionStartedAt = performance.now();
|
||||
const actionCpu = threadCpuUsage();
|
||||
const pending = action();
|
||||
const actionWallMs = performance.now() - actionStartedAt;
|
||||
const actionCost = threadCpuUsage(actionCpu);
|
||||
recordBlocking(actionWallMs, {
|
||||
wallMs: actionWallMs,
|
||||
cpuMs: (actionCost.user + actionCost.system) / 1_000,
|
||||
resource: "action()",
|
||||
});
|
||||
await pending;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
sample();
|
||||
observeGc(gc.takeRecords());
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
activity.disable();
|
||||
gc.disconnect();
|
||||
}
|
||||
longestBlocking = Math.max(longestBlocking, longestGc);
|
||||
process.stderr.write(
|
||||
`plugin compile main-thread blocking: ${longestBlocking.toFixed(2)} ms ${JSON.stringify({ longestWall, longestWallCpu, ...longestWallEvents, longestBlockingWindow, longestGc })}\n`,
|
||||
);
|
||||
const { blockingMs } = await measureMainThreadWork("plugin compile main-thread work", action);
|
||||
assert.ok(service.catalog()[0]?.clientBundle.includes("compiled fixture"));
|
||||
results.push({ blockingMs: longestBlocking });
|
||||
results.push({ blockingMs });
|
||||
}
|
||||
// Evaluate after both probes so parsing the returned code is not charged to compilation.
|
||||
const factory = globalThis.eval(service.catalog()[0]!.clientBundle) as (
|
||||
|
||||
@@ -588,6 +588,78 @@ test("routes plugin requests and releases its owned catalog subscription on clea
|
||||
expect(releasePluginSubscription).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
test("leaves out the client bundles a catalog request already holds", async () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const pluginRuntime: NonNullable<SessionOptions["pluginRuntime"]> = {
|
||||
before: async (_name, request) => request,
|
||||
emit: () => {},
|
||||
listPlugins: async () => [],
|
||||
getLogs: () => [],
|
||||
installDirectory: async () => {
|
||||
throw new Error("Unexpected install");
|
||||
},
|
||||
inspectDirectory: async () => ({ id: "unused" }),
|
||||
reloadPlugin: async () => {
|
||||
throw new Error("Unexpected reload");
|
||||
},
|
||||
enablePlugin: async () => {
|
||||
throw new Error("Unexpected enable");
|
||||
},
|
||||
disablePlugin: async () => {
|
||||
throw new Error("Unexpected disable");
|
||||
},
|
||||
removePlugin: async () => undefined,
|
||||
subscribe: () => () => {},
|
||||
catalog: () => [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
invokePluginRpc: async () => null,
|
||||
listUsageReports: async () => [],
|
||||
listLegacyUsage: async () => ({ fetchedAt: "2026-01-01T00:00:00.000Z", providers: [] }),
|
||||
};
|
||||
const session = createSessionForTest({ messages, pluginRuntime });
|
||||
|
||||
await session.handleMessage({
|
||||
type: "plugin.catalog.get.request",
|
||||
requestId: "reconnect",
|
||||
cachedClientBundles: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundleHash: "hash-old" },
|
||||
{ id: "removed", clientBundleHash: "hash-removed" },
|
||||
],
|
||||
});
|
||||
// Clients from before cachedClientBundles always receive every bundle.
|
||||
await session.handleMessage({ type: "plugin.catalog.get.request", requestId: "legacy" });
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "reconnect",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: "legacy",
|
||||
plugins: [
|
||||
{ id: "cached", clientBundle: "cached bundle", clientBundleHash: "hash-cached" },
|
||||
{ id: "changed", clientBundle: "new bundle", clientBundleHash: "hash-new" },
|
||||
{ id: "added", clientBundle: "added bundle", clientBundleHash: "hash-added" },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
describe("workspace label subscriptions", () => {
|
||||
type LabelSubscription = Awaited<ReturnType<WorkspaceLabelService["subscribe"]>>;
|
||||
type LabelChange = Parameters<Parameters<WorkspaceLabelService["subscribe"]>[0]["onChange"]>[0];
|
||||
|
||||
@@ -518,7 +518,7 @@ export interface SessionOptions {
|
||||
removePlugin(pluginId: string): Promise<void>;
|
||||
subscribe(listener: (pluginId: string) => void): () => void;
|
||||
subscribeSettings?(listener: (pluginId: string, settingsId: string) => void): () => void;
|
||||
catalog(): Array<{ id: string; clientBundle: string }>;
|
||||
catalog(): Array<{ id: string; clientBundle: string; clientBundleHash?: string }>;
|
||||
invokePluginRpc(pluginId: string, method: string, input: unknown): Promise<unknown>;
|
||||
listUsageReports(options?: {
|
||||
forceRefresh?: boolean;
|
||||
@@ -2584,12 +2584,17 @@ export class Session {
|
||||
});
|
||||
}
|
||||
if (msg.type === "plugin.catalog.get.request") {
|
||||
const cached = new Map(
|
||||
msg.cachedClientBundles?.map((entry) => [entry.id, entry.clientBundleHash]),
|
||||
);
|
||||
const plugins = (this.pluginRuntime?.catalog() ?? []).map(({ clientBundle, ...plugin }) =>
|
||||
plugin.clientBundleHash !== undefined && cached.get(plugin.id) === plugin.clientBundleHash
|
||||
? plugin
|
||||
: Object.assign(plugin, { clientBundle }),
|
||||
);
|
||||
this.emit({
|
||||
type: "plugin.catalog.get.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
plugins: this.pluginRuntime?.catalog() ?? [],
|
||||
},
|
||||
payload: { requestId: msg.requestId, plugins },
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
@@ -7805,6 +7810,7 @@ export class Session {
|
||||
direction,
|
||||
cursor,
|
||||
limit: msg.limit,
|
||||
wholeTurns: msg.wholeTurns,
|
||||
});
|
||||
const selectedTimeline = {
|
||||
timeline: fetchedControlTimeline,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { measureMainThreadWork } from "../../test-utils/main-thread-work.js";
|
||||
import { SessionDelivery } from "../session/owned-subscriptions/index.js";
|
||||
import { highlightCode, highlightCodeWithBudget } from "@getpaseo/highlight";
|
||||
import {
|
||||
@@ -840,26 +840,19 @@ describe("diff highlighting budget", () => {
|
||||
@@ -1 +1 @@
|
||||
-const value = Math.max(42, 7);
|
||||
+const value = Math.max(43, 7);`)[0];
|
||||
let lastTick = performance.now();
|
||||
let longestGap = 0;
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - lastTick);
|
||||
lastTick = now;
|
||||
}, 1);
|
||||
try {
|
||||
await setImmediate();
|
||||
const highlighted = await highlightDiffWithFileContent(
|
||||
file,
|
||||
cwd,
|
||||
source === "supplied" ? { oldFileContent } : {},
|
||||
const { result: highlighted, blockingMs } = await measureMainThreadWork(
|
||||
`diff highlight 5MiB ${source} main-thread work`,
|
||||
() =>
|
||||
highlightDiffWithFileContent(
|
||||
file,
|
||||
cwd,
|
||||
source === "supplied" ? { oldFileContent } : {},
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
console.info({ sourceBytes: Buffer.byteLength(oldFileContent), longestGap });
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(highlighted).toEqual(highlightDiffFromHunks(file));
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
@@ -952,23 +945,17 @@ it("highlights a one-line hunk from an over-budget JSON source without blocking
|
||||
@@ -2 +2 @@
|
||||
- "value": 42,
|
||||
+ "value": 43,`);
|
||||
let last = performance.now(),
|
||||
longestGap = 0;
|
||||
const timer = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - last);
|
||||
last = now;
|
||||
}, 1);
|
||||
try {
|
||||
const result = await highlightDiffWithFileContent(file, cwd, { oldFileContent });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
expect(longestGap).toBeLessThan(50);
|
||||
const { result, blockingMs } = await measureMainThreadWork(
|
||||
"diff highlight large JSON main-thread work",
|
||||
() => highlightDiffWithFileContent(file, cwd, { oldFileContent }),
|
||||
);
|
||||
expect(blockingMs).toBeLessThan(50);
|
||||
expect(result.hunks[0].lines.find((line) => line.type === "add")?.tokens).toContainEqual({
|
||||
text: "43",
|
||||
style: "number",
|
||||
});
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2062,6 +2062,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
providerSubagents: true,
|
||||
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement.
|
||||
projectedSubagentTimeline: true,
|
||||
wholeTurnTimeline: true,
|
||||
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
|
||||
providerSubagentNesting: true,
|
||||
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createServer } from "node:http";
|
||||
import { mkdtemp, readFile, writeFile, rm, chmod } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
diffWorkspaceGitRefs,
|
||||
@@ -6,14 +10,21 @@ import {
|
||||
fetchWorkspaceGitRemote,
|
||||
parseWorkspaceGitRefs,
|
||||
} from "./workspace-git-fetch.js";
|
||||
import type { RunGitCommand } from "../utils/run-git-command.js";
|
||||
import { runGitCommand, type RunGitCommand } from "../utils/run-git-command.js";
|
||||
|
||||
describe("workspace Git remote refs", () => {
|
||||
test("runs the complete fetch through the supplied Git runner", async () => {
|
||||
const commands: string[][] = [];
|
||||
let refRead = 0;
|
||||
const runGitCommand: RunGitCommand = async (args) => {
|
||||
const run: RunGitCommand = async (args, options) => {
|
||||
commands.push(args);
|
||||
if (args[0] === "fetch") {
|
||||
expect(options.envOverlay).toEqual({
|
||||
GIT_TERMINAL_PROMPT: "0",
|
||||
GIT_ASKPASS: "",
|
||||
GCM_INTERACTIVE: "0",
|
||||
});
|
||||
}
|
||||
const stdout =
|
||||
args[0] === "for-each-ref"
|
||||
? `refs/remotes/origin/main\0${refRead++ === 0 ? "aaaaaaaa" : "bbbbbbbb"}`
|
||||
@@ -22,7 +33,7 @@ describe("workspace Git remote refs", () => {
|
||||
};
|
||||
|
||||
await expect(
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, runGitCommand),
|
||||
fetchWorkspaceGitRemote("/repo", { onRefSnapshot() {} }, run),
|
||||
).resolves.toMatchObject({
|
||||
changes: [
|
||||
{
|
||||
@@ -109,3 +120,130 @@ describe("workspace Git remote refs", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// Serve a real bare repository over Git's dumb HTTP transport, behind Basic authentication.
|
||||
// Both the authentication challenge and successful fetch exercise Git itself.
|
||||
async function withAuthenticatedRemote(
|
||||
verify: (fixture: {
|
||||
cwd: string;
|
||||
run: RunGitCommand;
|
||||
root: string;
|
||||
origin: string;
|
||||
expectedOid: string;
|
||||
}) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const root = await mkdtemp(join(tmpdir(), "paseo-fetch-auth-"));
|
||||
const cwd = join(root, "checkout");
|
||||
const remote = join(root, "remote.git");
|
||||
const emptyConfig = join(root, "gitconfig");
|
||||
const askpass = join(root, "askpass.sh");
|
||||
const marker = join(root, "prompts");
|
||||
await writeFile(emptyConfig, "");
|
||||
await writeFile(
|
||||
askpass,
|
||||
`#!/bin/sh\nprintf 'prompt\\n' >> '${marker.replaceAll("'", "'\\''")}'\nexit 1\n`,
|
||||
);
|
||||
await chmod(askpass, 0o755);
|
||||
const askpassCommand = askpass;
|
||||
const run: RunGitCommand = (args, options) =>
|
||||
runGitCommand(args, {
|
||||
...options,
|
||||
env: {
|
||||
...options.env,
|
||||
GIT_CONFIG_GLOBAL: emptyConfig,
|
||||
GIT_CONFIG_NOSYSTEM: "1",
|
||||
GIT_ASKPASS: askpassCommand,
|
||||
SSH_ASKPASS: askpassCommand,
|
||||
},
|
||||
});
|
||||
const server = createServer(async (request, response) => {
|
||||
if (
|
||||
request.headers.authorization !== `Basic ${Buffer.from("fixture:secret").toString("base64")}`
|
||||
) {
|
||||
response.writeHead(401, { "WWW-Authenticate": 'Basic realm="git-test"' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const pathname = new URL(request.url!, "http://localhost").pathname;
|
||||
try {
|
||||
const contents = await readFile(join(root, pathname));
|
||||
response.writeHead(200, { "Content-Type": "application/octet-stream" });
|
||||
response.end(contents);
|
||||
} catch {
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
}
|
||||
});
|
||||
try {
|
||||
await run(["init", "--initial-branch=main", cwd], { cwd: root });
|
||||
await run(
|
||||
[
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"initial",
|
||||
],
|
||||
{ cwd },
|
||||
);
|
||||
await run(["clone", "--bare", cwd, remote], { cwd: root });
|
||||
await run(["update-server-info"], { cwd: remote });
|
||||
await run(["config", "credential.helper", ""], { cwd });
|
||||
await run(["config", "core.askPass", askpassCommand], { cwd });
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Expected TCP server");
|
||||
const origin = `http://127.0.0.1:${address.port}/remote.git`;
|
||||
await run(["remote", "add", "origin", origin], { cwd });
|
||||
const expectedOid = (await run(["rev-parse", "HEAD"], { cwd })).stdout.trim();
|
||||
await verify({ cwd, run, root, origin, expectedOid });
|
||||
} finally {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
||||
}
|
||||
}
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches never invoke askpass when credentials are missing",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root }) => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
expect(String(result.error)).toContain("terminal prompts disabled");
|
||||
}
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test.skipIf(process.platform === "win32")(
|
||||
"background fetches still use stored credentials",
|
||||
async () => {
|
||||
await withAuthenticatedRemote(async ({ cwd, run, root, origin, expectedOid }) => {
|
||||
const credentials = join(root, "credentials");
|
||||
await writeFile(credentials, origin.replace("http://", "http://fixture:secret@") + "\n");
|
||||
await run(["config", "credential.helper", `store --file="${credentials}"`], { cwd });
|
||||
|
||||
const result = await fetchWorkspaceGitRemote(cwd, { onRefSnapshot() {} }, run);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.changes).toContainEqual({
|
||||
kind: "added",
|
||||
ref: "origin/main",
|
||||
afterOid: expectedOid,
|
||||
});
|
||||
expect((await run(["rev-parse", "refs/remotes/origin/main"], { cwd })).stdout.trim()).toBe(
|
||||
expectedOid,
|
||||
);
|
||||
await expect(readFile(join(root, "prompts"), "utf8")).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -33,7 +33,9 @@ export async function fetchWorkspaceGitRemote(
|
||||
try {
|
||||
await run(["fetch", "origin", "--prune"], {
|
||||
cwd,
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0" },
|
||||
// Terminal prompting and graphical credential prompting have separate controls.
|
||||
// Keep cached credentials available, but never start a login from a passive fetch.
|
||||
envOverlay: { GIT_TERMINAL_PROMPT: "0", GIT_ASKPASS: "", GCM_INTERACTIVE: "0" },
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch (caught) {
|
||||
|
||||
@@ -534,6 +534,7 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
|
||||
const repositoryRoot = fileURLToPath(new URL("../../../../", import.meta.url));
|
||||
const source = `
|
||||
import assert from 'node:assert/strict';
|
||||
import { measureMainThreadWork } from './packages/server/src/test-utils/main-thread-work.ts';
|
||||
import { write } from 'node:fs';
|
||||
import { mkdtemp, mkdir, writeFile, symlink, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -575,29 +576,23 @@ test("Git refresh, command tracing and 10,000 ignored alias events do no synchro
|
||||
await symlink(worktree, alias, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
const subscription = await service.requestWorkingTreeWatch(alias, () => { notifications++; });
|
||||
const events = Array.from({ length: process.env.GIT_IO_TRACE ? 2 : 10000 }, (_, i) => ({ type: 'update', path: join(i % 2 ? alias : worktree, 'ignored', 'noise') }));
|
||||
// A shared CI runner can deschedule the child during a wall-clock sample.
|
||||
// Keep the 20ms gate, but take three independent batches and require one
|
||||
// responsive callback/timer pair. All batches must remain filesystem-free.
|
||||
const samples = [];
|
||||
for (let trial = 0; trial < (process.env.GIT_IO_TRACE ? 1 : 3); trial++) {
|
||||
await tick();
|
||||
let fsCalls = 0;
|
||||
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
|
||||
let fsCalls = 0;
|
||||
const hook = createHook({ init(_id, type) { if (type.startsWith('FSREQ')) fsCalls++; } });
|
||||
await tick();
|
||||
const { blockingMs } = await measureMainThreadWork('Git filter main-thread work', async () => {
|
||||
await marker('FILTER_START');
|
||||
hook.enable();
|
||||
const started = performance.now();
|
||||
const pulse = new Promise((resolve) => setTimeout(() => resolve(performance.now() - started), 0));
|
||||
callback(null, events);
|
||||
const filterMs = performance.now() - started;
|
||||
hook.disable();
|
||||
const loopGapMs = await pulse;
|
||||
await marker('FILTER_END');
|
||||
assert.equal(fsCalls, 0, "filesystem calls");
|
||||
assert.equal(notifications, 0, "ignored event notification");
|
||||
samples.push({ filterMs, loopGapMs, fsCalls });
|
||||
}
|
||||
console.log(JSON.stringify({ ...samples[0], samples }));
|
||||
assert.ok(process.env.GIT_IO_TRACE || samples.some(({ filterMs, loopGapMs }) => filterMs < 20 && loopGapMs < 20), 'no responsive filter sample: ' + JSON.stringify(samples));
|
||||
try {
|
||||
callback(null, events);
|
||||
} finally {
|
||||
hook.disable();
|
||||
await marker('FILTER_END');
|
||||
}
|
||||
});
|
||||
assert.equal(fsCalls, 0, "filesystem calls");
|
||||
assert.equal(notifications, 0, "ignored event notification");
|
||||
assert.ok(blockingMs < 20, 'blocking filter work: ' + blockingMs);
|
||||
console.log(JSON.stringify({ blockingMs, fsCalls }));
|
||||
// Alias identity remains useful after deletion: no per-event realpath can recover this file.
|
||||
callback(null, [{ type: 'delete', path: join(worktree, 'tracked.txt') }]);
|
||||
assert.equal(notifications, 1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -658,24 +659,20 @@ describe("WorkspaceReconciliationService", () => {
|
||||
workspaceRegistry,
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
let last = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - last);
|
||||
last = now;
|
||||
}, 1);
|
||||
let result;
|
||||
try {
|
||||
result = await service.runOnce();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 1));
|
||||
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
|
||||
"workspace reconciliation main-thread work",
|
||||
() => service.runOnce(),
|
||||
);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBe(20001);
|
||||
expect(result.changesApplied).toHaveLength(20000);
|
||||
expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
service.dispose();
|
||||
}
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
expect(result.changesApplied).toHaveLength(20000);
|
||||
expect([...workspaces.values()].every((workspace) => workspace.archivedAt !== null)).toBe(true);
|
||||
}, 30000);
|
||||
|
||||
test("preserves workspace archival that lands during boot reconciliation", async () => {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { createHook, executionAsyncResource } from "node:async_hooks";
|
||||
import { performance, PerformanceObserver, type PerformanceEntry } from "node:perf_hooks";
|
||||
import { MessagePort } from "node:worker_threads";
|
||||
|
||||
/** Measure main-thread work without charging idle IO or runner descheduling. */
|
||||
export async function measureMainThreadWork<T>(label: string, action: () => T | Promise<T>) {
|
||||
// Node 22.20 supplies this API; the repository's older declarations omit it.
|
||||
const threadCpuUsage: (previous?: NodeJS.CpuUsage) => NodeJS.CpuUsage = Reflect.get(
|
||||
process,
|
||||
"threadCpuUsage",
|
||||
);
|
||||
let blockingMs = 0;
|
||||
let filesystemRequests = 0;
|
||||
let yields = 0;
|
||||
let longestCallbackMs = 0;
|
||||
let longestCallbackCpuMs = 0;
|
||||
let longestGapMs = 0;
|
||||
let longestGcMs = 0;
|
||||
let gcCount = 0;
|
||||
let workerEvents = 0;
|
||||
let draining = false;
|
||||
const startedAt = performance.now();
|
||||
let previous = startedAt;
|
||||
let previousCpu = threadCpuUsage();
|
||||
let probe: ReturnType<typeof setInterval>;
|
||||
let drain: ReturnType<typeof setImmediate>;
|
||||
const callbacks = new Map<number, { wall: number; cpu: NodeJS.CpuUsage }>();
|
||||
const record = (wallMs: number, cpu: NodeJS.CpuUsage) => {
|
||||
const cpuMs = (cpu.user + cpu.system) / 1_000;
|
||||
// Windows CPU accounting is coarse. Cap charged CPU by the real span so a
|
||||
// counter increment cannot inflate a short callback. Zero-CPU waits drop out.
|
||||
blockingMs = Math.max(blockingMs, Math.min(wallMs, cpuMs));
|
||||
if (wallMs > longestCallbackMs) {
|
||||
longestCallbackMs = wallMs;
|
||||
longestCallbackCpuMs = cpuMs;
|
||||
}
|
||||
};
|
||||
const observeGc = (entries: readonly PerformanceEntry[]) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.startTime < startedAt) continue;
|
||||
gcCount++;
|
||||
longestGcMs = Math.max(longestGcMs, entry.duration);
|
||||
}
|
||||
};
|
||||
const gc = new PerformanceObserver((list) => observeGc(list.getEntries()));
|
||||
gc.observe({ entryTypes: ["gc"] });
|
||||
const activity = createHook({
|
||||
init(_id, type) {
|
||||
if (type.startsWith("FSREQ")) filesystemRequests++;
|
||||
if (!draining && type === "Immediate") yields++;
|
||||
if (type === "WORKER") workerEvents++;
|
||||
},
|
||||
before(id) {
|
||||
const resource = executionAsyncResource();
|
||||
if (resource === probe || resource === drain) return;
|
||||
if (resource instanceof MessagePort) workerEvents++;
|
||||
callbacks.set(id, { wall: performance.now(), cpu: threadCpuUsage() });
|
||||
},
|
||||
after(id) {
|
||||
const start = callbacks.get(id);
|
||||
if (!start) return;
|
||||
callbacks.delete(id);
|
||||
record(performance.now() - start.wall, threadCpuUsage(start.cpu));
|
||||
},
|
||||
}).enable();
|
||||
const sample = () => {
|
||||
const now = performance.now();
|
||||
const cpu = threadCpuUsage();
|
||||
const wallMs = now - previous;
|
||||
longestGapMs = Math.max(longestGapMs, wallMs);
|
||||
// Worker construction/message cloning can precede async_hooks.before.
|
||||
if (workerEvents) {
|
||||
blockingMs = Math.max(
|
||||
blockingMs,
|
||||
Math.min(wallMs, (cpu.user - previousCpu.user + cpu.system - previousCpu.system) / 1_000),
|
||||
);
|
||||
}
|
||||
workerEvents = 0;
|
||||
previous = now;
|
||||
previousCpu = cpu;
|
||||
};
|
||||
probe = setInterval(sample, 1);
|
||||
let result: T;
|
||||
try {
|
||||
// The caller's current callback began before the hook was installed.
|
||||
const wall = performance.now();
|
||||
const cpu = threadCpuUsage();
|
||||
const pending = action();
|
||||
record(performance.now() - wall, threadCpuUsage(cpu));
|
||||
result = await pending;
|
||||
draining = true;
|
||||
await new Promise<void>((resolve) => {
|
||||
drain = setImmediate(resolve);
|
||||
});
|
||||
sample();
|
||||
observeGc(gc.takeRecords());
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
activity.disable();
|
||||
gc.disconnect();
|
||||
}
|
||||
// GC CPU inside callbacks is charged above. Elapsed GC spans, like elapsed
|
||||
// callback spans, include preemption and remain diagnostics rather than gates.
|
||||
process.stderr.write(
|
||||
`${label}: ${JSON.stringify({ blockingMs, filesystemRequests, yields, longestCallbackMs, longestCallbackCpuMs, longestGcMs, gcCount, longestGapMs })}\n`,
|
||||
);
|
||||
return { result, blockingMs, filesystemRequests, yields };
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { writeFile, truncate, rm } from "node:fs/promises";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { measureMainThreadWork } from "../test-utils/main-thread-work.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
@@ -67,7 +67,7 @@ describe("paseo config file substrate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the longest loop stall below 20ms while admitting large configs", async () => {
|
||||
it("keeps main-thread work below 20ms while admitting large configs", async () => {
|
||||
const config = {
|
||||
scripts: Object.fromEntries(
|
||||
Array.from({ length: 7_000 }, (_, i) => [`script${i}`, { command: "echo hi" }]),
|
||||
@@ -75,48 +75,27 @@ describe("paseo config file substrate", () => {
|
||||
worktree: { setup: "npm ci" },
|
||||
};
|
||||
await writeFile(join(tempDir, "paseo.json"), JSON.stringify(config));
|
||||
let previous = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
try {
|
||||
result = await readPaseoConfigForEdit(tempDir);
|
||||
await nextTurn();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
const { result, blockingMs, filesystemRequests, yields } = await measureMainThreadWork(
|
||||
"project config 7000 scripts main-thread work",
|
||||
() => readPaseoConfigForEdit(tempDir),
|
||||
);
|
||||
expect(result).toEqual({ ok: true, config, revision: await statPaseoConfigPath(tempDir) });
|
||||
process.stdout.write(`project config 7,000 scripts max loop gap: ${longestGap.toFixed(2)}ms\n`);
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(yields).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("rejects a 32MiB config with a loop stall below 20ms", async () => {
|
||||
it("rejects a 32MiB config with main-thread work below 20ms", async () => {
|
||||
await writeFile(
|
||||
join(tempDir, "paseo.json"),
|
||||
JSON.stringify({ extra: "x".repeat(32 * 1024 * 1024) }),
|
||||
);
|
||||
let previous = performance.now();
|
||||
let longestGap = 0;
|
||||
const probe = setInterval(() => {
|
||||
const now = performance.now();
|
||||
longestGap = Math.max(longestGap, now - previous);
|
||||
previous = now;
|
||||
}, 1);
|
||||
let result: Awaited<ReturnType<typeof readPaseoConfigForEdit>>;
|
||||
try {
|
||||
result = await readPaseoConfigForEdit(tempDir);
|
||||
await nextTurn();
|
||||
longestGap = Math.max(longestGap, performance.now() - previous);
|
||||
} finally {
|
||||
clearInterval(probe);
|
||||
}
|
||||
process.stdout.write(`project config 32MiB max loop gap: ${longestGap.toFixed(2)}ms\n`);
|
||||
expect(longestGap).toBeLessThan(20);
|
||||
const { result, blockingMs, filesystemRequests } = await measureMainThreadWork(
|
||||
"project config 32MiB main-thread work",
|
||||
() => readPaseoConfigForEdit(tempDir),
|
||||
);
|
||||
expect(blockingMs).toBeLessThan(20);
|
||||
expect(filesystemRequests).toBeGreaterThan(0);
|
||||
expect(result).toEqual({ ok: false, error: { code: "invalid_project_config" } });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user