Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3402,6 +3402,23 @@ describe("adeRpcServer", () => {

it("scopes PTY and terminal ADE actions to the caller's lane or chat", async () => {
const fixture = createRuntime();
const getChatEventHistory = vi.fn(async (sessionId: string) => ({
sessionId,
events: [],
sessionFound: true,
}));
const getChatEventHistoryPage = vi.fn(async (
sessionId: string,
options: { beforeOffset: number },
) => ({
sessionId,
events: [],
startOffset: options.beforeOffset,
hasMore: options.beforeOffset > 0,
sessionFound: true,
}));
(fixture.runtime.agentChatService as any).getChatEventHistory = getChatEventHistory;
(fixture.runtime.agentChatService as any).getChatEventHistoryPage = getChatEventHistoryPage;
const ownChat = { id: "chat-1", laneId: "lane-1", chatSessionId: "chat-1" };
const ownTerminal = { id: "terminal-1", laneId: "lane-1", ptyId: "pty-1", chatSessionId: "chat-1" };
const otherTerminal = { id: "terminal-2", laneId: "lane-2", ptyId: "pty-2", chatSessionId: "chat-2" };
Expand Down Expand Up @@ -3462,6 +3479,22 @@ describe("adeRpcServer", () => {
expect(deniedChatRead.isError).toBe(true);
expect(fixture.runtime.agentChatService.getChatTranscript).not.toHaveBeenCalled();

const deniedChatHistory = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getChatEventHistory",
args: { sessionId: "chat-2", maxBytes: 65_536 },
});
expect(deniedChatHistory.isError).toBe(true);
expect(getChatEventHistory).not.toHaveBeenCalled();

const deniedChatHistoryPage = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getChatEventHistoryPage",
args: { sessionId: "chat-2", beforeOffset: 4_096, maxBytes: 65_536 },
});
expect(deniedChatHistoryPage.isError).toBe(true);
expect(getChatEventHistoryPage).not.toHaveBeenCalled();

const deniedChatSend = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "sendMessage",
Expand All @@ -3481,6 +3514,27 @@ describe("adeRpcServer", () => {
limit: 10,
});

const ownChatHistory = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getChatEventHistory",
args: { maxBytes: 65_536 },
});
expect(ownChatHistory?.isError).toBeUndefined();
expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", {
maxBytes: 65_536,
});

const ownChatHistoryPage = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getChatEventHistoryPage",
args: { beforeOffset: 4_096, maxBytes: 65_536 },
});
expect(ownChatHistoryPage?.isError).toBeUndefined();
expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-1", {
beforeOffset: 4_096,
maxBytes: 65_536,
});

const ownChatSend = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "sendMessage",
Expand Down Expand Up @@ -4258,6 +4312,35 @@ describe("adeRpcServer", () => {
expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled();
});

it("keeps explicit raw chat history available to unbound ade CLI callers", async () => {
const fixture = createRuntime();
const getChatEventHistoryPage = vi.fn(async (
sessionId: string,
options: { beforeOffset: number },
) => ({
sessionId,
events: [],
startOffset: options.beforeOffset,
hasMore: options.beforeOffset > 0,
sessionFound: true,
}));
(fixture.runtime.agentChatService as any).getChatEventHistoryPage = getChatEventHistoryPage;
const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" });
await initialize(handler, { callerId: "ade-cli:4242", role: "agent" });

const historyPage = await callTool(handler, "run_ade_action", {
domain: "chat",
action: "getChatEventHistoryPage",
args: { sessionId: "chat-2", beforeOffset: 4_096, maxBytes: 65_536 },
});

expect(historyPage?.isError).toBeUndefined();
expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-2", {
beforeOffset: 4_096,
maxBytes: 65_536,
});
});

