Merge branch 'scan-cleanup-opportunities'
This commit is contained in:
@@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { groupActivities, type AgentActivity } from "./agent-activity";
|
||||||
|
|
||||||
|
function timestamp(seconds: number): Date {
|
||||||
|
return new Date(`2026-01-01T00:00:${seconds.toString().padStart(2, "0")}.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("groupActivities", () => {
|
||||||
|
it("groups text chunks and merges tool call updates at the first tool position", () => {
|
||||||
|
const activities: AgentActivity[] = [
|
||||||
|
{
|
||||||
|
timestamp: timestamp(1),
|
||||||
|
update: {
|
||||||
|
kind: "agent_message_chunk",
|
||||||
|
content: { type: "text", text: "Hel" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: timestamp(2),
|
||||||
|
update: {
|
||||||
|
kind: "agent_message_chunk",
|
||||||
|
content: { type: "text", text: "lo" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: timestamp(3),
|
||||||
|
update: {
|
||||||
|
kind: "tool_call",
|
||||||
|
toolCallId: "tool_1",
|
||||||
|
title: "Read file",
|
||||||
|
status: "in_progress",
|
||||||
|
toolKind: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: timestamp(4),
|
||||||
|
update: {
|
||||||
|
kind: "agent_thought_chunk",
|
||||||
|
content: { type: "text", text: "Checking context" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timestamp: timestamp(5),
|
||||||
|
update: {
|
||||||
|
kind: "tool_call_update",
|
||||||
|
toolCallId: "tool_1",
|
||||||
|
status: "completed",
|
||||||
|
output: { ok: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(groupActivities(activities)).toEqual([
|
||||||
|
{
|
||||||
|
kind: "grouped_text",
|
||||||
|
messageType: "agent",
|
||||||
|
text: "Hello",
|
||||||
|
startTimestamp: timestamp(1),
|
||||||
|
endTimestamp: timestamp(2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "merged_tool_call",
|
||||||
|
toolCallId: "tool_1",
|
||||||
|
title: "Read file",
|
||||||
|
status: "completed",
|
||||||
|
toolKind: "read",
|
||||||
|
input: { path: "README.md" },
|
||||||
|
output: { ok: true },
|
||||||
|
content: undefined,
|
||||||
|
locations: undefined,
|
||||||
|
startTimestamp: timestamp(3),
|
||||||
|
endTimestamp: timestamp(5),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "grouped_text",
|
||||||
|
messageType: "thought",
|
||||||
|
text: "Checking context",
|
||||||
|
startTimestamp: timestamp(4),
|
||||||
|
endTimestamp: timestamp(4),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a merged tool call when an update arrives before the initial call", () => {
|
||||||
|
const activities: AgentActivity[] = [
|
||||||
|
{
|
||||||
|
timestamp: timestamp(1),
|
||||||
|
update: {
|
||||||
|
kind: "tool_call_update",
|
||||||
|
toolCallId: "tool_2",
|
||||||
|
title: "Shell",
|
||||||
|
status: "completed",
|
||||||
|
toolKind: "execute",
|
||||||
|
output: { exitCode: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(groupActivities(activities)).toEqual([
|
||||||
|
{
|
||||||
|
kind: "merged_tool_call",
|
||||||
|
toolCallId: "tool_2",
|
||||||
|
title: "Shell",
|
||||||
|
status: "completed",
|
||||||
|
toolKind: "execute",
|
||||||
|
input: undefined,
|
||||||
|
output: { exitCode: 0 },
|
||||||
|
content: undefined,
|
||||||
|
locations: undefined,
|
||||||
|
startTimestamp: timestamp(1),
|
||||||
|
endTimestamp: timestamp(1),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,3 @@
|
|||||||
/**
|
|
||||||
* Discriminated union types for session updates
|
|
||||||
*/
|
|
||||||
export type SessionUpdate =
|
export type SessionUpdate =
|
||||||
| UserMessageChunk
|
| UserMessageChunk
|
||||||
| AgentMessageChunk
|
| AgentMessageChunk
|
||||||
@@ -11,6 +8,22 @@ export type SessionUpdate =
|
|||||||
| AvailableCommandsUpdate
|
| AvailableCommandsUpdate
|
||||||
| CurrentModeUpdate;
|
| CurrentModeUpdate;
|
||||||
|
|
||||||
|
export type ToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
|
||||||
|
|
||||||
|
export type ToolKind =
|
||||||
|
| "read"
|
||||||
|
| "edit"
|
||||||
|
| "delete"
|
||||||
|
| "move"
|
||||||
|
| "search"
|
||||||
|
| "execute"
|
||||||
|
| "think"
|
||||||
|
| "fetch"
|
||||||
|
| "switch_mode"
|
||||||
|
| "other";
|
||||||
|
|
||||||
|
type TextMessageType = "user" | "agent" | "thought";
|
||||||
|
|
||||||
export interface UserMessageChunk {
|
export interface UserMessageChunk {
|
||||||
kind: "user_message_chunk";
|
kind: "user_message_chunk";
|
||||||
content: {
|
content: {
|
||||||
@@ -39,18 +52,8 @@ export interface ToolCall {
|
|||||||
kind: "tool_call";
|
kind: "tool_call";
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
title: string;
|
title: string;
|
||||||
status?: "pending" | "in_progress" | "completed" | "failed";
|
status?: ToolCallStatus;
|
||||||
toolKind?:
|
toolKind?: ToolKind;
|
||||||
| "read"
|
|
||||||
| "edit"
|
|
||||||
| "delete"
|
|
||||||
| "move"
|
|
||||||
| "search"
|
|
||||||
| "execute"
|
|
||||||
| "think"
|
|
||||||
| "fetch"
|
|
||||||
| "switch_mode"
|
|
||||||
| "other";
|
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
output?: Record<string, unknown>;
|
output?: Record<string, unknown>;
|
||||||
content?: unknown[];
|
content?: unknown[];
|
||||||
@@ -61,19 +64,8 @@ export interface ToolCallUpdate {
|
|||||||
kind: "tool_call_update";
|
kind: "tool_call_update";
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
status?: "pending" | "in_progress" | "completed" | "failed" | null;
|
status?: ToolCallStatus | null;
|
||||||
toolKind?:
|
toolKind?: ToolKind | null;
|
||||||
| "read"
|
|
||||||
| "edit"
|
|
||||||
| "delete"
|
|
||||||
| "move"
|
|
||||||
| "search"
|
|
||||||
| "execute"
|
|
||||||
| "think"
|
|
||||||
| "fetch"
|
|
||||||
| "switch_mode"
|
|
||||||
| "other"
|
|
||||||
| null;
|
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
output?: Record<string, unknown>;
|
output?: Record<string, unknown>;
|
||||||
content?: unknown[] | null;
|
content?: unknown[] | null;
|
||||||
@@ -102,44 +94,25 @@ export interface CurrentModeUpdate {
|
|||||||
currentModeId: string;
|
currentModeId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Activity item with timestamp
|
|
||||||
*/
|
|
||||||
export interface AgentActivity {
|
export interface AgentActivity {
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
update: SessionUpdate;
|
update: SessionUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Grouped text message (consecutive chunks combined)
|
|
||||||
*/
|
|
||||||
export interface GroupedTextMessage {
|
export interface GroupedTextMessage {
|
||||||
kind: "grouped_text";
|
kind: "grouped_text";
|
||||||
messageType: "user" | "agent" | "thought";
|
messageType: TextMessageType;
|
||||||
text: string;
|
text: string;
|
||||||
startTimestamp: Date;
|
startTimestamp: Date;
|
||||||
endTimestamp: Date;
|
endTimestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merged tool call (initial call + all updates combined)
|
|
||||||
*/
|
|
||||||
export interface MergedToolCall {
|
export interface MergedToolCall {
|
||||||
kind: "merged_tool_call";
|
kind: "merged_tool_call";
|
||||||
toolCallId: string;
|
toolCallId: string;
|
||||||
title: string;
|
title: string;
|
||||||
status: "pending" | "in_progress" | "completed" | "failed";
|
status: ToolCallStatus;
|
||||||
toolKind?:
|
toolKind?: ToolKind;
|
||||||
| "read"
|
|
||||||
| "edit"
|
|
||||||
| "delete"
|
|
||||||
| "move"
|
|
||||||
| "search"
|
|
||||||
| "execute"
|
|
||||||
| "think"
|
|
||||||
| "fetch"
|
|
||||||
| "switch_mode"
|
|
||||||
| "other";
|
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
output?: Record<string, unknown>;
|
output?: Record<string, unknown>;
|
||||||
content?: unknown[];
|
content?: unknown[];
|
||||||
@@ -148,89 +121,95 @@ export interface MergedToolCall {
|
|||||||
endTimestamp: Date;
|
endTimestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export type GroupedActivity = GroupedTextMessage | MergedToolCall | AgentActivity;
|
||||||
* Group consecutive text chunks into messages and merge tool calls by ID
|
|
||||||
*/
|
|
||||||
export function groupActivities(
|
|
||||||
activities: AgentActivity[],
|
|
||||||
): Array<GroupedTextMessage | MergedToolCall | AgentActivity> {
|
|
||||||
const result: Array<GroupedTextMessage | MergedToolCall | AgentActivity> = [];
|
|
||||||
|
|
||||||
// Track current text group
|
type TextGroup = {
|
||||||
let currentTextGroup: {
|
messageType: TextMessageType;
|
||||||
messageType: "user" | "agent" | "thought";
|
chunks: string[];
|
||||||
chunks: string[];
|
startTimestamp: Date;
|
||||||
startTimestamp: Date;
|
endTimestamp: Date;
|
||||||
endTimestamp: Date;
|
};
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
// Track tool calls by ID
|
type ToolCallAccumulator = Omit<MergedToolCall, "kind"> & {
|
||||||
const toolCallsById = new Map<
|
insertIndex: number;
|
||||||
string,
|
};
|
||||||
{
|
|
||||||
toolCallId: string;
|
function getMessageType(
|
||||||
title: string;
|
update: UserMessageChunk | AgentMessageChunk | AgentThoughtChunk,
|
||||||
status: "pending" | "in_progress" | "completed" | "failed";
|
): TextMessageType {
|
||||||
toolKind?:
|
switch (update.kind) {
|
||||||
| "read"
|
case "user_message_chunk":
|
||||||
| "edit"
|
return "user";
|
||||||
| "delete"
|
case "agent_message_chunk":
|
||||||
| "move"
|
return "agent";
|
||||||
| "search"
|
case "agent_thought_chunk":
|
||||||
| "execute"
|
return "thought";
|
||||||
| "think"
|
}
|
||||||
| "fetch"
|
}
|
||||||
| "switch_mode"
|
|
||||||
| "other";
|
function isTextChunk(
|
||||||
input?: Record<string, unknown>;
|
update: SessionUpdate,
|
||||||
output?: Record<string, unknown>;
|
): update is UserMessageChunk | AgentMessageChunk | AgentThoughtChunk {
|
||||||
content?: unknown[];
|
return (
|
||||||
locations?: unknown[];
|
update.kind === "user_message_chunk" ||
|
||||||
startTimestamp: Date;
|
update.kind === "agent_message_chunk" ||
|
||||||
endTimestamp: Date;
|
update.kind === "agent_thought_chunk"
|
||||||
insertIndex: number; // Track where to insert in result
|
);
|
||||||
}
|
}
|
||||||
>();
|
|
||||||
|
function isGroupedActivity(item: GroupedActivity | null): item is GroupedActivity {
|
||||||
|
return item !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMergedToolCall(toolCall: ToolCallAccumulator): MergedToolCall {
|
||||||
|
return {
|
||||||
|
kind: "merged_tool_call",
|
||||||
|
toolCallId: toolCall.toolCallId,
|
||||||
|
title: toolCall.title,
|
||||||
|
status: toolCall.status,
|
||||||
|
toolKind: toolCall.toolKind,
|
||||||
|
input: toolCall.input,
|
||||||
|
output: toolCall.output,
|
||||||
|
content: toolCall.content,
|
||||||
|
locations: toolCall.locations,
|
||||||
|
startTimestamp: toolCall.startTimestamp,
|
||||||
|
endTimestamp: toolCall.endTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupActivities(activities: AgentActivity[]): GroupedActivity[] {
|
||||||
|
const result: Array<GroupedActivity | null> = [];
|
||||||
|
const toolCallsById = new Map<string, ToolCallAccumulator>();
|
||||||
|
let currentTextGroup: TextGroup | null = null;
|
||||||
|
|
||||||
function flushTextGroup() {
|
function flushTextGroup() {
|
||||||
if (currentTextGroup) {
|
if (!currentTextGroup) {
|
||||||
result.push({
|
return;
|
||||||
kind: "grouped_text",
|
|
||||||
messageType: currentTextGroup.messageType,
|
|
||||||
text: currentTextGroup.chunks.join(""),
|
|
||||||
startTimestamp: currentTextGroup.startTimestamp,
|
|
||||||
endTimestamp: currentTextGroup.endTimestamp,
|
|
||||||
});
|
|
||||||
currentTextGroup = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
kind: "grouped_text",
|
||||||
|
messageType: currentTextGroup.messageType,
|
||||||
|
text: currentTextGroup.chunks.join(""),
|
||||||
|
startTimestamp: currentTextGroup.startTimestamp,
|
||||||
|
endTimestamp: currentTextGroup.endTimestamp,
|
||||||
|
});
|
||||||
|
currentTextGroup = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const activity of activities) {
|
for (const activity of activities) {
|
||||||
const update = activity.update;
|
const update = activity.update;
|
||||||
|
|
||||||
// Handle text chunks
|
if (isTextChunk(update)) {
|
||||||
if (
|
const messageType = getMessageType(update);
|
||||||
update.kind === "user_message_chunk" ||
|
|
||||||
update.kind === "agent_message_chunk" ||
|
|
||||||
update.kind === "agent_thought_chunk"
|
|
||||||
) {
|
|
||||||
const messageType =
|
|
||||||
update.kind === "user_message_chunk"
|
|
||||||
? "user"
|
|
||||||
: update.kind === "agent_message_chunk"
|
|
||||||
? "agent"
|
|
||||||
: "thought";
|
|
||||||
|
|
||||||
const text = update.content.text;
|
const text = update.content.text;
|
||||||
|
|
||||||
// If we have a current group of the same type, add to it
|
|
||||||
if (currentTextGroup && currentTextGroup.messageType === messageType) {
|
if (currentTextGroup && currentTextGroup.messageType === messageType) {
|
||||||
currentTextGroup.chunks.push(text);
|
currentTextGroup.chunks.push(text);
|
||||||
currentTextGroup.endTimestamp = activity.timestamp;
|
currentTextGroup.endTimestamp = activity.timestamp;
|
||||||
} else {
|
} else {
|
||||||
flushTextGroup();
|
flushTextGroup();
|
||||||
|
|
||||||
// Start new group
|
|
||||||
currentTextGroup = {
|
currentTextGroup = {
|
||||||
messageType,
|
messageType,
|
||||||
chunks: [text],
|
chunks: [text],
|
||||||
@@ -238,20 +217,16 @@ export function groupActivities(
|
|||||||
endTimestamp: activity.timestamp,
|
endTimestamp: activity.timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
} else if (update.kind === "tool_call" || update.kind === "tool_call_update") {
|
||||||
// Handle tool calls and updates
|
|
||||||
else if (update.kind === "tool_call" || update.kind === "tool_call_update") {
|
|
||||||
flushTextGroup();
|
flushTextGroup();
|
||||||
|
|
||||||
const toolCallId = update.toolCallId;
|
const toolCallId = update.toolCallId;
|
||||||
const existing = toolCallsById.get(toolCallId);
|
const existing = toolCallsById.get(toolCallId);
|
||||||
|
|
||||||
if (update.kind === "tool_call") {
|
if (update.kind === "tool_call") {
|
||||||
// Initial tool call
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
// Create new entry and placeholder in result
|
|
||||||
const insertIndex = result.length;
|
const insertIndex = result.length;
|
||||||
result.push(null as any); // Placeholder
|
result.push(null);
|
||||||
|
|
||||||
toolCallsById.set(toolCallId, {
|
toolCallsById.set(toolCallId, {
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -267,7 +242,6 @@ export function groupActivities(
|
|||||||
insertIndex,
|
insertIndex,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Update existing
|
|
||||||
existing.title = update.title;
|
existing.title = update.title;
|
||||||
if (update.status) existing.status = update.status;
|
if (update.status) existing.status = update.status;
|
||||||
if (update.toolKind) existing.toolKind = update.toolKind;
|
if (update.toolKind) existing.toolKind = update.toolKind;
|
||||||
@@ -278,9 +252,7 @@ export function groupActivities(
|
|||||||
existing.endTimestamp = activity.timestamp;
|
existing.endTimestamp = activity.timestamp;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Tool call update
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Merge update into existing
|
|
||||||
if (update.title) existing.title = update.title;
|
if (update.title) existing.title = update.title;
|
||||||
if (update.status) existing.status = update.status;
|
if (update.status) existing.status = update.status;
|
||||||
if (update.toolKind) existing.toolKind = update.toolKind;
|
if (update.toolKind) existing.toolKind = update.toolKind;
|
||||||
@@ -290,9 +262,8 @@ export function groupActivities(
|
|||||||
if (update.locations) existing.locations = update.locations;
|
if (update.locations) existing.locations = update.locations;
|
||||||
existing.endTimestamp = activity.timestamp;
|
existing.endTimestamp = activity.timestamp;
|
||||||
} else {
|
} else {
|
||||||
// Update without initial call - create entry
|
|
||||||
const insertIndex = result.length;
|
const insertIndex = result.length;
|
||||||
result.push(null as any); // Placeholder
|
result.push(null);
|
||||||
|
|
||||||
toolCallsById.set(toolCallId, {
|
toolCallsById.set(toolCallId, {
|
||||||
toolCallId,
|
toolCallId,
|
||||||
@@ -309,33 +280,17 @@ export function groupActivities(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
// Handle other activities
|
|
||||||
else {
|
|
||||||
flushTextGroup();
|
flushTextGroup();
|
||||||
result.push(activity);
|
result.push(activity);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush final text group
|
|
||||||
flushTextGroup();
|
flushTextGroup();
|
||||||
|
|
||||||
// Replace placeholders with merged tool calls
|
|
||||||
for (const toolCall of toolCallsById.values()) {
|
for (const toolCall of toolCallsById.values()) {
|
||||||
result[toolCall.insertIndex] = {
|
result[toolCall.insertIndex] = toMergedToolCall(toolCall);
|
||||||
kind: "merged_tool_call",
|
|
||||||
toolCallId: toolCall.toolCallId,
|
|
||||||
title: toolCall.title,
|
|
||||||
status: toolCall.status,
|
|
||||||
toolKind: toolCall.toolKind,
|
|
||||||
input: toolCall.input,
|
|
||||||
output: toolCall.output,
|
|
||||||
content: toolCall.content,
|
|
||||||
locations: toolCall.locations,
|
|
||||||
startTimestamp: toolCall.startTimestamp,
|
|
||||||
endTimestamp: toolCall.endTimestamp,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result.filter(isGroupedActivity);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user