Keep conversation turns distinct and history stable (#7990)

* fix(app): keep autonomous turns distinct and readable

Hidden system prompts merged autonomous work into the previous response and reopened completed turns. Preserve canonical turn boundaries and expose their triggers, while making received messages attributable and expanded turn details readable in one step.

* fix(timeline): mount history at actual run boundaries

* fix(timeline): load and retain complete available turns

* fix(app): summarize actions and preserve conversation events

* test(timeline): verify whole-turn paging stays responsive

* fix(app): group reasoning according to thinking preference

* test(app): align history journeys with whole-turn loading
This commit is contained in:
Mohamed Boudra
2026-10-27 13:57:17 +07:00
committed by GitHub
parent 3a1b146690
commit b12eefb28f
72 changed files with 1690 additions and 409 deletions
@@ -880,6 +880,66 @@ test("retries provider history hydration after a stream failure", async () => {
}
});
test("notification batches share the run they start or join", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-notification-turns-"));
const manager = new AgentManager({
clients: {
codex: new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})(),
},
logger,
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const batch = (prefix: string) =>
["one", "two"]
.map((id) =>
formatAgentMessage({
id: `${prefix}-${id}`,
source: { kind: "agent-notification", agentId: "worker", event: "finished" },
text: "Worker finished",
}),
)
.join("\n\n");
const consume = async (prompt: string, clientMessageId: string) => {
for await (const _event of manager.streamAgent(agent.id, prompt, { clientMessageId })) {
}
};
try {
const first = consume("human", "human");
await manager.waitForAgentRunStart(agent.id);
await expect(
manager.steerAgentRun(agent.id, batch("joined"), { clientMessageId: "joined-one" }),
).resolves.toEqual({ status: "accepted" });
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-1" });
await first;
const second = consume(batch("idle"), "idle-one");
await manager.waitForAgentRunStart(agent.id);
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "active-turn-2" });
await second;
expect(
manager.fetchTimeline(agent.id, { limit: 0 }).rows.map((row) => ({
turnId: row.turnId,
id: row.item.type === "tool_call" ? row.item.callId : row.item.type,
})),
).toEqual([
{ turnId: "active-turn-1", id: "user_message" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-one" },
{ turnId: "active-turn-1", id: "paseo-agent-message:joined-two" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-one" },
{ turnId: "active-turn-2", id: "paseo-agent-message:idle-two" },
]);
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("unavailable steer interrupts once and starts one replacement turn", async () => {
const session = new SteeringTestSession({ provider: "codex", cwd: process.cwd() });
session.steerResult = "unavailable";
@@ -11341,7 +11401,7 @@ test("listImportableSessions searches every provider result before global rankin
]);
});
test("user_message events wrapping a paseo-system envelope are not added to the timeline", async () => {
test("system-injected prompts appear as trigger tools in the live timeline", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11370,13 +11430,16 @@ test("user_message events wrapping a paseo-system envelope are not added to the
await manager.runAgent(snapshot.id, { text: "do something" });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
expect(userMessages[0].text).toBe("plain user message");
});
test("user_message events wrapping a paseo-system envelope are not restored during history replay", async () => {
test("system-injected prompts appear as trigger tools during history replay", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-envelope-history-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -11411,6 +11474,9 @@ test("user_message events wrapping a paseo-system envelope are not restored duri
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true });
const timeline = manager.getTimeline(snapshot.id);
expect(timeline).toContainEqual(
expect.objectContaining({ type: "tool_call", name: "heartbeat", status: "completed" }),
);
const userMessages = timeline.filter((item) => item.type === "user_message");
expect(userMessages).toHaveLength(1);
@@ -1,5 +1,5 @@
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { randomUUID } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
import type {
AgentPromptContentBlock,
@@ -175,9 +175,18 @@ function projectAgentMessage(item: AgentTimelineItem): AgentTimelineItem | null
if (item.type !== "user_message") return item;
const message = parseAgentMessage(item.text);
if (!message) {
// COMPAT(legacySystemEnvelope): added in v0.11.1; remove after 2027-04-08 once
// legacy notification history is retired. Schedules still use this envelope.
return isSystemInjectedEnvelope(item.text) ? null : item;
// Schedule bodies include their run ID, keeping identity stable across acceptance
// and provider replay even when the provider assigns its own message ID.
if (!isSystemInjectedEnvelope(item.text)) return item;
const text = item.text.slice("<paseo-system>\n".length, -"\n</paseo-system>".length);
return {
type: "tool_call",
callId: `paseo-system:${createHash("sha256").update(item.text).digest("hex")}`,
name: "heartbeat",
status: "completed",
error: null,
detail: { type: "plain_text", text, icon: "sparkles" },
};
}
const { source } = message;
if (!source) {
@@ -357,3 +357,21 @@ test("a human pasting an agent envelope stays a human message through provider r
clientMessageId: "human-submission",
});
});
test("schedule prompts remain visible as completed tool calls on replay", () => {
const text = formatSystemNotificationPrompt("Heartbeat: check progress");
expect(projectAgentMessages({ type: "user_message", messageId: "fire-1", text })).toEqual([
expect.objectContaining({
type: "tool_call",
status: "completed",
detail: { type: "plain_text", text: "Heartbeat: check progress", icon: "sparkles" },
}),
]);
});
test("schedule trigger identity survives the provider assigning a message ID", () => {
const text = formatSystemNotificationPrompt("Schedule fired (id=heartbeat, run=run-1).");
expect(projectAgentMessages({ type: "user_message", text })).toEqual(
projectAgentMessages({ type: "user_message", text, messageId: "provider-message" }),
);
});
@@ -17,6 +17,7 @@ export interface AgentTimelineCursor {
export type AgentTimelineFetchDirection = "tail" | "before" | "after";
export interface AgentTimelineFetchOptions {
wholeTurns?: boolean;
direction?: AgentTimelineFetchDirection;
cursor?: AgentTimelineCursor;
/**
@@ -4,6 +4,39 @@ import type { AgentTimelineItem } from "./agent-sdk-types.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
describe("InMemoryAgentTimelineStore", () => {
it("resets stale forward cursors to complete available turns", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent", { epoch: "current" });
for (let index = 0; index < 60; index++)
store.append(
"agent",
{
type: "tool_call",
callId: `call-${index}`,
name: "shell",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: null },
},
{ turnId: "run" },
);
const page = store.fetch("agent", {
direction: "after",
cursor: { epoch: "old", seq: 10 },
limit: 40,
wholeTurns: true,
});
expect(page.rows).toHaveLength(60);
expect(page).toMatchObject({
reset: true,
staleCursor: true,
startSeq: 1,
endSeq: 60,
hasOlder: false,
hasNewer: false,
});
});
it("clamps an overshooting before cursor into the bounded tail window", () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("agent-1", {
@@ -217,31 +250,35 @@ it("includes transitive tool updates in a contiguous projected tail", () => {
expect(tail).toMatchObject({ startSeq: 1, endSeq: 6, hasOlder: false });
});
it("serves tail 200 from 100,000 retained rows without stalling the loop", async () => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Generation and its GC are outside the measured request window.
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
const probe = new Promise<number>((resolve) =>
setImmediate(() => resolve(performance.now() - started)),
);
let page = store.fetch("large", { limit: 200 });
for (let repeat = 1; repeat < 8; repeat++) page = store.fetch("large", { limit: 200 });
const duration = performance.now() - started;
const loopGap = await probe;
console.info({ duration, loopGap });
expect(page.rows).toHaveLength(200);
expect(page.startSeq).toBe(99_801);
expect(page.endSeq).toBe(100_000);
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
// is measured separately. Eight requests amplify the old full-history work.
expect(duration).toBeLessThan(50);
expect(loopGap).toBeLessThan(50);
});
it.each([false, true])(
"serves tail 200 from 100,000 retained rows without stalling the loop (wholeTurns=%s)",
async (wholeTurns) => {
const store = new InMemoryAgentTimelineStore();
store.initialize("large");
for (let index = 0; index < 100_000; index++) {
store.append("large", { type: "user_message", text: String(index) });
}
// Generation and its GC are outside the measured request window.
await new Promise<void>((resolve) => setImmediate(resolve));
const started = performance.now();
const probe = new Promise<number>((resolve) =>
setImmediate(() => resolve(performance.now() - started)),
);
let page = store.fetch("large", { limit: 200, wholeTurns });
for (let repeat = 1; repeat < 8; repeat++)
page = store.fetch("large", { limit: 200, wholeTurns });
const duration = performance.now() - started;
const loopGap = await probe;
console.info({ duration, loopGap });
expect(page.rows).toHaveLength(200);
expect(page.startSeq).toBe(99_801);
expect(page.endSeq).toBe(100_000);
// CI runners can pause for scheduling/GC; the local per-request 2 ms gate
// is measured separately. Eight requests amplify the old full-history work.
expect(duration).toBeLessThan(50);
expect(loopGap).toBeLessThan(50);
},
);
it("matches the previous reprojecting selector on seeded randomized timelines", () => {
let seed = 0x76aabbcc;
@@ -107,6 +107,7 @@ export class InMemoryAgentTimelineStore {
direction: reset ? "tail" : direction,
cursorSeq: cursor?.seq,
limit: options?.limit ?? DEFAULT_TIMELINE_FETCH_LIMIT,
wholeTurns: options?.wholeTurns,
});
return {
epoch: state.epoch,
@@ -230,6 +230,7 @@ interface LargeAgentStreamPayloadRequest {
}
interface AgentStreamStressRequest {
tools: boolean;
count: number;
coalesced: boolean;
}
@@ -461,7 +462,7 @@ function parseLargeAgentStreamPayloadPrompt(
function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStressRequest | null {
const text = promptToText(prompt);
const match = /emit\s+(\d+)\s+(coalesced\s+)?agent stream updates/i.exec(text);
const match = /emit\s+(\d+)\s+(coalesced\s+|tool\s+)?agent stream updates/i.exec(text);
if (!match) {
return null;
}
@@ -471,7 +472,8 @@ function parseAgentStreamStressPrompt(prompt: AgentPromptInput): AgentStreamStre
}
return {
count: Math.min(count, 5_000),
coalesced: Boolean(match[2]),
coalesced: match[2]?.trim() === "coalesced",
tools: match[2]?.trim() === "tool",
};
}
@@ -1452,21 +1454,37 @@ export class MockLoadTestAgentSession implements AgentSession {
this.emitTurnStarted(turn);
for (let index = 0; index < stress.count; index += 1) {
this.emitTimeline(
turn.turnId,
stress.coalesced
? {
type: "assistant_message",
text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
}
: {
type: "todo",
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
},
);
let item: AgentTimelineItem;
if (stress.tools) {
item = {
type: "tool_call",
callId: `${turn.turnId}:stress:${index}`,
name: "Bash",
status: "completed",
error: null,
detail: { type: "shell", command: `echo stress-${index}`, output: `stress-${index}` },
};
} else if (stress.coalesced) {
item = {
type: "assistant_message",
text: `stress-update-${index}`,
messageId: turn.assistantMessageId,
};
} else {
item = {
type: "todo",
items: [{ text: `stress-update-${index}`, completed: index % 2 === 0 }],
};
}
this.emitTimeline(turn.turnId, item);
}
if (stress.tools)
this.emitTimeline(turn.turnId, {
type: "assistant_message",
text: `Completed ${stress.count} commands`,
messageId: turn.assistantMessageId,
});
this.activeTurn = null;
const usage = {
inputTokens: 1,
@@ -234,7 +234,7 @@ export function selectReferenceProjectedTimelinePage(input: {
// Existing selection fixtures exercise the retained owner through its public API.
export function selectProjectedTimelinePage(
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0],
input: Parameters<typeof selectReferenceProjectedTimelinePage>[0] & { wholeTurns?: boolean },
) {
const projection = new TimelineProjection(input.rows);
const bounds = input.bounds ?? getTimelineBounds(input.rows) ?? { minSeq: 0, maxSeq: 0 };
@@ -534,6 +534,154 @@ describe("selectProjectedTimelinePage", () => {
};
}
test("whole-turn pages extend past the soft target and keep prompt jumps whole", () => {
const rows: AgentTimelineRow[] = Array.from({ length: 125 }, (_, index) => ({
seq: index + 1,
timestamp: new Date(1000 + index).toISOString(),
turnId: ["short", "long", "latest"][Number(index >= 3) + Number(index >= 123)],
item: {
type: "tool_call",
callId: `tool-${index}`,
name: "shell",
status: "completed",
error: null,
detail: { type: "unknown", input: null, output: null },
},
}));
const tail = selectProjectedTimelinePage({
rows,
direction: "tail",
limit: 40,
wholeTurns: true,
});
expect(tail.entries.map((entry) => entry.seqStart)).toEqual(
rows.slice(3).map((row) => row.seq),
);
expect(tail).toMatchObject({ startSeq: 4, endSeq: 125, hasOlder: true, hasNewer: false });
const before = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 124,
limit: 40,
wholeTurns: true,
});
expect(before.entries.map((entry) => entry.seqStart)).toEqual(
rows.slice(3, 123).map((row) => row.seq),
);
expect(before.endSeq).toBe(123);
const older = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 4,
limit: 40,
wholeTurns: true,
});
expect(older.entries.map((entry) => entry.seqStart)).toEqual([1, 2, 3]);
expect(older.hasOlder).toBe(false);
const jump = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 70,
limit: 40,
wholeTurns: true,
});
expect(jump.entries.map((entry) => entry.seqStart)).toEqual(
rows.slice(3, 123).map((row) => row.seq),
);
expect(jump).toMatchObject({ startSeq: 4, endSeq: 123, hasOlder: true, hasNewer: true });
const nearPreviousPrompt = selectProjectedTimelinePage({
rows,
direction: "before",
cursorSeq: 10,
limit: 40,
wholeTurns: true,
});
expect(nearPreviousPrompt).toMatchObject({ startSeq: 1, endSeq: 123 });
expect(
selectProjectedTimelinePage({ rows, direction: "tail", limit: 40 }).entries,
).toHaveLength(40);
});
test("whole-turn pages share short runs and use the same deterministic legacy triggers", () => {
const rows: AgentTimelineRow[] = Array.from({ length: 24 }, (_, index) => ({
seq: index + 1,
timestamp: new Date(1000 + index).toISOString(),
item:
index % 2 === 0
? { type: "user_message", text: `prompt-${index}` }
: { type: "assistant_message", text: `reply-${index}` },
}));
const page = selectProjectedTimelinePage({
rows,
direction: "tail",
limit: 7,
wholeTurns: true,
});
expect(page.entries.map((entry) => entry.seqStart)).toEqual([17, 18, 19, 20, 21, 22, 23, 24]);
expect(
selectProjectedTimelinePage({ rows, direction: "tail", limit: 0, wholeTurns: true }).entries,
).toHaveLength(24);
rows.push({
...toolRow(25, "completed"),
item: { ...toolRow(25, "completed").item, name: "heartbeat" } as AgentTimelineRow["item"],
});
rows.push({
seq: 26,
timestamp: new Date().toISOString(),
item: { type: "assistant_message", text: "awake" },
});
expect(
selectProjectedTimelinePage({
rows,
direction: "tail",
limit: 1,
wholeTurns: true,
}).entries.map((entry) => entry.seqStart),
).toEqual([25, 26]);
});
test("whole-turn tails close interleaved source coverage without changing forward catch-up", () => {
const rows: AgentTimelineRow[] = [
{
seq: 1,
timestamp: new Date(1).toISOString(),
turnId: "first",
item: { type: "user_message", text: "go" },
},
{ ...toolRow(2, "running"), turnId: "first" },
{
seq: 3,
timestamp: new Date(3).toISOString(),
turnId: "second",
item: { type: "user_message", text: "next" },
},
{
seq: 4,
timestamp: new Date(4).toISOString(),
turnId: "second",
item: { type: "assistant_message", text: "done" },
},
{ ...toolRow(5, "completed"), turnId: "first" },
];
expect(
selectProjectedTimelinePage({ rows, direction: "tail", limit: 1, wholeTurns: true }),
).toMatchObject({
startSeq: 1,
endSeq: 5,
hasOlder: false,
hasNewer: false,
});
expect(
selectProjectedTimelinePage({
rows,
direction: "after",
cursorSeq: 2,
limit: 1,
wholeTurns: true,
}),
).toEqual(selectProjectedTimelinePage({ rows, direction: "after", cursorSeq: 2, limit: 1 }));
});
test("tail page returns full projected items instead of tool lifecycle deltas", () => {
const rows: AgentTimelineRow[] = [
{ seq: 1, timestamp: "2026-02-13T00:00:00.000Z", item: { type: "user_message", text: "go" } },
@@ -1,3 +1,4 @@
import { continuesTimelineTurn, isTimelineTurnTrigger } from "@getpaseo/protocol/timeline-turn";
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
import { timelineItemIdentity } from "@getpaseo/protocol/timeline-identity";
@@ -296,6 +297,7 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
export type ProjectedTimelineRow = AgentTimelineRow & TimelineProjectionEntry;
interface ProjectedTimelinePageOptions {
wholeTurns?: boolean;
bounds: { minSeq: number; maxSeq: number };
direction: TimelineLimitDirection;
cursorSeq?: number;
@@ -423,7 +425,8 @@ export class TimelineProjection {
private selectTail(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const requestedStart = limit === 0 ? 0 : Math.max(0, this.rows.length - limit);
const start = this.spans[this.spanIndex(requestedStart)].start;
const coveredStart = this.spans[this.spanIndex(requestedStart)].start;
const start = input.wholeTurns ? this.wholeTurnWindowStart(coveredStart) : coveredStart;
return {
entries: this.rows.slice(start),
startSeq: this.rows[start].seqStart,
@@ -433,6 +436,17 @@ export class TimelineProjection {
};
}
private wholeTurnWindowStart(requestedStart: number): number {
let start = requestedStart;
for (;;) {
while (start > 0 && continuesProjectedTurn(this.rows[start - 1], this.rows[start])) start--;
// Source coverage can pull in an earlier tool and therefore its entire run.
const coveredStart = this.spans[this.spanIndex(start)].start;
if (coveredStart === start) return start;
start = coveredStart;
}
}
private selectAfter(input: ProjectedTimelinePageOptions): ProjectedTimelinePageSelection {
const startSeq = Math.max(
input.bounds.minSeq,
@@ -480,13 +494,23 @@ export class TimelineProjection {
else high = middle;
}
const limit = Math.max(0, Math.floor(input.limit ?? 0));
const start = limit === 0 ? 0 : Math.max(0, low - limit);
const requestedStart = limit === 0 ? 0 : Math.max(0, low - limit);
const start = input.wholeTurns ? this.wholeTurnWindowStart(requestedStart) : requestedStart;
// Choose the window before extending the right edge, so a prompt jump
// cannot lose its target when the next turn is arbitrarily large.
if (input.wholeTurns) {
while (low < this.rows.length && continuesProjectedTurn(this.rows[low - 1], this.rows[low]))
low++;
}
let selectedEnd = endSeq;
if (input.wholeTurns)
selectedEnd = low === this.rows.length ? input.bounds.maxSeq : this.rows[low].seqStart - 1;
return {
entries: this.rows.slice(start, low),
startSeq: this.rows[start]?.seqStart ?? null,
endSeq,
endSeq: selectedEnd,
hasOlder: start > 0,
hasNewer: endSeq < input.bounds.maxSeq,
hasNewer: selectedEnd < input.bounds.maxSeq,
};
}
return { entries: [], startSeq: null, endSeq: null, hasOlder: false, hasNewer: true };
@@ -675,6 +699,13 @@ function selectProjectedEntriesAfter(input: {
};
}
function continuesProjectedTurn(
previous: TimelineProjectionEntry,
next: TimelineProjectionEntry,
): boolean {
return continuesTimelineTurn(previous.turnId, next.turnId, isTimelineTurnTrigger(next.item));
}
/**
* Apply a projected-count limit to a flat AgentTimelineItem[] without seq metadata.
* Used by callers that only have items in hand (e.g. MCP tools reading
+1
View File
@@ -7805,6 +7805,7 @@ export class Session {
direction,
cursor,
limit: msg.limit,
wholeTurns: msg.wholeTurns,
});
const selectedTimeline = {
timeline: fetchedControlTimeline,
@@ -2062,6 +2062,7 @@ export class VoiceAssistantWebSocketServer {
providerSubagents: true,
// COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement.
projectedSubagentTimeline: true,
wholeTurnTimeline: true,
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
providerSubagentNesting: true,
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.