fix(server): apply get_agent_activity limit to projected entries
The MCP get_agent_activity tool sliced the raw timeline before passing to the curator, so `limit: 5` could land mid-message and return a trailing fragment of streamed assistant text. Now goes through the existing projection pipeline so the limit counts whole projected entries — assistant deltas merged, tool calls collapsed by callId, reasoning chunks merged. Same bug fixed in the wait_for_agent timeout's "Recent activity" preview, which used the same raw slice. Consolidates the merge logic: activity-curator no longer ships its own collapseTimeline, it delegates to projectTimelineRows. One source of truth for "what is a complete message". Drops the meaningless `provider` parameter that was threaded through the projection module — merge logic never branched on it. The wire schema keeps `provider` per entry; session stamps it once at the emit boundary. Adds `reasoning_merge` to the projection kind enum (additive wire change).
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
|
||||
const DEFAULT_MAX_ITEMS = 0;
|
||||
const MAX_TOOL_INPUT_CHARS = 400;
|
||||
@@ -60,130 +61,19 @@ function formatToolSummary(summary: string | undefined): string | null {
|
||||
return `${normalized.slice(0, MAX_TOOL_SUMMARY_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
function hasNonEmptyObject(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function mergeUnknownValue(existing: unknown | null, incoming: unknown | null): unknown | null {
|
||||
if (incoming === null) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (!hasNonEmptyObject(incoming) && hasNonEmptyObject(existing)) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function mergeToolDetail(existing: ToolCallDetail, incoming: ToolCallDetail): ToolCallDetail {
|
||||
if (existing.type === "unknown" && incoming.type !== "unknown") {
|
||||
return incoming;
|
||||
}
|
||||
if (incoming.type === "unknown" && existing.type !== "unknown") {
|
||||
return existing;
|
||||
}
|
||||
if (existing.type === "unknown" && incoming.type === "unknown") {
|
||||
return {
|
||||
type: "unknown",
|
||||
input: mergeUnknownValue(existing.input, incoming.input),
|
||||
output: mergeUnknownValue(existing.output, incoming.output),
|
||||
};
|
||||
}
|
||||
if (existing.type === incoming.type) {
|
||||
return { ...existing, ...incoming } as ToolCallDetail;
|
||||
}
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function inputFromUnknownDetail(detail: ToolCallDetail): unknown {
|
||||
function inputFromUnknownDetail(
|
||||
detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"],
|
||||
): unknown {
|
||||
return detail.type === "unknown" ? detail.input : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse timeline items:
|
||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
||||
* - Merge consecutive assistant_message/reasoning into single items
|
||||
*/
|
||||
function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
const result: AgentTimelineItem[] = [];
|
||||
const toolCallMap = new Map<string, AgentTimelineItem>();
|
||||
let assistantBuffer = "";
|
||||
let reasoningBuffer = "";
|
||||
|
||||
function flushAssistant() {
|
||||
if (assistantBuffer) {
|
||||
result.push({ type: "assistant_message", text: assistantBuffer });
|
||||
assistantBuffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
function flushReasoning() {
|
||||
if (reasoningBuffer) {
|
||||
result.push({ type: "reasoning", text: reasoningBuffer });
|
||||
reasoningBuffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
function flushToolCalls() {
|
||||
for (const toolItem of toolCallMap.values()) {
|
||||
result.push(toolItem);
|
||||
}
|
||||
toolCallMap.clear();
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.type === "assistant_message") {
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
assistantBuffer += item.text;
|
||||
} else if (item.type === "reasoning") {
|
||||
flushAssistant();
|
||||
flushToolCalls();
|
||||
reasoningBuffer += item.text;
|
||||
} else if (item.type === "tool_call") {
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
const existing = toolCallMap.get(item.callId);
|
||||
if (existing && existing.type === "tool_call") {
|
||||
if (item.status === "failed") {
|
||||
toolCallMap.set(item.callId, {
|
||||
...existing,
|
||||
...item,
|
||||
detail: mergeToolDetail(existing.detail, item.detail),
|
||||
error: item.error,
|
||||
metadata: item.metadata,
|
||||
});
|
||||
} else {
|
||||
toolCallMap.set(item.callId, {
|
||||
...existing,
|
||||
...item,
|
||||
detail: mergeToolDetail(existing.detail, item.detail),
|
||||
error: null,
|
||||
metadata: item.metadata,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
toolCallMap.set(item.callId, item);
|
||||
}
|
||||
} else {
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
flushAssistant();
|
||||
flushReasoning();
|
||||
flushToolCalls();
|
||||
|
||||
return result;
|
||||
function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineItem[] {
|
||||
const rows = items.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: "",
|
||||
item,
|
||||
}));
|
||||
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,8 +87,7 @@ export function curateAgentActivity(
|
||||
return "No activity to display.";
|
||||
}
|
||||
|
||||
// Collapse timeline: dedupe tool calls, merge consecutive messages
|
||||
const collapsed = collapseTimeline(timeline);
|
||||
const collapsed = projectForCuration(timeline);
|
||||
|
||||
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||
const recentItems =
|
||||
|
||||
@@ -2009,4 +2009,68 @@ describe("agent snapshot MCP serialization", () => {
|
||||
"archived-activity-agent",
|
||||
);
|
||||
});
|
||||
|
||||
it("get_agent_activity limit counts projected messages, not raw deltas", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const snapshot = createManagedAgent({ id: "live-activity-agent", currentModeId: "default" });
|
||||
spies.agentManager.getAgent.mockReturnValue(snapshot);
|
||||
spies.agentManager.getTimeline.mockReturnValue([
|
||||
{ type: "user_message", text: "Say hi" },
|
||||
{ type: "assistant_message", text: "Hello " },
|
||||
{ type: "assistant_message", text: "world" },
|
||||
{ type: "assistant_message", text: "." },
|
||||
{ type: "assistant_message", text: " How" },
|
||||
{ type: "assistant_message", text: " are" },
|
||||
{ type: "assistant_message", text: " you?" },
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: createTestLogger(),
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent", limit: 1 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("Hello world. How are you?");
|
||||
});
|
||||
|
||||
it("get_agent_activity limit=2 returns the last two projected entries whole", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const snapshot = createManagedAgent({ id: "live-activity-agent-2", currentModeId: "default" });
|
||||
spies.agentManager.getAgent.mockReturnValue(snapshot);
|
||||
spies.agentManager.getTimeline.mockReturnValue([
|
||||
{ type: "user_message", text: "u1" },
|
||||
{ type: "assistant_message", text: "first " },
|
||||
{ type: "assistant_message", text: "answer" },
|
||||
{ type: "user_message", text: "u2" },
|
||||
{ type: "assistant_message", text: "second " },
|
||||
{ type: "assistant_message", text: "answer" },
|
||||
{ type: "user_message", text: "u3" },
|
||||
{ type: "assistant_message", text: "third " },
|
||||
{ type: "assistant_message", text: "answer" },
|
||||
]);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
logger: createTestLogger(),
|
||||
providerRegistry: {
|
||||
claude: createProviderDefinition({}),
|
||||
} as unknown as Record<AgentProvider, ProviderDefinition>,
|
||||
});
|
||||
const tool = registeredTool(server, "get_agent_activity");
|
||||
const response = await tool.callback({ agentId: "live-activity-agent-2", limit: 2 });
|
||||
|
||||
const content = String(response.structuredContent.content);
|
||||
expect(content).toContain("[User] u3");
|
||||
expect(content).toContain("third answer");
|
||||
expect(content).not.toContain("[User] u2");
|
||||
expect(content).not.toContain("second answer");
|
||||
expect(content).not.toContain("first answer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
toAgentPayload,
|
||||
} from "./agent-projections.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
|
||||
@@ -1935,18 +1936,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
|
||||
const activitiesToCurate = limit ? timeline.slice(-limit) : timeline;
|
||||
const selection = selectItemsByProjectedLimit({
|
||||
items: timeline,
|
||||
direction: "tail",
|
||||
limit: limit ?? 0,
|
||||
});
|
||||
const curatedContent = curateAgentActivity(selection.items);
|
||||
const { totalProjected, shownProjected } = selection;
|
||||
|
||||
const curatedContent = curateAgentActivity(activitiesToCurate);
|
||||
const totalCount = timeline.length;
|
||||
const shownCount = activitiesToCurate.length;
|
||||
|
||||
let countHeader: string;
|
||||
if (limit && shownCount < totalCount) {
|
||||
countHeader = `Showing ${shownCount} of ${totalCount} ${totalCount === 1 ? "activity" : "activities"} (limited to ${limit})`;
|
||||
} else {
|
||||
countHeader = `Showing all ${totalCount} ${totalCount === 1 ? "activity" : "activities"}`;
|
||||
}
|
||||
const noun = totalProjected === 1 ? "activity" : "activities";
|
||||
const countHeader =
|
||||
limit && shownProjected < totalProjected
|
||||
? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})`
|
||||
: `Showing all ${totalProjected} ${noun}`;
|
||||
|
||||
const contentWithCount = `${countHeader}\n\n${curatedContent}`;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||
import { curateAgentActivity } from "./activity-curator.js";
|
||||
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||
import type { AgentStorage } from "./agent-storage.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { serializeAgentSnapshot } from "../messages.js";
|
||||
@@ -147,7 +148,12 @@ export async function waitForAgentWithTimeout(
|
||||
if (error instanceof Error && error.message === "wait timeout") {
|
||||
const snapshot = agentManager.getAgent(agentId);
|
||||
const timeline = agentManager.getTimeline(agentId);
|
||||
const recentActivity = curateAgentActivity(timeline.slice(-5));
|
||||
const recent = selectItemsByProjectedLimit({
|
||||
items: timeline,
|
||||
direction: "tail",
|
||||
limit: 5,
|
||||
});
|
||||
const recentActivity = curateAgentActivity(recent.items);
|
||||
const waitedSeconds = Math.round(AGENT_WAIT_TIMEOUT_MS / 1000);
|
||||
const message = `Awaiting the agent timed out after ${waitedSeconds}s. This does not mean the agent failed - call wait_for_agent again to continue waiting.\n\nRecent activity:\n${recentActivity}`;
|
||||
return {
|
||||
|
||||
@@ -301,7 +301,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
||||
timestamp: `2026-02-01T00:00:0${index}.000Z`,
|
||||
item,
|
||||
}));
|
||||
const projected = projectTimelineRows(rows, "claude", "projected");
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
const projectedTaskCalls = projected.filter(
|
||||
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1",
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("projectTimelineRows", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows(rows, "codex", "projected");
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[0]?.item).toEqual({
|
||||
@@ -39,6 +39,32 @@ describe("projectTimelineRows", () => {
|
||||
expect(projected[0]?.collapsed).toContain("assistant_merge");
|
||||
});
|
||||
|
||||
test("merges adjacent reasoning chunks in projected mode", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
seq: 1,
|
||||
timestamp: "2026-02-13T00:00:00.000Z",
|
||||
item: { type: "reasoning", text: "Step " },
|
||||
},
|
||||
{
|
||||
seq: 2,
|
||||
timestamp: "2026-02-13T00:00:00.100Z",
|
||||
item: { type: "reasoning", text: "by step" },
|
||||
},
|
||||
{
|
||||
seq: 3,
|
||||
timestamp: "2026-02-13T00:00:00.200Z",
|
||||
item: { type: "assistant_message", text: "done" },
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[0]?.item).toEqual({ type: "reasoning", text: "Step by step" });
|
||||
expect(projected[0]?.collapsed).toContain("reasoning_merge");
|
||||
});
|
||||
|
||||
test("collapses tool lifecycle by callId and reports exact source seq ranges", () => {
|
||||
const rows: AgentTimelineRow[] = [
|
||||
{
|
||||
@@ -80,7 +106,7 @@ describe("projectTimelineRows", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows(rows, "codex", "projected");
|
||||
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
const tool = projected[0];
|
||||
@@ -110,7 +136,7 @@ describe("projectTimelineRows", () => {
|
||||
},
|
||||
];
|
||||
|
||||
const projected = projectTimelineRows(rows, "codex", "canonical");
|
||||
const projected = projectTimelineRows({ rows, mode: "canonical" });
|
||||
|
||||
expect(projected).toHaveLength(2);
|
||||
expect(projected[0]?.item).toEqual(rows[0]?.item);
|
||||
@@ -152,7 +178,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
});
|
||||
@@ -193,7 +218,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "claude",
|
||||
direction: "after",
|
||||
limit: 2,
|
||||
});
|
||||
@@ -247,7 +271,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
limit: 2,
|
||||
});
|
||||
@@ -306,7 +329,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "codex",
|
||||
direction: "after",
|
||||
limit: 1,
|
||||
});
|
||||
@@ -347,7 +369,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "claude",
|
||||
direction: "before",
|
||||
limit: 1,
|
||||
});
|
||||
@@ -405,7 +426,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
||||
|
||||
const selected = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
provider: "codex",
|
||||
direction: "tail",
|
||||
limit: 1,
|
||||
collapseToolLifecycle: false,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentProvider, AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineItem, ToolCallDetail } from "./agent-sdk-types.js";
|
||||
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||
|
||||
export type TimelineProjectionMode = "canonical" | "projected";
|
||||
@@ -8,11 +8,10 @@ export interface TimelineSeqRange {
|
||||
endSeq: number;
|
||||
}
|
||||
|
||||
export type TimelineProjectionKind = "assistant_merge" | "tool_lifecycle";
|
||||
export type TimelineProjectionKind = "assistant_merge" | "reasoning_merge" | "tool_lifecycle";
|
||||
export type TimelineLimitDirection = "tail" | "before" | "after";
|
||||
|
||||
export interface TimelineProjectionEntry {
|
||||
provider: AgentProvider;
|
||||
item: AgentTimelineItem;
|
||||
timestamp: string;
|
||||
seqStart: number;
|
||||
@@ -100,12 +99,8 @@ function mergeToolCallItems(
|
||||
return merged;
|
||||
}
|
||||
|
||||
function makeCanonicalEntries(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
provider: AgentProvider,
|
||||
): WorkingEntry[] {
|
||||
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] {
|
||||
return rows.map((row) => ({
|
||||
provider,
|
||||
item: row.item,
|
||||
timestamp: row.timestamp,
|
||||
seqStart: row.seq,
|
||||
@@ -156,6 +151,45 @@ function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
return output;
|
||||
}
|
||||
|
||||
function mergeReasoningChunks(entries: readonly WorkingEntry[]): WorkingEntry[] {
|
||||
const output: WorkingEntry[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const previous = output[output.length - 1];
|
||||
const shouldMerge =
|
||||
previous &&
|
||||
previous.item.type === "reasoning" &&
|
||||
entry.item.type === "reasoning" &&
|
||||
previous.seqEnd + 1 === entry.seqStart;
|
||||
|
||||
if (!shouldMerge || !previous) {
|
||||
output.push(entry);
|
||||
continue;
|
||||
}
|
||||
const previousReasoning = previous.item as Extract<AgentTimelineItem, { type: "reasoning" }>;
|
||||
const entryReasoning = entry.item as Extract<AgentTimelineItem, { type: "reasoning" }>;
|
||||
|
||||
const collapsedKinds = new Set<TimelineProjectionKind>([
|
||||
...previous.collapsed,
|
||||
...entry.collapsed,
|
||||
"reasoning_merge",
|
||||
]);
|
||||
|
||||
output[output.length - 1] = {
|
||||
...previous,
|
||||
item: {
|
||||
type: "reasoning",
|
||||
text: `${previousReasoning.text}${entryReasoning.text}`,
|
||||
},
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: mergeSeqRanges(previous.sourceSeqRanges, entry.sourceSeqRanges),
|
||||
collapsed: Array.from(collapsedKinds),
|
||||
};
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] {
|
||||
const output: WorkingEntry[] = [];
|
||||
|
||||
@@ -198,18 +232,18 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
||||
return output;
|
||||
}
|
||||
|
||||
export function projectTimelineRows(
|
||||
rows: readonly AgentTimelineRow[],
|
||||
provider: AgentProvider,
|
||||
mode: TimelineProjectionMode,
|
||||
): TimelineProjectionEntry[] {
|
||||
const canonical = makeCanonicalEntries(rows, provider);
|
||||
if (mode === "canonical") {
|
||||
export function projectTimelineRows(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
mode: TimelineProjectionMode;
|
||||
}): TimelineProjectionEntry[] {
|
||||
const canonical = makeCanonicalEntries(input.rows);
|
||||
if (input.mode === "canonical") {
|
||||
return canonical;
|
||||
}
|
||||
|
||||
const toolCollapsed = collapseToolLifecycle(canonical);
|
||||
return mergeAssistantChunks(toolCollapsed);
|
||||
const assistantMerged = mergeAssistantChunks(toolCollapsed);
|
||||
return mergeReasoningChunks(assistantMerged);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,17 +253,16 @@ export function projectTimelineRows(
|
||||
*/
|
||||
export function selectTimelineWindowByProjectedLimit(input: {
|
||||
rows: readonly AgentTimelineRow[];
|
||||
provider: AgentProvider;
|
||||
direction: TimelineLimitDirection;
|
||||
limit: number;
|
||||
collapseToolLifecycle?: boolean;
|
||||
}): ProjectedWindowSelection {
|
||||
const { rows, provider, direction } = input;
|
||||
const { rows, direction } = input;
|
||||
const limit = Math.max(0, Math.floor(input.limit));
|
||||
const collapseTools = input.collapseToolLifecycle ?? true;
|
||||
const canonical = makeCanonicalEntries(rows, provider);
|
||||
const projectedAll = mergeAssistantChunks(
|
||||
collapseTools ? collapseToolLifecycle(canonical) : canonical,
|
||||
const canonical = makeCanonicalEntries(rows);
|
||||
const projectedAll = mergeReasoningChunks(
|
||||
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
|
||||
);
|
||||
|
||||
if (projectedAll.length === 0) {
|
||||
@@ -308,3 +341,37 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
||||
maxSeq: Number.isFinite(maxSeq) ? maxSeq : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* `agentManager.getTimeline`). Index position is treated as canonical seq.
|
||||
*/
|
||||
export interface ProjectedItemSelection {
|
||||
items: AgentTimelineItem[];
|
||||
totalProjected: number;
|
||||
shownProjected: number;
|
||||
}
|
||||
|
||||
export function selectItemsByProjectedLimit(input: {
|
||||
items: readonly AgentTimelineItem[];
|
||||
direction: TimelineLimitDirection;
|
||||
limit: number;
|
||||
}): ProjectedItemSelection {
|
||||
const rows: AgentTimelineRow[] = input.items.map((item, index) => ({
|
||||
seq: index + 1,
|
||||
timestamp: "",
|
||||
item,
|
||||
}));
|
||||
const projectedAll = projectTimelineRows({ rows, mode: "projected" });
|
||||
const window = selectTimelineWindowByProjectedLimit({
|
||||
rows,
|
||||
direction: input.direction,
|
||||
limit: input.limit,
|
||||
});
|
||||
return {
|
||||
items: window.selectedRows.map((row) => row.item),
|
||||
totalProjected: projectedAll.length,
|
||||
shownProjected: window.projectedEntries.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7313,7 +7313,6 @@ export class Session {
|
||||
direction: AgentTimelineFetchDirection;
|
||||
cursor: AgentTimelineCursor | undefined;
|
||||
requestedLimit: number;
|
||||
provider: AgentSnapshotPayload["provider"];
|
||||
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
||||
}): {
|
||||
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
||||
@@ -7321,13 +7320,12 @@ export class Session {
|
||||
minSeq: number | null;
|
||||
maxSeq: number | null;
|
||||
} {
|
||||
const { agentId, direction, cursor, requestedLimit, provider } = params;
|
||||
const { agentId, direction, cursor, requestedLimit } = params;
|
||||
let timeline = params.timeline;
|
||||
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
|
||||
let fetchLimit = projectedLimit;
|
||||
let projectedWindow = selectTimelineWindowByProjectedLimit({
|
||||
rows: timeline.rows,
|
||||
provider,
|
||||
direction,
|
||||
limit: projectedLimit,
|
||||
collapseToolLifecycle: false,
|
||||
@@ -7362,7 +7360,6 @@ export class Session {
|
||||
});
|
||||
projectedWindow = selectTimelineWindowByProjectedLimit({
|
||||
rows: timeline.rows,
|
||||
provider,
|
||||
direction,
|
||||
limit: projectedLimit,
|
||||
collapseToolLifecycle: false,
|
||||
@@ -7424,11 +7421,10 @@ export class Session {
|
||||
direction,
|
||||
cursor,
|
||||
requestedLimit,
|
||||
provider: snapshot.provider,
|
||||
timeline,
|
||||
});
|
||||
timeline = projectedResult.timeline;
|
||||
entries = projectTimelineRows(projectedResult.selectedRows, snapshot.provider, projection);
|
||||
entries = projectTimelineRows({ rows: projectedResult.selectedRows, mode: projection });
|
||||
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) {
|
||||
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
|
||||
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
|
||||
@@ -7440,7 +7436,7 @@ export class Session {
|
||||
const lastRow = timeline.rows[timeline.rows.length - 1];
|
||||
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
|
||||
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null;
|
||||
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection);
|
||||
entries = projectTimelineRows({ rows: timeline.rows, mode: projection });
|
||||
}
|
||||
|
||||
this.emit({
|
||||
@@ -7460,7 +7456,15 @@ export class Session {
|
||||
endCursor,
|
||||
hasOlder,
|
||||
hasNewer,
|
||||
entries,
|
||||
entries: entries.map((entry) => ({
|
||||
provider: snapshot.provider,
|
||||
item: entry.item,
|
||||
timestamp: entry.timestamp,
|
||||
seqStart: entry.seqStart,
|
||||
seqEnd: entry.seqEnd,
|
||||
sourceSeqRanges: entry.sourceSeqRanges,
|
||||
collapsed: entry.collapsed,
|
||||
})),
|
||||
error: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2390,7 +2390,7 @@ export const AgentTimelineEntryPayloadSchema = z.object({
|
||||
seqStart: z.number().int().nonnegative(),
|
||||
seqEnd: z.number().int().nonnegative(),
|
||||
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
|
||||
collapsed: z.array(z.enum(["assistant_merge", "tool_lifecycle"])),
|
||||
collapsed: z.array(z.enum(["assistant_merge", "reasoning_merge", "tool_lifecycle"])),
|
||||
});
|
||||
|
||||
export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user