From 162c7ce127198b066bf73efada01fdf4846d314e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 14:40:37 +0200 Subject: [PATCH] fix(chat): allow failed turns to be forked Failed turns can end in Paseo-generated timeline items without provider message IDs. Anchor forks to canonical timeline positions while retaining legacy boundaries for compatibility. Place the fork summary before the new prompt and delimit it as structured chat history. --- docs/timeline-sync.md | 8 ++++ packages/app/e2e/assistant-fork-menu.spec.ts | 22 +++++++++ .../src/agent-stream/turn-boundary.test.ts | 24 +++++++++- .../app/src/agent-stream/turn-boundary.ts | 24 +++++++++- packages/app/src/agent-stream/turn-footer.tsx | 27 +++++++++-- packages/app/src/agent-stream/view.tsx | 25 +++++++--- packages/app/src/attachments/types.ts | 1 + packages/app/src/components/message.tsx | 13 ++---- .../timeline/session-stream-reducers.test.ts | 46 ++++++++++++++++++- .../src/timeline/session-stream-reducers.ts | 33 ++++++++++--- packages/app/src/types/stream.ts | 39 ++++++++++++++-- packages/client/src/daemon-client.ts | 2 + .../protocol/src/messages.attachments.test.ts | 43 +++++++++++++++++ packages/protocol/src/messages.ts | 4 ++ .../src/server/agent/activity-curator.test.ts | 23 ++++++++++ .../src/server/agent/activity-curator.ts | 44 ++++++++++++++---- .../src/server/agent/create-agent/create.ts | 32 +------------ .../server/agent/prompt-attachments.test.ts | 36 ++++++++++++++- .../src/server/agent/prompt-attachments.ts | 34 ++++++++++++++ .../providers/mock-load-test-agent.test.ts | 32 +++++++++++++ .../agent/providers/mock-load-test-agent.ts | 36 ++++++++++++++- packages/server/src/server/session.ts | 44 ++++-------------- .../server/src/server/websocket-server.ts | 2 + 23 files changed, 486 insertions(+), 108 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 86414257ac..020ee0ed4d 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -37,6 +37,14 @@ Initialization timeouts guard lack of catch-up progress, not the full multi-page The first load of an agent without a local cursor is different: it fetches a bounded latest tail page. Older history remains user-driven by scrolling upward. +## Durable item anchors + +Provider message IDs are not guaranteed for every displayed item. Paseo-generated system errors are one example. Rendered item indices are not durable either because pagination and projection can merge source rows. + +Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. + +The daemon validates that the epoch is current and the exact source sequence still exists before slicing rows. It slices before projection so later lifecycle updates cannot leak into the selected context. + ## Resume behavior When a client resumes with a known cursor, it catches up after that cursor to completion. It does not replace the view with a latest tail page, because tail pagination can skip the middle of a long background run. diff --git a/packages/app/e2e/assistant-fork-menu.spec.ts b/packages/app/e2e/assistant-fork-menu.spec.ts index 47a26f531f..965c87b943 100644 --- a/packages/app/e2e/assistant-fork-menu.spec.ts +++ b/packages/app/e2e/assistant-fork-menu.spec.ts @@ -54,6 +54,28 @@ async function expectChatHistoryPill(page: Page): Promise { test.describe("Assistant fork menu", () => { test.describe.configure({ timeout: 180_000 }); + test("forks a failed assistant turn that has no provider message id", async ({ + page, + seedForkWorkspace, + }) => { + const session = await seedForkWorkspace({ + repoPrefix: "assistant-fork-failed-turn-", + title: "Assistant fork failed turn", + model: "ten-second-stream", + }); + + await openAgentRoute(page, session); + await expectComposerVisible(page); + await submitMessage(page, "Emit a synthetic turn failure."); + await expect(page.getByText("[System Error] Requested mock provider failure")).toBeVisible({ + timeout: 30_000, + }); + + await openAssistantForkMenu(page); + await page.getByTestId("assistant-fork-menu-new-tab").click(); + await expectChatHistoryPill(page); + }); + test("focuses a forked assistant turn in a new workspace draft tab", async ({ page, seedForkWorkspace, diff --git a/packages/app/src/agent-stream/turn-boundary.test.ts b/packages/app/src/agent-stream/turn-boundary.test.ts index c18fad104a..c755eab588 100644 --- a/packages/app/src/agent-stream/turn-boundary.test.ts +++ b/packages/app/src/agent-stream/turn-boundary.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; import type { StreamItem } from "@/types/stream"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { + resolveAssistantTurnBoundaryMessageId, + resolveAssistantTurnForkBoundary, +} from "./turn-boundary"; function timestamp(seed: number): Date { return new Date(`2026-01-01T00:00:${seed.toString().padStart(2, "0")}.000Z`); @@ -62,3 +65,22 @@ describe("resolveAssistantTurnBoundaryMessageId", () => { ).toBeUndefined(); }); }); + +describe("resolveAssistantTurnForkBoundary", () => { + it("forks a failed assistant turn from its Paseo timeline cursor without a provider message id", () => { + const failedTurn = { + ...assistantMessage("assistant-error", 2), + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }; + + expect( + resolveAssistantTurnForkBoundary({ + items: [userMessage("user-1", 1), failedTurn], + startIndex: 1, + supportsTimelineCursor: true, + }), + ).toEqual({ + boundaryCursor: { epoch: "timeline-1", seq: 42 }, + }); + }); +}); diff --git a/packages/app/src/agent-stream/turn-boundary.ts b/packages/app/src/agent-stream/turn-boundary.ts index 75fa63a3e2..637c83b72a 100644 --- a/packages/app/src/agent-stream/turn-boundary.ts +++ b/packages/app/src/agent-stream/turn-boundary.ts @@ -1,4 +1,8 @@ -import type { StreamItem } from "@/types/stream"; +import type { StreamItem, TimelinePosition } from "@/types/stream"; + +export type AssistantTurnForkBoundary = + | { boundaryCursor: TimelinePosition; boundaryMessageId?: string } + | { boundaryCursor?: undefined; boundaryMessageId: string }; export function resolveAssistantTurnBoundaryMessageId(input: { items: readonly StreamItem[]; @@ -11,3 +15,21 @@ export function resolveAssistantTurnBoundaryMessageId(input: { // Forking without the selected assistant's durable message id would send the wrong slice. return item.messageId || undefined; } + +export function resolveAssistantTurnForkBoundary(input: { + items: readonly StreamItem[]; + startIndex: number; + supportsTimelineCursor: boolean; +}): AssistantTurnForkBoundary | undefined { + const item = input.items[input.startIndex]; + if (item?.kind !== "assistant_message") { + return undefined; + } + if (input.supportsTimelineCursor && item.timelineCursor) { + return { + boundaryCursor: item.timelineCursor, + ...(item.messageId ? { boundaryMessageId: item.messageId } : {}), + }; + } + return item.messageId ? { boundaryMessageId: item.messageId } : undefined; +} diff --git a/packages/app/src/agent-stream/turn-footer.tsx b/packages/app/src/agent-stream/turn-footer.tsx index 2f7bf3c8b0..5b7e01e8d7 100644 --- a/packages/app/src/agent-stream/turn-footer.tsx +++ b/packages/app/src/agent-stream/turn-footer.tsx @@ -9,7 +9,7 @@ import { collectAssistantTurnContentForStreamRenderStrategy, type StreamStrategy, } from "./strategy"; -import { resolveAssistantTurnBoundaryMessageId } from "./turn-boundary"; +import { resolveAssistantTurnForkBoundary, type AssistantTurnForkBoundary } from "./turn-boundary"; import { AssistantTurnFooter, LiveElapsed, @@ -31,7 +31,7 @@ const workingIndicatorColorMapping = (theme: Theme) => ({ export type TurnContentStrategy = StreamStrategy; export type AssistantTurnForkHandler = (input: { target: AssistantForkTarget; - boundaryMessageId?: string; + boundary: AssistantTurnForkBoundary; }) => Promise | void; export const TurnFooter = memo(function TurnFooter({ @@ -39,12 +39,14 @@ export const TurnFooter = memo(function TurnFooter({ inFlightTurnStartedAt, host, strategy, + supportsTimelineCursor, onForkAssistantTurn, }: { isRunning: boolean; inFlightTurnStartedAt: Date | null; host: TurnFooterHost | null; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { if (isRunning) { @@ -63,6 +65,7 @@ export const TurnFooter = memo(function TurnFooter({ items={host.items} timing={host.timing} startIndex={host.startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> ); @@ -73,12 +76,14 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { return ( @@ -88,6 +93,7 @@ export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({ items={items} timing={timing} startIndex={startIndex} + supportsTimelineCursor={supportsTimelineCursor} onForkAssistantTurn={onForkAssistantTurn} /> @@ -130,12 +136,14 @@ function CompletedTurnFooter({ items, timing, startIndex, + supportsTimelineCursor, onForkAssistantTurn, }: { strategy: TurnContentStrategy; items: StreamItem[]; timing?: TurnTiming; startIndex: number; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }) { const getContent = useCallback( @@ -147,18 +155,27 @@ function CompletedTurnFooter({ }), [strategy, items, startIndex], ); - const boundaryMessageId = resolveAssistantTurnBoundaryMessageId({ + const boundary = resolveAssistantTurnForkBoundary({ items, startIndex, + supportsTimelineCursor, }); + const handleFork = useCallback( + (target: AssistantForkTarget) => { + if (!boundary) { + return; + } + return onForkAssistantTurn?.({ target, boundary }); + }, + [boundary, onForkAssistantTurn], + ); return ( ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 8250461f11..6e1bc4858a 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -139,6 +139,7 @@ function renderStreamItemWithTurnFooter(input: { content: ReactNode; layoutItem: StreamLayoutItem; strategy: TurnContentStrategy; + supportsTimelineCursor: boolean; onForkAssistantTurn?: AssistantTurnForkHandler; }): ReactNode { if (!input.content) { @@ -152,6 +153,7 @@ function renderStreamItemWithTurnFooter(input: { items={footerHost.items} timing={footerHost.timing} startIndex={footerHost.startIndex} + supportsTimelineCursor={input.supportsTimelineCursor} onForkAssistantTurn={input.onForkAssistantTurn} /> ) : null; @@ -278,6 +280,7 @@ function buildChatHistoryAttachment(input: { serverId: input.serverId, agentId: input.agentId, boundaryMessageId: input.payload.boundaryMessageId, + boundaryCursor: input.payload.boundaryCursor, itemCount: input.payload.itemCount, }, }; @@ -365,6 +368,10 @@ const AgentStreamViewComponent = forwardRef + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContextCursor === true, + ); const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ @@ -462,7 +469,7 @@ const AgentStreamViewComponent = forwardRef { + async ({ target, boundary }) => { try { if (!supportsAgentForkContext) { toast?.error(t("message.actions.forkUnavailable")); @@ -474,10 +481,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); - const payload = await client.buildAgentForkContext( - agentId, - boundaryMessageId ? { boundaryMessageId } : {}, - ); + const payload = await client.buildAgentForkContext(agentId, boundary); const attachment = buildChatHistoryAttachment({ draftId, serverId: resolvedServerId, @@ -826,10 +830,17 @@ const AgentStreamViewComponent = forwardRef ) : null, @@ -864,6 +876,7 @@ const AgentStreamViewComponent = forwardRef(() => { diff --git a/packages/app/src/attachments/types.ts b/packages/app/src/attachments/types.ts index 82e447f927..2199ce29fa 100644 --- a/packages/app/src/attachments/types.ts +++ b/packages/app/src/attachments/types.ts @@ -79,6 +79,7 @@ export interface ChatHistoryContextAttachment { serverId: string; agentId: string; boundaryMessageId?: string | null; + boundaryCursor?: { epoch: string; seq: number } | null; itemCount?: number; }; } diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index af51a82ca7..ec9f1c6ca7 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -562,11 +562,7 @@ interface AssistantTurnFooterProps { getContent: () => string; completedAt?: Date; durationMs?: number; - forkBoundaryMessageId?: string; - onFork?: (input: { - target: AssistantForkTarget; - boundaryMessageId?: string; - }) => Promise | void; + onFork?: (target: AssistantForkTarget) => Promise | void; } const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({ @@ -611,7 +607,6 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ getContent, completedAt, durationMs, - forkBoundaryMessageId, onFork, }: AssistantTurnFooterProps) { const [hovered, setHovered] = useState(false); @@ -654,11 +649,11 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({ }, [canSwap]); const handleFork = useCallback( (target: AssistantForkTarget) => { - return onFork?.({ target, boundaryMessageId: forkBoundaryMessageId }); + return onFork?.(target); }, - [forkBoundaryMessageId, onFork], + [onFork], ); - const canFork = Boolean(onFork && forkBoundaryMessageId); + const canFork = Boolean(onFork); return ( diff --git a/packages/app/src/timeline/session-stream-reducers.test.ts b/packages/app/src/timeline/session-stream-reducers.test.ts index a3e6acf5e1..e0026371c5 100644 --- a/packages/app/src/timeline/session-stream-reducers.test.ts +++ b/packages/app/src/timeline/session-stream-reducers.test.ts @@ -187,6 +187,25 @@ const baseStreamInput: ProcessAgentStreamEventInput = { // --------------------------------------------------------------------------- describe("processTimelineResponse", () => { + it("preserves the canonical end cursor on a projected assistant message", () => { + const result = processTimelineResponse({ + ...baseTimelineInput, + payload: { + ...baseTimelineInput.payload, + epoch: "timeline-1", + entries: [makeTimelineEntry(40, "[System Error] failed", "assistant_message", 42)], + }, + }); + + expect(result.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("returns error path when payload.error is set", () => { const result = processTimelineResponse({ ...baseTimelineInput, @@ -892,7 +911,12 @@ describe("processTimelineResponse", () => { }); it("merges assistant chunks across the older-page prepend boundary", () => { - const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")]; + const currentTail = [ + { + ...makeAssistantItem("newer chunk", "assistant-newer"), + timelineCursor: { epoch: "epoch-1", seq: 3 }, + }, + ]; const existingCursor: TimelineCursor = { epoch: "epoch-1", startSeq: 3, @@ -914,6 +938,9 @@ describe("processTimelineResponse", () => { }); expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]); + expect(result.tail[0]).toEqual( + expect.objectContaining({ timelineCursor: { epoch: "epoch-1", seq: 3 } }), + ); expect(result.cursor).toEqual({ epoch: "epoch-1", startSeq: 1, @@ -1222,6 +1249,23 @@ describe("processTimelineResponse", () => { // --------------------------------------------------------------------------- describe("processAgentStreamEvent", () => { + it("preserves the live timeline cursor on an assistant error", () => { + const result = processAgentStreamEvent({ + ...baseStreamInput, + event: makeAssistantTimelineEvent("[System Error] failed"), + epoch: "timeline-1", + seq: 42, + }); + + expect(result.head).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "[System Error] failed", + timelineCursor: { epoch: "timeline-1", seq: 42 }, + }), + ]); + }); + it("passes through non-timeline events without cursor changes", () => { const turnEvent: AgentStreamEventPayload = { type: "turn_completed", diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 22b0062f23..aaa0e21c03 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -510,6 +510,7 @@ function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: Strea ...olderLast, text: `${olderLast.text}${currentFirst.text}`, timestamp: currentFirst.timestamp, + ...(currentFirst.timelineCursor ? { timelineCursor: currentFirst.timelineCursor } : {}), }, ...currentTail.slice(1), ]; @@ -519,8 +520,9 @@ function replaceLiveAssistantWithProjectedText(params: { head: StreamItem[]; event: AgentStreamEventPayload; timestamp: Date; + timelineCursor: { epoch: string; seq: number }; }): StreamItem[] | null { - const { head, event, timestamp } = params; + const { head, event, timestamp, timelineCursor } = params; if (event.type !== "timeline" || event.item.type !== "assistant_message") { return null; } @@ -537,6 +539,7 @@ function replaceLiveAssistantWithProjectedText(params: { ...current, text: event.item.text, timestamp, + timelineCursor, }; return next; } @@ -575,19 +578,22 @@ function applyTimelineIncrementalPath(args: { if (acceptedUnits.length > 0) { if (payload.direction === "before") { const olderTail = hydrateStreamState( - acceptedUnits.map(({ event, timestamp }) => ({ + acceptedUnits.map(({ event, timestamp, seqEnd }) => ({ event, timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, })), { source: "canonical" }, ); nextTail = mergePrependedCanonicalTail(olderTail, currentTail); } else if (currentHead.length > 0) { - for (const { event, timestamp } of acceptedUnits) { + for (const { event, timestamp, seqEnd } of acceptedUnits) { + const timelineCursor = { epoch: payload.epoch, seq: seqEnd }; const replacedHead = replaceLiveAssistantWithProjectedText({ head: nextHead, event, timestamp, + timelineCursor, }); if (replacedHead) { nextHead = replacedHead; @@ -599,15 +605,17 @@ function applyTimelineIncrementalPath(args: { event, timestamp, source: "canonical", + timelineCursor, }); nextTail = applied.tail; nextHead = applied.head; } } else { nextTail = acceptedUnits.reduce( - (state, { event, timestamp }) => + (state, { event, timestamp, seqEnd }) => reduceStreamUpdate(state, event, timestamp, { source: "canonical", + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, }), currentTail, ); @@ -681,8 +689,16 @@ export function processTimelineResponse( const toHydratedEvents = ( units: TimelineUnit[], - ): Array<{ event: AgentStreamEventPayload; timestamp: Date }> => - units.map(({ event, timestamp }) => ({ event, timestamp })); + ): Array<{ + event: AgentStreamEventPayload; + timestamp: Date; + timelineCursor: { epoch: string; seq: number }; + }> => + units.map(({ event, timestamp, seqEnd }) => ({ + event, + timestamp, + timelineCursor: { epoch: payload.epoch, seq: seqEnd }, + })); // ------------------------------------------------------------------ // Derive bootstrap policy (replace vs incremental) @@ -934,6 +950,10 @@ export function processAgentStreamEvent( input; const sequencing = processTimelineSequencingGate({ event, seq, epoch, currentCursor }); + const timelineCursor = + event.type === "timeline" && seq !== undefined && epoch !== undefined + ? { epoch, seq } + : undefined; // ------------------------------------------------------------------ // Apply stream event to tail/head @@ -945,6 +965,7 @@ export function processAgentStreamEvent( event, timestamp, source: "live", + timelineCursor, }) : { tail: currentTail, diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index e059c0e986..13728a70e8 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -107,12 +107,18 @@ export interface AssistantMessageItem { kind: "assistant_message"; id: string; messageId?: string; + timelineCursor?: TimelinePosition; text: string; timestamp: Date; blockGroupId?: string; blockIndex?: number; } +export interface TimelinePosition { + epoch: string; + seq: number; +} + export type ThoughtStatus = "loading" | "ready"; export interface ThoughtItem { @@ -202,6 +208,7 @@ export type StreamUpdateSource = "live" | "canonical"; interface StreamUpdateOptions { source?: StreamUpdateSource; reservedItemIds?: ReadonlySet; + timelineCursor?: TimelinePosition; } function isRecord(value: unknown): value is Record { @@ -385,6 +392,7 @@ function appendAssistantMessage( source: StreamUpdateSource, messageId?: string, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const { chunk, hasContent } = normalizeChunk(text); if (!chunk) { @@ -401,6 +409,7 @@ function appendAssistantMessage( ...last, text: `${last.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -1), updated]; } @@ -418,6 +427,7 @@ function appendAssistantMessage( ...secondLast, text: `${secondLast.text}${chunk}`, timestamp, + ...(timelineCursor ? { timelineCursor } : {}), }; return [...state.slice(0, -2), updated, last]; } @@ -432,6 +442,7 @@ function appendAssistantMessage( kind: "assistant_message", id: entryId, ...(messageId ? { messageId } : {}), + ...(timelineCursor ? { timelineCursor } : {}), text: chunk, timestamp, }; @@ -817,6 +828,7 @@ function reduceTimelineEvent( timestamp: Date, source: StreamUpdateSource, reservedItemIds?: ReadonlySet, + timelineCursor?: TimelinePosition, ): StreamItem[] { const item = event.item; switch (item.type) { @@ -831,6 +843,7 @@ function reduceTimelineEvent( source, item.messageId, reservedItemIds, + timelineCursor, ), ); case "reasoning": @@ -876,7 +889,14 @@ export function reduceStreamUpdate( const source = options?.source ?? "live"; switch (event.type) { case "timeline": - return reduceTimelineEvent(state, event, timestamp, source, options?.reservedItemIds); + return reduceTimelineEvent( + state, + event, + timestamp, + source, + options?.reservedItemIds, + options?.timelineCursor, + ); case "thread_started": case "turn_started": case "turn_completed": @@ -898,11 +918,12 @@ export function hydrateStreamState( events: Array<{ event: AgentStreamEventPayload; timestamp: Date; + timelineCursor?: TimelinePosition; }>, options?: { source?: StreamUpdateSource }, ): StreamItem[] { - const hydrated = events.reduce((state, { event, timestamp }) => { - return reduceStreamUpdate(state, event, timestamp, options); + const hydrated = events.reduce((state, { event, timestamp, timelineCursor }) => { + return reduceStreamUpdate(state, event, timestamp, { ...options, timelineCursor }); }, []); return finalizeActiveThoughts(hydrated); @@ -1186,6 +1207,7 @@ export function applyStreamEvent(params: { event: AgentStreamEventPayload; timestamp: Date; source?: StreamUpdateSource; + timelineCursor?: TimelinePosition; }): ApplyStreamEventResult { const { tail, head, event, timestamp } = params; const source = params.source ?? "live"; @@ -1256,7 +1278,11 @@ export function applyStreamEvent(params: { ), ) : undefined; - const reduced = reduceStreamUpdate(nextHead, event, timestamp, { source, reservedItemIds }); + const reduced = reduceStreamUpdate(nextHead, event, timestamp, { + source, + reservedItemIds, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextHead) { nextHead = reduced; changedHead = true; @@ -1275,7 +1301,10 @@ export function applyStreamEvent(params: { } // For non-streamable kinds or non-timeline events, apply to tail - const reduced = reduceStreamUpdate(nextTail, event, timestamp, { source }); + const reduced = reduceStreamUpdate(nextTail, event, timestamp, { + source, + timelineCursor: params.timelineCursor, + }); if (reduced !== nextTail) { nextTail = reduced; changedTail = true; diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 1f827a0b37..aff20708b8 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -538,6 +538,7 @@ function normalizeListCommandsOptions( return { agentId: input, ...legacyOptions }; } export interface AgentForkContextOptions { + boundaryCursor?: FetchAgentTimelineCursor; boundaryMessageId?: string; requestId?: string; } @@ -2555,6 +2556,7 @@ export class DaemonClient { type: "agent.fork_context.request", agentId, requestId: resolvedRequestId, + ...(options.boundaryCursor ? { boundaryCursor: options.boundaryCursor } : {}), ...(options.boundaryMessageId ? { boundaryMessageId: options.boundaryMessageId } : {}), }); diff --git a/packages/protocol/src/messages.attachments.test.ts b/packages/protocol/src/messages.attachments.test.ts index 57f52d2403..2a037d9139 100644 --- a/packages/protocol/src/messages.attachments.test.ts +++ b/packages/protocol/src/messages.attachments.test.ts @@ -1,12 +1,55 @@ import { describe, expect, it } from "vitest"; import { + AgentForkContextRequestMessageSchema, + AgentForkContextResponseMessageSchema, CreateAgentRequestMessageSchema, CreatePaseoWorktreeRequestSchema, SendAgentMessageRequestSchema, } from "./messages.js"; describe("shared messages attachments", () => { + it("preserves an optional timeline cursor on fork-context messages", () => { + const boundaryCursor = { epoch: "timeline-1", seq: 42 }; + const request = AgentForkContextRequestMessageSchema.parse({ + type: "agent.fork_context.request", + agentId: "agent-1", + requestId: "fork-1", + boundaryCursor, + }); + const response = AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: null, + boundaryCursor, + error: null, + }, + }); + + expect(request.boundaryCursor).toEqual(boundaryCursor); + expect(response.payload.boundaryCursor).toEqual(boundaryCursor); + }); + + it("accepts a legacy fork-context response without a timeline cursor", () => { + expect( + AgentForkContextResponseMessageSchema.parse({ + type: "agent.fork_context.response", + payload: { + requestId: "fork-1", + agentId: "agent-1", + attachment: null, + itemCount: 2, + boundaryMessageId: "assistant-1", + error: null, + }, + }).payload.boundaryCursor, + ).toBeUndefined(); + }); + it("keeps valid review attachments", () => { const parsed = SendAgentMessageRequestSchema.parse({ type: "send_agent_message_request", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index ec05be1769..9dd516c9e9 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1321,6 +1321,7 @@ export const ProviderSubagentTimelineRequestMessageSchema = z.object({ export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), + boundaryCursor: AgentTimelineCursorSchema.optional(), boundaryMessageId: z.string().optional(), requestId: z.string(), }); @@ -2429,6 +2430,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12. @@ -3129,6 +3132,7 @@ export const AgentForkContextResponseMessageSchema = z.object({ attachment: TextAttachmentSchema.nullable(), itemCount: z.number().int().nonnegative(), boundaryMessageId: z.string().nullable(), + boundaryCursor: AgentTimelineCursorSchema.nullable().optional(), error: z.string().nullable(), }), }); diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 75ae75fe77..f82d91297d 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -315,6 +315,8 @@ second line'`, contextKind: "chat_history", title: "Chat history", }); + expect(result.attachment.text).toMatch(/^\n/); + expect(result.attachment.text).toMatch(/\n<\/chat-history-summary>$/); 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"); @@ -394,6 +396,27 @@ second line'`, expect(result.attachment.text).not.toContain("after boundary"); }); + it("selects a synthetic assistant error by its timeline cursor", () => { + const result = buildAgentForkContextAttachment({ + timelineEpoch: "timeline-1", + boundaryCursor: { epoch: "timeline-1", seq: 2 }, + rows: [ + row(1, { type: "user_message", text: "Try the task", messageId: "user-1" }), + row(2, { type: "assistant_message", text: "[System Error] provider failed" }), + row(3, { + type: "assistant_message", + text: "This belongs to a later turn.", + messageId: "assistant-2", + }), + ], + }); + + expect(result.boundaryCursor).toEqual({ epoch: "timeline-1", seq: 2 }); + expect(result.boundaryMessageId).toBeNull(); + expect(result.attachment.text).toContain("[System Error] provider failed"); + expect(result.attachment.text).not.toContain("This belongs to a later turn."); + }); + it("rejects missing assistant boundaries instead of silently using the wrong context", () => { expect(() => buildAgentForkContextAttachment({ diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 359fb4280e..60e676d5c2 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -218,28 +218,46 @@ export function curateAgentActivity( function selectForkContextRows(input: { rows: readonly AgentTimelineRow[]; + timelineEpoch?: string; + boundaryCursor?: { epoch: string; seq: number } | null; boundaryMessageId?: string | null; -}): { items: AgentTimelineItem[]; boundaryMessageId: string | null } { +}): { + items: AgentTimelineItem[]; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { + const boundaryCursor = input.boundaryCursor ?? null; const boundaryMessageId = input.boundaryMessageId?.trim() || null; - if (!boundaryMessageId) { + if (!boundaryCursor && !boundaryMessageId) { const projected = projectTimelineRows({ rows: input.rows, mode: "projected" }); return { items: projected.map((entry) => entry.item), + boundaryCursor: null, boundaryMessageId: null, }; } - const boundaryIndex = input.rows.findLastIndex( - (row) => row.item.type === "assistant_message" && row.item.messageId === boundaryMessageId, - ); + if (boundaryCursor && boundaryCursor.epoch !== input.timelineEpoch) { + throw new Error("Selected timeline position is no longer available."); + } + const boundaryIndex = boundaryCursor + ? input.rows.findIndex((row) => row.seq === boundaryCursor.seq) + : 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."); + throw new Error( + boundaryCursor + ? "Selected timeline position is no longer available." + : "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), + boundaryCursor, boundaryMessageId, }; } @@ -263,17 +281,26 @@ function buildForkContextText(input: { if (cwd) { header.push(`Source directory: ${cwd}`); } - return `${header.join("\n")}\n\n${input.body}`; + return `\n${header.join("\n")}\n\n${input.body}\n`; } export function buildAgentForkContextAttachment(input: { rows: readonly AgentTimelineRow[]; + timelineEpoch?: string; + boundaryCursor?: { epoch: string; seq: number } | null; boundaryMessageId?: string | null; agentTitle?: string | null; cwd?: string | null; -}): { attachment: TextAgentAttachment; itemCount: number; boundaryMessageId: string | null } { +}): { + attachment: TextAgentAttachment; + itemCount: number; + boundaryCursor: { epoch: string; seq: number } | null; + boundaryMessageId: string | null; +} { const selected = selectForkContextRows({ rows: input.rows, + timelineEpoch: input.timelineEpoch, + boundaryCursor: input.boundaryCursor, boundaryMessageId: input.boundaryMessageId, }); const entries = curateProjectedActivityEntries(selected.items, { @@ -299,6 +326,7 @@ export function buildAgentForkContextAttachment(input: { }), }, itemCount: selected.items.length, + boundaryCursor: selected.boundaryCursor, boundaryMessageId: selected.boundaryMessageId, }; } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 60c4d2df64..7954ec1d4a 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -13,16 +13,12 @@ import type { } from "../../worktree-session.js"; import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js"; import type { AgentManager, CreateAgentOptions, ManagedAgent } from "../agent-manager.js"; -import type { - AgentPromptContentBlock, - AgentPromptInput, - AgentRunOptions, - AgentSessionConfig, -} from "../agent-sdk-types.js"; +import type { AgentPromptInput, AgentRunOptions, AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentStorage } from "../agent-storage.js"; import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js"; import { setupFinishNotification, startCreatedAgentInitialPrompt } from "../agent-prompt.js"; import { resolveCreateAgentTitles } from "../create-agent-title.js"; +import { buildAgentPrompt } from "../prompt-attachments.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { @@ -486,30 +482,6 @@ async function sendInitialPrompt( } } -function buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], -): AgentPromptInput { - const normalized = text.trim(); - const hasImages = (images?.length ?? 0) > 0; - const hasAttachments = (attachments?.length ?? 0) > 0; - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; -} - function requireParentAgent(agentManager: AgentManager, parentAgentId: string): ManagedAgent { const parentAgent = agentManager.getAgent(parentAgentId); if (!parentAgent) { diff --git a/packages/server/src/server/agent/prompt-attachments.test.ts b/packages/server/src/server/agent/prompt-attachments.test.ts index d187415c0a..42820c9ea9 100644 --- a/packages/server/src/server/agent/prompt-attachments.test.ts +++ b/packages/server/src/server/agent/prompt-attachments.test.ts @@ -1,8 +1,42 @@ import { describe, expect, it } from "vitest"; -import { buildAgentBranchNameSeed, renderPromptAttachmentAsText } from "./prompt-attachments.js"; +import { + buildAgentBranchNameSeed, + buildAgentPrompt, + renderPromptAttachmentAsText, +} from "./prompt-attachments.js"; describe("prompt attachments", () => { + it("places fork history before the new user prompt", () => { + const chatHistory = { + type: "text" as const, + mimeType: "text/plain", + contextKind: "chat_history" as const, + title: "Chat history", + text: "\nPrevious work\n", + }; + const issue = { + type: "github_issue" as const, + mimeType: "application/github-issue", + number: 55, + title: "Issue", + url: "https://github.com/getpaseo/paseo/issues/55", + }; + + expect( + buildAgentPrompt( + " Take a different approach ", + [{ data: "image-data", mimeType: "image/png" }], + [issue, chatHistory], + ), + ).toEqual([ + chatHistory, + { type: "text", text: "Take a different approach" }, + { type: "image", data: "image-data", mimeType: "image/png" }, + issue, + ]); + }); + it("renders github_pr attachments as readable text", () => { expect( renderPromptAttachmentAsText({ diff --git a/packages/server/src/server/agent/prompt-attachments.ts b/packages/server/src/server/agent/prompt-attachments.ts index 129018f2f5..c1dba82765 100644 --- a/packages/server/src/server/agent/prompt-attachments.ts +++ b/packages/server/src/server/agent/prompt-attachments.ts @@ -1,7 +1,41 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages"; +import type { AgentPromptContentBlock, AgentPromptInput } from "./agent-sdk-types.js"; const REVIEW_LINE_MARKERS = { add: "+", remove: "-", context: " " } as const; +export function buildAgentPrompt( + text: string, + images?: Array<{ data: string; mimeType: string }>, + attachments?: AgentAttachment[], +): AgentPromptInput { + const normalized = text.trim(); + const hasImages = (images?.length ?? 0) > 0; + const hasAttachments = (attachments?.length ?? 0) > 0; + if (!hasImages && !hasAttachments) { + return normalized; + } + + const chatHistoryAttachments: AgentAttachment[] = []; + const otherAttachments: AgentAttachment[] = []; + for (const attachment of attachments ?? []) { + if (attachment.type === "text" && attachment.contextKind === "chat_history") { + chatHistoryAttachments.push(attachment); + } else { + otherAttachments.push(attachment); + } + } + + const blocks: AgentPromptContentBlock[] = [...chatHistoryAttachments]; + if (normalized.length > 0) { + blocks.push({ type: "text", text: normalized }); + } + for (const image of images ?? []) { + blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); + } + blocks.push(...otherAttachments); + return blocks; +} + export function renderPromptAttachmentAsText(attachment: AgentAttachment): string { switch (attachment.type) { case "github_pr": { diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index 51763d3365..98d904076b 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -179,6 +179,38 @@ describe("MockLoadTestAgentClient", () => { expect(events).toHaveLength(eventCountAfterInterrupt); }); + test("emits a terminal failure without an assistant provider message", async () => { + vi.useFakeTimers(); + const client = new MockLoadTestAgentClient(); + const session = await client.createSession({ + provider: "mock", + cwd: process.cwd(), + model: "ten-second-stream", + }); + const events: AgentStreamEvent[] = []; + const unsubscribe = session.subscribe((event) => events.push(event)); + + await session.startTurn("Emit a synthetic turn failure."); + await vi.advanceTimersByTimeAsync(0); + unsubscribe(); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: expect.objectContaining({ type: "user_message" }), + }), + ); + expect( + events.filter( + (event) => event.type === "timeline" && event.item.type === "assistant_message", + ), + ).toHaveLength(0); + expect(events.at(-1)).toMatchObject({ + type: "turn_failed", + error: "Requested mock provider failure", + }); + }); + test("emits the free-write question scenario selected by prompt", async () => { vi.useFakeTimers(); const client = new MockLoadTestAgentClient(); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 1609fe4a2c..d371ef1ac4 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -152,6 +152,10 @@ function shouldEmitPlanApprovalPrompt(prompt: AgentPromptInput): boolean { return /emit\s+(?:a\s+)?synthetic\s+plan\s+approval/i.test(promptToText(prompt)); } +function shouldEmitTurnFailure(prompt: AgentPromptInput): boolean { + return /emit\s+(?:a\s+)?synthetic\s+turn\s+failure/i.test(promptToText(prompt)); +} + function parseMockQuestionPrompt(prompt: AgentPromptInput): MockQuestionPromptRequest | null { const text = promptToText(prompt); if (!/emit\s+(?:a\s+)?synthetic\s+questions?/i.test(text)) { @@ -652,7 +656,9 @@ export class MockLoadTestAgentSession implements AgentSession { const stress = parseAgentStreamStressPrompt(prompt); const questionPrompt = parseMockQuestionPrompt(prompt); const structuredBranchName = parseStructuredBranchNamePrompt(prompt); - if (structuredBranchName) { + if (shouldEmitTurnFailure(prompt)) { + this.scheduleFailedTurn(turn); + } else if (structuredBranchName) { this.scheduleStructuredJsonTurn(turn, structuredBranchName); } else if (shouldEmitPlanApprovalPrompt(prompt)) { this.schedulePlanApprovalTurn(turn); @@ -816,6 +822,34 @@ export class MockLoadTestAgentSession implements AgentSession { turn.timer.unref?.(); } + private scheduleFailedTurn(turn: ActiveTurn): void { + turn.timer = setTimeout(() => { + if (this.activeTurn !== turn) { + return; + } + this.clearTurnTimer(turn); + this.emit({ + type: "turn_started", + provider: this.provider, + turnId: turn.turnId, + }); + this.activeTurn = null; + this.emit({ + type: "turn_failed", + provider: this.provider, + turnId: turn.turnId, + error: "Requested mock provider failure", + }); + turn.resolve({ + sessionId: this.id, + finalText: "", + timeline: [], + canceled: false, + }); + }, 0); + turn.timer.unref?.(); + } + private scheduleStressTurn(turn: ActiveTurn, stress: AgentStreamStressRequest): void { turn.timer = setTimeout(() => { this.emitStressTurn(turn, stress); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cc38733b25..644eb21a94 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -100,13 +100,12 @@ import { type TimelineProjectionMode, } from "./agent/timeline-projection.js"; import { buildAgentForkContextAttachment } from "./agent/activity-curator.js"; +import { buildAgentPrompt } from "./agent/prompt-attachments.js"; import type { StructuredGenerationDaemonConfig } from "./agent/structured-generation-providers.js"; import { getAgentStreamEventTurnId, type AgentPersistenceHandle, type AgentPermissionResponse, - type AgentPromptContentBlock, - type AgentPromptInput, type AgentRunOptions, type AgentSessionConfig, } from "./agent/agent-sdk-types.js"; @@ -1027,33 +1026,6 @@ export class Session { // No unsolicited agent list hydration. Callers must use fetch_agents_request. } - /** - * Normalize a user prompt (with optional image metadata) for AgentManager - */ - private buildAgentPrompt( - text: string, - images?: Array<{ data: string; mimeType: string }>, - attachments?: AgentAttachment[], - ): AgentPromptInput { - const normalized = text?.trim() ?? ""; - const hasImages = Boolean(images && images.length > 0); - const hasAttachments = Boolean(attachments && attachments.length > 0); - if (!hasImages && !hasAttachments) { - return normalized; - } - const blocks: AgentPromptContentBlock[] = []; - if (normalized.length > 0) { - blocks.push({ type: "text", text: normalized }); - } - for (const image of images ?? []) { - blocks.push({ type: "image", data: image.data, mimeType: image.mimeType }); - } - for (const attachment of attachments ?? []) { - blocks.push(attachment); - } - return blocks; - } - /** * Interrupt the agent's active run so the next prompt starts a fresh turn. * Returns once the manager confirms the stream has been cancelled. @@ -2438,7 +2410,7 @@ export class Session { ); const promptText = options?.spokenInput ? wrapSpokenInput(text) : text; - const prompt = this.buildAgentPrompt(promptText, images, attachments); + const prompt = buildAgentPrompt(promptText, images, attachments); try { await sendPromptToAgent({ @@ -5502,12 +5474,14 @@ export class Session { logger: this.sessionLogger, }); const agentPayload = await this.buildAgentPayload(snapshot); - const rows = this.agentManager.fetchTimeline(msg.agentId, { + const timeline = this.agentManager.fetchTimeline(msg.agentId, { direction: "tail", limit: 0, - }).rows; + }); const forkContext = buildAgentForkContextAttachment({ - rows, + rows: timeline.rows, + timelineEpoch: timeline.epoch, + boundaryCursor: msg.boundaryCursor, boundaryMessageId: msg.boundaryMessageId, agentTitle: agentPayload.title, cwd: snapshot.cwd, @@ -5520,6 +5494,7 @@ export class Session { agentId: msg.agentId, attachment: forkContext.attachment, itemCount: forkContext.itemCount, + boundaryCursor: forkContext.boundaryCursor, boundaryMessageId: forkContext.boundaryMessageId, error: null, }, @@ -5536,6 +5511,7 @@ export class Session { agentId: msg.agentId, attachment: null, itemCount: 0, + boundaryCursor: msg.boundaryCursor ?? null, boundaryMessageId: msg.boundaryMessageId ?? null, error: error instanceof Error ? error.message : String(error), }, @@ -5563,7 +5539,7 @@ export class Session { try { const agentId = resolved.agentId; - const prompt = this.buildAgentPrompt(msg.text, msg.images, msg.attachments); + const prompt = buildAgentPrompt(msg.text, msg.images, msg.attachments); this.sessionLogger.trace( { agentId, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 05ce7e886b..ff371979a0 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1260,6 +1260,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(agentForkContextCursor): added in v0.1.108, remove gate after 2027-01-14. + agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, // COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.