mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Phase 1d: extract in-memory timeline store
This commit is contained in:
@@ -1091,6 +1091,58 @@ describe("AgentManager", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("getAgent does not expose committed history internals once manager owns the seam", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-boundary-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
codex: new TestAgentClient(),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000138",
|
||||
});
|
||||
|
||||
const snapshot = await manager.createAgent({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
});
|
||||
|
||||
manager.recordUserMessage(snapshot.id, "hello boundary", {
|
||||
messageId: "msg-boundary-1",
|
||||
emitState: false,
|
||||
});
|
||||
await manager.appendTimelineItem(snapshot.id, {
|
||||
type: "assistant_message",
|
||||
text: "history stays behind manager",
|
||||
});
|
||||
|
||||
const live = manager.getAgent(snapshot.id) as Record<string, unknown>;
|
||||
expect(live).not.toBeNull();
|
||||
expect("timeline" in live).toBe(false);
|
||||
expect("timelineRows" in live).toBe(false);
|
||||
expect("timelineNextSeq" in live).toBe(false);
|
||||
|
||||
expect(manager.getTimeline(snapshot.id)).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "hello boundary",
|
||||
messageId: "msg-boundary-1",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "history stays behind manager",
|
||||
},
|
||||
]);
|
||||
|
||||
const fetched = manager.fetchTimeline(snapshot.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
});
|
||||
expect(fetched.rows.map((row) => row.seq)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("streams assistant chunks provisionally and commits one finalized assistant row", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provisional-timeline-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -31,6 +31,10 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
import {
|
||||
InMemoryAgentTimelineStore,
|
||||
type SeedAgentTimelineOptions,
|
||||
} from "./agent-timeline-store.js";
|
||||
import { AGENT_PROVIDER_IDS } from "./provider-manifest.js";
|
||||
|
||||
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
||||
@@ -164,9 +168,6 @@ type ManagedAgentBase = {
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
pendingReplacement: boolean;
|
||||
timeline: AgentTimelineItem[];
|
||||
timelineRows: AgentTimelineRow[];
|
||||
timelineNextSeq: number;
|
||||
provisionalAssistantText: string | null;
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
historyPrimed: boolean;
|
||||
@@ -253,7 +254,6 @@ type SubscriptionRecord = {
|
||||
agentId: string | null;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
const BUSY_STATUSES: AgentLifecycleStatus[] = ["initializing", "running"];
|
||||
const AgentIdSchema = z.string().uuid();
|
||||
|
||||
@@ -299,6 +299,7 @@ function normalizeMessageId(messageId: string | undefined): string | undefined {
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly agents = new Map<string, ActiveManagedAgent>();
|
||||
private readonly timelineStore = new InMemoryAgentTimelineStore();
|
||||
private readonly pendingForegroundRuns = new Map<string, PendingForegroundRun>();
|
||||
private readonly subscribers = new Set<SubscriptionRecord>();
|
||||
private readonly idFactory: () => string;
|
||||
@@ -498,95 +499,18 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
getTimeline(id: string): AgentTimelineItem[] {
|
||||
const agent = this.requireAgent(id);
|
||||
return [...agent.timeline];
|
||||
this.requireAgent(id);
|
||||
return this.timelineStore.getItems(id);
|
||||
}
|
||||
|
||||
getTimelineRows(id: string): AgentTimelineRow[] {
|
||||
const agent = this.requireAgent(id);
|
||||
const { rows } = this.ensureTimelineState(agent);
|
||||
return rows.map((row) => ({ ...row }));
|
||||
this.requireAgent(id);
|
||||
return this.timelineStore.getRows(id);
|
||||
}
|
||||
|
||||
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const agent = this.requireAgent(id);
|
||||
const { rows, nextSeq, minSeq, maxSeq } = this.ensureTimelineState(agent);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
requestedLimit === undefined
|
||||
? DEFAULT_TIMELINE_FETCH_LIMIT
|
||||
: Math.max(0, Math.floor(requestedLimit));
|
||||
const cursor = options?.cursor;
|
||||
|
||||
const window: AgentTimelineWindow = { minSeq, maxSeq, nextSeq };
|
||||
|
||||
const selectAll = limit === 0;
|
||||
const cloneRows = (items: AgentTimelineRow[]) => items.map((row) => ({ ...row }));
|
||||
|
||||
if (rows.length === 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected = selectAll || limit >= rows.length ? rows : rows.slice(rows.length - limit);
|
||||
const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq;
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder,
|
||||
hasNewer: false,
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "after") {
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll ? rows.slice(startIdx) : rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
}
|
||||
|
||||
// direction === "before"
|
||||
const beforeSeq = cursor?.seq ?? nextSeq;
|
||||
const endExclusive = rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? rows : rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
const hasOlder = selected.length > 0 && selected[0]!.seq > minSeq;
|
||||
const hasNewer = endExclusive >= 0;
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder,
|
||||
hasNewer,
|
||||
rows: cloneRows(selected),
|
||||
};
|
||||
this.requireAgent(id);
|
||||
return this.timelineStore.fetch(id, options);
|
||||
}
|
||||
|
||||
async createAgent(
|
||||
@@ -656,10 +580,6 @@ export class AgentManager {
|
||||
await this.cancelAgentRun(agentId);
|
||||
existing = this.requireAgent(agentId);
|
||||
}
|
||||
const timelineState = this.ensureTimelineState(existing);
|
||||
const preservedTimeline = [...existing.timeline];
|
||||
const preservedTimelineRows = timelineState.rows.map((row) => ({ ...row }));
|
||||
const preservedTimelineNextSeq = timelineState.nextSeq;
|
||||
const preservedProvisionalAssistantText = existing.provisionalAssistantText;
|
||||
const preservedHistoryPrimed = existing.historyPrimed;
|
||||
const preservedLastUsage = existing.lastUsage;
|
||||
@@ -703,9 +623,6 @@ export class AgentManager {
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
lastUserMessageAt: existing.lastUserMessageAt,
|
||||
timeline: preservedTimeline,
|
||||
timelineRows: preservedTimelineRows,
|
||||
timelineNextSeq: preservedTimelineNextSeq,
|
||||
provisionalAssistantText: preservedProvisionalAssistantText,
|
||||
historyPrimed: preservedHistoryPrimed,
|
||||
lastUsage: preservedLastUsage,
|
||||
@@ -752,6 +669,7 @@ export class AgentManager {
|
||||
activeForegroundTurnId: null,
|
||||
};
|
||||
await session.close();
|
||||
this.timelineStore.delete(agentId);
|
||||
this.emitState(closedAgent);
|
||||
this.logger.trace({ agentId }, "closeAgent: completed");
|
||||
}
|
||||
@@ -992,7 +910,7 @@ export class AgentManager {
|
||||
};
|
||||
const updatedAt = this.touchUpdatedAt(agent);
|
||||
agent.lastUserMessageAt = updatedAt;
|
||||
const row = this.recordTimeline(agent, item);
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
this.dispatchStream(
|
||||
agentId,
|
||||
{
|
||||
@@ -1012,7 +930,7 @@ export class AgentManager {
|
||||
async appendTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.touchUpdatedAt(agent);
|
||||
const row = this.recordTimeline(agent, item);
|
||||
const row = this.recordTimeline(agentId, item);
|
||||
this.dispatchStream(
|
||||
agentId,
|
||||
{
|
||||
@@ -1492,13 +1410,13 @@ export class AgentManager {
|
||||
await this.hydrateTimeline(agent);
|
||||
}
|
||||
|
||||
private getLastAssistantMessage(agentId: string): string | null {
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const agent = this.agents.get(agentId);
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getLastAssistantMessageFromTimeline(agent.timeline);
|
||||
return this.timelineStore.getLastAssistantMessage(agentId);
|
||||
}
|
||||
|
||||
private getLastAssistantMessageFromTimeline(
|
||||
@@ -1697,19 +1615,20 @@ export class AgentManager {
|
||||
const initialPersistedTitle = await this.resolveInitialPersistedTitle(resolvedAgentId, config);
|
||||
|
||||
const now = new Date();
|
||||
const initialTimeline = options?.timeline ? [...options.timeline] : [];
|
||||
const initialTimelineRows = options?.timelineRows?.length
|
||||
? options.timelineRows.map((row) => ({ ...row }))
|
||||
: this.buildTimelineRowsFromItems(
|
||||
initialTimeline,
|
||||
options?.timelineNextSeq ?? 1,
|
||||
(options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
|
||||
);
|
||||
const derivedNextSeq =
|
||||
options?.timelineNextSeq ??
|
||||
(initialTimelineRows.length
|
||||
? initialTimelineRows[initialTimelineRows.length - 1]!.seq + 1
|
||||
: 1);
|
||||
const timelineSeed: SeedAgentTimelineOptions | null =
|
||||
options?.timeline?.length ||
|
||||
options?.timelineRows?.length ||
|
||||
options?.timelineNextSeq !== undefined
|
||||
? {
|
||||
items: options?.timeline,
|
||||
rows: options?.timelineRows,
|
||||
nextSeq: options?.timelineNextSeq,
|
||||
timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
|
||||
}
|
||||
: null;
|
||||
if (timelineSeed || !this.timelineStore.has(resolvedAgentId)) {
|
||||
this.timelineStore.initialize(resolvedAgentId, timelineSeed ?? { timestamp: now.toISOString() });
|
||||
}
|
||||
|
||||
const managed = {
|
||||
id: resolvedAgentId,
|
||||
@@ -1729,9 +1648,6 @@ export class AgentManager {
|
||||
activeForegroundTurnId: null,
|
||||
foregroundTurnWaiters: new Set(),
|
||||
unsubscribeSession: null,
|
||||
timeline: initialTimeline,
|
||||
timelineRows: initialTimelineRows,
|
||||
timelineNextSeq: derivedNextSeq,
|
||||
provisionalAssistantText: options?.provisionalAssistantText ?? null,
|
||||
persistence: attachPersistenceCwd(session.describePersistence(), config.cwd),
|
||||
historyPrimed: options?.historyPrimed ?? false,
|
||||
@@ -1864,42 +1780,6 @@ export class AgentManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
private buildTimelineRowsFromItems(
|
||||
items: readonly AgentTimelineItem[],
|
||||
startSeq: number,
|
||||
timestamp: string,
|
||||
): AgentTimelineRow[] {
|
||||
let nextSeq = startSeq;
|
||||
return items.map((item) => {
|
||||
const row: AgentTimelineRow = {
|
||||
seq: nextSeq,
|
||||
timestamp,
|
||||
item,
|
||||
};
|
||||
nextSeq += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
private ensureTimelineState(agent: ManagedAgent): {
|
||||
rows: AgentTimelineRow[];
|
||||
nextSeq: number;
|
||||
minSeq: number;
|
||||
maxSeq: number;
|
||||
} {
|
||||
const minSeq = agent.timelineRows.length ? agent.timelineRows[0]!.seq : 0;
|
||||
const maxSeq = agent.timelineRows.length
|
||||
? agent.timelineRows[agent.timelineRows.length - 1]!.seq
|
||||
: 0;
|
||||
|
||||
return {
|
||||
rows: agent.timelineRows,
|
||||
nextSeq: agent.timelineNextSeq,
|
||||
minSeq,
|
||||
maxSeq,
|
||||
};
|
||||
}
|
||||
|
||||
private async persistSnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
@@ -1974,27 +1854,16 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
agent.historyPrimed = true;
|
||||
const canonicalUserMessagesById = new Map(
|
||||
agent.timelineRows.flatMap<[string, string]>((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const messageId = normalizeMessageId(row.item.messageId);
|
||||
if (!messageId) {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
}),
|
||||
);
|
||||
const canonicalUserMessagesById = this.timelineStore.getCanonicalUserMessagesById(agent.id);
|
||||
const pendingTurnItems: AgentTimelineItem[] = [];
|
||||
let bufferedAssistantText = "";
|
||||
const flushPendingTurn = () => {
|
||||
for (const item of pendingTurnItems) {
|
||||
this.recordTimeline(agent, item);
|
||||
this.recordTimeline(agent.id, item);
|
||||
}
|
||||
pendingTurnItems.length = 0;
|
||||
if (bufferedAssistantText) {
|
||||
this.recordTimeline(agent, {
|
||||
this.recordTimeline(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: bufferedAssistantText,
|
||||
});
|
||||
@@ -2023,7 +1892,7 @@ export class AgentManager {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
this.recordTimeline(agent, event.item);
|
||||
this.recordTimeline(agent.id, event.item);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -2103,14 +1972,12 @@ export class AgentManager {
|
||||
const eventMessageId = normalizeMessageId(event.item.messageId);
|
||||
const eventText = event.item.text;
|
||||
if (eventMessageId) {
|
||||
const alreadyRecorded = agent.timelineRows.some((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const rowMessageId = normalizeMessageId(row.item.messageId);
|
||||
return rowMessageId === eventMessageId && row.item.text === eventText;
|
||||
});
|
||||
if (alreadyRecorded) {
|
||||
if (
|
||||
this.timelineStore.hasCommittedUserMessage(agent.id, {
|
||||
messageId: eventMessageId,
|
||||
text: eventText,
|
||||
})
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2125,7 +1992,7 @@ export class AgentManager {
|
||||
if (event.item.type === "tool_call" && event.item.status === "running") {
|
||||
break;
|
||||
}
|
||||
timelineRow = this.recordTimeline(agent, event.item);
|
||||
timelineRow = this.recordTimeline(agent.id, event.item);
|
||||
if (!options?.fromHistory && event.item.type === "user_message") {
|
||||
agent.lastUserMessageAt = new Date();
|
||||
this.emitState(agent);
|
||||
@@ -2146,7 +2013,7 @@ export class AgentManager {
|
||||
type: "assistant_message",
|
||||
text: agent.provisionalAssistantText,
|
||||
};
|
||||
timelineRow = this.recordTimeline(agent, item);
|
||||
timelineRow = this.recordTimeline(agent.id, item);
|
||||
if (!options?.fromHistory) {
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
@@ -2316,13 +2183,13 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
||||
const lastItem = agent.timelineRows[agent.timelineRows.length - 1]?.item;
|
||||
const lastItem = this.timelineStore.getLastItem(agent.id);
|
||||
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
||||
return;
|
||||
}
|
||||
|
||||
const item: AgentTimelineItem = { type: "assistant_message", text };
|
||||
const row = this.recordTimeline(agent, item);
|
||||
const row = this.recordTimeline(agent.id, item);
|
||||
this.dispatchStream(
|
||||
agent.id,
|
||||
{
|
||||
@@ -2352,17 +2219,8 @@ export class AgentManager {
|
||||
return parts.join("\n\n");
|
||||
}
|
||||
|
||||
private recordTimeline(agent: ManagedAgent, item: AgentTimelineItem): AgentTimelineRow {
|
||||
const timelineState = this.ensureTimelineState(agent);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: timelineState.nextSeq,
|
||||
timestamp: new Date().toISOString(),
|
||||
item,
|
||||
};
|
||||
agent.timelineNextSeq = timelineState.nextSeq + 1;
|
||||
agent.timeline.push(item);
|
||||
timelineState.rows.push(row);
|
||||
return row;
|
||||
private recordTimeline(agentId: string, item: AgentTimelineItem): AgentTimelineRow {
|
||||
return this.timelineStore.append(agentId, item);
|
||||
}
|
||||
|
||||
private emitState(agent: ManagedAgent, options?: { persist?: boolean }): void {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { AgentTimelineItem } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentTimelineFetchOptions,
|
||||
AgentTimelineFetchResult,
|
||||
AgentTimelineRow,
|
||||
} from "./agent-manager.js";
|
||||
|
||||
export type SeedAgentTimelineOptions = {
|
||||
items?: readonly AgentTimelineItem[];
|
||||
rows?: readonly AgentTimelineRow[];
|
||||
nextSeq?: number;
|
||||
timestamp?: string;
|
||||
};
|
||||
|
||||
type AgentTimelineState = {
|
||||
rows: AgentTimelineRow[];
|
||||
nextSeq: number;
|
||||
};
|
||||
|
||||
const DEFAULT_TIMELINE_FETCH_LIMIT = 200;
|
||||
|
||||
function cloneRow(row: AgentTimelineRow): AgentTimelineRow {
|
||||
return { ...row };
|
||||
}
|
||||
|
||||
function normalizeTimelineMessageId(messageId: string | undefined): string | undefined {
|
||||
if (typeof messageId !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = messageId.trim();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
export class InMemoryAgentTimelineStore {
|
||||
private readonly states = new Map<string, AgentTimelineState>();
|
||||
|
||||
has(agentId: string): boolean {
|
||||
return this.states.has(agentId);
|
||||
}
|
||||
|
||||
initialize(agentId: string, options?: SeedAgentTimelineOptions): void {
|
||||
const timestamp = options?.timestamp ?? new Date().toISOString();
|
||||
const rows = options?.rows?.length
|
||||
? options.rows.map(cloneRow)
|
||||
: this.buildRowsFromItems(options?.items ?? [], options?.nextSeq ?? 1, timestamp);
|
||||
const nextSeq =
|
||||
options?.nextSeq ?? (rows.length ? rows[rows.length - 1]!.seq + 1 : 1);
|
||||
this.states.set(agentId, {
|
||||
rows,
|
||||
nextSeq,
|
||||
});
|
||||
}
|
||||
|
||||
delete(agentId: string): void {
|
||||
this.states.delete(agentId);
|
||||
}
|
||||
|
||||
getItems(agentId: string): AgentTimelineItem[] {
|
||||
return this.requireState(agentId).rows.map((row) => row.item);
|
||||
}
|
||||
|
||||
getRows(agentId: string): AgentTimelineRow[] {
|
||||
return this.requireState(agentId).rows.map(cloneRow);
|
||||
}
|
||||
|
||||
fetch(agentId: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
||||
const state = this.requireState(agentId);
|
||||
const direction = options?.direction ?? "tail";
|
||||
const requestedLimit = options?.limit;
|
||||
const limit =
|
||||
requestedLimit === undefined
|
||||
? DEFAULT_TIMELINE_FETCH_LIMIT
|
||||
: Math.max(0, Math.floor(requestedLimit));
|
||||
const cursor = options?.cursor;
|
||||
const minSeq = state.rows.length ? state.rows[0]!.seq : 0;
|
||||
const maxSeq = state.rows.length ? state.rows[state.rows.length - 1]!.seq : 0;
|
||||
const selectAll = limit === 0;
|
||||
|
||||
const window = {
|
||||
minSeq,
|
||||
maxSeq,
|
||||
nextSeq: state.nextSeq,
|
||||
};
|
||||
|
||||
if (state.rows.length === 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: false,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "tail") {
|
||||
const selected =
|
||||
selectAll || limit >= state.rows.length ? state.rows : state.rows.slice(state.rows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: false,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
if (direction === "after") {
|
||||
const baseSeq = cursor?.seq ?? 0;
|
||||
const startIdx = state.rows.findIndex((row) => row.seq > baseSeq);
|
||||
if (startIdx < 0) {
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: baseSeq >= minSeq,
|
||||
hasNewer: false,
|
||||
rows: [],
|
||||
};
|
||||
}
|
||||
|
||||
const selected = selectAll
|
||||
? state.rows.slice(startIdx)
|
||||
: state.rows.slice(startIdx, startIdx + limit);
|
||||
const lastSelected = selected[selected.length - 1];
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected[0]!.seq > minSeq,
|
||||
hasNewer: Boolean(lastSelected && lastSelected.seq < maxSeq),
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
const beforeSeq = cursor?.seq ?? state.nextSeq;
|
||||
const endExclusive = state.rows.findIndex((row) => row.seq >= beforeSeq);
|
||||
const boundedRows = endExclusive < 0 ? state.rows : state.rows.slice(0, endExclusive);
|
||||
const selected =
|
||||
selectAll || limit >= boundedRows.length
|
||||
? boundedRows
|
||||
: boundedRows.slice(boundedRows.length - limit);
|
||||
return {
|
||||
direction,
|
||||
window,
|
||||
hasOlder: selected.length > 0 && selected[0]!.seq > minSeq,
|
||||
hasNewer: endExclusive >= 0,
|
||||
rows: selected.map(cloneRow),
|
||||
};
|
||||
}
|
||||
|
||||
append(
|
||||
agentId: string,
|
||||
item: AgentTimelineItem,
|
||||
options?: { timestamp?: string },
|
||||
): AgentTimelineRow {
|
||||
const state = this.requireState(agentId);
|
||||
const row: AgentTimelineRow = {
|
||||
seq: state.nextSeq,
|
||||
timestamp: options?.timestamp ?? new Date().toISOString(),
|
||||
item,
|
||||
};
|
||||
state.nextSeq += 1;
|
||||
state.rows.push(row);
|
||||
return cloneRow(row);
|
||||
}
|
||||
|
||||
getLastItem(agentId: string): AgentTimelineItem | null {
|
||||
const state = this.requireState(agentId);
|
||||
return state.rows[state.rows.length - 1]?.item ?? null;
|
||||
}
|
||||
|
||||
getLastAssistantMessage(agentId: string): string | null {
|
||||
const rows = this.requireState(agentId).rows;
|
||||
const chunks: string[] = [];
|
||||
for (let i = rows.length - 1; i >= 0; i -= 1) {
|
||||
const item = rows[i]!.item;
|
||||
if (item.type !== "assistant_message") {
|
||||
if (chunks.length > 0) {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
chunks.push(item.text);
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return chunks.reverse().join("");
|
||||
}
|
||||
|
||||
getCanonicalUserMessagesById(agentId: string): Map<string, string> {
|
||||
const entries = this.requireState(agentId).rows.flatMap<[string, string]>((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return [];
|
||||
}
|
||||
const messageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
if (!messageId) {
|
||||
return [];
|
||||
}
|
||||
return [[messageId, row.item.text]];
|
||||
});
|
||||
return new Map(entries);
|
||||
}
|
||||
|
||||
hasCommittedUserMessage(agentId: string, options: { messageId: string; text: string }): boolean {
|
||||
const messageId = normalizeTimelineMessageId(options.messageId);
|
||||
if (!messageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.requireState(agentId).rows.some((row) => {
|
||||
if (row.item.type !== "user_message") {
|
||||
return false;
|
||||
}
|
||||
const rowMessageId = normalizeTimelineMessageId(row.item.messageId);
|
||||
return rowMessageId === messageId && row.item.text === options.text;
|
||||
});
|
||||
}
|
||||
|
||||
private requireState(agentId: string): AgentTimelineState {
|
||||
const state = this.states.get(agentId);
|
||||
if (!state) {
|
||||
throw new Error(`Unknown agent '${agentId}'`);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
private buildRowsFromItems(
|
||||
items: readonly AgentTimelineItem[],
|
||||
startSeq: number,
|
||||
timestamp: string,
|
||||
): AgentTimelineRow[] {
|
||||
let nextSeq = startSeq;
|
||||
return items.map((item) => {
|
||||
const row: AgentTimelineRow = {
|
||||
seq: nextSeq,
|
||||
timestamp,
|
||||
item,
|
||||
};
|
||||
nextSeq += 1;
|
||||
return row;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
||||
const agentManager = {
|
||||
setAgentAttentionCallback: vi.fn(),
|
||||
getAgent: vi.fn(() => null),
|
||||
getLastAssistantMessage: vi.fn(() => null),
|
||||
...agentManagerOverrides,
|
||||
};
|
||||
|
||||
@@ -93,18 +94,16 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
});
|
||||
|
||||
it("uses assistant preview text for push notifications with markdown removed", () => {
|
||||
const getLastAssistantMessage = vi.fn(
|
||||
() => "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
);
|
||||
const { server } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
timeline: [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
getLastAssistantMessage,
|
||||
});
|
||||
|
||||
(server as any).broadcastAgentAttention({
|
||||
@@ -122,22 +121,19 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
reason: "finished",
|
||||
},
|
||||
});
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-1");
|
||||
});
|
||||
|
||||
it("sends push notifications regardless of UI label presence", () => {
|
||||
const getLastAssistantMessage = vi.fn(() => "Done.");
|
||||
const { server } = createServer({
|
||||
getAgent: vi.fn(() => ({
|
||||
config: { title: null },
|
||||
cwd: "/tmp/worktree",
|
||||
labels: {},
|
||||
timeline: [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "Done.",
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
})),
|
||||
getLastAssistantMessage,
|
||||
});
|
||||
|
||||
(server as any).broadcastAgentAttention({
|
||||
@@ -147,5 +143,6 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
||||
});
|
||||
|
||||
expect(pushMocks.sendPush).toHaveBeenCalledTimes(1);
|
||||
expect(getLastAssistantMessage).toHaveBeenCalledWith("agent-2");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ import {
|
||||
} from "./agent-attention-policy.js";
|
||||
import {
|
||||
buildAgentAttentionNotificationPayload,
|
||||
findLatestAssistantMessageFromTimeline,
|
||||
findLatestPermissionRequest,
|
||||
} from "../shared/agent-attention-notification.js";
|
||||
|
||||
@@ -1263,7 +1262,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
reason: params.reason,
|
||||
serverId: this.serverId,
|
||||
agentId: params.agentId,
|
||||
assistantMessage: agent ? findLatestAssistantMessageFromTimeline(agent.timeline) : null,
|
||||
assistantMessage: this.agentManager.getLastAssistantMessage(params.agentId),
|
||||
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user