mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
* feat(pi): surface extension notify in the agent timeline Pi extension ctx.ui.notify() messages were dropped or buffered away and never appeared anywhere in Paseo. Emit them as notification timeline items so they show in the agent chat feed, with info/warning/error levels mapped to activity log styling. * fix id * feat(agents): unify timeline notifications Represent Pi and OpenCode notices as one first-class timeline item instead of provider-specific synthetic tool calls. Gate the new wire variant so older strict clients keep working during version drift. * test(omp): update notice expectations --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
285 lines
7.9 KiB
TypeScript
285 lines
7.9 KiB
TypeScript
import { describe, expect, test } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
AgentSnapshotPayloadSchema,
|
|
AgentTimelineItemPayloadSchema,
|
|
ServerInfoStatusPayloadSchema,
|
|
SessionOutboundMessageSchema,
|
|
WSHelloMessageSchema,
|
|
} from "./messages.js";
|
|
|
|
const LegacySubAgentToolCallSchema = z.object({
|
|
type: z.literal("tool_call"),
|
|
callId: z.string(),
|
|
name: z.string(),
|
|
status: z.enum(["running", "completed", "failed", "canceled"]),
|
|
error: z.unknown().nullable(),
|
|
detail: z.object({
|
|
type: z.literal("sub_agent"),
|
|
subAgentType: z.string().optional(),
|
|
description: z.string().optional(),
|
|
log: z.string(),
|
|
// Copied from v0.1.65-beta.3: actions was required even though the UI ignored it.
|
|
actions: z.array(
|
|
z.object({
|
|
index: z.number().int().positive(),
|
|
toolName: z.string(),
|
|
summary: z.string().optional(),
|
|
}),
|
|
),
|
|
}),
|
|
});
|
|
|
|
const LegacyAgentCapabilityFlagsSchema = z.object({
|
|
supportsStreaming: z.boolean(),
|
|
supportsSessionPersistence: z.boolean(),
|
|
supportsDynamicModes: z.boolean(),
|
|
supportsMcpServers: z.boolean(),
|
|
supportsReasoningStream: z.boolean(),
|
|
supportsToolInvocations: z.boolean(),
|
|
});
|
|
|
|
const LegacyAgentSnapshotPayloadSchema = AgentSnapshotPayloadSchema.extend({
|
|
capabilities: LegacyAgentCapabilityFlagsSchema,
|
|
});
|
|
|
|
describe("wire schema compatibility", () => {
|
|
test("hello parses with and without the project update capability", () => {
|
|
const legacy = WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "legacy-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
});
|
|
const capable = WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { project_updates: true },
|
|
});
|
|
|
|
expect([legacy, capable]).toEqual([
|
|
{
|
|
type: "hello",
|
|
clientId: "legacy-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
},
|
|
{
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { project_updates: true },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("timeline replacement invalidation is opt-in and carries no timeline rows", () => {
|
|
expect(
|
|
WSHelloMessageSchema.parse({
|
|
type: "hello",
|
|
clientId: "capable-client",
|
|
clientType: "mobile",
|
|
protocolVersion: 1,
|
|
capabilities: { timeline_replacement_invalidation: true },
|
|
}).capabilities,
|
|
).toEqual({ timeline_replacement_invalidation: true });
|
|
|
|
expect(
|
|
SessionOutboundMessageSchema.parse({
|
|
type: "agent.timeline.replacement",
|
|
payload: { agentId: "agent-1", epoch: "epoch-2" },
|
|
}),
|
|
).toEqual({
|
|
type: "agent.timeline.replacement",
|
|
payload: { agentId: "agent-1", epoch: "epoch-2" },
|
|
});
|
|
});
|
|
|
|
test("server info strips unknown legacy features while accepting former turn identity", () => {
|
|
const parsed = ServerInfoStatusPayloadSchema.parse({
|
|
status: "server_info",
|
|
serverId: "legacy-server",
|
|
features: {
|
|
workspaceGithubClone: true,
|
|
agentTurnIdentity: true,
|
|
},
|
|
});
|
|
|
|
expect(parsed).toEqual({
|
|
status: "server_info",
|
|
serverId: "legacy-server",
|
|
hostname: null,
|
|
version: null,
|
|
features: { agentTurnIdentity: true },
|
|
});
|
|
});
|
|
|
|
test("assistant timeline message ids are optional on the wire", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "assistant_message",
|
|
text: "old daemon shape",
|
|
}),
|
|
).toEqual({
|
|
type: "assistant_message",
|
|
text: "old daemon shape",
|
|
});
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "assistant_message",
|
|
text: "new daemon shape",
|
|
messageId: "msg-1",
|
|
}),
|
|
).toEqual({
|
|
type: "assistant_message",
|
|
text: "new daemon shape",
|
|
messageId: "msg-1",
|
|
});
|
|
});
|
|
|
|
test("task progress fields are optional on the wire", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "todo",
|
|
items: [{ text: "Legacy task", completed: false }],
|
|
}),
|
|
).toEqual({ type: "todo", items: [{ text: "Legacy task", completed: false }] });
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "todo",
|
|
items: [
|
|
{
|
|
id: "task-1",
|
|
text: "Current task",
|
|
activeForm: "Working on current task",
|
|
status: "in_progress",
|
|
completed: false,
|
|
},
|
|
],
|
|
}),
|
|
).toEqual({
|
|
type: "todo",
|
|
items: [
|
|
{
|
|
id: "task-1",
|
|
text: "Current task",
|
|
activeForm: "Working on current task",
|
|
status: "in_progress",
|
|
completed: false,
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("sub_agent tool-call payload still parses against the v0.1.65-beta.3 schema", () => {
|
|
const parsed = LegacySubAgentToolCallSchema.parse({
|
|
type: "tool_call",
|
|
callId: "call-sub-agent-1",
|
|
name: "Task",
|
|
status: "completed",
|
|
error: null,
|
|
detail: {
|
|
type: "sub_agent",
|
|
subAgentType: "Explore",
|
|
description: "Inspect repository structure",
|
|
childSessionId: "child-session-1",
|
|
log: "[Read] README.md",
|
|
actions: [],
|
|
},
|
|
});
|
|
|
|
expect(parsed.detail.actions).toEqual([]);
|
|
});
|
|
|
|
test("old clients parse agent snapshots with rewind capabilities", () => {
|
|
const parsed = LegacyAgentSnapshotPayloadSchema.parse({
|
|
id: "agent-1",
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-05-23T00:00:00.000Z",
|
|
updatedAt: "2026-05-23T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
supportsRewindConversation: true,
|
|
supportsRewindFiles: true,
|
|
supportsRewindBoth: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: null,
|
|
labels: {},
|
|
});
|
|
|
|
expect(parsed.capabilities).toEqual({
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
});
|
|
});
|
|
|
|
test("new clients parse agent snapshots without rewind capabilities", () => {
|
|
const parsed = AgentSnapshotPayloadSchema.parse({
|
|
id: "agent-1",
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
model: null,
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-05-23T00:00:00.000Z",
|
|
updatedAt: "2026-05-23T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: null,
|
|
labels: {},
|
|
});
|
|
|
|
expect(parsed.capabilities.supportsRewindConversation).toBe(false);
|
|
expect(parsed.capabilities.supportsRewindFiles).toBe(false);
|
|
expect(parsed.capabilities.supportsRewindBoth).toBe(false);
|
|
});
|
|
|
|
test("notification timeline items parse their level and message", () => {
|
|
expect(
|
|
AgentTimelineItemPayloadSchema.parse({
|
|
type: "notification",
|
|
level: "warning",
|
|
message: "Command blocked by user",
|
|
}),
|
|
).toEqual({
|
|
type: "notification",
|
|
level: "warning",
|
|
message: "Command blocked by user",
|
|
});
|
|
});
|
|
});
|