Files
paseo/packages/protocol/src/messages.wire-compat.test.ts
T
Mohamed Boudra 184ab66cde Show sub-agents as launch markers in the parent timeline (#8276)
* fix: make sub-agent cards launch markers

Sub-agent tool calls no longer accumulate the child's activity into
`log`. Claude, Codex, OpenCode, OMP and Pi emit the card when the child
starts and when its label or status changes; the child's messages and
tool calls stay in the sub-agent's own timeline. The app no longer
renders a sub-agent log.

The wire schema makes `log` optional. Daemons still send `log: ""` so
apps that require the field keep parsing the card.

* fix(server): put a tintinweb foreground answer in the child timeline

A foreground @tintinweb/pi-subagents child has no session file, so its
answer only exists in the Agent tool result. Emit that text as the
child's assistant message when the child finishes. Tag every sub_agent
producer's `log: ""` with COMPAT(subagent-log) so the shim removal
finds them.

* fix(server): settle child compactions and keep sub-agent cards stable

- Codex: a child compaction shown as loading now completes on the
  child's thread/compacted or turn end, once.
- Claude: a streamed child tool call's empty start input is a
  placeholder, so the assistant message's input reaches its card.
- OMP: the task card follows only its first child, so later children
  no longer re-send an unchanged card.

* fix(server): keep a gotgenes foreground answer on replay

A foreground @gotgenes/pi-subagents child finds its session file only
through the live runtime marker, which history does not keep. Without a
file, emit the spawn result text as the child's assistant message.
2026-10-30 21:26:38 +07:00

591 lines
18 KiB
TypeScript

import { describe, expect, test } from "vitest";
import { z } from "zod";
import {
MutableDaemonConfigSchema,
MutableDaemonConfigPatchSchema,
UsageReportEntrySchema,
AgentSnapshotPayloadSchema,
AgentTimelineItemPayloadSchema,
ServerInfoStatusPayloadSchema,
SessionOutboundMessageSchema,
WSHelloMessageSchema,
WorkspaceSetupSnapshotSchema,
WorkspaceSetupProgressMessageSchema,
AgentTimelineEntryPayloadSchema,
} from "./messages.js";
test("terminal listings accept older rows and retain new per-terminal directories", () => {
const response = {
type: "list_terminals_response",
payload: {
requestId: "terminal-list",
cwd: "/workspace",
terminals: [{ id: "terminal", name: "Shell", workspaceId: "workspace" }],
},
};
expect(SessionOutboundMessageSchema.parse(response)).toEqual(response);
const withDirectory = {
...response,
payload: {
...response.payload,
terminals: [{ ...response.payload.terminals[0], cwd: "/workspace/subdirectory" }],
},
};
expect(SessionOutboundMessageSchema.parse(withDirectory)).toEqual(withDirectory);
});
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("a sub-agent launch marker still parses against the v0.1.65-beta.3 schema", () => {
const card = {
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: "",
actions: [],
},
} as const;
const parsed = LegacySubAgentToolCallSchema.parse(JSON.parse(JSON.stringify(card)));
expect(parsed.detail).toMatchObject({ log: "", actions: [] });
const { log: _log, ...withoutLog } = card.detail;
expect(LegacySubAgentToolCallSchema.safeParse({ ...card, detail: withoutLog }).success).toBe(
false,
);
expect(AgentTimelineItemPayloadSchema.parse({ ...card, detail: withoutLog })).toMatchObject({
detail: { type: "sub_agent", description: "Inspect repository structure" },
});
expect(
AgentTimelineItemPayloadSchema.parse({
...card,
detail: { ...card.detail, log: "[Read] README.md" },
}),
).toMatchObject({ detail: { log: "[Read] README.md" } });
});
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",
});
});
});
test("0.8 timeline and setup capabilities remain optional in the hello", () => {
const hello = { type: "hello", clientId: "compat", clientType: "mobile", protocolVersion: 1 };
expect(WSHelloMessageSchema.safeParse(hello).success).toBe(true);
expect(
WSHelloMessageSchema.parse({
...hello,
capabilities: { plugin_timeline_items: true, workspace_setup_blocked: true },
}).capabilities,
).toEqual({ plugin_timeline_items: true, workspace_setup_blocked: true });
});
test("plugin rows and identity merges require a capable receiver", () => {
const item = {
type: "plugin",
id: "task",
pluginId: "tasks",
kind: "tasks",
version: 1,
data: { text: "Working" },
};
expect(AgentTimelineItemPayloadSchema.parse(item)).toEqual(item);
const legacyItems = z.object({
type: z.enum([
"user_message",
"assistant_message",
"reasoning",
"tool_call",
"todo",
"error",
"notification",
"compaction",
]),
});
expect(legacyItems.safeParse(item).success).toBe(false);
const entry = {
provider: "codex",
item,
timestamp: "2026-09-07T00:00:00.000Z",
seqStart: 1,
seqEnd: 2,
sourceSeqRanges: [{ startSeq: 1, endSeq: 2 }],
collapsed: ["identity"],
};
expect(AgentTimelineEntryPayloadSchema.parse(entry)).toEqual(entry);
const legacyCollapsed = z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"]));
expect(legacyCollapsed.safeParse(entry.collapsed).success).toBe(false);
});
test("blocked setup preserves the legacy failed shape and optional provenance", () => {
const snapshot = {
status: "blocked",
error: null,
detail: {
type: "worktree_setup",
worktreePath: "/workspace",
branchName: "fork",
log: "",
commands: [],
},
blockedSource: {
kind: "change_request",
forge: "github",
number: 42,
headRepository: "contributor/project",
},
};
expect(WorkspaceSetupSnapshotSchema.parse(snapshot)).toEqual(snapshot);
const legacyStatus = z.enum(["running", "completed", "failed"]);
const legacySnapshot = WorkspaceSetupSnapshotSchema.omit({ blockedSource: true }).extend({
status: legacyStatus,
});
expect(legacySnapshot.safeParse(snapshot).success).toBe(false);
const failed = { ...snapshot, status: "failed", error: "Update Paseo to review and run setup." };
expect(legacySnapshot.safeParse(failed).success).toBe(true);
const progress = {
type: "workspace_setup_progress",
payload: { ...failed, workspaceId: "workspace" },
};
expect(WorkspaceSetupProgressMessageSchema.parse(progress)).toEqual(progress);
expect(WorkspaceSetupSnapshotSchema.parse(legacySnapshot.parse(failed))).toEqual(
legacySnapshot.parse(failed),
);
});
test("usage login errors are additive and older reports still parse", () => {
const entry = {
id: "codex:account",
account: {},
fetchedAt: "2026-10-05T00:00:00.000Z",
sourceId: "codex",
sourceLabel: "Codex",
report: { status: "error", error: "Usage API returned 500" },
};
const legacy = z.object({
type: z.literal("usage.list_reports.update"),
payload: z.object({
requestId: z.string(),
report: z.object({
id: z.string(),
account: z.object({ label: z.string().optional() }),
fetchedAt: z.string(),
sourceId: z.string(),
sourceLabel: z.string(),
icon: z.string().optional(),
report: z.discriminatedUnion("status", [
z.object({ status: z.literal("available"), windows: z.array(z.unknown()) }),
z.object({ status: z.literal("unavailable"), problem: z.unknown() }),
z.object({ status: z.literal("error"), error: z.string() }),
]),
}),
}),
});
const oldMessage = {
type: "usage.list_reports.update",
payload: { requestId: "usage", report: entry },
};
const newMessage = {
...oldMessage,
payload: {
...oldMessage.payload,
report: {
...entry,
loginErrors: [{ harness: "Codex", report: entry.report }],
},
},
};
expect(SessionOutboundMessageSchema.parse(oldMessage)).toEqual(oldMessage);
expect(SessionOutboundMessageSchema.parse(newMessage)).toEqual(newMessage);
expect(legacy.parse(newMessage)).toEqual(oldMessage);
});
test("usage entries accept older failures and additive successful snapshots", () => {
const oldEntry = {
id: "source:account",
sourceId: "source",
sourceLabel: "Source",
account: {},
fetchedAt: "2026-10-17T10:06:00Z",
report: { status: "error", error: "Rate limited" },
};
expect(UsageReportEntrySchema.parse(oldEntry)).toEqual(oldEntry);
const retained = {
...oldEntry,
lastSuccessful: {
fetchedAt: "2026-10-17T10:00:00Z",
report: { status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 42 }] },
},
};
expect(UsageReportEntrySchema.parse(retained)).toEqual(retained);
const available = {
...oldEntry,
report: retained.lastSuccessful.report,
successfulAt: "2026-10-17T10:00:15Z",
};
expect(UsageReportEntrySchema.parse(available)).toEqual(available);
expect(
UsageReportEntrySchema.omit({ lastSuccessful: true, successfulAt: true }).parse(available),
).toEqual({ ...oldEntry, report: available.report });
// An older client's entry shape strips the additive snapshot and still sees the failure.
expect(UsageReportEntrySchema.omit({ lastSuccessful: true }).parse(retained)).toEqual(oldEntry);
expect(
UsageReportEntrySchema.safeParse({
...oldEntry,
lastSuccessful: { fetchedAt: oldEntry.fetchedAt, report: oldEntry.report },
}).success,
).toBe(false);
});
// Frozen setup detail shape from v0.8.0: log is required, command output is optional.
const ReleasedSetupDetailSchema = z.object({
type: z.literal("worktree_setup"),
worktreePath: z.string(),
branchName: z.string(),
log: z.string(),
commands: z.array(
z.object({
index: z.number().int().positive(),
command: z.string(),
cwd: z.string(),
log: z.string().optional().default(""),
status: z.enum(["running", "completed", "failed"]),
exitCode: z.number().nullable(),
durationMs: z.number().nonnegative().optional(),
}),
),
truncated: z.boolean().optional(),
});
test("released setup clients parse empty combined logs and retain command output", () => {
const detail = {
type: "worktree_setup",
worktreePath: "/workspace",
branchName: "feature",
log: "",
commands: [
{
index: 1,
command: "echo ready",
cwd: "/workspace",
log: "ready\n",
status: "completed",
exitCode: 0,
},
],
};
const parsed = ReleasedSetupDetailSchema.parse(JSON.parse(JSON.stringify(detail)));
expect(parsed).toEqual(detail);
expect(parsed.commands[0]?.log).toBe("ready\n");
const { log: _log, ...withoutLegacyField } = detail;
expect(ReleasedSetupDetailSchema.safeParse(withoutLegacyField).success).toBe(false);
expect(
WorkspaceSetupSnapshotSchema.parse({
status: "completed",
detail: { ...detail, log: "old daemon combined output" },
error: null,
}).detail.log,
).toBe("old daemon combined output");
});
test("local STT language survives wire reads and patches", () => {
const patch = {
features: {
dictation: { stt: { provider: "local", language: "fr" } },
voiceMode: { stt: { provider: "local", language: null } },
},
};
expect(MutableDaemonConfigPatchSchema.parse(patch)).toEqual(patch);
});
test("speech config reads remain compatible with clients before features and future providers", () => {
const oldSchema = MutableDaemonConfigSchema.omit({ features: true, envOverrides: true });
const oldRead = oldSchema.parse({
providers: {},
relay: { enabled: false },
mcp: { enabled: true, injectIntoAgents: true },
});
const read = {
...oldRead,
features: {
dictation: { enabled: true, stt: { provider: "local", language: "fr" } },
voiceMode: {
enabled: false,
stt: { provider: "future", model: "future-model", region: "eu" },
tts: { provider: "openai", hasApiKey: false },
},
},
envOverrides: { "features.voiceMode.stt.provider": "PASEO_VOICE_STT_PROVIDER" },
};
expect(oldSchema.parse(read)).toEqual(read);
expect(MutableDaemonConfigSchema.parse(oldRead)).toEqual(oldRead);
expect(MutableDaemonConfigSchema.parse(read).features?.dictation.stt).toEqual(
read.features.dictation.stt,
);
expect(MutableDaemonConfigSchema.parse(read).features?.voiceMode.stt).toEqual(
read.features.voiceMode.stt,
);
const oldPatchSchema = MutableDaemonConfigPatchSchema.omit({ features: true });
const oldPatch = oldPatchSchema.parse({ relay: { enabled: true } });
expect(MutableDaemonConfigPatchSchema.parse(oldPatch)).toEqual(oldPatch);
expect(
MutableDaemonConfigPatchSchema.parse({
features: { voiceMode: { stt: { provider: "future", region: "eu" } } },
}).features?.voiceMode?.stt,
).toEqual({ provider: "future", region: "eu" });
});