it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => {
const fixture = createRuntime();
const startArgs = {
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/adeRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2399,6 +2399,8 @@ function scopeTerminalAdeActionArgs(

const SCOPED_CHAT_ACTIONS = new Set([
"readTranscript",
"getChatEventHistory",
"getChatEventHistoryPage",
"sendMessage",
"createScheduledWork",
"listScheduledWork",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
import type { RawData, WebSocket } from "ws";
import type {
AgentChatEventEnvelope,
AgentChatEventHistoryPage,
CloneProjectInput,
CreateProjectInput,
ListMyGitHubReposInput,
ProjectBrowseInput,
PersonalChatScopeContract,
SyncChatSubscribePayload,
SyncChatSubscribeSnapshotPayload,
SyncChatHistoryRequestPayload,
SyncChatUnsubscribePayload,
SyncCommandPayload,
SyncRemoteCommandDescriptor,
Expand Down Expand Up @@ -172,6 +174,52 @@ function optionalString(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

function unavailableChatHistoryPage(
sessionId: string,
beforeOffset: number,
): AgentChatEventHistoryPage {
return {
sessionId,
events: [],
startOffset: beforeOffset,
hasMore: beforeOffset > 0,
sessionFound: false,
unavailable: true,
};
}

function normalizeChatHistoryPage(
value: unknown,
sessionId: string,
beforeOffset: number,
): AgentChatEventHistoryPage {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return unavailableChatHistoryPage(sessionId, beforeOffset);
}
const record = value as Record<string, unknown>;
const startOffset = typeof record.startOffset === "number" && Number.isFinite(record.startOffset)
? Math.max(0, Math.floor(record.startOffset))
: null;
if (
optionalString(record.sessionId) !== sessionId
|| !Array.isArray(record.events)
|| startOffset == null
|| startOffset > beforeOffset
|| typeof record.hasMore !== "boolean"
|| typeof record.sessionFound !== "boolean"
) {
return unavailableChatHistoryPage(sessionId, beforeOffset);
}
return {
sessionId,
events: record.events as AgentChatEventEnvelope[],
startOffset,
hasMore: record.hasMore,
sessionFound: record.sessionFound,
...(record.unavailable === true ? { unavailable: true } : {}),
};
}

function normalizePeerMetadata(value: unknown): SyncPeerMetadata | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
Expand Down Expand Up @@ -813,7 +861,7 @@ export function createBrainProjectActionsSyncHandler(
}
const maxBytes = typeof payload.maxBytes === "number" && Number.isFinite(payload.maxBytes)
? Math.max(1_024, Math.min(2_000_000, Math.floor(payload.maxBytes)))
: 2_000_000;
: 256 * 1_024;
// Capture the tail point before reading history. Events committed
// during the snapshot can then be replayed (and client-deduped), but
// can never fall into the gap between history collection and offset.
Expand All @@ -824,7 +872,12 @@ export function createBrainProjectActionsSyncHandler(
const history = (await args.personalChatScope.call("getEventHistory", {
sessionId,
maxBytes,
})).result as { events?: AgentChatEventEnvelope[]; truncated?: boolean };
})).result as {
events?: AgentChatEventEnvelope[];
truncated?: boolean;
tailStartOffset?: number | null;
hasOlderHistory?: boolean;
};
if (!isCurrent()) return;
const turnActive = await args.personalChatScope.isTurnActive(sessionId);
if (!isCurrent()) return;
Expand All @@ -833,11 +886,49 @@ export function createBrainProjectActionsSyncHandler(
sessionId,
capturedAt: nowIso(),
truncated: history.truncated === true,
tailStartOffset: history.tailStartOffset ?? 0,
hasOlderHistory: history.hasOlderHistory
?? (history.truncated === true && (history.tailStartOffset ?? 0) > 0),
cursorKind: "byte",
events: history.events ?? [],
turnActive,
} satisfies SyncChatSubscribeSnapshotPayload, envelope.requestId);
break;
}
case "chat_history": {
const payload = envelope.payload as SyncChatHistoryRequestPayload | null;
const sessionId = optionalString(payload?.sessionId) ?? "";
const beforeOffset = typeof payload?.beforeOffset === "number" && Number.isFinite(payload.beforeOffset)
? Math.max(0, Math.floor(payload.beforeOffset))
: 0;
if (
!sessionId
|| payload?.chatScope !== "personal"
|| !args.personalChatScope
|| !peer.personalChatSubscriptions.has(sessionId)
) {
send(peer.ws, "chat_history", unavailableChatHistoryPage(sessionId, beforeOffset), envelope.requestId);
break;
}
let page = unavailableChatHistoryPage(sessionId, beforeOffset);
try {
const rawPage = (await args.personalChatScope.call("getEventHistoryPage", {
sessionId,
beforeOffset,
...(typeof payload.maxBytes === "number" ? { maxBytes: payload.maxBytes } : {}),
})).result;
page = normalizeChatHistoryPage(rawPage, sessionId, beforeOffset);
} catch (error) {
args.logger.warn("sync_brain.chat_history_failed", {
sessionId,
beforeOffset,
error: error instanceof Error ? error.message : String(error),
});
}
if (!isCurrent()) return;
send(peer.ws, "chat_history", page, envelope.requestId);
break;
}
case "chat_unsubscribe": {
const payload = envelope.payload as SyncChatUnsubscribePayload | null;
if (payload?.chatScope === "personal") {
Expand Down
Loading