Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 300e6f9282 test(app): retain large Markdown restore diagnostic 2026-10-21 05:29:31 +02:00
23b05bbcc5 Keep archived agents out of discovery after history reads (#7532)
* fix(server): report archived agents correctly over MCP

An archived agent is resumed back into memory whenever something reads its history:
agent-loading passes `{ purpose: "history" }` to resumeAgentFromPersistence exactly when
`record.archivedAt` is set. `list_agents` then sees it as a live ManagedAgent, which
carries no archive timestamp, so AgentDirectory's archive predicate passes it and the
stored record's timestamp never reaches the result:

- `list_agents` returns the agent with `archivedAt: null`, because
  serializeSnapshotWithMetadata merges only the title from storage. A caller reconciling
  ownership from that list concludes an archived agent is still running.
- a default call (`includeArchived: false`) returns it anyway.

Storage stays the single owner of the timestamp. The MCP boundary merges the stored
record the same way the WebSocket path does in Session.enrichAgentPayload, reusing the
storage read it already makes for the title, and `archivedAt` becomes a projection option
beside `title`. `list_agents` filters archives after projection, where the timestamp is
known.

Copying the timestamp onto the managed agent was the other option and is worse:
unarchiveSnapshot clears only the stored record, so a managed copy would report an
unarchived agent as archived until it was reloaded.

The wire change is additive: `archivedAt` is already nullable and optional in the
protocol, and already populated on the stored path.

Fixes #4753

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): keep archived agents out of discovery after history reads

Apply archive filtering after stored metadata is projected in the shared directory. Exercise archive, history hydration, MCP discovery, WebSocket discovery and unarchive through a real daemon instead of mocked manager snapshots.

Co-authored-by: Thomas Van <minhthong@gmail.com>

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 04:54:43 +02:00
paseo-bot[bot]andThomas Van 1c62748084 Notify callers when detached agents finish (#7528)
* fix(server): notify the caller when a detached child finishes

`create_agent` with a detached relationship and `notifyOnFinish` arms a finish watcher
with `requireParentOwnership: true`. At fire time that guard checks that the child is
still labelled as the caller's, but resolveCreateAgentIntent strips the parent label from
a detached child on purpose, so the check can never pass and the caller never hears the
child it created finish.

The create path now requires parent ownership only for a child created with a parent.
The guard itself is unchanged: a parented child whose label later points elsewhere stays
silent, as agent-prompt.test.ts ("detaching a child ends its parent-owned finish
notification") requires. Only the create call knows the caller asked for a detached
child, which is why the fix lives there.

Tests drive the real finish watcher from createAgentCommand, with the finish published
as the manager publishes it: a detached child notifies its caller once, and a parented
child moved to another parent does not. Requiring ownership always fails the first;
never requiring it fails the second.

Fixes #6908

* test(server): verify detached completion through agent-scoped MCP

Exercise creation, stored parent labels, provider completion, and caller delivery
with the real daemon and injected provider clients. Cover detached and parented
creation and suppression after reparenting without replacing manager methods.

Co-authored-by: Thomas Van <minhthong@gmail.com>

---------

Co-authored-by: Thomas Van <minhthong@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-21 03:54:02 +02:00
Mohamed Boudra 068f272a00 perf(plugins): keep turn-ended payloads independent of history (#7477)
Stop materializing and copying the entire conversation through lifecycle hooks. Keep the deprecated timeline field empty and direct plugins to bounded timeline reads.
2026-10-20 16:26:01 +07:00
16 changed files with 423 additions and 36 deletions
@@ -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
+4
View File
@@ -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"],
+17 -8
View File
@@ -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 {
+1 -1
View File
@@ -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.");
}
+22 -13
View File
@@ -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