Fork assistant turns into new drafts (#1788)

* feat(chat): fork assistant turns into new drafts

Builds chat-history attachments on the daemon and routes them into draft composers so users can continue an assistant turn in an existing workspace tab or New Workspace.

* fix(chat): clean up fork draft handling

* fix(chat): address fork draft review feedback

* fix(server): build fork context from bounded timeline

* fix(app): restore assistant turn footer in streams

* fix(app): preserve fork draft setup

* fix(app): tighten assistant fork footers

* fix(app): lock active fork draft handoff

* fix(app): preserve attachment-only draft retries

* fix(app): unify composer attachment scopes

* fix(app): align chat history attachment labels

* fix(app): centralize attachment pill content

* fix(app): trim fork context for workspace naming
This commit is contained in:
Mohamed Boudra
2026-06-29 18:29:59 +02:00
committed by GitHub
parent e63a971968
commit 85acaceb16
54 changed files with 2364 additions and 312 deletions
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { curateAgentActivity } from "./activity-curator.js";
import { buildAgentForkContextAttachment, curateAgentActivity } from "./activity-curator.js";
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
function toolCallItem(params: {
callId: string;
@@ -29,6 +30,14 @@ function toolCallItem(params: {
};
}
function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
return {
seq,
timestamp: `2026-06-28T00:00:${String(seq).padStart(2, "0")}.000Z`,
item,
};
}
describe("curateAgentActivity", () => {
it("renders user/assistant/reasoning entries", () => {
const timeline: AgentTimelineItem[] = [
@@ -217,4 +226,140 @@ second line'`,
it("returns a default message when timeline is empty", () => {
expect(curateAgentActivity([])).toBe("No activity to display.");
});
it("builds fork context from user messages, assistant messages, and tool summaries", () => {
const result = buildAgentForkContextAttachment({
agentTitle: "Source Agent",
cwd: "/repo",
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Ship the thing", messageId: "user-1" }),
row(2, { type: "reasoning", text: "private chain of thought" }),
row(
3,
toolCallItem({
callId: "read-1",
name: "read_file",
detail: {
type: "read",
filePath: "src/index.ts",
content: "console.log('hi')",
},
}),
),
row(
4,
toolCallItem({
callId: "external-1",
name: "paseo__create_agent",
input: { initialPrompt: "do not include raw external tool input" },
}),
),
row(5, {
type: "assistant_message",
text: "Done.",
messageId: "assistant-1",
}),
row(6, {
type: "assistant_message",
text: "Later answer.",
messageId: "assistant-2",
}),
],
});
expect(result.boundaryMessageId).toBe("assistant-1");
expect(result.attachment).toMatchObject({
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
});
expect(result.attachment.text).toContain("Source agent: Source Agent");
expect(result.attachment.text).toContain("Source directory: /repo");
expect(result.attachment.text).toContain("[User] Ship the thing");
expect(result.attachment.text).toContain("[Read] src/index.ts");
expect(result.attachment.text).toContain("[paseo__create_agent]");
expect(result.attachment.text).toContain("[Assistant] Done.");
expect(result.attachment.text).not.toContain("private chain of thought");
expect(result.attachment.text).not.toContain("do not include raw external tool input");
expect(result.attachment.text).not.toContain("Later answer.");
});
it("does not cap fork context to the generic recent activity limit", () => {
const messageRows = Array.from({ length: 25 }, (_, index) =>
row(index + 1, {
type: "user_message",
text: `Message ${index + 1}`,
messageId: `user-${index + 1}`,
}),
);
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
...messageRows,
row(26, {
type: "assistant_message",
text: "Done.",
messageId: "assistant-1",
}),
],
});
expect(result.itemCount).toBe(26);
expect(result.attachment.text).toContain("[User] Message 1");
expect(result.attachment.text).toContain("[User] Message 25");
expect(result.attachment.text).toContain("[Assistant] Done.");
});
it("selects the fork boundary before collapsing later tool updates", () => {
const result = buildAgentForkContextAttachment({
boundaryMessageId: "assistant-1",
rows: [
row(1, { type: "user_message", text: "Run it", messageId: "user-1" }),
row(
2,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "running",
detail: {
type: "plain_text",
label: "before boundary",
},
}),
),
row(3, {
type: "assistant_message",
text: "Partial result.",
messageId: "assistant-1",
}),
row(
4,
toolCallItem({
callId: "terminal-1",
name: "terminal",
status: "completed",
detail: {
type: "plain_text",
label: "after boundary",
},
}),
),
],
});
expect(result.attachment.text).toContain("[Terminal] before boundary");
expect(result.attachment.text).toContain("[Assistant] Partial result.");
expect(result.attachment.text).not.toContain("after boundary");
});
it("rejects missing assistant boundaries instead of silently using the wrong context", () => {
expect(() =>
buildAgentForkContextAttachment({
boundaryMessageId: "missing",
rows: [row(1, { type: "assistant_message", text: "Done.", messageId: "assistant-1" })],
}),
).toThrow("Selected assistant message is no longer available.");
});
});
@@ -1,4 +1,6 @@
import type { AgentTimelineItem } from "./agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { AgentTimelineRow } from "./agent-timeline-store-types.js";
import { isLikelyExternalToolName } from "@getpaseo/protocol/tool-name-normalization";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import { projectTimelineRows } from "./timeline-projection.js";
@@ -10,12 +12,16 @@ const MAX_TOOL_SUMMARY_CHARS = 200;
interface ActivityCuratorOptions {
maxItems?: number;
labelAssistantMessages?: boolean;
includeKinds?: readonly AgentTimelineItem["type"][];
includeExternalToolInput?: boolean;
}
interface ActivityEntry {
text: string;
}
type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>;
function appendText(buffer: string, text: string): string {
const normalized = text.trim();
if (!normalized) {
@@ -95,24 +101,56 @@ function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineI
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
}
function curateAgentActivityEntries(
timeline: AgentTimelineItem[],
function shouldIncludeItem(item: AgentTimelineItem, options?: ActivityCuratorOptions): boolean {
if (!options?.includeKinds) {
return true;
}
return options.includeKinds.includes(item.type);
}
function formatToolCallEntry(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
options?: ActivityCuratorOptions,
): ActivityEntry {
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
const display = buildToolCallDisplayModel({
name: item.name,
status: item.status,
error: item.error,
detail: item.detail,
metadata: item.metadata,
});
const displayName = display.displayName;
const summary = formatToolSummary(display.summary);
if (
(options?.includeExternalToolInput ?? true) &&
isLikelyExternalToolName(item.name) &&
inputJson
) {
return activityEntry(`[${displayName}] ${inputJson}`);
}
return activityEntry(summary ? `[${displayName}] ${summary}` : `[${displayName}]`);
}
function curateProjectedActivityEntries(
items: readonly AgentTimelineItem[],
options?: ActivityCuratorOptions,
): ActivityEntry[] {
if (timeline.length === 0) {
if (items.length === 0) {
return [];
}
const collapsed = projectForCuration(timeline);
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
const recentItems =
maxItems > 0 && collapsed.length > maxItems ? collapsed.slice(-maxItems) : collapsed;
const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items;
const entries: ActivityEntry[] = [];
const buffers = { message: "", thought: "" };
for (const item of recentItems) {
if (!shouldIncludeItem(item, options)) {
continue;
}
switch (item.type) {
case "user_message":
flushBuffers(entries, buffers, options);
@@ -126,25 +164,7 @@ function curateAgentActivityEntries(
break;
case "tool_call": {
flushBuffers(entries, buffers, options);
const inputJson = formatToolInputJson(inputFromUnknownDetail(item.detail));
const display = buildToolCallDisplayModel({
name: item.name,
status: item.status,
error: item.error,
detail: item.detail,
metadata: item.metadata,
});
const displayName = display.displayName;
const summary = formatToolSummary(display.summary);
if (isLikelyExternalToolName(item.name) && inputJson) {
entries.push(activityEntry(`[${displayName}] ${inputJson}`));
break;
}
if (summary) {
entries.push(activityEntry(`[${displayName}] ${summary}`));
} else {
entries.push(activityEntry(`[${displayName}]`));
}
entries.push(formatToolCallEntry(item, options));
break;
}
case "todo":
@@ -172,6 +192,14 @@ function curateAgentActivityEntries(
return entries;
}
function curateAgentActivityEntries(
timeline: AgentTimelineItem[],
options?: ActivityCuratorOptions,
): ActivityEntry[] {
const collapsed = projectForCuration(timeline);
return curateProjectedActivityEntries(collapsed, options);
}
/**
* Convert normalized agent timeline items into a concise text summary.
*/
@@ -184,3 +212,90 @@ export function curateAgentActivity(
? entries.map((entry) => entry.text).join("\n")
: "No activity to display.";
}
function selectForkContextRows(input: {
rows: readonly AgentTimelineRow[];
boundaryMessageId?: string | null;
}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } {
const boundaryMessageId = input.boundaryMessageId?.trim() || null;
if (!boundaryMessageId) {
const projected = projectTimelineRows({ rows: input.rows, mode: "projected" });
return {
items: projected.map((entry) => entry.item),
boundaryMessageId: null,
};
}
const boundaryIndex = input.rows.findLastIndex(
(row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId,
);
if (boundaryIndex < 0) {
throw new Error("Selected assistant message is no longer available.");
}
const selectedRows = input.rows.slice(0, boundaryIndex + 1);
const projected = projectTimelineRows({ rows: selectedRows, mode: "projected" });
return {
items: projected.map((entry) => entry.item),
boundaryMessageId,
};
}
function trimContextMetadata(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
function buildForkContextText(input: {
body: string;
agentTitle?: string | null;
cwd?: string | null;
}): string {
const header = ["Chat history from a previous Paseo agent."];
const agentTitle = trimContextMetadata(input.agentTitle);
const cwd = trimContextMetadata(input.cwd);
if (agentTitle) {
header.push(`Source agent: ${agentTitle}`);
}
if (cwd) {
header.push(`Source directory: ${cwd}`);
}
return `${header.join("\n")}\n\n${input.body}`;
}
export function buildAgentForkContextAttachment(input: {
rows: readonly AgentTimelineRow[];
boundaryMessageId?: string | null;
agentTitle?: string | null;
cwd?: string | null;
}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } {
const selected = selectForkContextRows({
rows: input.rows,
boundaryMessageId: input.boundaryMessageId,
});
const entries = curateProjectedActivityEntries(selected.items, {
maxItems: 0,
labelAssistantMessages: true,
includeKinds: ["user_message", "assistant_message", "tool_call"],
includeExternalToolInput: false,
});
const body =
entries.length > 0
? entries.map((entry) => entry.text).join("\n")
: "No chat history to display.";
return {
attachment: {
type: "text",
mimeType: "text/plain",
contextKind: "chat_history",
title: "Chat history",
text: buildForkContextText({
body,
agentTitle: input.agentTitle,
cwd: input.cwd,
}),
},
itemCount: selected.items.length,
boundaryMessageId: selected.boundaryMessageId,
};
}
@@ -97,6 +97,7 @@ const MODELS: AgentModelDefinition[] = [
interface ActiveTurn {
turnId: string;
assistantMessageId: string;
prompt: AgentPromptInput;
startedAt: number;
cycle: number;
@@ -609,12 +610,14 @@ export class MockLoadTestAgentSession implements AgentSession {
const profile = resolveModelProfile(this.modelId);
const turnId = randomUUID();
const assistantMessageId = randomUUID();
let resolve!: (result: AgentRunResult) => void;
const completed = new Promise<AgentRunResult>((promiseResolve) => {
resolve = promiseResolve;
});
const turn: ActiveTurn = {
turnId,
assistantMessageId,
prompt,
startedAt: Date.now(),
cycle: 0,
@@ -860,6 +863,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: finalText,
messageId: turn.assistantMessageId,
});
this.activeTurn = null;
this.emit({
@@ -874,6 +878,7 @@ export class MockLoadTestAgentSession implements AgentSession {
{
type: "assistant_message",
text: finalText,
messageId: turn.assistantMessageId,
},
],
canceled: false,
@@ -989,6 +994,7 @@ export class MockLoadTestAgentSession implements AgentSession {
? {
type: "assistant_message",
text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
}
: {
type: "todo",
@@ -1067,6 +1073,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: `data:image/png;base64,${payload}`,
messageId: turn.assistantMessageId,
});
}
@@ -1133,6 +1140,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: event.text,
messageId: turn.assistantMessageId,
});
return;
}
@@ -1178,6 +1186,7 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: "\n\n_(end of synthetic stream)_\n",
messageId: turn.assistantMessageId,
});
this.finishTurnWithText(turn, "Synthetic load test complete");
}
+64 -2
View File
@@ -99,6 +99,7 @@ import {
type TimelineProjectionEntry,
type TimelineProjectionMode,
} from "./agent/timeline-projection.js";
import { buildAgentForkContextAttachment } from "./agent/activity-curator.js";
import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js";
import {
getAgentStreamEventTurnId,
@@ -1371,6 +1372,7 @@ export class Session {
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentTimelineMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1450,6 +1452,17 @@ export class Session {
}
}
private dispatchAgentTimelineMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
return undefined;
}
}
private dispatchAgentLifecycleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "fetch_agents_request":
@@ -1484,8 +1497,6 @@ export class Session {
return this.handleRefreshAgentRequest(msg);
case "cancel_agent_request":
return this.handleCancelAgentRequest(msg.agentId, msg.requestId);
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent_permission_response":
return this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response);
case "clear_agent_attention":
@@ -5355,6 +5366,57 @@ export class Session {
}
}
private async handleAgentForkContextRequest(
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
): Promise<void> {
try {
const snapshot = await ensureAgentLoaded(msg.agentId, {
agentManager: this.agentManager,
agentStorage: this.agentStorage,
logger: this.sessionLogger,
});
const agentPayload = await this.buildAgentPayload(snapshot);
const rows = this.agentManager.fetchTimeline(msg.agentId, {
direction: "tail",
limit: 0,
}).rows;
const forkContext = buildAgentForkContextAttachment({
rows,
boundaryMessageId: msg.boundaryMessageId,
agentTitle: agentPayload.title,
cwd: snapshot.cwd,
});
this.emit({
type: "agent.fork_context.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
attachment: forkContext.attachment,
itemCount: forkContext.itemCount,
boundaryMessageId: forkContext.boundaryMessageId,
error: null,
},
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId: msg.agentId },
"Failed to handle agent.fork_context.request",
);
this.emit({
type: "agent.fork_context.response",
payload: {
requestId: msg.requestId,
agentId: msg.agentId,
attachment: null,
itemCount: 0,
boundaryMessageId: msg.boundaryMessageId ?? null,
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleSendAgentMessageRequest(
msg: Extract<SessionInboundMessage, { type: "send_agent_message_request" }>,
): Promise<void> {
@@ -1200,6 +1200,8 @@ export class VoiceAssistantWebSocketServer {
daemonDiagnostics: true,
// COMPAT(daemonSelfUpdate): added in v0.1.93, remove gate after 2026-12-13.
daemonSelfUpdate: true,
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true,
},
};
}