mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 11:12:53 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
300e6f9282 | ||
|
|
23b05bbcc5 | ||
|
|
1c62748084 | ||
|
|
068f272a00 |
@@ -0,0 +1,29 @@
|
||||
# Large Markdown preview restore diagnostic
|
||||
|
||||
Reproduces the Android path in [issue #1384](https://github.com/getpaseo/paseo/issues/1384), using the public synthetic document in [the Android report](https://github.com/getpaseo/paseo/issues/1384#issuecomment-6186501327).
|
||||
|
||||
## Preconditions
|
||||
|
||||
Use an isolated Android emulator with an official Paseo APK and a connected daemon. Generate `mixed-500k.md` from the report's public generator with seed 1. Verify its SHA-256 is `23a5ec243319e62c5314523b44db8854c0b4d305793be74bc8e33cd16b48a0bc` (467,495 bytes).
|
||||
|
||||
1. Open a workspace containing the fixture.
|
||||
2. Open Explorer → Files → `mixed-500k.md`, with Preview selected.
|
||||
3. Return the preview to the top, where its heading is `Partition review payload limit`. Leave this file selected and close the application.
|
||||
4. Run the diagnostic on that emulator:
|
||||
|
||||
```sh
|
||||
agent-device test packages/app/e2e/mobile/agent-device/markdown-preview-repro/restore.android.ad --platform android --device <emulator-name>
|
||||
```
|
||||
|
||||
The heading must become accessibility-visible within 15 seconds after application launch. This is a diagnostic responsiveness bound, not a product rendering SLA. The check includes startup, reconnection, rendering, and accessibility collection. A failure before Preview, or with the document scrolled away from its heading, does not establish a Markdown rendering failure.
|
||||
|
||||
## Observed comparison
|
||||
|
||||
On the same Android 15/API 35 emulator, with identical document bytes and a Linux daemon:
|
||||
|
||||
- Official 0.11.2 (versionCode 11002): the diagnostic failed its heading wait, completing in 16.6 seconds. A native stack during restore captured the main thread in `SpannableStringBuilder.restoreInvariants`, through React Native `TextLayoutManager` and Fabric mounting. The document eventually appeared.
|
||||
- Official 0.12.0 (versionCode 12000): the same diagnostic passed in 1.32 seconds after returning the document to the top. The preview also scrolled successfully.
|
||||
|
||||
An initial 0.12.0 launch stopped at Loading workspace; selecting the fixture workspace again recovered it. A separate run with the preview left scrolled down failed the heading check, as expected from the precondition. Neither result is attributed to Markdown rendering.
|
||||
|
||||
No Android ANR dialog or white-screen crash was observed. Physical Android devices, iOS, desktop, private documents, and the reported inability to close the file remain unverified. The version comparison does not isolate an individual commit as the sole cause of the difference.
|
||||
@@ -0,0 +1,5 @@
|
||||
context platform=android timeout=20000 retries=0
|
||||
|
||||
open "sh.paseo"
|
||||
wait "text=\"Partition review payload limit\"" 15000
|
||||
close
|
||||
@@ -50,6 +50,10 @@ export interface PluginLifecycleEvents {
|
||||
agent: PluginHookAgent;
|
||||
turnId: string | null;
|
||||
outcome: PluginTurnOutcome;
|
||||
/**
|
||||
* @deprecated Always empty. Fetch history on demand with
|
||||
* context.paseo.agents.ref(agent.id).timeline.refetch({ direction: "tail", limit: 20 }).
|
||||
*/
|
||||
timeline: readonly AgentTimelineItem[];
|
||||
};
|
||||
"agent.permission_requested": { agent: PluginHookAgent; request: AgentPermissionRequest };
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
formatAgentMessage,
|
||||
formatSystemNotificationPrompt,
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { expect, test, vi, onTestFinished } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -11892,3 +11892,102 @@ test.each([false, true])(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{
|
||||
event: {
|
||||
type: "turn_completed",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "completed" },
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "turn_failed",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
error: "Failed",
|
||||
code: "test",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "failed", error: { message: "Failed", code: "test" } },
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "turn_canceled",
|
||||
provider: "codex",
|
||||
turnId: "hook-turn",
|
||||
reason: "Stopped",
|
||||
} satisfies AgentStreamEvent,
|
||||
outcome: { kind: "canceled", reason: "Stopped" },
|
||||
},
|
||||
])(
|
||||
"lifecycle hooks receive no conversation for $event.type and stream dispatch never reads timeline items",
|
||||
async ({ event: terminalEvent, outcome }) => {
|
||||
class CountingTimelineStore extends InMemoryAgentTimelineStore {
|
||||
itemReads = 0;
|
||||
override getItems(agentId: string): AgentTimelineItem[] {
|
||||
this.itemReads += 1;
|
||||
return super.getItems(agentId);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-hook-payload-"));
|
||||
const client = new SessionRecordingAgentClient();
|
||||
const timelineStore = new CountingTimelineStore();
|
||||
const events: Array<{ name: string; event: unknown }> = [];
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
timelineStore,
|
||||
pluginLifecycle: {
|
||||
before: async (_name, request) => request,
|
||||
emit: (name, event) => {
|
||||
events.push({ name, event });
|
||||
},
|
||||
},
|
||||
});
|
||||
onTestFinished(async () => {
|
||||
await manager.closeAgent(manager.listAgents()[0]!.id);
|
||||
await manager.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
await manager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "A long conversation".repeat(10_000),
|
||||
});
|
||||
expect(manager.getTimeline(agent.id)).toHaveLength(1);
|
||||
const readsBeforeDispatch = timelineStore.itemReads;
|
||||
const session = client.sessions[0]!;
|
||||
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "hook-turn" });
|
||||
await expect
|
||||
.poll(() => events.filter(({ name }) => name === "agent.turn_started").length)
|
||||
.toBe(1);
|
||||
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
||||
session.pushEvent(terminalEvent);
|
||||
await expect
|
||||
.poll(() => events.filter(({ name }) => name === "agent.turn_ended"))
|
||||
.toEqual([
|
||||
{
|
||||
name: "agent.turn_ended",
|
||||
event: {
|
||||
agent: {
|
||||
id: agent.id,
|
||||
workspaceId: null,
|
||||
parentAgentId: null,
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
title: null,
|
||||
},
|
||||
turnId: "hook-turn",
|
||||
outcome,
|
||||
timeline: [],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(timelineStore.itemReads).toBe(readsBeforeDispatch);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -326,6 +326,7 @@ export interface AgentManagerOptions {
|
||||
registry?: AgentStorage;
|
||||
onAgentAttention?: AgentAttentionCallback;
|
||||
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
||||
timelineStore?: InMemoryAgentTimelineStore;
|
||||
durableTimelineStore?: AgentTimelineStore;
|
||||
terminalManager?: TerminalManager | null;
|
||||
mcpBaseUrl?: string;
|
||||
@@ -741,7 +742,7 @@ export class AgentManager {
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
|
||||
private readonly agents = new Map<string, LiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly timelineStore: InMemoryAgentTimelineStore;
|
||||
private readonly providerSubagents = new ProviderSubagentStore();
|
||||
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
||||
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
||||
@@ -778,7 +779,8 @@ export class AgentManager {
|
||||
|
||||
constructor(options: AgentManagerOptions) {
|
||||
this.pluginLifecycle = options.pluginLifecycle;
|
||||
this.idFactory = options?.idFactory ?? (() => randomUUID());
|
||||
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
|
||||
this.idFactory = options.idFactory ?? (() => randomUUID());
|
||||
this.registry = options?.registry;
|
||||
this.durableTimelineStore = options?.durableTimelineStore;
|
||||
this.onAgentAttention = options?.onAgentAttention;
|
||||
@@ -5164,7 +5166,6 @@ export class AgentManager {
|
||||
this.pluginLifecycle,
|
||||
describeHookAgent({ ...agent, title: agent.config.title }),
|
||||
event,
|
||||
this.timelineStore.getItems(agentId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ interface ProjectionOptions {
|
||||
title?: string | null;
|
||||
createdAt?: string;
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Archive timestamp, supplied by a caller holding the stored record. A live
|
||||
* managed agent cannot know this: an archived agent is resumed back into
|
||||
* memory to serve a history read, so being in the manager is not evidence
|
||||
* that it is active. Storage stays the single owner.
|
||||
*/
|
||||
archivedAt?: string | null;
|
||||
}
|
||||
|
||||
interface RecentProviderSessionProjectionOptions {
|
||||
@@ -139,6 +146,7 @@ export function toAgentPayload(
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
AgentListItemPayloadSchema,
|
||||
AgentSnapshotPayloadSchema,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { CodexAppServerAgentClient } from "./providers/codex-app-server-agent.js";
|
||||
import { ClaudeAgentClient } from "./providers/claude/agent.js";
|
||||
|
||||
// Opt in to the same workflow with the installed providers instead of test clients.
|
||||
const real = process.env.PASEO_ARCHIVE_REAL === "1";
|
||||
const StatusSchema = z.object({ snapshot: AgentSnapshotPayloadSchema });
|
||||
const ListSchema = z.object({ agents: z.array(AgentListItemPayloadSchema) });
|
||||
|
||||
test.each(["codex", "claude"] as const)(
|
||||
"archived %s history remains archived across MCP discovery",
|
||||
async (provider) => {
|
||||
const cwd = await mkdtemp(path.join(os.tmpdir(), "archive-discovery-"));
|
||||
const logger = pino({ level: "silent" });
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: real
|
||||
? {
|
||||
[provider]:
|
||||
provider === "codex"
|
||||
? new CodexAppServerAgentClient(logger)
|
||||
: new ClaudeAgentClient({ logger }),
|
||||
}
|
||||
: createTestAgentClients(),
|
||||
pluginsEnabled: false,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const mcp = new Client({ name: "archive-discovery-test", version: "1.0.0" });
|
||||
const call = async (name: string, args: Record<string, unknown>) => {
|
||||
const result = await mcp.callTool({ name, arguments: args });
|
||||
if (result.isError) throw new Error(JSON.stringify(result.content));
|
||||
return result.structuredContent;
|
||||
};
|
||||
const list = async (includeArchived: boolean) =>
|
||||
ListSchema.parse(await call("list_agents", { cwd, includeArchived })).agents;
|
||||
const status = async (agentId: string) =>
|
||||
StatusSchema.parse(await call("get_agent_status", { agentId })).snapshot;
|
||||
try {
|
||||
await client.connect();
|
||||
await mcp.connect(
|
||||
new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)),
|
||||
);
|
||||
const realModel = provider === "codex" ? "gpt-5.6-sol" : "haiku";
|
||||
const agent = await client.createAgent({
|
||||
config: {
|
||||
provider,
|
||||
cwd,
|
||||
model: real ? realModel : undefined,
|
||||
modeId: provider === "codex" ? "full-access" : "bypassPermissions",
|
||||
},
|
||||
});
|
||||
await daemon.daemon.agentManager.runAgent(
|
||||
agent.id,
|
||||
"Reply with ARCHIVE_OK. Do not use tools.",
|
||||
);
|
||||
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
|
||||
await call("archive_agent", { agentId: agent.id });
|
||||
const archivedAt = (await status(agent.id)).archivedAt;
|
||||
expect(archivedAt).toEqual(expect.any(String));
|
||||
expect((await list(false)).map((item) => item.id)).not.toContain(agent.id);
|
||||
|
||||
const timeline = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 0 });
|
||||
expect(timeline.entries.length).toBeGreaterThan(0);
|
||||
expect.soft((await status(agent.id)).archivedAt).toBe(archivedAt);
|
||||
expect.soft((await list(false)).map((item) => item.id)).not.toContain(agent.id);
|
||||
expect
|
||||
.soft((await list(true)).find((item) => item.id === agent.id)?.archivedAt)
|
||||
.toBe(archivedAt);
|
||||
const ordinary = await client.fetchAgents({ filter: { includeArchived: false } });
|
||||
expect.soft(ordinary.entries.map((entry) => entry.agent.id)).not.toContain(agent.id);
|
||||
const inclusive = await client.fetchAgents({ filter: { includeArchived: true } });
|
||||
expect
|
||||
.soft(inclusive.entries.find((entry) => entry.agent.id === agent.id)?.agent.archivedAt)
|
||||
.toBe(archivedAt);
|
||||
|
||||
await client.refreshAgent(agent.id);
|
||||
expect((await status(agent.id)).archivedAt).toBeNull();
|
||||
expect((await list(false)).map((item) => item.id)).toContain(agent.id);
|
||||
} finally {
|
||||
await mcp.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
120_000,
|
||||
);
|
||||
@@ -206,7 +206,8 @@ export async function createAgentCommand(
|
||||
agentStorage: dependencies.agentStorage,
|
||||
childAgentId: snapshot.id,
|
||||
callerAgentId: input.callerAgentId,
|
||||
requireParentOwnership: true,
|
||||
// Detached agents have no parent label, but still notify their creator.
|
||||
requireParentOwnership: !(input.detached ?? false),
|
||||
logger: dependencies.logger,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -60,6 +60,8 @@ export class AgentDirectory {
|
||||
const active = await Promise.all(
|
||||
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
);
|
||||
return [...active, ...stored].filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
return [...active, ...stored]
|
||||
.filter(matches)
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -313,6 +313,124 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe("Suite A: Core Fixes", () => {
|
||||
test.each([
|
||||
{ relationship: "detached", reparent: false, notifications: 1 },
|
||||
{ relationship: "subagent", reparent: false, notifications: 1 },
|
||||
{ relationship: "subagent", reparent: true, notifications: 0 },
|
||||
] as const)(
|
||||
"create_agent $relationship (reparent=$reparent) sends $notifications finish notification(s)",
|
||||
async ({ relationship, reparent, notifications }) => {
|
||||
let releaseChild!: () => void;
|
||||
let childReachedGate!: () => void;
|
||||
const heldTurn = new Promise<void>((resolve) => {
|
||||
releaseChild = resolve;
|
||||
});
|
||||
const childAtGate = new Promise<void>((resolve) => {
|
||||
childReachedGate = resolve;
|
||||
});
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Notification caller" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
async beforeTurnComplete(prompt) {
|
||||
if (
|
||||
typeof prompt === "string" &&
|
||||
parseAgentMessage(prompt).text === "Hold notification child"
|
||||
) {
|
||||
childReachedGate();
|
||||
await heldTurn;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Notification caller",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({
|
||||
host: "127.0.0.1",
|
||||
port: daemon.port,
|
||||
agentId: caller.id,
|
||||
}),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: relationship },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Notification child",
|
||||
initialPrompt: "Hold notification child",
|
||||
notifyOnFinish: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
await childAtGate;
|
||||
expect(daemon.daemon.agentManager.getAgent(childId)?.labels?.[PARENT_AGENT_ID_LABEL]).toBe(
|
||||
relationship === "subagent" ? caller.id : undefined,
|
||||
);
|
||||
if (reparent) {
|
||||
const other = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Other parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
await daemon.daemon.agentManager.setLabels(childId, {
|
||||
[PARENT_AGENT_ID_LABEL]: other.id,
|
||||
});
|
||||
}
|
||||
releaseChild();
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child completion",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(childId)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
if (notifications) {
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "caller finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toEqual({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
title: "Notification child",
|
||||
event: "finished",
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).text).toContain(
|
||||
"Notification child) finished.",
|
||||
);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "caller notification drained",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
} else {
|
||||
// Allow the terminal event's asynchronous storage reads and notification dispatch to drain.
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
expect(callerPrompts).toHaveLength(notifications);
|
||||
} finally {
|
||||
releaseChild();
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
test.each([
|
||||
["Stop", "cancelAgent"],
|
||||
["Reload", "refreshAgent"],
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AgentPermissionRequest } from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
import { StoredScheduleSchema } from "@getpaseo/protocol/schedule/types";
|
||||
import type { AgentProvider } from "./agent-sdk-types.js";
|
||||
@@ -202,27 +202,36 @@ export function sanitizePermissionRequest(
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export async function resolveAgentTitle(
|
||||
async function resolveAgentStorageRecord(
|
||||
agentStorage: AgentStorage,
|
||||
agentId: string,
|
||||
logger: Logger,
|
||||
): Promise<string | null> {
|
||||
): Promise<StoredAgentRecord | null> {
|
||||
try {
|
||||
const record = await agentStorage.get(agentId);
|
||||
return record?.title ?? null;
|
||||
return (await agentStorage.get(agentId)) ?? null;
|
||||
} catch (error) {
|
||||
logger.error({ err: error, agentId }, "Failed to load agent title");
|
||||
logger.error({ err: error, agentId }, "Failed to load agent record");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Storage owns the archive timestamp, and a live snapshot cannot carry it: an
|
||||
// archived agent is resumed back into memory to serve a history read (see the
|
||||
// `purpose: "history"` resume in agent-loading), so presence in the manager is
|
||||
// not evidence that the agent is active. Merge the stored record here, the same
|
||||
// way the WebSocket path does in Session.enrichAgentPayload, rather than
|
||||
// copying the value onto the managed agent, where it would be a second source
|
||||
// of truth that unarchive has to remember to clear.
|
||||
export async function serializeSnapshotWithMetadata(
|
||||
agentStorage: AgentStorage,
|
||||
snapshot: ManagedAgent,
|
||||
logger: Logger,
|
||||
) {
|
||||
const title = await resolveAgentTitle(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, { title });
|
||||
const record = await resolveAgentStorageRecord(agentStorage, snapshot.id, logger);
|
||||
return serializeAgentSnapshot(snapshot, {
|
||||
title: record?.title ?? null,
|
||||
archivedAt: record?.archivedAt ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDurationString(input: string): number {
|
||||
|
||||
@@ -16,7 +16,7 @@ function validateStreamEventPayload(payload: unknown): AgentStreamEventPayload |
|
||||
|
||||
export function serializeAgentSnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null },
|
||||
options?: { title?: string | null; archivedAt?: string | null },
|
||||
): AgentSnapshotPayload {
|
||||
return toAgentPayload(agent, options);
|
||||
}
|
||||
|
||||
@@ -146,10 +146,12 @@ export default function contribute(server) {
|
||||
{ hook: "agent.permission_resolved", event: { resolution: { behavior: "deny" } } },
|
||||
{
|
||||
hook: "agent.turn_ended",
|
||||
event: { outcome: { kind: "completed" }, timeline: expect.any(Array) },
|
||||
event: { outcome: { kind: "completed" }, timeline: [] },
|
||||
},
|
||||
{ hook: "agent.turn_started", event: { agent: { id: agent.id } } },
|
||||
]);
|
||||
const page = await client.fetchAgentTimeline(agent.id, { direction: "tail", limit: 20 });
|
||||
expect(page.entries.length).toBeGreaterThan(0);
|
||||
await client.archiveAgent(agent.id);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentStreamEvent, AgentTimelineItem } from "../../agent/agent-sdk-types.js";
|
||||
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
|
||||
import { z } from "zod";
|
||||
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
|
||||
import type {
|
||||
@@ -94,7 +94,6 @@ export function publishAgentStream(
|
||||
lifecycle: PluginLifecycle,
|
||||
agent: PluginHookAgent,
|
||||
event: AgentStreamEvent,
|
||||
timeline: readonly AgentTimelineItem[],
|
||||
): void {
|
||||
if (event.type === "turn_started") {
|
||||
lifecycle.emit("agent.turn_started", { agent, turnId: event.turnId ?? null });
|
||||
@@ -102,21 +101,21 @@ export function publishAgentStream(
|
||||
lifecycle.emit("agent.turn_ended", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline,
|
||||
timeline: [],
|
||||
outcome: { kind: "completed" },
|
||||
});
|
||||
} else if (event.type === "turn_failed") {
|
||||
lifecycle.emit("agent.turn_ended", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline,
|
||||
timeline: [],
|
||||
outcome: { kind: "failed", error: { message: event.error, code: event.code } },
|
||||
});
|
||||
} else if (event.type === "turn_canceled") {
|
||||
lifecycle.emit("agent.turn_ended", {
|
||||
agent,
|
||||
turnId: event.turnId ?? null,
|
||||
timeline,
|
||||
timeline: [],
|
||||
outcome: { kind: "canceled", reason: event.reason },
|
||||
});
|
||||
} else if (event.type === "permission_requested") {
|
||||
|
||||
@@ -21,9 +21,10 @@ export default function contribute(server: PluginServerContext) {
|
||||
if (event.outcome.kind === "canceled") {
|
||||
return;
|
||||
}
|
||||
const text = latestOutputText(event.timeline);
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
console.log("Sending a follow-up after out of credits", event.agent.id);
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
|
||||
@@ -654,9 +654,11 @@ export default function contribute(server: PluginServerContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = latestOutputText(event.timeline);
|
||||
const agent = context.paseo.agents.ref(event.agent.id);
|
||||
const page = await agent.timeline.refetch({ direction: "tail", limit: 20 });
|
||||
const text = latestOutputText(page.entries.map(({ item }) => item));
|
||||
if (/out of credits/i.test(text)) {
|
||||
await context.paseo.agents.ref(event.agent.id).send("Try again.");
|
||||
await agent.send("Try again.");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -670,6 +672,9 @@ Turn ends: "out of credits"
|
||||
→ a new turn starts
|
||||
```
|
||||
|
||||
The handler fetches only the last 20 timeline items. Output before that page is not inspected.
|
||||
Use `cursor` and `direction` to page further when your plugin needs more history.
|
||||
|
||||
This sends a new message with the existing SDK. Persistent matches keep sending follow-ups;
|
||||
add limits or delays in your plugin when needed. Attachments and tool effects are not replayed.
|
||||
|
||||
@@ -721,22 +726,26 @@ plans, and mode changes; requesting permission does not end the turn.
|
||||
|
||||
### Events
|
||||
|
||||
| Name | Event fields | Trigger |
|
||||
| ---------------------------- | ---------------------------------------- | -------------------------------------------------- |
|
||||
| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume |
|
||||
| `agent.closed` | `agent` | A live agent runtime closes |
|
||||
| `agent.turn_started` | `agent`, `turnId` | Live turn starts |
|
||||
| `agent.turn_ended` | `agent`, `turnId`, `outcome`, `timeline` | Live turn completes, fails, or is canceled |
|
||||
| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending |
|
||||
| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared |
|
||||
| `agent.archived` | `agent`, `archivedAt` | Archive state is saved |
|
||||
| `workspace.created` | `workspace` | Record created; directory available |
|
||||
| `workspace.archived` | `workspace` | Archive state is saved |
|
||||
| Name | Event fields | Trigger |
|
||||
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------- |
|
||||
| `agent.created` | `agent` | Ordinary creation finishes; excludes import/resume |
|
||||
| `agent.closed` | `agent` | A live agent runtime closes |
|
||||
| `agent.turn_started` | `agent`, `turnId` | Live turn starts |
|
||||
| `agent.turn_ended` | `agent`, `turnId`, `outcome`, deprecated `timeline: []` | Live turn completes, fails, or is canceled |
|
||||
| `agent.permission_requested` | `agent`, `request` | Permission or question becomes pending |
|
||||
| `agent.permission_resolved` | `agent`, `requestId`, `resolution` | Pending request is answered or cleared |
|
||||
| `agent.archived` | `agent`, `archivedAt` | Archive state is saved |
|
||||
| `workspace.created` | `workspace` | Record created; directory available |
|
||||
| `workspace.archived` | `workspace` | Archive state is saved |
|
||||
|
||||
Agent events exclude the daemon's private disposable helpers. Public background workspaces and
|
||||
their ordinary agents run the same lifecycle hooks as other workspaces. Archive events can precede
|
||||
runtime/worktree cleanup; observe lifecycle completion separately when cleanup matters.
|
||||
|
||||
`agent.turn_ended.timeline` is deprecated and always empty. Fetch history on demand with
|
||||
`context.paseo.agents.ref(event.agent.id).timeline.refetch({ direction: "tail", limit: 20 })`.
|
||||
The hook payload size does not grow with conversation history.
|
||||
|
||||
**Shared payload shapes** (`@getpaseo/plugin/server`):
|
||||
|
||||
```ts
|
||||
|
||||
Reference in New Issue
Block a user