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 { isLikelyExternalToolName } from "./tool-name-normalization.js";
|
||||||
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
import { buildToolCallDisplayModel } from "../../shared/tool-call-display.js";
|
||||||
|
import { projectTimelineRows } from "./timeline-projection.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_ITEMS = 0;
|
const DEFAULT_MAX_ITEMS = 0;
|
||||||
const MAX_TOOL_INPUT_CHARS = 400;
|
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)}...`;
|
return `${normalized.slice(0, MAX_TOOL_SUMMARY_CHARS - 3)}...`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasNonEmptyObject(value: unknown): boolean {
|
function inputFromUnknownDetail(
|
||||||
return (
|
detail: Extract<AgentTimelineItem, { type: "tool_call" }>["detail"],
|
||||||
typeof value === "object" &&
|
): unknown {
|
||||||
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 {
|
|
||||||
return detail.type === "unknown" ? detail.input : null;
|
return detail.type === "unknown" ? detail.input : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function projectForCuration(items: readonly AgentTimelineItem[]): AgentTimelineItem[] {
|
||||||
* Collapse timeline items:
|
const rows = items.map((item, index) => ({
|
||||||
* - Dedupe tool calls by callId (pending/completed -> single)
|
seq: index + 1,
|
||||||
* - Merge consecutive assistant_message/reasoning into single items
|
timestamp: "",
|
||||||
*/
|
item,
|
||||||
function collapseTimeline(items: AgentTimelineItem[]): AgentTimelineItem[] {
|
}));
|
||||||
const result: AgentTimelineItem[] = [];
|
return projectTimelineRows({ rows, mode: "projected" }).map((entry) => entry.item);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -197,8 +87,7 @@ export function curateAgentActivity(
|
|||||||
return "No activity to display.";
|
return "No activity to display.";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collapse timeline: dedupe tool calls, merge consecutive messages
|
const collapsed = projectForCuration(timeline);
|
||||||
const collapsed = collapseTimeline(timeline);
|
|
||||||
|
|
||||||
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
const maxItems = options?.maxItems ?? DEFAULT_MAX_ITEMS;
|
||||||
const recentItems =
|
const recentItems =
|
||||||
|
|||||||
@@ -2009,4 +2009,68 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
"archived-activity-agent",
|
"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,
|
toAgentPayload,
|
||||||
} from "./agent-projections.js";
|
} from "./agent-projections.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
|
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
|
import { isStoredAgentProviderAvailable } from "../persistence-hooks.js";
|
||||||
@@ -1935,18 +1936,19 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom
|
|||||||
const timeline = agentManager.getTimeline(agentId);
|
const timeline = agentManager.getTimeline(agentId);
|
||||||
const snapshot = agentManager.getAgent(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 noun = totalProjected === 1 ? "activity" : "activities";
|
||||||
const totalCount = timeline.length;
|
const countHeader =
|
||||||
const shownCount = activitiesToCurate.length;
|
limit && shownProjected < totalProjected
|
||||||
|
? `Showing ${shownProjected} of ${totalProjected} ${noun} (limited to ${limit})`
|
||||||
let countHeader: string;
|
: `Showing all ${totalProjected} ${noun}`;
|
||||||
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 contentWithCount = `${countHeader}\n\n${curatedContent}`;
|
const contentWithCount = `${countHeader}\n\n${curatedContent}`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
import type { AgentManager, ManagedAgent, WaitForAgentResult } from "./agent-manager.js";
|
||||||
import { curateAgentActivity } from "./activity-curator.js";
|
import { curateAgentActivity } from "./activity-curator.js";
|
||||||
|
import { selectItemsByProjectedLimit } from "./timeline-projection.js";
|
||||||
import type { AgentStorage } from "./agent-storage.js";
|
import type { AgentStorage } from "./agent-storage.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
import { serializeAgentSnapshot } from "../messages.js";
|
import { serializeAgentSnapshot } from "../messages.js";
|
||||||
@@ -147,7 +148,12 @@ export async function waitForAgentWithTimeout(
|
|||||||
if (error instanceof Error && error.message === "wait timeout") {
|
if (error instanceof Error && error.message === "wait timeout") {
|
||||||
const snapshot = agentManager.getAgent(agentId);
|
const snapshot = agentManager.getAgent(agentId);
|
||||||
const timeline = agentManager.getTimeline(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 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}`;
|
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 {
|
return {
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
|
|||||||
timestamp: `2026-02-01T00:00:0${index}.000Z`,
|
timestamp: `2026-02-01T00:00:0${index}.000Z`,
|
||||||
item,
|
item,
|
||||||
}));
|
}));
|
||||||
const projected = projectTimelineRows(rows, "claude", "projected");
|
const projected = projectTimelineRows({ rows, mode: "projected" });
|
||||||
const projectedTaskCalls = projected.filter(
|
const projectedTaskCalls = projected.filter(
|
||||||
(entry) => entry.item.type === "tool_call" && entry.item.callId === "task-call-1",
|
(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).toHaveLength(2);
|
||||||
expect(projected[0]?.item).toEqual({
|
expect(projected[0]?.item).toEqual({
|
||||||
@@ -39,6 +39,32 @@ describe("projectTimelineRows", () => {
|
|||||||
expect(projected[0]?.collapsed).toContain("assistant_merge");
|
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", () => {
|
test("collapses tool lifecycle by callId and reports exact source seq ranges", () => {
|
||||||
const rows: AgentTimelineRow[] = [
|
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);
|
expect(projected).toHaveLength(2);
|
||||||
const tool = projected[0];
|
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).toHaveLength(2);
|
||||||
expect(projected[0]?.item).toEqual(rows[0]?.item);
|
expect(projected[0]?.item).toEqual(rows[0]?.item);
|
||||||
@@ -152,7 +178,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "codex",
|
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
@@ -193,7 +218,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "claude",
|
|
||||||
direction: "after",
|
direction: "after",
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
@@ -247,7 +271,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "codex",
|
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 2,
|
limit: 2,
|
||||||
});
|
});
|
||||||
@@ -306,7 +329,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "codex",
|
|
||||||
direction: "after",
|
direction: "after",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
@@ -347,7 +369,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "claude",
|
|
||||||
direction: "before",
|
direction: "before",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
@@ -405,7 +426,6 @@ describe("selectTimelineWindowByProjectedLimit", () => {
|
|||||||
|
|
||||||
const selected = selectTimelineWindowByProjectedLimit({
|
const selected = selectTimelineWindowByProjectedLimit({
|
||||||
rows,
|
rows,
|
||||||
provider: "codex",
|
|
||||||
direction: "tail",
|
direction: "tail",
|
||||||
limit: 1,
|
limit: 1,
|
||||||
collapseToolLifecycle: false,
|
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";
|
import type { AgentTimelineRow } from "./agent-manager.js";
|
||||||
|
|
||||||
export type TimelineProjectionMode = "canonical" | "projected";
|
export type TimelineProjectionMode = "canonical" | "projected";
|
||||||
@@ -8,11 +8,10 @@ export interface TimelineSeqRange {
|
|||||||
endSeq: number;
|
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 type TimelineLimitDirection = "tail" | "before" | "after";
|
||||||
|
|
||||||
export interface TimelineProjectionEntry {
|
export interface TimelineProjectionEntry {
|
||||||
provider: AgentProvider;
|
|
||||||
item: AgentTimelineItem;
|
item: AgentTimelineItem;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
seqStart: number;
|
seqStart: number;
|
||||||
@@ -100,12 +99,8 @@ function mergeToolCallItems(
|
|||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeCanonicalEntries(
|
function makeCanonicalEntries(rows: readonly AgentTimelineRow[]): WorkingEntry[] {
|
||||||
rows: readonly AgentTimelineRow[],
|
|
||||||
provider: AgentProvider,
|
|
||||||
): WorkingEntry[] {
|
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
provider,
|
|
||||||
item: row.item,
|
item: row.item,
|
||||||
timestamp: row.timestamp,
|
timestamp: row.timestamp,
|
||||||
seqStart: row.seq,
|
seqStart: row.seq,
|
||||||
@@ -156,6 +151,45 @@ function collapseToolLifecycle(entries: readonly WorkingEntry[]): WorkingEntry[]
|
|||||||
return output;
|
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[] {
|
function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[] {
|
||||||
const output: WorkingEntry[] = [];
|
const output: WorkingEntry[] = [];
|
||||||
|
|
||||||
@@ -198,18 +232,18 @@ function mergeAssistantChunks(entries: readonly WorkingEntry[]): WorkingEntry[]
|
|||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectTimelineRows(
|
export function projectTimelineRows(input: {
|
||||||
rows: readonly AgentTimelineRow[],
|
rows: readonly AgentTimelineRow[];
|
||||||
provider: AgentProvider,
|
mode: TimelineProjectionMode;
|
||||||
mode: TimelineProjectionMode,
|
}): TimelineProjectionEntry[] {
|
||||||
): TimelineProjectionEntry[] {
|
const canonical = makeCanonicalEntries(input.rows);
|
||||||
const canonical = makeCanonicalEntries(rows, provider);
|
if (input.mode === "canonical") {
|
||||||
if (mode === "canonical") {
|
|
||||||
return canonical;
|
return canonical;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolCollapsed = collapseToolLifecycle(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: {
|
export function selectTimelineWindowByProjectedLimit(input: {
|
||||||
rows: readonly AgentTimelineRow[];
|
rows: readonly AgentTimelineRow[];
|
||||||
provider: AgentProvider;
|
|
||||||
direction: TimelineLimitDirection;
|
direction: TimelineLimitDirection;
|
||||||
limit: number;
|
limit: number;
|
||||||
collapseToolLifecycle?: boolean;
|
collapseToolLifecycle?: boolean;
|
||||||
}): ProjectedWindowSelection {
|
}): ProjectedWindowSelection {
|
||||||
const { rows, provider, direction } = input;
|
const { rows, direction } = input;
|
||||||
const limit = Math.max(0, Math.floor(input.limit));
|
const limit = Math.max(0, Math.floor(input.limit));
|
||||||
const collapseTools = input.collapseToolLifecycle ?? true;
|
const collapseTools = input.collapseToolLifecycle ?? true;
|
||||||
const canonical = makeCanonicalEntries(rows, provider);
|
const canonical = makeCanonicalEntries(rows);
|
||||||
const projectedAll = mergeAssistantChunks(
|
const projectedAll = mergeReasoningChunks(
|
||||||
collapseTools ? collapseToolLifecycle(canonical) : canonical,
|
mergeAssistantChunks(collapseTools ? collapseToolLifecycle(canonical) : canonical),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (projectedAll.length === 0) {
|
if (projectedAll.length === 0) {
|
||||||
@@ -308,3 +341,37 @@ export function selectTimelineWindowByProjectedLimit(input: {
|
|||||||
maxSeq: Number.isFinite(maxSeq) ? maxSeq : null,
|
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;
|
direction: AgentTimelineFetchDirection;
|
||||||
cursor: AgentTimelineCursor | undefined;
|
cursor: AgentTimelineCursor | undefined;
|
||||||
requestedLimit: number;
|
requestedLimit: number;
|
||||||
provider: AgentSnapshotPayload["provider"];
|
|
||||||
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
||||||
}): {
|
}): {
|
||||||
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
timeline: ReturnType<AgentManager["fetchTimeline"]>;
|
||||||
@@ -7321,13 +7320,12 @@ export class Session {
|
|||||||
minSeq: number | null;
|
minSeq: number | null;
|
||||||
maxSeq: number | null;
|
maxSeq: number | null;
|
||||||
} {
|
} {
|
||||||
const { agentId, direction, cursor, requestedLimit, provider } = params;
|
const { agentId, direction, cursor, requestedLimit } = params;
|
||||||
let timeline = params.timeline;
|
let timeline = params.timeline;
|
||||||
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
|
const projectedLimit = Math.max(1, Math.floor(requestedLimit));
|
||||||
let fetchLimit = projectedLimit;
|
let fetchLimit = projectedLimit;
|
||||||
let projectedWindow = selectTimelineWindowByProjectedLimit({
|
let projectedWindow = selectTimelineWindowByProjectedLimit({
|
||||||
rows: timeline.rows,
|
rows: timeline.rows,
|
||||||
provider,
|
|
||||||
direction,
|
direction,
|
||||||
limit: projectedLimit,
|
limit: projectedLimit,
|
||||||
collapseToolLifecycle: false,
|
collapseToolLifecycle: false,
|
||||||
@@ -7362,7 +7360,6 @@ export class Session {
|
|||||||
});
|
});
|
||||||
projectedWindow = selectTimelineWindowByProjectedLimit({
|
projectedWindow = selectTimelineWindowByProjectedLimit({
|
||||||
rows: timeline.rows,
|
rows: timeline.rows,
|
||||||
provider,
|
|
||||||
direction,
|
direction,
|
||||||
limit: projectedLimit,
|
limit: projectedLimit,
|
||||||
collapseToolLifecycle: false,
|
collapseToolLifecycle: false,
|
||||||
@@ -7424,11 +7421,10 @@ export class Session {
|
|||||||
direction,
|
direction,
|
||||||
cursor,
|
cursor,
|
||||||
requestedLimit,
|
requestedLimit,
|
||||||
provider: snapshot.provider,
|
|
||||||
timeline,
|
timeline,
|
||||||
});
|
});
|
||||||
timeline = projectedResult.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) {
|
if (projectedResult.minSeq !== null && projectedResult.maxSeq !== null) {
|
||||||
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
|
startCursor = { epoch: timeline.epoch, seq: projectedResult.minSeq };
|
||||||
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
|
endCursor = { epoch: timeline.epoch, seq: projectedResult.maxSeq };
|
||||||
@@ -7440,7 +7436,7 @@ export class Session {
|
|||||||
const lastRow = timeline.rows[timeline.rows.length - 1];
|
const lastRow = timeline.rows[timeline.rows.length - 1];
|
||||||
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
|
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null;
|
||||||
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.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({
|
this.emit({
|
||||||
@@ -7460,7 +7456,15 @@ export class Session {
|
|||||||
endCursor,
|
endCursor,
|
||||||
hasOlder,
|
hasOlder,
|
||||||
hasNewer,
|
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,
|
error: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2390,7 +2390,7 @@ export const AgentTimelineEntryPayloadSchema = z.object({
|
|||||||
seqStart: z.number().int().nonnegative(),
|
seqStart: z.number().int().nonnegative(),
|
||||||
seqEnd: z.number().int().nonnegative(),
|
seqEnd: z.number().int().nonnegative(),
|
||||||
sourceSeqRanges: z.array(AgentTimelineSeqRangeSchema),
|
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({
|
export const FetchAgentTimelineResponseMessageSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user