mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e39f914a82 | ||
|
|
953b448774 | ||
|
|
4b90b754c0 |
+2
-44
@@ -25,50 +25,8 @@ Reload releases the old runtime before resuming its durable session: an idle pro
|
||||
still own an exclusive writer. A close failure retains that runtime for cleanup and blocks the
|
||||
replacement. Once closure succeeds, a failed resume leaves the durable agent closed and retryable.
|
||||
|
||||
Idle resumable runtimes are collected after one hour without activity, checked once a minute.
|
||||
Collection releases the process and preserves the agent record, conversation, relationships, and
|
||||
provider session. The next prompt resumes that session. Set `agents.idleRuntimeCleanup: false` in
|
||||
host config to disable automatic collection; this setting takes effect at daemon startup.
|
||||
|
||||
Foreground runs, permissions and responses, queued deliveries, lifecycle operations, and working
|
||||
managed or native descendants prevent collection. Outstanding local or remote finish subscriptions
|
||||
also protect their caller, including registration, recovery and notification dispatch acknowledgment.
|
||||
An idle managed child can still own background work.
|
||||
|
||||
The daemon baseline always applies. An optional provider `canStopRuntime()` adds a veto: false,
|
||||
errors and timeouts retain ownership, while true cannot override daemon blockers. Without a provider
|
||||
check, the baseline decides collection after the same one-hour idle period. Once protected work
|
||||
finishes, a fresh idle hour starts. There is no deadline that overrides a veto.
|
||||
|
||||
The `idle-runtime-cleanup/` module owns the clocks, eligibility policy and sweep lifetime.
|
||||
AgentManager supplies captured runtime capabilities and owns atomic revalidation and shutdown.
|
||||
Finish subscriptions expose caller protection through their public capability; the collector never
|
||||
reads their watches or durable records.
|
||||
|
||||
The collector rechecks daemon state after asynchronous probes and event draining, in the existing
|
||||
close/load lifecycle lane. A prompt admitted before closure prevents collection. A prompt arriving
|
||||
after closure starts waits for release and resumes the saved session.
|
||||
|
||||
Provider checks follow the signals their runtimes expose:
|
||||
|
||||
- Claude uses its process-local background roster and pending completion notifications. A fresh
|
||||
process starts empty. CLI 2.1.292 or newer is known to support the roster; older binaries must
|
||||
demonstrate support by emitting one. Unknown versions stay resident until then.
|
||||
- Codex queries background terminals for loaded threads, including native children, and protects
|
||||
pending provider requests. App servers without the terminal-list RPC stay resident.
|
||||
- OpenCode checks active executions and descendants. V1 checks PTYs; v2 also checks shell and
|
||||
inbox inventories. The shared server remains leased while a session vetoes collection.
|
||||
- Pi checks streaming, compaction, pending replies, and adapter-owned operations. Known native
|
||||
subagent signals also protect their parent through the daemon's subagent store.
|
||||
- OMP additionally requires `get_state.isSettled: true` and `hasPendingAsyncWork: false`.
|
||||
These protect jobs with unsuppressed completion delivery, pending deliveries, queued result
|
||||
injection, and admitted or scheduled continuations that can re-wake the session. Older OMP
|
||||
runtimes without either field stay resident. These signals are not an inventory of arbitrary
|
||||
processes.
|
||||
- ACP has no provider safety check, so its adapters use baseline-only collection.
|
||||
|
||||
These checks do not discover arbitrary detached processes or work hidden by third-party
|
||||
extensions. A provider plugin adds its own veto through the [runtime safety contract](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
Idle agents remain resident indefinitely. Runtime closure happens only through an explicit lifecycle
|
||||
action such as archive, replacement, reload, workspace teardown, or daemon shutdown.
|
||||
|
||||
A provider runtime can still die on its own — crash, OOM kill, host suspend. Work the agent parked
|
||||
inside that process dies with it: Claude Code's background Bash shells, `Monitor` watches, and
|
||||
|
||||
@@ -498,11 +498,6 @@ Validate and apply it inside the provider; core does not know your option shape.
|
||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||
merge semantics.
|
||||
|
||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||
the plugin bridge transports that query through the existing provider request/event protocol.
|
||||
|
||||
Agent refresh closes the current provider session and opens it again with current configuration and
|
||||
persistence. Re-read credentials and provider-owned configuration on `session.open`; consume the
|
||||
daemon launch from `connect` and the per-session env and MCP servers from `session.open`. There is
|
||||
|
||||
@@ -1415,12 +1415,14 @@ test("collapsing and expanding restores the real horizontal scroll offset", asyn
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ settingsKey, fontSize }) => {
|
||||
if (localStorage.getItem(settingsKey)) {
|
||||
const settings = JSON.parse(localStorage.getItem(settingsKey) ?? "{}");
|
||||
if (settings.codeFontSize !== undefined) {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(
|
||||
settingsKey,
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
theme: "dark",
|
||||
sendBehavior: "interrupt",
|
||||
serviceUrlBehavior: "ask",
|
||||
|
||||
@@ -143,7 +143,7 @@ async function configureOverviewToolCalls(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ test("measures an overview heading that becomes loading after its idle mount", a
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
async function useChatDisplay(page: Page, chatDisplay: ChatDisplay): Promise<void> {
|
||||
await page.addInitScript((value) => {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: value }));
|
||||
}, chatDisplay);
|
||||
}
|
||||
|
||||
async function seedAnsweredAgent(title: string) {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "turn-collapse-",
|
||||
title,
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "Why does the chat stop following new output?",
|
||||
});
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
await agent.client.sendAgentMessage(agent.agentId, "Make the fix and run the burst script.");
|
||||
await agent.client.waitForFinish(agent.agentId, 30_000);
|
||||
return agent;
|
||||
}
|
||||
|
||||
/** Chat rows between the latest turn's summary line and its reply, in document order. */
|
||||
async function rowsBetweenLatestSummaryAndReply(page: Page): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const rows = [...document.querySelectorAll("[data-message-id]")];
|
||||
const summaryRow = rows.findLastIndex((row) => row.querySelector("[data-testid=turn-summary]"));
|
||||
const replyRow = rows.findLastIndex((row) =>
|
||||
row.querySelector("[data-testid=assistant-message]"),
|
||||
);
|
||||
return replyRow - summaryRow - 1;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Turn collapse", () => {
|
||||
test("folds each answered turn behind a summary line that opens it", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await useChatDisplay(page, "collapseTurns");
|
||||
const agent = await seedAnsweredAgent("Turn collapse");
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
|
||||
const summary = page.getByTestId("turn-summary").last().getByRole("button");
|
||||
await expect(summary).toHaveText(/^Wrote \d+ messages?, edited 1 file/);
|
||||
// Text chunks that stream back to back merge into one message, so the reply holds
|
||||
// at least the stream's last chunk.
|
||||
await expect(page.getByTestId("assistant-message").last()).toHaveText(
|
||||
/\(end of synthetic stream\)$/,
|
||||
);
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBeGreaterThan(0);
|
||||
await expect(summary).toBeInViewport();
|
||||
|
||||
await summary.click();
|
||||
await expect.poll(() => rowsBetweenLatestSummaryAndReply(page)).toBe(0);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -180,6 +180,11 @@ const test = daemonTest.extend<{
|
||||
localStorage.setItem("@paseo:daemon-registry", JSON.stringify([daemon, ...extraHosts]));
|
||||
localStorage.removeItem("@paseo:settings");
|
||||
localStorage.setItem("@paseo:create-agent-preferences", JSON.stringify(preferences));
|
||||
// New installs collapse finished turns; specs assert on the rows inside them, so they
|
||||
// start from the full chat unless they seed their own settings.
|
||||
if (!localStorage.getItem("@paseo:app-settings")) {
|
||||
localStorage.setItem("@paseo:app-settings", JSON.stringify({ chatDisplay: "full" }));
|
||||
}
|
||||
},
|
||||
{
|
||||
daemon: testDaemon,
|
||||
|
||||
@@ -116,7 +116,7 @@ export async function withTimelinePlugin(
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"@paseo:app-settings",
|
||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||
JSON.stringify({ chatDisplay: "collapseToolCalls" }),
|
||||
);
|
||||
});
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
|
||||
@@ -63,8 +63,9 @@ export async function seedSidebarFooterPreferences(
|
||||
): Promise<void> {
|
||||
await page.addInitScript(
|
||||
({ key, sidebarFooterItems }) => {
|
||||
if (localStorage.getItem(key)) return;
|
||||
localStorage.setItem(key, JSON.stringify({ sidebarFooterItems }));
|
||||
const settings = JSON.parse(localStorage.getItem(key) ?? "{}");
|
||||
if (settings.sidebarFooterItems) return;
|
||||
localStorage.setItem(key, JSON.stringify({ ...settings, sidebarFooterItems }));
|
||||
},
|
||||
{ key: APP_SETTINGS_KEY, sidebarFooterItems: preferences },
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ function viewport(root: HTMLElement, capture: Capture) {
|
||||
const handle: StreamViewportHandle = {
|
||||
scrollToBottom() {},
|
||||
prepareForViewportChange() {},
|
||||
holdReadingPosition() {},
|
||||
scrollToMessage(_messageId, occurrence) {
|
||||
if (occurrence) capture.occurrence = occurrence;
|
||||
},
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const viewportRef = { current: viewport };
|
||||
@@ -316,6 +317,7 @@ describe("useChatOutline", () => {
|
||||
current: {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
},
|
||||
};
|
||||
@@ -371,6 +373,7 @@ describe("useChatOutline", () => {
|
||||
const viewport: StreamViewportHandle = {
|
||||
scrollToBottom: vi.fn(),
|
||||
prepareForViewportChange: vi.fn(),
|
||||
holdReadingPosition: vi.fn(),
|
||||
scrollToMessage,
|
||||
};
|
||||
const livePrompt = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/tim
|
||||
import type { InstalledPlugin } from "@/plugins/types";
|
||||
import {
|
||||
applyStreamEvent,
|
||||
getStreamItemMessageId,
|
||||
hydrateStreamState,
|
||||
type StreamItem,
|
||||
type ToolCallItem,
|
||||
@@ -13,7 +14,7 @@ import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAgentStreamRenderModel } from "./model";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
|
||||
const runtime = {
|
||||
paseo: {},
|
||||
@@ -97,7 +98,11 @@ function pluginData(items: StreamItem[]): unknown[] {
|
||||
return items.flatMap((item) => (item.kind === "plugin" ? [item.data] : []));
|
||||
}
|
||||
|
||||
const presentationOptions = { level: "overview" as const, isTurnActive: true };
|
||||
const presentationOptions = {
|
||||
display: "collapseToolCalls" as const,
|
||||
expandedTurnHostIds: new Set<string>(),
|
||||
isTurnActive: true,
|
||||
};
|
||||
|
||||
function streamHarness(transform?: TimelineItemTransform) {
|
||||
let state: { tail: StreamItem[]; head: StreamItem[] } = { tail: [], head: [] };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ChatDisplay } from "@/hooks/use-settings";
|
||||
import type { TimelineItemTransform } from "@/plugins/timeline/model";
|
||||
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
|
||||
import {
|
||||
@@ -5,23 +6,32 @@ import {
|
||||
projectToolCallDetailLevel,
|
||||
type PreparedToolCallHistory,
|
||||
type ToolCallDetailLevel,
|
||||
type ToolCallDetailProjection,
|
||||
} from "@/tool-calls/detail-level/projection";
|
||||
import type { StreamItem, UserMessageItem } from "@/types/stream";
|
||||
import { collapseTurns, type TurnCollapse } from "./turn-collapse/model";
|
||||
|
||||
interface PresentationInput {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
transform: TimelineItemTransform | undefined;
|
||||
level: ToolCallDetailLevel;
|
||||
display: ChatDisplay;
|
||||
/** User messages whose collapsed turn the reader opened. */
|
||||
expandedTurnHostIds: ReadonlySet<string>;
|
||||
isTurnActive: boolean;
|
||||
}
|
||||
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
export interface StreamPresentation extends ToolCallDetailProjection {
|
||||
turns: Omit<TurnCollapse, "tail">;
|
||||
}
|
||||
|
||||
const NO_COLLAPSED_TURNS: StreamPresentation["turns"] = collapseTurns({
|
||||
tail: [],
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
});
|
||||
|
||||
/** Source messages reach plugins before display projection. */
|
||||
export function createStreamPresentation() {
|
||||
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
|
||||
@@ -47,12 +57,47 @@ export function createStreamPresentation() {
|
||||
let preparedTail: StreamItem[] | undefined;
|
||||
let preparedLevel: ToolCallDetailLevel | undefined;
|
||||
let preparedHistory: PreparedToolCallHistory | null = null;
|
||||
let collapsedSource: StreamItem[] | undefined;
|
||||
let collapsedIsLatestTurnOpen = false;
|
||||
let collapsedExpandedHostIds: ReadonlySet<string> | undefined;
|
||||
let collapsed: TurnCollapse | undefined;
|
||||
|
||||
function presentRows(item: StreamItem): StreamItem[] {
|
||||
return [item.kind === "user_message" ? presentUserMessage(item) : item];
|
||||
}
|
||||
|
||||
return (input: PresentationInput) => {
|
||||
function collapse(
|
||||
projection: ToolCallDetailProjection,
|
||||
input: PresentationInput,
|
||||
): StreamPresentation {
|
||||
if (input.display !== "collapseTurns") {
|
||||
return { ...projection, turns: NO_COLLAPSED_TURNS };
|
||||
}
|
||||
// Live text updates reach the head only, so the folded history is reused until the
|
||||
// retained rows, the latest turn's liveness, or the reader's expanded turns change.
|
||||
const isLatestTurnOpen = input.isTurnActive || projection.head.length > 0;
|
||||
if (
|
||||
!collapsed ||
|
||||
collapsedSource !== projection.tail ||
|
||||
collapsedIsLatestTurnOpen !== isLatestTurnOpen ||
|
||||
collapsedExpandedHostIds !== input.expandedTurnHostIds
|
||||
) {
|
||||
collapsed = collapseTurns({
|
||||
tail: projection.tail,
|
||||
isLatestTurnOpen,
|
||||
expandedHostIds: input.expandedTurnHostIds,
|
||||
getGroupedCalls: (itemId) => projection.groupsByHostId.get(itemId)?.run.calls,
|
||||
});
|
||||
collapsedSource = projection.tail;
|
||||
collapsedIsLatestTurnOpen = isLatestTurnOpen;
|
||||
collapsedExpandedHostIds = input.expandedTurnHostIds;
|
||||
}
|
||||
const { tail, ...turns } = collapsed;
|
||||
return { ...projection, tail, turns };
|
||||
}
|
||||
|
||||
return (input: PresentationInput): StreamPresentation => {
|
||||
const level: ToolCallDetailLevel = input.display === "full" ? "detailed" : "overview";
|
||||
// Retained history is not reprojected or regrouped on each live text update.
|
||||
if (historySource !== input.tail || historyTransform !== input.transform) {
|
||||
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
|
||||
@@ -66,17 +111,20 @@ export function createStreamPresentation() {
|
||||
const blocks = presentRows(item);
|
||||
head.push(...blocks);
|
||||
}
|
||||
if (preparedTail !== historyRows || preparedLevel !== input.level) {
|
||||
preparedHistory = prepareToolCallHistory(input.level, historyRows);
|
||||
if (preparedTail !== historyRows || preparedLevel !== level) {
|
||||
preparedHistory = prepareToolCallHistory(level, historyRows);
|
||||
preparedTail = historyRows;
|
||||
preparedLevel = input.level;
|
||||
preparedLevel = level;
|
||||
}
|
||||
return projectToolCallDetailLevel({
|
||||
level: input.level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
});
|
||||
return collapse(
|
||||
projectToolCallDetailLevel({
|
||||
level,
|
||||
tail: historyRows,
|
||||
head,
|
||||
preparedHistory,
|
||||
isTurnActive: input.isTurnActive,
|
||||
}),
|
||||
input,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,6 +361,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
bottomAnchorController.prepareForStickyViewportChange();
|
||||
markNativeViewportSettling();
|
||||
},
|
||||
holdReadingPosition: () => bottomAnchorController.detachByUser(),
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
return () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { DomOverlayScrollbar } from "@/components/ui/overlay-scrollbar/dom-overlay-scrollbar";
|
||||
@@ -44,7 +44,6 @@ import {
|
||||
type HistoryStartSettleScheduler,
|
||||
} from "./history-start-settle-scheduler";
|
||||
import { useChatFindSelectedMessageId } from "@/agent-stream/chat-find";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { useScrollToMessage } from "./use-scroll-to-message.web";
|
||||
|
||||
interface CreateWebStreamStrategyInput {
|
||||
@@ -1174,6 +1173,11 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
scheduleStickToBottom();
|
||||
},
|
||||
holdReadingPosition: () => {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
readingAnchor.scroll(scrollContainerRef.current?.scrollTop ?? 0);
|
||||
},
|
||||
scrollToMessage,
|
||||
};
|
||||
viewportRef.current = handle;
|
||||
@@ -1186,6 +1190,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
forceStickToBottom,
|
||||
readingAnchor,
|
||||
scheduleStickToBottom,
|
||||
scrollToMessage,
|
||||
viewportRef,
|
||||
|
||||
@@ -55,6 +55,8 @@ export interface StreamViewportHandle {
|
||||
focus?: () => void;
|
||||
scrollToBottom: (reason?: BottomAnchorLocalRequest["reason"]) => void;
|
||||
prepareForViewportChange: () => void;
|
||||
/** Stop following output so the rows on screen stay put while rows below them open or close. */
|
||||
holdReadingPosition: () => void;
|
||||
scrollToMessage?: (messageId: string, occurrence?: ScrollToMessageOccurrence) => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { StreamItem, ToolCallItem } from "@/types/stream";
|
||||
import { collapseTurns, type CollapseTurnsInput } from "./model";
|
||||
|
||||
const at = new Date("2026-01-01T00:00:00.000Z");
|
||||
|
||||
function user(id: string): StreamItem {
|
||||
return { kind: "user_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function reply(id: string): StreamItem {
|
||||
return { kind: "assistant_message", id, text: id, timestamp: at };
|
||||
}
|
||||
|
||||
function thought(id: string): StreamItem {
|
||||
return { kind: "thought", id, text: id, timestamp: at, status: "ready" };
|
||||
}
|
||||
|
||||
function shell(id: string): ToolCallItem {
|
||||
return {
|
||||
kind: "tool_call",
|
||||
id,
|
||||
timestamp: at,
|
||||
payload: {
|
||||
source: "agent",
|
||||
data: {
|
||||
provider: "claude",
|
||||
callId: id,
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "shell", command: "ls" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function collapse(tail: StreamItem[], options: Partial<CollapseTurnsInput> = {}) {
|
||||
return collapseTurns({
|
||||
tail,
|
||||
isLatestTurnOpen: false,
|
||||
expandedHostIds: new Set(),
|
||||
getGroupedCalls: () => undefined,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
const ids = (items: StreamItem[]) => items.map((item) => item.id);
|
||||
|
||||
describe("collapseTurns", () => {
|
||||
it("folds a finished turn's work into its user message and keeps the reply", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
thought("t1"),
|
||||
reply("early"),
|
||||
shell("c1"),
|
||||
shell("c2"),
|
||||
reply("final"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "final"]);
|
||||
expect(result.turnsByHostId.get("u1")).toEqual({
|
||||
hostId: "u1",
|
||||
expanded: false,
|
||||
summary: {
|
||||
messageCount: 1,
|
||||
tools: expect.objectContaining({ commandCount: 2 }),
|
||||
},
|
||||
});
|
||||
expect(result.hostIdByFoldedMessageId.get("early")).toBe("u1");
|
||||
});
|
||||
|
||||
it("shows an expanded turn's work as nested rows between the prompt and the reply", () => {
|
||||
const result = collapse([user("u1"), thought("t1"), shell("c1"), reply("final")], {
|
||||
expandedHostIds: new Set(["u1"]),
|
||||
});
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "t1", "c1", "final"]);
|
||||
expect([...result.nestedItemIds]).toEqual(["t1", "c1"]);
|
||||
expect(result.turnsByHostId.get("u1")?.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the latest turn alone while it is still producing output", () => {
|
||||
const tail = [user("u1"), shell("c1"), reply("r1"), user("u2"), shell("c2"), reply("r2")];
|
||||
|
||||
const result = collapse(tail, { isLatestTurnOpen: true });
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "u2", "c2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("keeps rows after the reply, and turns with no work before the reply, as they are", () => {
|
||||
const result = collapse([
|
||||
user("u1"),
|
||||
shell("c1"),
|
||||
reply("r1"),
|
||||
shell("after"),
|
||||
user("u2"),
|
||||
reply("r2"),
|
||||
]);
|
||||
|
||||
expect(ids(result.tail)).toEqual(["u1", "r1", "after", "u2", "r2"]);
|
||||
expect([...result.turnsByHostId.keys()]).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
it("does not fold a turn that only thought before its reply", () => {
|
||||
const tail = [user("u1"), thought("t1"), reply("r1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fold a turn without a reply or whose prompt is above the loaded history", () => {
|
||||
const tail = [shell("c0"), reply("r0"), user("u1"), shell("c1")];
|
||||
|
||||
const result = collapse(tail);
|
||||
|
||||
expect(result.tail).toBe(tail);
|
||||
expect(result.turnsByHostId.size).toBe(0);
|
||||
});
|
||||
|
||||
it("counts every call a tool-call group row stands for", () => {
|
||||
const group = [shell("c1"), shell("c2"), shell("c3")];
|
||||
const result = collapse([user("u1"), group[0]!, reply("final")], {
|
||||
getGroupedCalls: (itemId) => (itemId === "c1" ? group : undefined),
|
||||
});
|
||||
|
||||
expect(result.turnsByHostId.get("u1")?.summary).toMatchObject({
|
||||
tools: { commandCount: 3 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getStreamItemMessageId, type StreamItem, type ToolCallItem } from "@/types/stream";
|
||||
import { summarizeToolCalls, type OverviewSummary } from "@/tool-calls/detail-level/overview/model";
|
||||
|
||||
export interface TurnSummary {
|
||||
messageCount: number;
|
||||
tools: OverviewSummary;
|
||||
}
|
||||
|
||||
export interface CollapsedTurn {
|
||||
/** The user message the turn answers. Its row carries the turn's summary line. */
|
||||
hostId: string;
|
||||
expanded: boolean;
|
||||
summary: TurnSummary;
|
||||
}
|
||||
|
||||
export interface TurnCollapse {
|
||||
tail: StreamItem[];
|
||||
turnsByHostId: ReadonlyMap<string, CollapsedTurn>;
|
||||
/** Rows of expanded turns, which render inside the turn's indent rail. */
|
||||
nestedItemIds: ReadonlySet<string>;
|
||||
/** The turn a folded message belongs to, for surfaces that must reveal it. */
|
||||
hostIdByFoldedMessageId: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
export interface CollapseTurnsInput {
|
||||
tail: StreamItem[];
|
||||
/** The latest turn is still producing output, so it stays unfolded. */
|
||||
isLatestTurnOpen: boolean;
|
||||
expandedHostIds: ReadonlySet<string>;
|
||||
/** The calls a tool-call group row stands for; undefined for a single call. */
|
||||
getGroupedCalls: (itemId: string) => readonly ToolCallItem[] | undefined;
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ReadonlyMap<string, CollapsedTurn> = new Map();
|
||||
const EMPTY_IDS: ReadonlySet<string> = new Set();
|
||||
const EMPTY_HOSTS: ReadonlyMap<string, string> = new Map();
|
||||
|
||||
function summarizeTurn(
|
||||
items: readonly StreamItem[],
|
||||
getGroupedCalls: CollapseTurnsInput["getGroupedCalls"],
|
||||
): TurnSummary {
|
||||
const calls: ToolCallItem[] = [];
|
||||
let messageCount = 0;
|
||||
for (const item of items) {
|
||||
if (item.kind === "assistant_message") messageCount += 1;
|
||||
else if (item.kind === "tool_call") calls.push(...(getGroupedCalls(item.id) ?? [item]));
|
||||
}
|
||||
return { messageCount, tools: summarizeToolCalls(calls) };
|
||||
}
|
||||
|
||||
function hasWork(summary: TurnSummary): boolean {
|
||||
return summary.messageCount > 0 || Object.values(summary.tools).some((count) => count > 0);
|
||||
}
|
||||
|
||||
function findLastAssistantIndex(tail: StreamItem[], start: number, end: number): number {
|
||||
for (let index = end - 1; index >= start; index -= 1) {
|
||||
if (tail[index]?.kind === "assistant_message") return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds each answered turn into its user message: everything the agent did before its last
|
||||
* message collapses into one summary line, and that message stays in the chat. A turn folds
|
||||
* only once it is complete, its user message is loaded, and it sent messages or called tools
|
||||
* before its reply. Thoughts fold with the turn but are not counted.
|
||||
*/
|
||||
export function collapseTurns(input: CollapseTurnsInput): TurnCollapse {
|
||||
const { tail } = input;
|
||||
const output: StreamItem[] = [];
|
||||
const turnsByHostId = new Map<string, CollapsedTurn>();
|
||||
const nestedItemIds = new Set<string>();
|
||||
const hostIdByFoldedMessageId = new Map<string, string>();
|
||||
|
||||
let runStart = 0;
|
||||
const flushRun = (runEnd: number) => {
|
||||
const host = tail[runStart - 1];
|
||||
const isClosed = runEnd < tail.length || !input.isLatestTurnOpen;
|
||||
const replyIndex = findLastAssistantIndex(tail, runStart, runEnd);
|
||||
if (host?.kind !== "user_message" || !isClosed || replyIndex <= runStart) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const folded = tail.slice(runStart, replyIndex);
|
||||
const summary = summarizeTurn(folded, input.getGroupedCalls);
|
||||
if (!hasWork(summary)) {
|
||||
output.push(...tail.slice(runStart, runEnd));
|
||||
return;
|
||||
}
|
||||
const expanded = input.expandedHostIds.has(host.id);
|
||||
turnsByHostId.set(host.id, { hostId: host.id, expanded, summary });
|
||||
for (const item of folded) {
|
||||
hostIdByFoldedMessageId.set(getStreamItemMessageId(item), host.id);
|
||||
if (expanded) nestedItemIds.add(item.id);
|
||||
}
|
||||
if (expanded) output.push(...folded);
|
||||
output.push(...tail.slice(replyIndex, runEnd));
|
||||
};
|
||||
|
||||
for (let index = 0; index < tail.length; index += 1) {
|
||||
if (tail[index]?.kind !== "user_message") continue;
|
||||
flushRun(index);
|
||||
output.push(tail[index]!);
|
||||
runStart = index + 1;
|
||||
}
|
||||
flushRun(tail.length);
|
||||
|
||||
if (turnsByHostId.size === 0) {
|
||||
return {
|
||||
tail,
|
||||
turnsByHostId: EMPTY_TURNS,
|
||||
nestedItemIds: EMPTY_IDS,
|
||||
hostIdByFoldedMessageId: EMPTY_HOSTS,
|
||||
};
|
||||
}
|
||||
return {
|
||||
tail: output.length === tail.length ? tail : output,
|
||||
turnsByHostId,
|
||||
nestedItemIds,
|
||||
hostIdByFoldedMessageId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Summary } from "lucide-react-native";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useOverviewSummaryLabel } from "@/tool-calls/detail-level/overview/label";
|
||||
import type { CollapsedTurn } from "./model";
|
||||
|
||||
/**
|
||||
* The row a collapsed turn leaves under its user message, styled like a tool-call group.
|
||||
* Pressing it opens the turn's rows beneath it.
|
||||
*/
|
||||
export const TurnSummaryLine = memo(function TurnSummaryLine({
|
||||
turn,
|
||||
onExpandedChange,
|
||||
}: {
|
||||
turn: CollapsedTurn;
|
||||
onExpandedChange: (hostId: string, expanded: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { messageCount, tools } = turn.summary;
|
||||
const messageParts = useMemo(
|
||||
() =>
|
||||
messageCount > 0
|
||||
? [
|
||||
t(`turnSummary.messages.${messageCount === 1 ? "one" : "other"}`, {
|
||||
count: messageCount,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
[messageCount, t],
|
||||
);
|
||||
const label = useOverviewSummaryLabel(tools, messageParts);
|
||||
const toggle = useCallback(
|
||||
() => onExpandedChange(turn.hostId, !turn.expanded),
|
||||
[onExpandedChange, turn.expanded, turn.hostId],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExpandableBadge
|
||||
testID="turn-summary"
|
||||
label={label}
|
||||
icon={Summary}
|
||||
isExpanded={turn.expanded}
|
||||
onToggle={toggle}
|
||||
borderlessWhenExpanded
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
import type { ScrollToMessageOccurrence } from "./strategy";
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import { findMountedWindowStart, getMountedRecentStreamItems } from "./history-window";
|
||||
import { getStreamItemMessageId } from "./presentation";
|
||||
|
||||
// Callers reveal a message, not a row: an assistant message is several block rows and
|
||||
// the window has to open at the first of them.
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
type InlinePathTarget,
|
||||
} from "@/components/message";
|
||||
import { PlanCard } from "@/components/plan-card";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import { getStreamItemMessageId, type StreamItem } from "@/types/stream";
|
||||
import type { PendingMessageSubmission } from "@/composer/submission/model";
|
||||
import type { TurnPresentation } from "@/timeline/turn-liveness";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
@@ -62,8 +62,10 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { ToolCallDetailsContent } from "@/components/tool-call-details";
|
||||
import { QuestionFormCard } from "@/components/question-form-card";
|
||||
import { ToolCallSheetProvider } from "@/components/tool-call-sheet";
|
||||
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||
import { createStreamPresentation } from "./presentation";
|
||||
import { OverviewToolCallGroupView } from "@/tool-calls/detail-level/overview/view";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { TurnSummaryLine } from "./turn-collapse/summary-line";
|
||||
import { type AgentStreamRenderModel, buildAgentStreamRenderModel } from "./model";
|
||||
import { resolveStreamRenderStrategy } from "./strategy-resolver";
|
||||
import { type StreamSegmentRenderers, type StreamViewportHandle } from "./strategy";
|
||||
@@ -148,9 +150,16 @@ function renderPendingPermissionsNode(input: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a row sits in an opened turn: its summary line's row, a row inside the turn, or the
|
||||
* turn's last row. The rail runs through the gaps between them so the turn reads as one block.
|
||||
*/
|
||||
type TurnIndent = "none" | "openedHost" | "nested" | "lastNested";
|
||||
|
||||
function renderStreamItemWithTurnFooter(input: {
|
||||
content: ReactNode;
|
||||
layoutItem: StreamLayoutItem;
|
||||
turnIndent: TurnIndent;
|
||||
strategy: TurnContentStrategy;
|
||||
supportsTimelineCursor: boolean;
|
||||
onForkAssistantTurn?: AssistantTurnForkHandler;
|
||||
@@ -171,7 +180,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
/>
|
||||
) : null;
|
||||
const content = (
|
||||
<StreamItemWrapper itemId={input.layoutItem.item.id} gapBelow={input.layoutItem.gapBelow}>
|
||||
<StreamItemWrapper gapBelow={input.layoutItem.gapBelow} turnIndent={input.turnIndent}>
|
||||
{input.content}
|
||||
</StreamItemWrapper>
|
||||
);
|
||||
@@ -333,7 +342,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const autoExpandReasoning = useSettings((settings) => settings.autoExpandReasoning);
|
||||
const toolCallDetailLevel = useSettings((settings) => settings.toolCallDetailLevel);
|
||||
const chatDisplay = useSettings((settings) => settings.chatDisplay);
|
||||
const chatOutlineEnabled = useSettings((settings) => settings.chatOutlineEnabled);
|
||||
const contentMaxWidth = useSettings(resolveContentMaxWidth);
|
||||
const viewportRef = useRef<StreamViewportHandle | null>(null);
|
||||
@@ -357,6 +366,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const [expandedToolCallGroupIds, setExpandedToolCallGroupIds] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [expandedTurnHostIds, setExpandedTurnHostIds] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
// Get serverId (fallback to agent's serverId if not provided)
|
||||
const resolvedServerId = serverId ?? context.serverId ?? "";
|
||||
@@ -421,6 +431,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
setIsNearBottom(true);
|
||||
setExpandedInlineToolCallIds(new Set());
|
||||
setExpandedToolCallGroupIds(new Set());
|
||||
setExpandedTurnHostIds(new Set());
|
||||
}, [agentId]);
|
||||
|
||||
const isActive = useRetainedPanelActive();
|
||||
@@ -512,7 +523,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
tail: effectiveStreamItems,
|
||||
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
|
||||
transform: transformTimelineItem,
|
||||
level: toolCallDetailLevel,
|
||||
display: chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
}),
|
||||
[
|
||||
@@ -520,7 +532,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
effectiveStreamItems,
|
||||
effectiveStreamHead,
|
||||
transformTimelineItem,
|
||||
toolCallDetailLevel,
|
||||
chatDisplay,
|
||||
expandedTurnHostIds,
|
||||
isTurnActive,
|
||||
],
|
||||
);
|
||||
@@ -651,6 +664,43 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
[streamRenderStrategy],
|
||||
);
|
||||
|
||||
const setTurnExpanded = useCallback((hostId: string, expanded: boolean) => {
|
||||
// A turn opens below its summary line; keep that line under the reader's pointer.
|
||||
viewportRef.current?.holdReadingPosition();
|
||||
setExpandedTurnHostIds((previous) => {
|
||||
if (previous.has(hostId) === expanded) return previous;
|
||||
const next = new Set(previous);
|
||||
if (expanded) {
|
||||
next.add(hostId);
|
||||
} else {
|
||||
next.delete(hostId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
// Chat find reveals a match inside a collapsed turn by opening the turn first.
|
||||
const revealLoadedMessage = useStableEvent((messageId: string): boolean => {
|
||||
const hostId = presentation.turns.hostIdByFoldedMessageId.get(messageId);
|
||||
if (hostId && !expandedTurnHostIds.has(hostId)) {
|
||||
setTurnExpanded(hostId, true);
|
||||
return true;
|
||||
}
|
||||
return revealLoadedHistory(messageId);
|
||||
});
|
||||
// Read through stable events so a collapse rerun does not change the renderer identity;
|
||||
// rows whose turn state changed are revised through `historyRowRevision`.
|
||||
const getCollapsedTurn = useStableEvent((hostId: string) =>
|
||||
presentation.turns.turnsByHostId.get(hostId),
|
||||
);
|
||||
const getTurnIndent = useStableEvent((layoutItem: StreamLayoutItem): TurnIndent => {
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
if (nestedItemIds.has(layoutItem.item.id)) {
|
||||
const below = layoutItem.belowItem;
|
||||
return below && nestedItemIds.has(below.id) ? "nested" : "lastNested";
|
||||
}
|
||||
return getCollapsedTurn(layoutItem.item.id)?.expanded ? "openedHost" : "none";
|
||||
});
|
||||
|
||||
const setToolCallGroupExpanded = useCallback((groupId: string, expanded: boolean) => {
|
||||
setExpandedToolCallGroupIds((previous) => {
|
||||
const next = new Set(previous);
|
||||
@@ -665,7 +715,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const renderUserMessageItem = useCallback(
|
||||
(layoutItem: StreamLayoutItem, item: Extract<StreamItem, { kind: "user_message" }>) => {
|
||||
return (
|
||||
const collapsedTurn = getCollapsedTurn(item.id);
|
||||
const message = (
|
||||
<UserMessage
|
||||
serverId={resolvedServerId}
|
||||
agentId={agentId}
|
||||
@@ -684,8 +735,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
}
|
||||
/>
|
||||
);
|
||||
if (!collapsedTurn) {
|
||||
return message;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{message}
|
||||
<TurnSummaryLine turn={collapsedTurn} onExpandedChange={setTurnExpanded} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
[context.capabilities, agentId, client, pendingClientMessageIds, resolvedServerId],
|
||||
[
|
||||
context.capabilities,
|
||||
agentId,
|
||||
client,
|
||||
getCollapsedTurn,
|
||||
pendingClientMessageIds,
|
||||
resolvedServerId,
|
||||
setTurnExpanded,
|
||||
],
|
||||
);
|
||||
|
||||
const renderAssistantMessageItem = useCallback(
|
||||
@@ -893,12 +961,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
return renderStreamItemWithTurnFooter({
|
||||
content,
|
||||
layoutItem,
|
||||
turnIndent: getTurnIndent(layoutItem),
|
||||
strategy: streamRenderStrategy,
|
||||
supportsTimelineCursor: supportsAgentForkContextCursor,
|
||||
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
|
||||
});
|
||||
},
|
||||
[
|
||||
getTurnIndent,
|
||||
handleForkAssistantTurn,
|
||||
readOnly,
|
||||
renderStreamItemContent,
|
||||
@@ -1060,13 +1130,25 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const streamScrollEnabled =
|
||||
!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion() ||
|
||||
expandedInlineToolCallIds.size === 0;
|
||||
const { nestedItemIds } = presentation.turns;
|
||||
const historyRowRevision = useMemo(
|
||||
() => ({
|
||||
contentById: presentation.historyGroupUpdatesByHostId,
|
||||
displayStateById: expandedToolCallGroupIds,
|
||||
displayStateById: {
|
||||
has: (id: string) =>
|
||||
expandedToolCallGroupIds.has(id) ||
|
||||
expandedTurnHostIds.has(id) ||
|
||||
nestedItemIds.has(id),
|
||||
},
|
||||
globalDisplayState: isMobile,
|
||||
}),
|
||||
[expandedToolCallGroupIds, isMobile, presentation.historyGroupUpdatesByHostId],
|
||||
[
|
||||
expandedToolCallGroupIds,
|
||||
expandedTurnHostIds,
|
||||
isMobile,
|
||||
nestedItemIds,
|
||||
presentation.historyGroupUpdatesByHostId,
|
||||
],
|
||||
);
|
||||
|
||||
const findItems = useMemo(
|
||||
@@ -1080,7 +1162,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
epoch={timelineEpoch}
|
||||
items={findItems}
|
||||
viewportRef={viewportRef}
|
||||
revealLoadedMessage={revealLoadedHistory}
|
||||
revealLoadedMessage={revealLoadedMessage}
|
||||
visibleMessageIds={mountedMessageIds}
|
||||
>
|
||||
<ToolCallSheetProvider>
|
||||
@@ -1763,15 +1845,41 @@ const permissionStyles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
interface StreamItemWrapperProps {
|
||||
itemId: string;
|
||||
gapBelow: number;
|
||||
turnIndent: TurnIndent;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function StreamItemWrapper({ gapBelow, children }: StreamItemWrapperProps) {
|
||||
function StreamItemWrapper({ gapBelow, turnIndent, children }: StreamItemWrapperProps) {
|
||||
const wrapperStyle = useMemo(
|
||||
() => [stylesheet.streamItemWrapper, { marginBottom: gapBelow }],
|
||||
[gapBelow],
|
||||
() => [
|
||||
stylesheet.streamItemWrapper,
|
||||
{ marginBottom: turnIndent === "nested" || turnIndent === "openedHost" ? 0 : gapBelow },
|
||||
],
|
||||
[gapBelow, turnIndent],
|
||||
);
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
const gapStyle = useMemo(() => ({ paddingBottom: gapBelow }), [gapBelow]);
|
||||
switch (turnIndent) {
|
||||
case "none":
|
||||
return <View style={wrapperStyle}>{children}</View>;
|
||||
case "openedHost":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
{children}
|
||||
<IndentRail style={gapStyle} />
|
||||
</View>
|
||||
);
|
||||
case "nested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail style={gapStyle}>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
case "lastNested":
|
||||
return (
|
||||
<View style={wrapperStyle}>
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
|
||||
/**
|
||||
* Collapsed chat rows badge their summary with an icon whose center sits 7px into the content
|
||||
* column (`ExpandableBadge` bleeds 13px left, then 1px border, 8px padding, 22px icon). The rail
|
||||
* hangs from that center so opened rows read as children of the line that opened them.
|
||||
*/
|
||||
const RAIL_LEFT = 7;
|
||||
const RAIL_WIDTH = 1;
|
||||
export const INDENT_RAIL_CONTENT_OFFSET = 21;
|
||||
|
||||
/** Indents opened chat rows under the summary that collapsed them, behind a vertical line. */
|
||||
export function IndentRail({
|
||||
children,
|
||||
style,
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}) {
|
||||
return <View style={[styles.rail, style]}>{children}</View>;
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
rail: {
|
||||
marginLeft: RAIL_LEFT,
|
||||
paddingLeft: INDENT_RAIL_CONTENT_OFFSET - RAIL_LEFT - RAIL_WIDTH,
|
||||
borderLeftWidth: RAIL_WIDTH,
|
||||
borderLeftColor: theme.colors.border,
|
||||
},
|
||||
}));
|
||||
@@ -971,6 +971,10 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
// Details that do not sit in a card leave the header as a lone highlighted pill.
|
||||
pressableExpandedDetached: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
detailWrapperBorderless: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
@@ -2048,7 +2052,10 @@ export const ExpandableBadge = memo(function ExpandableBadge({
|
||||
expandableBadgeStylesheet.pressable,
|
||||
isPressed && isInteractive ? expandableBadgeStylesheet.pressablePressed : null,
|
||||
isExpanded && expandableBadgeStylesheet.pressableExpanded,
|
||||
isExpanded && !borderlessWhenExpanded && expandableBadgeStylesheet.pressableExpandedAttached,
|
||||
isExpanded &&
|
||||
(borderlessWhenExpanded
|
||||
? expandableBadgeStylesheet.pressableExpandedDetached
|
||||
: expandableBadgeStylesheet.pressableExpandedAttached),
|
||||
],
|
||||
[borderlessWhenExpanded, isExpanded, isInteractive, isPressed],
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
saveAppSettings as saveAppSettingsPure,
|
||||
type AppSettings,
|
||||
type AppSettingsUpdate,
|
||||
type ChatDisplay,
|
||||
type OpenInSidePanePreferences,
|
||||
type PullRequestOpenLocation,
|
||||
type DesktopSettingsBridge,
|
||||
@@ -84,6 +85,7 @@ export {
|
||||
export type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
ChatDisplay,
|
||||
AppLanguage,
|
||||
OpenInSidePanePreferences,
|
||||
PullRequestOpenLocation,
|
||||
|
||||
@@ -185,6 +185,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
|
||||
expect(result).toEqual(DEFAULT_CLIENT_SETTINGS);
|
||||
expect(DEFAULT_CLIENT_SETTINGS.language).toBe("system");
|
||||
expect(DEFAULT_CLIENT_SETTINGS.chatDisplay).toBe("collapseTurns");
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual(
|
||||
DEFAULT_CLIENT_SETTINGS,
|
||||
);
|
||||
@@ -454,6 +455,7 @@ describe("loadAppSettingsFromStorage", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "dark",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
expect(JSON.parse(deps.storage.entries.get(APP_SETTINGS_KEY) ?? "null")).toEqual({
|
||||
manageBuiltInDaemon: false,
|
||||
@@ -592,6 +594,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -636,6 +639,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
manageBuiltInDaemon: false,
|
||||
releaseChannel: "beta",
|
||||
});
|
||||
@@ -657,6 +661,7 @@ describe("loadSettingsFromStorage", () => {
|
||||
...DEFAULT_APP_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
chatDisplay: "full",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -732,7 +737,7 @@ describe("saveAppSettings", () => {
|
||||
...DEFAULT_CLIENT_SETTINGS,
|
||||
theme: "light",
|
||||
contentFontSize: DEFAULT_UI_BASE_FONT_SIZE,
|
||||
toolCallDetailLevel: "overview",
|
||||
chatDisplay: "collapseToolCalls",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -787,27 +792,45 @@ describe("appearance settings", () => {
|
||||
expect(result.contentFontSize).toBe(DEFAULT_UI_BASE_FONT_SIZE);
|
||||
expect(result.codeFontSize).toBe(DEFAULT_CODE_FONT_SIZE);
|
||||
expect(result.syntaxTheme).toBe("one");
|
||||
expect(result.toolCallDetailLevel).toBe("detailed");
|
||||
expect(result.chatDisplay).toBe("full");
|
||||
});
|
||||
|
||||
it("migrates the enabled compact tool call preference to overview", async () => {
|
||||
it("migrates the enabled compact tool call preference to collapsed tool calls", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ compactToolCalls: true }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("overview");
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseToolCalls");
|
||||
});
|
||||
|
||||
it("clears settings with an unrecognized tool call detail level", async () => {
|
||||
it.each([
|
||||
["overview", "collapseToolCalls"],
|
||||
["concise", "collapseToolCalls"],
|
||||
["detailed", "full"],
|
||||
["unknown", "full"],
|
||||
])("reads the %s tool call detail level as the %s chat display", async (legacy, display) => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: "unknown" }),
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({ toolCallDetailLevel: legacy }),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).toolCallDetailLevel).toBe("detailed");
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe(display);
|
||||
});
|
||||
|
||||
it("keeps a stored chat display over the tool call detail level it replaced", async () => {
|
||||
const deps = makeDeps({
|
||||
storage: createInMemoryKeyValueStorage({
|
||||
[APP_SETTINGS_KEY]: JSON.stringify({
|
||||
chatDisplay: "collapseTurns",
|
||||
toolCallDetailLevel: "detailed",
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
expect((await loadAppSettingsFromStorage(deps)).chatDisplay).toBe("collapseTurns");
|
||||
});
|
||||
|
||||
it("migrates a switched-off checks row item to the hidden checks display", async () => {
|
||||
|
||||
@@ -40,7 +40,8 @@ export type WorkspaceTitleSource = "title" | "branch";
|
||||
export type PullRequestOpenLocation = "main" | "side" | "explorer";
|
||||
/** What a sidebar workspace row shows in the space to the right of its title. */
|
||||
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
/** How much of an agent's work the chat shows between the user's message and the reply. */
|
||||
export type ChatDisplay = "full" | "collapseToolCalls" | "collapseTurns";
|
||||
|
||||
const ThemePreferenceSchema = z.enum([
|
||||
...THEME_OPTIONS.map((option) => option.name),
|
||||
@@ -100,7 +101,7 @@ export interface AppSettings {
|
||||
/** How usage reads and which windows the sidebar summary shows. */
|
||||
usage: UsagePreferences;
|
||||
autoExpandReasoning: boolean;
|
||||
toolCallDetailLevel: ToolCallDetailLevel;
|
||||
chatDisplay: ChatDisplay;
|
||||
chatOutlineEnabled: boolean;
|
||||
vimKeybindings: boolean;
|
||||
filePanelAssociations: Record<string, string>;
|
||||
@@ -156,7 +157,7 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
|
||||
sidebarFooterItems: [],
|
||||
usage: DEFAULT_USAGE_PREFERENCES,
|
||||
autoExpandReasoning: false,
|
||||
toolCallDetailLevel: "detailed",
|
||||
chatDisplay: "collapseTurns",
|
||||
chatOutlineEnabled: true,
|
||||
vimKeybindings: false,
|
||||
filePanelAssociations: {},
|
||||
@@ -199,6 +200,7 @@ const SidebarRowItemsSchema = z
|
||||
type StoredAppSettingsFallback = AppSettings & {
|
||||
uiFontSize?: number;
|
||||
compactToolCalls?: boolean;
|
||||
toolCallDetailLevel?: "overview" | "detailed";
|
||||
manageBuiltInDaemon?: boolean;
|
||||
releaseChannel?: ReleaseChannel;
|
||||
needsWrite: boolean;
|
||||
@@ -251,11 +253,13 @@ const StoredAppSettingsSchema = z
|
||||
sidebarFooterItems: z.array(z.object({ key: z.string(), visible: z.boolean() })).catch([]),
|
||||
usage: UsagePreferencesSchema,
|
||||
autoExpandReasoning: z.boolean().catch(false),
|
||||
chatDisplay: z.enum(["full", "collapseToolCalls", "collapseTurns"]).optional().catch("full"),
|
||||
// COMPAT(toolCallDetailLevel): replaced by chatDisplay in v0.13, remove after 2027-04-24.
|
||||
toolCallDetailLevel: z
|
||||
.enum(["overview", "detailed"])
|
||||
.or(z.literal("concise").transform(() => "overview" as const))
|
||||
.optional()
|
||||
.catch("detailed"),
|
||||
.catch(undefined),
|
||||
// COMPAT(compactToolCalls): migrated in v0.1.105, remove after 2027-01-12.
|
||||
compactToolCalls: z.boolean().optional().catch(undefined),
|
||||
chatOutlineEnabled: z.boolean().catch(true),
|
||||
@@ -305,8 +309,10 @@ const StoredAppSettingsSchema = z
|
||||
(isChecksHiddenByLegacyRowItem(stored.sidebarRowItems)
|
||||
? "none"
|
||||
: DEFAULT_SIDEBAR_CHECKS_DISPLAY);
|
||||
const toolCallDetailLevel =
|
||||
stored.toolCallDetailLevel ?? (stored.compactToolCalls ? "overview" : "detailed");
|
||||
const groupsToolCalls = stored.toolCallDetailLevel
|
||||
? stored.toolCallDetailLevel === "overview"
|
||||
: stored.compactToolCalls === true;
|
||||
const chatDisplay = stored.chatDisplay ?? (groupsToolCalls ? "collapseToolCalls" : "full");
|
||||
return {
|
||||
...stored,
|
||||
openInSidePane,
|
||||
@@ -321,7 +327,7 @@ const StoredAppSettingsSchema = z
|
||||
stored.sidebarRowItems.services ??
|
||||
(stored.sidebarRowItems.scripts === false ? false : DEFAULT_SIDEBAR_ROW_ITEMS.services),
|
||||
},
|
||||
toolCallDetailLevel,
|
||||
chatDisplay,
|
||||
needsWrite,
|
||||
};
|
||||
})
|
||||
@@ -449,6 +455,7 @@ export function normalizeAppSettings(value: unknown): AppSettings {
|
||||
manageBuiltInDaemon: _manageBuiltInDaemon,
|
||||
releaseChannel: _releaseChannel,
|
||||
compactToolCalls: _compactToolCalls,
|
||||
toolCallDetailLevel: _toolCallDetailLevel,
|
||||
uiFontSize: _uiFontSize,
|
||||
...settings
|
||||
} = StoredAppSettingsSchema.parse(value);
|
||||
|
||||
@@ -2008,6 +2008,12 @@ export const ar: TranslationResources = {
|
||||
input: "مدخل",
|
||||
output: "الإخراج",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "كتب {{count}} رسالة",
|
||||
other: "كتب {{count}} رسائل",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "حرّر {{count}} ملفًا",
|
||||
@@ -2312,12 +2318,13 @@ export const ar: TranslationResources = {
|
||||
label: "عرض التفكير دائماً",
|
||||
description: "إظهار تفكير الوكيل وخطوات الاستدلال بشكل كامل بشكل افتراضي",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "عرض استدعاءات الأدوات",
|
||||
description: "كيفية ظهور استدعاءات الأدوات في المخطط الزمني",
|
||||
chatDisplay: {
|
||||
label: "عرض المحادثة",
|
||||
description: "مقدار عمل الوكيل الذي تعرضه المحادثة",
|
||||
options: {
|
||||
overview: "ملخص",
|
||||
detailed: "التفاصيل الكاملة",
|
||||
full: "كامل",
|
||||
collapseToolCalls: "طيّ استدعاءات الأدوات",
|
||||
collapseTurns: "طيّ الأدوار",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2033,6 +2033,12 @@ export const en = {
|
||||
input: "Input",
|
||||
output: "Output",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "wrote {{count}} message",
|
||||
other: "wrote {{count}} messages",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "edited {{count}} file",
|
||||
@@ -2400,12 +2406,13 @@ export const en = {
|
||||
label: "Always expand reasoning",
|
||||
description: "Show agent thinking and chain-of-thought blocks fully expanded by default",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Tool call display",
|
||||
description: "How tool calls appear in the timeline",
|
||||
chatDisplay: {
|
||||
label: "Chat display",
|
||||
description: "How much of an agent's work the chat shows",
|
||||
options: {
|
||||
overview: "Summary",
|
||||
detailed: "Full detail",
|
||||
full: "Full",
|
||||
collapseToolCalls: "Collapse tool calls",
|
||||
collapseTurns: "Collapse turns",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2056,6 +2056,12 @@ export const es: TranslationResources = {
|
||||
input: "Aporte",
|
||||
output: "Producción",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escribió {{count}} mensaje",
|
||||
other: "escribió {{count}} mensajes",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editó {{count}} archivo",
|
||||
@@ -2328,12 +2334,13 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Mostrar los bloques de pensamiento y razonamiento del agente totalmente expandidos de forma predeterminada",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Visualización de llamadas a herramientas",
|
||||
description: "Cómo aparecen las llamadas a herramientas en la cronología",
|
||||
chatDisplay: {
|
||||
label: "Visualización del chat",
|
||||
description: "Cuánto del trabajo del agente muestra el chat",
|
||||
options: {
|
||||
overview: "Resumen",
|
||||
detailed: "Detalle completo",
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Contraer llamadas a herramientas",
|
||||
collapseTurns: "Contraer turnos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2055,6 +2055,12 @@ export const fr: TranslationResources = {
|
||||
input: "Entrée",
|
||||
output: "Sortie",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} message écrit",
|
||||
other: "{{count}} messages écrits",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} fichier modifié",
|
||||
@@ -2326,12 +2332,13 @@ export const fr: TranslationResources = {
|
||||
label: "Toujours afficher le raisonnement",
|
||||
description: "Afficher par défaut le raisonnement de l’agent entièrement déplié",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Affichage des appels d’outils",
|
||||
description: "Comment les appels d’outils apparaissent dans la chronologie",
|
||||
chatDisplay: {
|
||||
label: "Affichage du chat",
|
||||
description: "La part du travail de l’agent affichée dans le chat",
|
||||
options: {
|
||||
overview: "Résumé",
|
||||
detailed: "Détails complets",
|
||||
full: "Complet",
|
||||
collapseToolCalls: "Réduire les appels d’outils",
|
||||
collapseTurns: "Réduire les tours",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2028,6 +2028,12 @@ export const ja: TranslationResources = {
|
||||
input: "入力",
|
||||
output: "出力",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}}件のメッセージを送信",
|
||||
other: "{{count}}件のメッセージを送信",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}}個のファイルを編集",
|
||||
@@ -2296,12 +2302,13 @@ export const ja: TranslationResources = {
|
||||
label: "常に思考プロセスを展開",
|
||||
description: "デフォルトでAIのエージェント思考・推論ブロックを完全に展開して表示します",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "ツール呼び出しの表示",
|
||||
description: "タイムラインでのツール呼び出しの表示方法",
|
||||
chatDisplay: {
|
||||
label: "チャットの表示",
|
||||
description: "チャットに表示するエージェントの作業量",
|
||||
options: {
|
||||
overview: "要約",
|
||||
detailed: "すべての詳細",
|
||||
full: "すべて",
|
||||
collapseToolCalls: "ツール呼び出しを折りたたむ",
|
||||
collapseTurns: "ターンを折りたたむ",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2018,6 +2018,12 @@ export const ko: TranslationResources = {
|
||||
input: "입력",
|
||||
output: "출력",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "{{count}} 메시지를 작성했습니다.",
|
||||
other: "{{count}} 메시지를 작성했습니다.",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "{{count}} 파일을 편집했습니다.",
|
||||
@@ -2287,12 +2293,13 @@ export const ko: TranslationResources = {
|
||||
label: "추론 항상 펼치기",
|
||||
description: "에이전트의 사고 및 추론 블록을 기본적으로 모두 펼쳐 표시합니다.",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "도구 호출 표시",
|
||||
description: "타임라인에 도구 호출이 표시되는 방식",
|
||||
chatDisplay: {
|
||||
label: "채팅 표시",
|
||||
description: "채팅에 표시할 에이전트 작업의 양",
|
||||
options: {
|
||||
overview: "요약",
|
||||
detailed: "전체 세부정보",
|
||||
full: "전체",
|
||||
collapseToolCalls: "도구 호출 접기",
|
||||
collapseTurns: "턴 접기",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2041,6 +2041,12 @@ export const ptBR: TranslationResources = {
|
||||
input: "Entrada",
|
||||
output: "Saída",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "escreveu {{count}} mensagem",
|
||||
other: "escreveu {{count}} mensagens",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "editou {{count}} arquivo",
|
||||
@@ -2311,12 +2317,13 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Mostrar os blocos de pensamento e raciocínio do agente totalmente expandidos por padrão",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Exibição de chamadas de ferramentas",
|
||||
description: "Como as chamadas de ferramentas aparecem na linha do tempo",
|
||||
chatDisplay: {
|
||||
label: "Exibição do chat",
|
||||
description: "Quanto do trabalho do agente o chat mostra",
|
||||
options: {
|
||||
overview: "Resumo",
|
||||
detailed: "Detalhes completos",
|
||||
full: "Completo",
|
||||
collapseToolCalls: "Recolher chamadas de ferramentas",
|
||||
collapseTurns: "Recolher turnos",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -2041,6 +2041,12 @@ export const ru: TranslationResources = {
|
||||
input: "Входные данные",
|
||||
output: "Выходные данные",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "написано {{count}} сообщение",
|
||||
other: "написаны сообщения ({{count}})",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "изменён {{count}} файл",
|
||||
@@ -2313,12 +2319,13 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"По умолчанию полностью разворачивать блоки размышлений и хода рассуждений агента",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "Отображение вызовов инструментов",
|
||||
description: "Как вызовы инструментов отображаются на временной шкале",
|
||||
chatDisplay: {
|
||||
label: "Отображение чата",
|
||||
description: "Какую часть работы агента показывает чат",
|
||||
options: {
|
||||
overview: "Сводка",
|
||||
detailed: "Все подробности",
|
||||
full: "Полностью",
|
||||
collapseToolCalls: "Сворачивать вызовы инструментов",
|
||||
collapseTurns: "Сворачивать ходы",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -1986,6 +1986,12 @@ export const zhCN: TranslationResources = {
|
||||
input: "输入",
|
||||
output: "输出",
|
||||
},
|
||||
turnSummary: {
|
||||
messages: {
|
||||
one: "写了 {{count}} 条消息",
|
||||
other: "写了 {{count}} 条消息",
|
||||
},
|
||||
},
|
||||
toolCallGroup: {
|
||||
editedFiles: {
|
||||
one: "编辑了 {{count}} 个文件",
|
||||
@@ -2251,12 +2257,13 @@ export const zhCN: TranslationResources = {
|
||||
label: "始终展开推理过程",
|
||||
description: "默认情况下完全展开 AI 的思考和推理过程",
|
||||
},
|
||||
toolCallDetail: {
|
||||
label: "工具调用显示",
|
||||
description: "工具调用在时间线中的显示方式",
|
||||
chatDisplay: {
|
||||
label: "聊天显示",
|
||||
description: "聊天中显示多少智能体的工作",
|
||||
options: {
|
||||
overview: "摘要",
|
||||
detailed: "完整详情",
|
||||
full: "完整",
|
||||
collapseToolCalls: "折叠工具调用",
|
||||
collapseTurns: "折叠轮次",
|
||||
},
|
||||
},
|
||||
language: {
|
||||
|
||||
@@ -8,29 +8,25 @@ import {
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppSettings, type AppSettings } from "@/hooks/use-settings";
|
||||
import { useAppSettings, type ChatDisplay } from "@/hooks/use-settings";
|
||||
|
||||
const TOOL_CALL_DETAIL_LEVELS: readonly AppSettings["toolCallDetailLevel"][] = [
|
||||
"detailed",
|
||||
"overview",
|
||||
];
|
||||
const CHAT_DISPLAYS: readonly ChatDisplay[] = ["full", "collapseToolCalls", "collapseTurns"];
|
||||
|
||||
export function ChatSection() {
|
||||
const { t } = useTranslation();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
|
||||
const toolCallDetailOptions = useMemo(
|
||||
const chatDisplayOptions = useMemo(
|
||||
() =>
|
||||
TOOL_CALL_DETAIL_LEVELS.map((value) => ({
|
||||
CHAT_DISPLAYS.map((value) => ({
|
||||
value,
|
||||
label: t(`settings.general.toolCallDetail.options.${value}`),
|
||||
label: t(`settings.general.chatDisplay.options.${value}`),
|
||||
})),
|
||||
[t],
|
||||
);
|
||||
|
||||
const changeToolCallDetailLevel = useCallback(
|
||||
(toolCallDetailLevel: AppSettings["toolCallDetailLevel"]) =>
|
||||
void updateSettings({ toolCallDetailLevel }),
|
||||
const changeChatDisplay = useCallback(
|
||||
(chatDisplay: ChatDisplay) => void updateSettings({ chatDisplay }),
|
||||
[updateSettings],
|
||||
);
|
||||
const changeAutoExpandReasoning = useCallback(
|
||||
@@ -53,11 +49,11 @@ export function ChatSection() {
|
||||
onValueChange={changeAutoExpandReasoning}
|
||||
/>
|
||||
<SettingsSelect
|
||||
label={t("settings.general.toolCallDetail.label")}
|
||||
hint={t("settings.general.toolCallDetail.description")}
|
||||
value={settings.toolCallDetailLevel}
|
||||
options={toolCallDetailOptions}
|
||||
onValueChange={changeToolCallDetailLevel}
|
||||
label={t("settings.general.chatDisplay.label")}
|
||||
hint={t("settings.general.chatDisplay.description")}
|
||||
value={settings.chatDisplay}
|
||||
options={chatDisplayOptions}
|
||||
onValueChange={changeChatDisplay}
|
||||
/>
|
||||
{isNative ? null : (
|
||||
<SettingsSwitch
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { OverviewSummary } from "./model";
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined = parts[0] ?? "";
|
||||
if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else if (parts.length > 2) {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One sentence of what a stretch of agent work did, such as "Edited 2 files and ran 3 commands".
|
||||
* Leading parts come first, for summaries that count more than tool calls.
|
||||
*/
|
||||
export function useOverviewSummaryLabel(
|
||||
summary: OverviewSummary,
|
||||
leadingParts: readonly string[] = [],
|
||||
): string {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const parts = [...leadingParts];
|
||||
const entries = [
|
||||
[summary.editedFileCount, "toolCallGroup.editedFiles"],
|
||||
[summary.commandCount, "toolCallGroup.commands"],
|
||||
[summary.readFileCount, "toolCallGroup.readFiles"],
|
||||
[summary.searchCount, "toolCallGroup.searches"],
|
||||
[summary.otherToolCount, "toolCallGroup.otherTools"],
|
||||
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
|
||||
] as const;
|
||||
for (const [count, key] of entries) {
|
||||
if (count > 0) {
|
||||
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
|
||||
}
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [leadingParts, summary, t]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isPaseoToolName } from "@getpaseo/protocol/tool-name-normalization";
|
||||
import type { ToolCallItem } from "@/types/stream";
|
||||
import { describeToolCall, type ToolCallRun } from "../grouping";
|
||||
|
||||
const DIRECT_PASEO_TOOL_PREFIX = "paseo_";
|
||||
@@ -28,19 +29,18 @@ function isSearchCall(name: string): boolean {
|
||||
return DIRECT_SEARCH_TOOL_SUFFIX_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
/** Counts tool calls by what they did, for any summary of a stretch of agent work. */
|
||||
export function summarizeToolCalls(calls: readonly ToolCallItem[]): OverviewSummary {
|
||||
const editedFiles = new Set<string>();
|
||||
const readFiles = new Set<string>();
|
||||
let isLoading = false;
|
||||
let commandCount = 0;
|
||||
let searchCount = 0;
|
||||
let otherToolCount = 0;
|
||||
let paseoCallCount = 0;
|
||||
|
||||
for (const call of run.calls) {
|
||||
for (const call of calls) {
|
||||
const descriptor = describeToolCall(call);
|
||||
const normalizedName = descriptor.name.trim().toLowerCase();
|
||||
isLoading ||= descriptor.status === "running" || descriptor.status === "executing";
|
||||
if (isPaseoCall(descriptor.name, normalizedName)) {
|
||||
paseoCallCount += 1;
|
||||
} else if (descriptor.detail.type === "edit" || descriptor.detail.type === "write") {
|
||||
@@ -56,7 +56,7 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
}
|
||||
}
|
||||
|
||||
const summary = {
|
||||
return {
|
||||
editedFileCount: editedFiles.size,
|
||||
commandCount,
|
||||
readFileCount: readFiles.size,
|
||||
@@ -64,10 +64,17 @@ export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
otherToolCount,
|
||||
paseoCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildOverviewGroup(run: ToolCallRun): OverviewToolCallGroup {
|
||||
const isLoading = run.calls.some((call) => {
|
||||
const status = describeToolCall(call).status;
|
||||
return status === "running" || status === "executing";
|
||||
});
|
||||
return {
|
||||
mode: "overview",
|
||||
run,
|
||||
isLoading,
|
||||
summary,
|
||||
summary: summarizeToolCalls(run.calls),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { memo, useCallback, useMemo, useRef, type ReactNode } from "react";
|
||||
import React, { memo, useCallback, useRef, type ReactNode } from "react";
|
||||
import { ScrollView } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wrench } from "lucide-react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { IndentRail } from "@/components/indent-rail";
|
||||
import { ExpandableBadge } from "@/components/message";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { type OverviewSummary, type OverviewToolCallGroup } from "./model";
|
||||
import { useOverviewSummaryLabel } from "./label";
|
||||
import type { OverviewToolCallGroup } from "./model";
|
||||
import { OverviewToolCallGroupSheet } from "./sheet";
|
||||
|
||||
interface OverviewGroupProps {
|
||||
@@ -18,41 +19,6 @@ interface OverviewGroupProps {
|
||||
|
||||
const TOOL_CALL_GROUP_MAX_HEIGHT = 400;
|
||||
|
||||
function joinSummaryParts(parts: string[], conjunction: string): string {
|
||||
if (parts.length === 0) {
|
||||
return "";
|
||||
}
|
||||
let joined = parts[0] ?? "";
|
||||
if (parts.length === 2) {
|
||||
joined = `${parts[0]} ${conjunction} ${parts[1]}`;
|
||||
} else if (parts.length > 2) {
|
||||
joined = `${parts.slice(0, -1).join(", ")}, ${conjunction} ${parts.at(-1)}`;
|
||||
}
|
||||
const firstCharacter = joined[0];
|
||||
return firstCharacter ? `${firstCharacter.toLocaleUpperCase()}${joined.slice(1)}` : joined;
|
||||
}
|
||||
|
||||
function useOverviewSummary(summary: OverviewSummary): string {
|
||||
const { t } = useTranslation();
|
||||
return useMemo(() => {
|
||||
const parts: string[] = [];
|
||||
const entries = [
|
||||
[summary.editedFileCount, "toolCallGroup.editedFiles"],
|
||||
[summary.commandCount, "toolCallGroup.commands"],
|
||||
[summary.readFileCount, "toolCallGroup.readFiles"],
|
||||
[summary.searchCount, "toolCallGroup.searches"],
|
||||
[summary.otherToolCount, "toolCallGroup.otherTools"],
|
||||
[summary.paseoCallCount, "toolCallGroup.paseoCalls"],
|
||||
] as const;
|
||||
for (const [count, key] of entries) {
|
||||
if (count > 0) {
|
||||
parts.push(t(`${key}.${count === 1 ? "one" : "other"}`, { count }));
|
||||
}
|
||||
}
|
||||
return joinSummaryParts(parts, t("toolCallGroup.and"));
|
||||
}, [summary, t]);
|
||||
}
|
||||
|
||||
export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView({
|
||||
group,
|
||||
expanded,
|
||||
@@ -62,7 +28,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
}: OverviewGroupProps) {
|
||||
const scrollRef = useRef<ScrollView>(null);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const aggregateSummary = useOverviewSummary(group.summary);
|
||||
const aggregateSummary = useOverviewSummaryLabel(group.summary);
|
||||
const scrollToLatest = useCallback(() => {
|
||||
scrollRef.current?.scrollToEnd({ animated: false });
|
||||
}, []);
|
||||
@@ -82,7 +48,7 @@ export const OverviewToolCallGroupView = memo(function OverviewToolCallGroupView
|
||||
showsVerticalScrollIndicator
|
||||
onContentSizeChange={scrollToLatest}
|
||||
>
|
||||
{children}
|
||||
<IndentRail>{children}</IndentRail>
|
||||
</ScrollView>
|
||||
),
|
||||
[children, scrollToLatest],
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
import {
|
||||
groupLiveToolCalls,
|
||||
prepareGroupedHistory,
|
||||
@@ -8,7 +7,8 @@ import {
|
||||
} from "./grouping";
|
||||
import { buildOverviewGroup, type OverviewToolCallGroup } from "./overview/model";
|
||||
|
||||
export type { ToolCallDetailLevel } from "@/hooks/use-settings/storage";
|
||||
/** Whether consecutive tool calls show one by one or as one summary row. */
|
||||
export type ToolCallDetailLevel = "overview" | "detailed";
|
||||
export type ToolCallDetailGroup = OverviewToolCallGroup;
|
||||
|
||||
export interface PreparedToolCallHistory {
|
||||
|
||||
@@ -771,6 +771,12 @@ export type AgentToolCallItem = ToolCallItem & {
|
||||
payload: { source: "agent"; data: AgentToolCallData };
|
||||
};
|
||||
|
||||
/** Find and scrolling address a plugin row by the source it replaces. */
|
||||
export function getStreamItemMessageId(item: StreamItem): string {
|
||||
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||
return item.id;
|
||||
}
|
||||
|
||||
export function isAgentToolCallItem(item: StreamItem): item is AgentToolCallItem {
|
||||
return item.kind === "tool_call" && item.payload.source === "agent";
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ export const PROVIDER_CAPABILITIES = [
|
||||
"prompt.image",
|
||||
"prompt.output_schema",
|
||||
"prompt.steer",
|
||||
"session.can_stop_runtime",
|
||||
"session.archive",
|
||||
"session.configure",
|
||||
"session.list",
|
||||
@@ -278,7 +277,6 @@ export type ProviderInput =
|
||||
}
|
||||
| { type: "session.archive"; requestId: string; persistence: ProviderPersistence }
|
||||
| { type: "session.unarchive"; requestId: string; persistence: ProviderPersistence }
|
||||
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string }
|
||||
| { type: "session.close"; requestId: string; sessionId: string };
|
||||
|
||||
export interface ProviderThinkingOption {
|
||||
@@ -554,7 +552,6 @@ export type ProviderTimelineItem =
|
||||
});
|
||||
|
||||
export type ProviderEvent =
|
||||
| { type: "session.can_stop_runtime"; requestId: string; sessionId: string; canStop: boolean }
|
||||
| { type: "catalog"; requestId: string; catalog: ProviderCatalog }
|
||||
| { type: "sessions"; requestId: string; sessions: ProviderSessionSummary[] }
|
||||
| { type: "request.completed"; requestId: string }
|
||||
@@ -637,8 +634,6 @@ function requiredPromptCapabilities(prompt: ProviderPrompt): readonly ProviderCa
|
||||
|
||||
export function requiredProviderCapabilities(input: ProviderInput): readonly ProviderCapability[] {
|
||||
switch (input.type) {
|
||||
case "session.can_stop_runtime":
|
||||
return ["session.can_stop_runtime"];
|
||||
case "catalog":
|
||||
case "session.interrupt":
|
||||
case "session.close":
|
||||
@@ -960,13 +955,6 @@ export const ProviderInputSchema: z.ZodType<ProviderInput> = z.discriminatedUnio
|
||||
persistence: persistenceSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.can_stop_runtime"),
|
||||
requestId: idSchema,
|
||||
sessionId: idSchema,
|
||||
})
|
||||
.strict(),
|
||||
z.object({ type: z.literal("session.close"), requestId: idSchema, sessionId: idSchema }).strict(),
|
||||
]);
|
||||
|
||||
@@ -1338,14 +1326,6 @@ export const ProviderEventSchema: z.ZodType<ProviderEvent> = z.discriminatedUnio
|
||||
sessionId: idSchema,
|
||||
})
|
||||
.strip(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.can_stop_runtime"),
|
||||
requestId: idSchema,
|
||||
sessionId: idSchema,
|
||||
canStop: z.boolean(),
|
||||
})
|
||||
.strip(),
|
||||
z
|
||||
.object({
|
||||
type: z.literal("session.closed"),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
formatAgentMessage,
|
||||
@@ -10008,47 +10007,6 @@ test("closeAgent persists one final closed snapshot", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("idle runtime cleanup honors a provider veto and resumes the saved conversation", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-cleanup-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
let safe = false;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = await super.createSession(config);
|
||||
session.canStopRuntime = async () => safe;
|
||||
return session;
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const collect = runtimeCleanup(manager);
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
const now = new Date(Date.now() + 61 * 60_000);
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
safe = true;
|
||||
expect(await collect({ now })).toEqual({ collected: [], failures: [] });
|
||||
const later = new Date(now.getTime() + 61 * 60_000);
|
||||
expect((await collect({ now: later })).collected).toEqual([
|
||||
{ agentId: agent.id, provider: "codex", sessionId: agent.persistence?.sessionId },
|
||||
]);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
const resumed = await ensureAgentLoaded(agent.id, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
expect(resumed.id).toBe(agent.id);
|
||||
expect(resumed.persistence).toEqual(agent.persistence);
|
||||
await manager.runAgent(agent.id, "Continue the saved conversation");
|
||||
} finally {
|
||||
await manager.closeAgent(manager.listAgents()[0]?.id ?? "missing").catch(() => undefined);
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("idle agents remain resident until an explicit lifecycle action closes them", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-residency-"));
|
||||
let closeCount = 0;
|
||||
@@ -10993,12 +10951,12 @@ test("tryRunOutOfBand gives providers the command another agent sent, not its en
|
||||
|
||||
try {
|
||||
const fromAgent = prepareAgentMessage("/handled", { kind: "agent-message", agentId: "sender" });
|
||||
expect(await manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(manager.tryRunOutOfBand(agent.id, fromAgent.prompt)).toBe(true);
|
||||
expect(seenPrompts).toEqual(["/handled"]);
|
||||
|
||||
// A human who pastes an envelope gets it quoted, and it stays a plain prompt.
|
||||
const pasted = prepareAgentMessage(String(fromAgent.prompt));
|
||||
expect(await manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(manager.tryRunOutOfBand(agent.id, pasted.prompt)).toBe(false);
|
||||
expect(seenPrompts.at(-1)).toBe(pasted.prompt);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
@@ -12125,176 +12083,3 @@ test("reads the last durable assistant response without loading a stored-only ch
|
||||
);
|
||||
expect(manager.getAgent("stored-child")).toBeNull();
|
||||
});
|
||||
|
||||
function runtimeCleanup(manager: AgentManager) {
|
||||
let now = Date.now();
|
||||
let protectedCaller = (_agentId: string) => false;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
logger,
|
||||
now: () => now,
|
||||
isProtected: (agentId) => protectedCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return (options: { now: Date; isProtected?: (agentId: string) => boolean }) => {
|
||||
now = options.now.getTime();
|
||||
protectedCaller = options.isProtected ?? (() => false);
|
||||
return cleanup.sweep();
|
||||
};
|
||||
}
|
||||
|
||||
function idleCleanupHarness() {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-idle-protection-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const commands: AgentPromptInput[] = [];
|
||||
const prompts: AgentPromptInput[] = [];
|
||||
const recordPrompts = (session: AgentSession) => {
|
||||
session.tryHandleOutOfBand = (prompt) =>
|
||||
prompt === "/maintenance"
|
||||
? {
|
||||
run: async () => {
|
||||
commands.push(prompt);
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const start = session.startTurn.bind(session);
|
||||
session.startTurn = (prompt, options) => {
|
||||
prompts.push(prompt);
|
||||
return start(prompt, options);
|
||||
};
|
||||
return session;
|
||||
};
|
||||
const sessions: AgentSession[] = [];
|
||||
const probes: Array<(() => Promise<boolean>) | undefined> = [];
|
||||
const closeEntered = deferred<void>();
|
||||
let closeGate: Promise<void> | undefined;
|
||||
let closes = 0;
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
config?: Partial<AgentSessionConfig>,
|
||||
): Promise<AgentSession> {
|
||||
const session = await super.resumeSession(handle, config);
|
||||
session.describePersistence = () => handle;
|
||||
return recordPrompts(session);
|
||||
}
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const index = sessions.length;
|
||||
const session: AgentSession = await super.createSession(config);
|
||||
session.canStopRuntime = async () => probes[index]?.() ?? false;
|
||||
session.close = async () => {
|
||||
closes++;
|
||||
closeEntered.resolve();
|
||||
await closeGate;
|
||||
};
|
||||
sessions.push(session);
|
||||
return recordPrompts(session);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
onTestFinished(async () => {
|
||||
closeGate = undefined;
|
||||
await Promise.all(manager.listAgents().map((agent) => manager.closeAgent(agent.id)));
|
||||
await storage.flush();
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
});
|
||||
return {
|
||||
commands,
|
||||
sweep: runtimeCleanup(manager),
|
||||
manager,
|
||||
storage,
|
||||
sessions,
|
||||
probes,
|
||||
prompts,
|
||||
closeEntered,
|
||||
get closes() {
|
||||
return closes;
|
||||
},
|
||||
holdClose: (gate: Promise<void>) => {
|
||||
closeGate = gate;
|
||||
},
|
||||
create: (labels?: Record<string, string>) =>
|
||||
manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
labels,
|
||||
}),
|
||||
later: (minutes: number) => new Date(Date.now() + minutes * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
test("idle cleanup rechecks new prompt activity after an asynchronous safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
const entered = deferred<void>();
|
||||
const probe = deferred<boolean>();
|
||||
h.probes[0] = () => {
|
||||
entered.resolve();
|
||||
return probe.promise;
|
||||
};
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await entered.promise;
|
||||
await h.manager.runAgent(agent.id, "arrived during safety query");
|
||||
probe.resolve(true);
|
||||
expect((await sweep).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup retains a parent whose idle managed child owns background work", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const parent = await h.create();
|
||||
await h.create({ [PARENT_AGENT_ID_LABEL]: parent.id });
|
||||
h.probes[0] = async () => true;
|
||||
h.probes[1] = async () => false;
|
||||
expect((await h.sweep({ now: h.later(61) })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("idle cleanup rechecks queued deliveries admitted during the safety probe", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
await h.create();
|
||||
let queued = false;
|
||||
h.probes[0] = async () => {
|
||||
queued = true;
|
||||
return true;
|
||||
};
|
||||
expect((await h.sweep({ now: h.later(61), isProtected: () => queued })).collected).toEqual([]);
|
||||
expect(h.closes).toBe(0);
|
||||
});
|
||||
|
||||
test("a prompt racing idle cleanup waits for close and resumes exactly once", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const run = h.manager.runAgent(agent.id, "arrived while closing");
|
||||
gate.resolve();
|
||||
expect((await sweep).collected).toHaveLength(1);
|
||||
const result = await run;
|
||||
expect(result.sessionId).toBe(agent.persistence?.sessionId);
|
||||
expect(h.closes).toBe(1);
|
||||
expect(h.prompts).toEqual(["arrived while closing"]);
|
||||
});
|
||||
|
||||
test("an out-of-band command racing idle cleanup waits for the resumed runtime", async () => {
|
||||
const h = idleCleanupHarness();
|
||||
const agent = await h.create();
|
||||
h.probes[0] = async () => true;
|
||||
const gate = deferred<void>();
|
||||
h.holdClose(gate.promise);
|
||||
const sweep = h.sweep({ now: h.later(61) });
|
||||
await h.closeEntered.promise;
|
||||
const command = startAgentRun(h.manager, agent.id, "/maintenance", logger);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const deliveredWhileClosing = [...h.commands];
|
||||
gate.resolve();
|
||||
await sweep;
|
||||
expect(deliveredWhileClosing).toEqual([]);
|
||||
expect(await command).toEqual({ disposition: "out_of_band" });
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||
@@ -760,8 +758,6 @@ export class AgentManager {
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
@@ -1751,64 +1747,6 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
|
||||
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
|
||||
if (!this.registry) return [];
|
||||
const candidates: RuntimeCleanupCandidate[] = [];
|
||||
for (const agent of this.agents.values()) {
|
||||
const updatedAt = agent.updatedAt;
|
||||
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
||||
if (!identity) {
|
||||
identity = Object.freeze({});
|
||||
this.runtimeCleanupIdentities.set(agent.session, identity);
|
||||
}
|
||||
const isEligible = () =>
|
||||
this.agents.get(agent.id) === agent &&
|
||||
agent.updatedAt === updatedAt &&
|
||||
this.runtimeHasNoPendingWork(agent);
|
||||
candidates.push({
|
||||
identity,
|
||||
agentId: agent.id,
|
||||
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
||||
provider: agent.provider,
|
||||
sessionId: agent.persistence?.sessionId ?? null,
|
||||
lastActivityAt: updatedAt.getTime(),
|
||||
isEligible,
|
||||
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
||||
closeIfEligible: async (revalidate) => {
|
||||
if (!agent.persistence?.sessionId) return false;
|
||||
// Block prompt admission before entering the close lane. A skipped close also
|
||||
// releases this barrier, so the waiting prompt keeps the existing runtime.
|
||||
this.collectingRuntimes.add(agent.id);
|
||||
try {
|
||||
return await this.runLifecycleMutation(agent.id, () =>
|
||||
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
|
||||
);
|
||||
} finally {
|
||||
this.collectingRuntimes.delete(agent.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
|
||||
return (
|
||||
agent.lifecycle === "idle" &&
|
||||
!agent.internal &&
|
||||
agent.activeForegroundTurnId === null &&
|
||||
!this.runs.hasRun(agent.id) &&
|
||||
!agent.pendingReplacement &&
|
||||
agent.pendingPermissions.size === 0 &&
|
||||
agent.inFlightPermissionResponses.size === 0 &&
|
||||
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
||||
!this.foregroundMutationTails.has(agent.id) &&
|
||||
!this.sessionEventTails.has(agent.id) &&
|
||||
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
||||
);
|
||||
}
|
||||
|
||||
closeAgent(agentId: string): Promise<void> {
|
||||
const existing = this.inFlightAgentCloses.get(agentId);
|
||||
if (existing) {
|
||||
@@ -1829,7 +1767,7 @@ export class AgentManager {
|
||||
return close;
|
||||
}
|
||||
|
||||
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
|
||||
private async closeAgentRuntime(agentId: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -1844,7 +1782,6 @@ export class AgentManager {
|
||||
"agent.manager.close.start",
|
||||
);
|
||||
await this.drainSessionEvents(agentId);
|
||||
if (canClose && !canClose()) return false;
|
||||
// Retain ownership until shutdown succeeds. A failed close may still own a
|
||||
// native writer, so publishing a resumable closed snapshot would orphan it.
|
||||
await agent.session.close();
|
||||
@@ -1870,7 +1807,6 @@ export class AgentManager {
|
||||
if (persistError !== undefined) {
|
||||
throw persistError;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
||||
@@ -2523,19 +2459,7 @@ export class AgentManager {
|
||||
* emitted by the handler flow through dispatchStream so they persist and
|
||||
* broadcast like normal timeline events.
|
||||
*/
|
||||
async tryRunOutOfBand(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): Promise<boolean> {
|
||||
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
tryRunOutOfBand(agentId: string, prompt: AgentPromptInput, options?: AgentRunOptions): boolean {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// Providers parse slash commands from the raw text, so a command another agent sent
|
||||
// must reach them without its sender envelope.
|
||||
@@ -2546,7 +2470,6 @@ export class AgentManager {
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
this.touchUpdatedAt(agent);
|
||||
if (options?.clientMessageId) {
|
||||
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
||||
this.emitState(agent);
|
||||
@@ -2566,7 +2489,7 @@ export class AgentManager {
|
||||
}
|
||||
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
||||
};
|
||||
void this.runForegroundMutation(agentId, async () => {
|
||||
void (async () => {
|
||||
try {
|
||||
await handler.run({ emit: dispatch });
|
||||
} catch (error) {
|
||||
@@ -2577,7 +2500,7 @@ export class AgentManager {
|
||||
item: { type: "assistant_message", text: `[Error] ${text}` },
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -2661,27 +2584,11 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async *streamAfterRuntimeCollection(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.waitForAgentClose(agentId);
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: this,
|
||||
agentStorage: this.requireRegistry(),
|
||||
logger: this.logger,
|
||||
});
|
||||
yield* this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
streamAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
|
||||
return this.streamAfterRuntimeCollection(agentId, prompt, options);
|
||||
const existingAgent = this.requireSessionAgent(agentId);
|
||||
this.logger.trace(
|
||||
{
|
||||
|
||||
@@ -283,33 +283,3 @@ test("batches retain each sender's attachment context and image order", async ()
|
||||
)?.map((message) => message.source?.agentId),
|
||||
).toEqual(["one", "two"]);
|
||||
});
|
||||
|
||||
test("runtime safety sees queued messages until provider dispatch settles", async () => {
|
||||
const clock = createClock();
|
||||
let release!: () => void;
|
||||
const dispatched = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const delivery = createAgentMessageDelivery({
|
||||
schedule: clock.schedule,
|
||||
dispatch: async () => {
|
||||
await dispatched;
|
||||
return "steered";
|
||||
},
|
||||
onError: (error) => {
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
delivery.enqueue({
|
||||
recipientId: "parent",
|
||||
prompt: "finished",
|
||||
source: { kind: "agent-message", agentId: "child" },
|
||||
});
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
clock.advance(2000);
|
||||
expect(delivery.hasPending("parent")).toBe(true);
|
||||
release();
|
||||
await delivery.close();
|
||||
expect(delivery.hasPending("parent")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -43,8 +43,6 @@ interface Dependencies {
|
||||
}
|
||||
export interface AgentMessageDelivery {
|
||||
enqueue(message: Message): Receipt;
|
||||
/** Queued messages and provider dispatches still owned by this recipient. */
|
||||
hasPending(recipientId: string): boolean;
|
||||
/** Stop admission, flush pending messages, and await provider dispatch acknowledgments. */
|
||||
close(): Promise<void>;
|
||||
/** Existing completion waits include agent messages admitted before the wait. */
|
||||
@@ -140,7 +138,6 @@ export function createAgentMessageDelivery(dependencies: Dependencies): AgentMes
|
||||
}
|
||||
|
||||
return {
|
||||
hasPending: (recipientId) => pending.has(recipientId) || dispatching.has(recipientId),
|
||||
enqueue(message) {
|
||||
if (closed) throw new Error("Agent message delivery is closed");
|
||||
let resolve!: Admitted["resolve"];
|
||||
|
||||
@@ -110,7 +110,7 @@ export async function startAgentRun(
|
||||
// Out-of-band commands (e.g. /goal pause) must run WITHOUT canceling an
|
||||
// in-flight turn — replaceAgentRun would interrupt the running turn. The
|
||||
// intercept lives at this layer so it covers every prompt entrypoint.
|
||||
if (await agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
if (agentManager.tryRunOutOfBand(agentId, prompt, options?.runOptions)) {
|
||||
options?.onDispatch?.("out_of_band");
|
||||
return { disposition: "out_of_band" };
|
||||
}
|
||||
|
||||
@@ -694,10 +694,6 @@ export interface AgentSession {
|
||||
* still uncertain.
|
||||
*/
|
||||
interrupt(): Promise<void>;
|
||||
/** True only when releasing this runtime loses no background work or pending replies.
|
||||
* An optional additional veto: missing methods use daemon baseline eligibility;
|
||||
* implemented checks that reject or return false keep the runtime resident. */
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
/** Release live runtime resources without archiving or deleting the durable native session. */
|
||||
close(): Promise<void>;
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createIdleRuntimeCleanup } from "../idle-runtime-cleanup/index.js";
|
||||
import { HostObservationError } from "../host-operations/index.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -413,87 +412,3 @@ test("shutdown during observation setup owns rejection of the already-canceled n
|
||||
expect(await state.store.list()).toHaveLength(1);
|
||||
expect(state.delivery.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
test("remote finish intent protects its caller through registration, observation and delivery", async () => {
|
||||
const s = await scenario();
|
||||
let time = 61 * 60_000;
|
||||
let collected = false;
|
||||
const runtime = {
|
||||
identity: {},
|
||||
agentId: "parent",
|
||||
parentAgentId: undefined,
|
||||
provider: "test",
|
||||
sessionId: "parent",
|
||||
lastActivityAt: 0,
|
||||
isEligible: () => !collected,
|
||||
closeIfEligible: async (revalidate: () => boolean) => {
|
||||
if (!revalidate()) return false;
|
||||
collected = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => (collected ? [] : [runtime]),
|
||||
isProtected: (agentId) => s.service.hasPendingForCaller(agentId),
|
||||
schedule: () => () => {},
|
||||
});
|
||||
const savedPut = s.store.put.bind(s.store);
|
||||
const writing = deferred<void>();
|
||||
s.store.put = async (record) => {
|
||||
await writing.promise;
|
||||
await savedPut(record);
|
||||
};
|
||||
const registration = s.service.register({ ...intent, serverId: "remote" }, true);
|
||||
const protectedDuringRegistration = s.service.hasPendingForCaller?.("parent") ?? false;
|
||||
writing.resolve();
|
||||
const accepted = await registration;
|
||||
expect(protectedDuringRegistration).toBe(true);
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
accepted.accept();
|
||||
await until(() => s.observation.waits.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.observation.waits[0]!.result.resolve(outcome());
|
||||
await until(() => s.delivery.prompts.length === 1);
|
||||
expect(s.service.hasPendingForCaller("parent")).toBe(true);
|
||||
s.delivery.acknowledgments[0]!.resolve("steered");
|
||||
await until(() => !s.service.hasPendingForCaller("parent"));
|
||||
expect(await s.store.list()).toEqual([]);
|
||||
time = 62 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toEqual([]);
|
||||
time = 122 * 60_000;
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
await cleanup.close();
|
||||
});
|
||||
|
||||
test("finish recovery protects callers before durable intent has loaded", async () => {
|
||||
const s = await scenario();
|
||||
await s.service.register({ ...intent, serverId: "remote" });
|
||||
await s.service.close();
|
||||
const loading = deferred<void>();
|
||||
const recovered = createFinishSubscriptions({
|
||||
store: {
|
||||
...s.store,
|
||||
list: async () => {
|
||||
await loading.promise;
|
||||
return s.store.list();
|
||||
},
|
||||
},
|
||||
messages: () => s.delivery,
|
||||
open: s.observation.open.bind(s.observation),
|
||||
callerIsActive: async () => true,
|
||||
serverId: "origin",
|
||||
logger: createTestLogger(),
|
||||
});
|
||||
services.push(recovered);
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
const starting = recovered.start();
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
loading.resolve();
|
||||
await starting;
|
||||
expect(recovered.hasPendingForCaller("parent")).toBe(true);
|
||||
expect(recovered.hasPendingForCaller("unrelated")).toBe(false);
|
||||
});
|
||||
|
||||
@@ -49,8 +49,6 @@ export interface FinishRegistration {
|
||||
}
|
||||
export interface FinishSubscriptions {
|
||||
start(): Promise<void>;
|
||||
/** Pending notification intent, including registration, recovery and unacknowledged delivery. */
|
||||
hasPendingForCaller(agentId: string): boolean;
|
||||
register(
|
||||
input: Omit<FinishSubscription, "id">,
|
||||
pendingDispatch?: boolean,
|
||||
@@ -74,8 +72,6 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
const tasks = new Set<Promise<void>>();
|
||||
const watches = new Map<string, Set<Watch>>();
|
||||
let closed = false;
|
||||
let recovering = true;
|
||||
const registering = new Map<string, number>();
|
||||
let mutations = Promise.resolve();
|
||||
function serialize<T>(action: () => Promise<T>): Promise<T> {
|
||||
const result = mutations.then(action);
|
||||
@@ -304,13 +300,6 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
}
|
||||
|
||||
return {
|
||||
hasPendingForCaller(agentId) {
|
||||
if (recovering || registering.has(agentId)) return true;
|
||||
for (const group of watches.values()) {
|
||||
for (const watch of group) if (watch.records[0]!.callerAgentId === agentId) return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
start: () =>
|
||||
serialize(async () => {
|
||||
const groups = new Map<string, FinishSubscription[]>();
|
||||
@@ -320,11 +309,9 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
groups.set(key(record), records);
|
||||
}
|
||||
for (const records of groups.values()) attach(records, false);
|
||||
recovering = false;
|
||||
}),
|
||||
register: (input, pendingDispatch = false) => {
|
||||
registering.set(input.callerAgentId, (registering.get(input.callerAgentId) ?? 0) + 1);
|
||||
return serialize(async () => {
|
||||
register: (input, pendingDispatch = false) =>
|
||||
serialize(async () => {
|
||||
if (closed) throw new Error("Finish subscriptions are closed");
|
||||
const record: FinishSubscription = {
|
||||
...input,
|
||||
@@ -350,18 +337,13 @@ export function createFinishSubscriptions(deps: Dependencies): FinishSubscriptio
|
||||
},
|
||||
reject: () =>
|
||||
serialize(async () => {
|
||||
await remove(watch);
|
||||
stop(watch, actualGroup);
|
||||
await remove(watch);
|
||||
}),
|
||||
};
|
||||
if (!pendingDispatch) registration.accept();
|
||||
return registration;
|
||||
}).finally(() => {
|
||||
const remaining = registering.get(input.callerAgentId)! - 1;
|
||||
if (remaining) registering.set(input.callerAgentId, remaining);
|
||||
else registering.delete(input.callerAgentId);
|
||||
});
|
||||
},
|
||||
}),
|
||||
async close() {
|
||||
closed = true;
|
||||
await mutations;
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
import { createIdleRuntimeCleanup, type RuntimeCleanupCandidate } from "./index.js";
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((yes) => {
|
||||
resolve = yes;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
class Runtime {
|
||||
readonly identity = {};
|
||||
busy = false;
|
||||
closed = false;
|
||||
resumable = true;
|
||||
activity = 0;
|
||||
parentAgentId: string | undefined;
|
||||
probe: (() => Promise<boolean>) | undefined;
|
||||
constructor(readonly id = "agent") {}
|
||||
capture(): RuntimeCleanupCandidate {
|
||||
const activity = this.activity;
|
||||
return {
|
||||
identity: this.identity,
|
||||
agentId: this.id,
|
||||
parentAgentId: this.parentAgentId,
|
||||
provider: "test",
|
||||
sessionId: this.resumable ? this.id : null,
|
||||
lastActivityAt: activity,
|
||||
isEligible: () => !this.closed && !this.busy && this.activity === activity,
|
||||
canStopRuntime: this.probe,
|
||||
closeIfEligible: async (eligible) => {
|
||||
if (!eligible()) return false;
|
||||
this.closed = true;
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
function scenario(enabled = true) {
|
||||
const runtime = new Runtime();
|
||||
const runtimes = [runtime];
|
||||
let time = 0;
|
||||
let protectedCaller = false;
|
||||
let tick!: () => void;
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled,
|
||||
logger: createTestLogger(),
|
||||
now: () => time,
|
||||
runtimes: () => runtimes.filter((item) => !item.closed).map((item) => item.capture()),
|
||||
isProtected: () => protectedCaller,
|
||||
schedule: (callback, interval) => {
|
||||
expect(interval).toBe(60_000);
|
||||
tick = callback;
|
||||
return () => {};
|
||||
},
|
||||
});
|
||||
onTestFinished(() => cleanup.close());
|
||||
return {
|
||||
runtime,
|
||||
runtimes,
|
||||
cleanup,
|
||||
tick: () => tick(),
|
||||
protect: (value: boolean) => {
|
||||
protectedCaller = value;
|
||||
},
|
||||
sweep: (minutes: number) => {
|
||||
time = minutes * 60_000;
|
||||
return cleanup.sweep();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("a runtime without a provider check is collected at exactly one hour", async () => {
|
||||
const s = scenario();
|
||||
expect((await s.sweep(59)).collected).toEqual([]);
|
||||
expect((await s.sweep(60)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("activity restarts the single one-hour idle period", async () => {
|
||||
const s = scenario();
|
||||
await s.sweep(59);
|
||||
s.runtime.activity = 59 * 60_000;
|
||||
expect((await s.sweep(118)).collected).toEqual([]);
|
||||
expect((await s.sweep(119)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test.each(["foreground", "finish subscription"])(
|
||||
"a positive provider check cannot override %s",
|
||||
async (blocker) => {
|
||||
const s = scenario();
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
if (blocker === "foreground") s.runtime.busy = true;
|
||||
else s.protect(true);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
expect(calls).toBe(0);
|
||||
s.runtime.busy = false;
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(121)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["veto", "error"])(
|
||||
"a supported provider %s retains the runtime until a fresh idle hour passes",
|
||||
async (decision) => {
|
||||
const s = scenario();
|
||||
s.runtime.probe = async () => {
|
||||
if (decision === "error") throw new Error("Probe failed");
|
||||
return false;
|
||||
};
|
||||
const blocked = await s.sweep(61);
|
||||
expect(blocked.collected).toEqual([]);
|
||||
expect(blocked.failures).toHaveLength(decision === "error" ? 1 : 0);
|
||||
await s.sweep(119);
|
||||
s.runtime.probe = async () => true;
|
||||
expect((await s.sweep(120)).collected).toEqual([]);
|
||||
expect((await s.sweep(179)).collected).toEqual([]);
|
||||
expect((await s.sweep(180)).collected).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
test("an idle managed descendant with provider-held work vetoes its parent", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.probe = async () => false;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.probe = async () => true;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("new caller protection during an async provider probe prevents stale collection", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const collecting = s.sweep(61);
|
||||
s.protect(true);
|
||||
probe.resolve(true);
|
||||
expect((await collecting).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
s.protect(false);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a supported provider timeout retains ownership even if it later returns true", async () => {
|
||||
const s = scenario();
|
||||
const probe = deferred<boolean>();
|
||||
s.runtime.probe = () => probe.promise;
|
||||
const result = await s.sweep(61);
|
||||
expect(result.collected).toEqual([]);
|
||||
expect(result.failures[0]?.error).toEqual(new Error("Runtime safety probe timed out for agent"));
|
||||
probe.resolve(true);
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
}, 15_000);
|
||||
|
||||
test("the host opt-out disables explicit sweeps and scheduled ticks", async () => {
|
||||
const s = scenario(false);
|
||||
let calls = 0;
|
||||
s.runtime.probe = async () => {
|
||||
calls++;
|
||||
return true;
|
||||
};
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
s.tick();
|
||||
await s.cleanup.close();
|
||||
expect(calls).toBe(0);
|
||||
expect(s.runtime.closed).toBe(false);
|
||||
});
|
||||
|
||||
test("a managed descendant without persistence still protects its parent while working", async () => {
|
||||
const s = scenario();
|
||||
const child = new Runtime("initializing-child");
|
||||
child.parentAgentId = s.runtime.id;
|
||||
child.resumable = false;
|
||||
child.busy = true;
|
||||
s.runtimes.push(child);
|
||||
expect((await s.sweep(61)).collected).toEqual([]);
|
||||
child.busy = false;
|
||||
expect((await s.sweep(62)).collected).toEqual([]);
|
||||
expect((await s.sweep(122)).collected).toEqual([
|
||||
{ agentId: "agent", provider: "test", sessionId: "agent" },
|
||||
]);
|
||||
expect(child.closed).toBe(false);
|
||||
});
|
||||
@@ -1,149 +0,0 @@
|
||||
import type { Logger } from "pino";
|
||||
import { withTimeout } from "../../../utils/promise-timeout.js";
|
||||
|
||||
/** A captured runtime capability. Its lifecycle owner revalidates before releasing it. */
|
||||
export interface RuntimeCleanupCandidate {
|
||||
readonly identity: object;
|
||||
readonly agentId: string;
|
||||
readonly parentAgentId: string | undefined;
|
||||
readonly provider: string;
|
||||
readonly sessionId: string | null;
|
||||
readonly lastActivityAt: number;
|
||||
isEligible(): boolean;
|
||||
canStopRuntime?(): Promise<boolean>;
|
||||
closeIfEligible(revalidate: () => boolean): Promise<boolean>;
|
||||
}
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
runtimes(): readonly RuntimeCleanupCandidate[];
|
||||
isProtected?(agentId: string): boolean;
|
||||
logger: Pick<Logger, "debug" | "info" | "warn">;
|
||||
now?: () => number;
|
||||
schedule?: (tick: () => void, intervalMs: number) => () => void;
|
||||
}
|
||||
interface CollectionEntry {
|
||||
agentId: string;
|
||||
provider: string;
|
||||
sessionId: string;
|
||||
}
|
||||
interface CollectionResult {
|
||||
collected: CollectionEntry[];
|
||||
failures: Array<{ agentId: string; error: unknown }>;
|
||||
}
|
||||
const IDLE_PERIOD_MS = 60 * 60_000;
|
||||
|
||||
/** Owns idle clocks, baseline eligibility, optional provider vetoes and the sweep lifetime. */
|
||||
export function createIdleRuntimeCleanup(options: Options) {
|
||||
const protectedAt = new WeakMap<object, { at: number; protected: boolean }>();
|
||||
let collection: Promise<CollectionResult> | null = null;
|
||||
let stopped = false;
|
||||
const isProtected = options.isProtected ?? (() => false);
|
||||
const now = options.now ?? Date.now;
|
||||
|
||||
function familyOf(root: RuntimeCleanupCandidate, runtimes: readonly RuntimeCleanupCandidate[]) {
|
||||
const family = new Map([[root.agentId, root]]);
|
||||
for (const parent of family.values()) {
|
||||
for (const child of runtimes) {
|
||||
if (child.parentAgentId === parent.agentId) family.set(child.agentId, child);
|
||||
}
|
||||
}
|
||||
return [...family.values()];
|
||||
}
|
||||
function baselineAllows(family: readonly RuntimeCleanupCandidate[]) {
|
||||
return family.every((runtime) => runtime.isEligible() && !isProtected(runtime.agentId));
|
||||
}
|
||||
async function providersAllow(family: readonly RuntimeCleanupCandidate[]) {
|
||||
for (const runtime of family) {
|
||||
if (!runtime.canStopRuntime) continue;
|
||||
const allowed = await withTimeout(
|
||||
runtime.canStopRuntime(),
|
||||
10_000,
|
||||
`Runtime safety probe timed out for ${runtime.agentId}`,
|
||||
);
|
||||
if (allowed !== true) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
async function collect(): Promise<CollectionResult> {
|
||||
const result: CollectionResult = { collected: [], failures: [] };
|
||||
if (!options.enabled || stopped) return result;
|
||||
const timestamp = now();
|
||||
const runtimes = options.runtimes();
|
||||
for (const runtime of runtimes) {
|
||||
if (!runtime.sessionId) continue;
|
||||
const family = familyOf(runtime, runtimes);
|
||||
try {
|
||||
if (!baselineAllows(family) || !(await providersAllow(family))) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
options.logger.debug(
|
||||
{ agentId: runtime.agentId },
|
||||
"Idle runtime retained by a baseline or provider veto",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const previous = protectedAt.get(runtime.identity);
|
||||
if (previous?.protected)
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: false });
|
||||
const idleSince = Math.max(
|
||||
runtime.lastActivityAt,
|
||||
protectedAt.get(runtime.identity)?.at ?? 0,
|
||||
);
|
||||
if (timestamp - idleSince < IDLE_PERIOD_MS) continue;
|
||||
const revalidate = () => {
|
||||
const currentFamily = familyOf(runtime, options.runtimes());
|
||||
return (
|
||||
currentFamily.length === family.length &&
|
||||
currentFamily.every((current) =>
|
||||
family.some((captured) => captured.identity === current.identity),
|
||||
) &&
|
||||
baselineAllows(family)
|
||||
);
|
||||
};
|
||||
if (await runtime.closeIfEligible(revalidate)) {
|
||||
const entry = {
|
||||
agentId: runtime.agentId,
|
||||
provider: runtime.provider,
|
||||
sessionId: runtime.sessionId,
|
||||
};
|
||||
result.collected.push(entry);
|
||||
options.logger.info(entry, "Collected idle agent runtime");
|
||||
} else {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
}
|
||||
} catch (error) {
|
||||
protectedAt.set(runtime.identity, { at: timestamp, protected: true });
|
||||
result.failures.push({ agentId: runtime.agentId, error });
|
||||
options.logger.warn(
|
||||
{ agentId: runtime.agentId, err: error },
|
||||
"Idle runtime cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function sweep(): Promise<CollectionResult> {
|
||||
if (collection) return collection;
|
||||
collection = collect().finally(() => {
|
||||
collection = null;
|
||||
});
|
||||
return collection;
|
||||
}
|
||||
const schedule =
|
||||
options.schedule ??
|
||||
((tick, intervalMs) => {
|
||||
const timer = setInterval(tick, intervalMs);
|
||||
timer.unref();
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
const cancel = schedule(() => {
|
||||
void sweep().catch((error) => options.logger.warn({ err: error }, "Idle runtime sweep failed"));
|
||||
}, 60_000);
|
||||
return {
|
||||
sweep,
|
||||
async close() {
|
||||
stopped = true;
|
||||
cancel();
|
||||
await collection;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class FakePermissionAgentManager {
|
||||
return this.permissionResult;
|
||||
}
|
||||
|
||||
async tryRunOutOfBand(): Promise<boolean> {
|
||||
tryRunOutOfBand(): boolean {
|
||||
return this.outOfBandHandled;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import { relative } from "node:path";
|
||||
import type {
|
||||
ProviderConnection,
|
||||
@@ -1239,85 +1234,3 @@ test("config-required plugin membership is independent of enablement and other p
|
||||
await plugins.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test.each([true, false, "error", "wrong-session", "missing"] as const)(
|
||||
"plugin runtime safety query: %s",
|
||||
async (answer) => {
|
||||
const harness = createProviderHarness({
|
||||
capabilities:
|
||||
answer === "missing" ? CAPABILITIES : [...CAPABILITIES, "session.can_stop_runtime"],
|
||||
handleInput: async (input, emit) => {
|
||||
if (input.type !== "session.can_stop_runtime") return false;
|
||||
if (answer === "error")
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "Probe failed" },
|
||||
});
|
||||
else
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: answer === "wrong-session" ? "child-1" : input.sessionId,
|
||||
canStop: answer === true,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([harness.registration]);
|
||||
const client = registry.clients()[harness.registration.id]!;
|
||||
const session = await client.createSession({ provider: harness.registration.id, cwd: "/tmp" });
|
||||
try {
|
||||
if (answer === "error" || answer === "wrong-session") {
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow(
|
||||
answer === "error" ? "Probe failed" : "another session",
|
||||
);
|
||||
} else if (answer === "missing") expect(session.canStopRuntime).toBeUndefined();
|
||||
else expect(await session.canStopRuntime!()).toBe(answer === true);
|
||||
const requests = harness.inputs.filter((input) => input.type === "session.can_stop_runtime");
|
||||
expect(requests).toHaveLength(answer === "missing" ? 0 : 1);
|
||||
if (answer !== "missing")
|
||||
expect(requests[0]).toMatchObject({
|
||||
sessionId: harness.inputs.find((input) => input.type === "session.open")?.sessionId,
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await registry.shutdown();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("plugin without a runtime safety capability uses baseline collection without a query", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "plugin-baseline-cleanup-"));
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(join(home, "agents"), logger);
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({ clients: registry.clients(), registry: storage, logger });
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: home },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const cleanup = createIdleRuntimeCleanup({
|
||||
enabled: true,
|
||||
logger,
|
||||
runtimes: () => manager.getRuntimeCleanupCandidates(),
|
||||
now: () => agent.updatedAt.getTime() + 60 * 60_000,
|
||||
schedule: () => () => {},
|
||||
});
|
||||
try {
|
||||
expect(agent.session?.canStopRuntime).toBeUndefined();
|
||||
expect((await cleanup.sweep()).collected).toHaveLength(1);
|
||||
expect(manager.getAgent(agent.id)).toBeNull();
|
||||
expect(harness.inputs.filter((input) => input.type === "session.can_stop_runtime")).toEqual([]);
|
||||
} finally {
|
||||
await cleanup.close();
|
||||
await Promise.all(manager.listAgents().map((item) => manager.closeAgent(item.id)));
|
||||
await registry.shutdown();
|
||||
await storage.flush();
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,15 +113,9 @@ function providerError(error: ProviderError): Error {
|
||||
|
||||
function isProviderRequestReply(
|
||||
event: ProviderEvent,
|
||||
): event is Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
> {
|
||||
): event is Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }> {
|
||||
return (
|
||||
event.type === "request.completed" ||
|
||||
event.type === "catalog" ||
|
||||
event.type === "sessions" ||
|
||||
event.type === "session.can_stop_runtime"
|
||||
event.type === "request.completed" || event.type === "catalog" || event.type === "sessions"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,21 +564,10 @@ class ProviderRuntime {
|
||||
}
|
||||
|
||||
private finishRequest(
|
||||
event: Extract<
|
||||
ProviderEvent,
|
||||
{ type: "request.completed" | "catalog" | "sessions" | "session.can_stop_runtime" }
|
||||
>,
|
||||
event: Extract<ProviderEvent, { type: "request.completed" | "catalog" | "sessions" }>,
|
||||
): void {
|
||||
const request = this.requests.get(event.requestId);
|
||||
if (!request) return;
|
||||
if (
|
||||
event.type === "session.can_stop_runtime" &&
|
||||
this.providerSessions.get(event.sessionId)?.id !== request.sessionId
|
||||
) {
|
||||
this.requests.delete(event.requestId);
|
||||
request.reject(new Error("Runtime safety response belongs to another session"));
|
||||
return;
|
||||
}
|
||||
this.requests.delete(event.requestId);
|
||||
request.resolve(event);
|
||||
}
|
||||
@@ -652,27 +635,6 @@ class ProviderRuntimeSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.terminal ||
|
||||
this.prompts.size ||
|
||||
this.activeTurnIds.size ||
|
||||
!this.capabilities.includes("session.can_stop_runtime")
|
||||
)
|
||||
return false;
|
||||
const event = await this.runtime.complete(
|
||||
{
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: randomUUID(),
|
||||
sessionId: this.providerSessionId,
|
||||
},
|
||||
AbortSignal.timeout(10_000),
|
||||
);
|
||||
if (event.type !== "session.can_stop_runtime")
|
||||
throw new Error("Invalid runtime safety response");
|
||||
return event.canStop;
|
||||
}
|
||||
|
||||
async configure(changes: ProviderConfigChanges): Promise<void> {
|
||||
const input: Extract<ProviderInput, { type: "session.configure" }> = {
|
||||
type: "session.configure",
|
||||
@@ -1239,11 +1201,6 @@ class PluginAgentSession implements AgentSession {
|
||||
for (const event of this.history) yield event;
|
||||
}
|
||||
|
||||
get canStopRuntime(): (() => Promise<boolean>) | undefined {
|
||||
if (!this.bridge.negotiatedCapabilities.includes("session.can_stop_runtime")) return undefined;
|
||||
return () => this.bridge.canStopRuntime();
|
||||
}
|
||||
|
||||
async getRuntimeInfo() {
|
||||
return {
|
||||
provider: this.provider,
|
||||
|
||||
@@ -469,7 +469,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()),
|
||||
interrupt: () => inner.interrupt(),
|
||||
close: () => inner.close(),
|
||||
canStopRuntime: inner.canStopRuntime?.bind(inner),
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { Query, SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -708,137 +706,3 @@ describe("background Claude subagents", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => {
|
||||
const safety = new ClaudeRuntimeSafety();
|
||||
const observe = (message: unknown) => safety.observe(message as SDKMessage);
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
observe({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [
|
||||
{ task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" },
|
||||
{ task_id: "monitor", task_type: "local_bash", description: "watch build" },
|
||||
{ task_id: "native-child", task_type: "local_agent", description: "research" },
|
||||
],
|
||||
});
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
for (const task_id of ["quiet-shell", "monitor"])
|
||||
observe({ type: "system", subtype: "task_notification", task_id });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "task_notification", task_id: "native-child" });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
safety.reset();
|
||||
observe({ type: "system", subtype: "init", claude_code_version: "2.1.220" });
|
||||
expect(safety.canStopRuntime()).toBe(false);
|
||||
observe({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(safety.canStopRuntime()).toBe(true);
|
||||
});
|
||||
|
||||
test("Claude session runtime safety observes pumped background work and autonomous replies", async () => {
|
||||
let wake: (() => void) | undefined;
|
||||
const channel = { closed: false };
|
||||
const queue: Array<{ message: SDKMessage; consumed(): void }> = [];
|
||||
const pump = (async function* () {
|
||||
while (!channel.closed) {
|
||||
const entry = queue.shift();
|
||||
if (!entry) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
yield entry.message;
|
||||
entry.consumed();
|
||||
}
|
||||
})();
|
||||
const push = (message: unknown) =>
|
||||
new Promise<void>((consumed) => {
|
||||
queue.push({ message: message as SDKMessage, consumed });
|
||||
wake?.();
|
||||
});
|
||||
const end = () => {
|
||||
channel.closed = true;
|
||||
wake?.();
|
||||
};
|
||||
let nativeInput!: AsyncIterator<SDKUserMessage>;
|
||||
let connected!: () => void;
|
||||
const connection = new Promise<void>((resolve) => {
|
||||
connected = resolve;
|
||||
});
|
||||
// The SDK Query is injected through the production adapter's existing launch port.
|
||||
const query = {
|
||||
next: () => pump.next(),
|
||||
return: async () => {
|
||||
end();
|
||||
return pump.return(undefined);
|
||||
},
|
||||
close: end,
|
||||
interrupt: async () => {},
|
||||
setPermissionMode: async () => {},
|
||||
setModel: async () => {},
|
||||
supportedModels: async () => [],
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]() {
|
||||
return this;
|
||||
},
|
||||
} as unknown as Query;
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
queryFactory: ({ prompt }) => {
|
||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
||||
nativeInput = prompt[Symbol.asyncIterator]();
|
||||
connected();
|
||||
return query;
|
||||
},
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
const init = {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "runtime-safety",
|
||||
claude_code_version: "2.1.292",
|
||||
permissionMode: "default",
|
||||
};
|
||||
const result = {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
total_cost_usd: 0,
|
||||
};
|
||||
try {
|
||||
await session.startTurn("start background work");
|
||||
await connection;
|
||||
const input = await nativeInput.next();
|
||||
await push({ type: "command_lifecycle", command_uuid: input.value.uuid, state: "started" });
|
||||
await push(init);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "background_tasks_changed",
|
||||
tasks: [{ task_id: "monitor", task_type: "local_bash", description: "quiet monitor" }],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({ type: "system", subtype: "background_tasks_changed", tasks: [] });
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push({
|
||||
type: "system",
|
||||
subtype: "task_notification",
|
||||
task_id: "monitor",
|
||||
status: "completed",
|
||||
output: "ready",
|
||||
summary: "ready",
|
||||
});
|
||||
// Completion can wake Claude autonomously after the foreground turn has ended.
|
||||
await push(init);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
await push(result);
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -2256,8 +2255,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private query: Query | null = null;
|
||||
private readonly runtimeSafety = new ClaudeRuntimeSafety();
|
||||
private processingSdkMessages = 0;
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
private childProcess: ChildProcess | null = null;
|
||||
@@ -2933,18 +2930,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.persistence;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
return (
|
||||
!this.activeForegroundTurnId &&
|
||||
!this.autonomousTurn &&
|
||||
!this.mainTurnInFlight &&
|
||||
this.unstartedMessageUuids.size === 0 &&
|
||||
this.pendingPermissions.size === 0 &&
|
||||
this.processingSdkMessages === 0 &&
|
||||
this.runtimeSafety.canStopRuntime()
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -3438,7 +3423,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
const options = await this.buildOptions(launchMode);
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.runtimeSafety.reset();
|
||||
// A fresh Claude process has no turn of its own in flight.
|
||||
this.mainTurnInFlight = false;
|
||||
this.query = claudeQuery(
|
||||
@@ -4094,17 +4078,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
};
|
||||
const handlePumpedMessage = async (message: SDKMessage): Promise<boolean> => {
|
||||
this.processingSdkMessages++;
|
||||
try {
|
||||
this.runtimeSafety.observe(message);
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) return true;
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
} finally {
|
||||
this.processingSdkMessages--;
|
||||
logRawMessage(message);
|
||||
consecutiveInterruptAbortRecoveries = 0;
|
||||
if (await this.handleMissingResumedConversation(message, activeQuery)) {
|
||||
return true;
|
||||
}
|
||||
await this.routeSdkMessageFromPump(message);
|
||||
return false;
|
||||
};
|
||||
const drainActiveQuery = async (): Promise<boolean> => {
|
||||
for await (const message of activeQuery) {
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { parseClaudeCodeVersion } from "./model-manifest.js";
|
||||
|
||||
/** Process-local background ownership. Completion delivery can follow an empty roster. */
|
||||
export class ClaudeRuntimeSafety {
|
||||
private supported = false;
|
||||
private liveTasks = new Set<string>();
|
||||
private readonly awaitingNotifications = new Set<string>();
|
||||
|
||||
reset(): void {
|
||||
this.supported = false;
|
||||
this.liveTasks.clear();
|
||||
this.awaitingNotifications.clear();
|
||||
}
|
||||
|
||||
observe(message: SDKMessage): void {
|
||||
if (message.type !== "system") return;
|
||||
if (message.subtype === "init") {
|
||||
// 2.1.292 is a known roster-capable release. Older binaries can demonstrate
|
||||
// support by sending a roster; a fresh process starts with an empty roster.
|
||||
const version = parseClaudeCodeVersion(message.claude_code_version ?? "");
|
||||
if (
|
||||
version &&
|
||||
(version[0] > 2 ||
|
||||
(version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292))))
|
||||
)
|
||||
this.supported = true;
|
||||
}
|
||||
if (message.subtype === "background_tasks_changed") {
|
||||
this.supported = true;
|
||||
this.liveTasks = new Set(message.tasks.map((task) => task.task_id));
|
||||
for (const id of this.liveTasks) this.awaitingNotifications.add(id);
|
||||
}
|
||||
if (message.subtype === "task_notification") {
|
||||
this.awaitingNotifications.delete(message.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
canStopRuntime(): boolean {
|
||||
return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0;
|
||||
}
|
||||
}
|
||||
@@ -7167,35 +7167,3 @@ describe("Codex interactive resume startup", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex runtime safety queries live terminals and retains unsupported app servers", async () => {
|
||||
let response: unknown = { data: [], nextCursor: null };
|
||||
const appServer = createFakeCodexAppServer({ "thread/backgroundTerminals/list": () => response });
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
await appServer.waitForTurnStart();
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
appServer.completeTurn({ threadId: "thread-1" });
|
||||
await expect.poll(() => session.canStopRuntime()).toBe(true);
|
||||
response = { data: [{ processId: "shell-1" }], nextCursor: null };
|
||||
expect(await session.canStopRuntime()).toBe(false);
|
||||
response = {};
|
||||
await expect(session.canStopRuntime()).rejects.toThrow();
|
||||
response = { __jsonRpcError: { code: -32601, message: "Method not found" } };
|
||||
await expect(session.canStopRuntime()).rejects.toThrow("Method not found");
|
||||
expect(
|
||||
appServer
|
||||
.requests()
|
||||
.findLast((request) => request.method === "thread/backgroundTerminals/list"),
|
||||
).toMatchObject({ params: { threadId: "thread-1" } });
|
||||
appServer.assertNoErrors();
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5029,34 +5029,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
!this.client ||
|
||||
this.client.hasPendingRequests() ||
|
||||
!this.currentThreadId ||
|
||||
this.activeForegroundTurnId ||
|
||||
this.currentTurnId ||
|
||||
this.pendingForegroundStart ||
|
||||
this.pendingPermissionHandlers.size ||
|
||||
this.getPendingPermissions().length ||
|
||||
this.pendingSubAgentNotificationsByThreadId.size
|
||||
)
|
||||
return false;
|
||||
// Older app servers reject this request. A failed or malformed probe must retain
|
||||
// the process: command completion alone does not mean its terminal has exited.
|
||||
const loaded = z
|
||||
.object({ data: z.array(z.string()) })
|
||||
.parse(await this.client.request("thread/loaded/list", {}, 10_000));
|
||||
const threadIds = new Set([this.currentThreadId, ...loaded.data]);
|
||||
for (const threadId of threadIds) {
|
||||
const response = z
|
||||
.object({ data: z.array(z.unknown()), nextCursor: z.string().nullable() })
|
||||
.parse(await this.client.request("thread/backgroundTerminals/list", { threadId }, 10_000));
|
||||
if (response.data.length > 0 || response.nextCursor !== null) return false;
|
||||
}
|
||||
return !this.client.hasPendingRequests();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true;
|
||||
this.clearPendingPermissions();
|
||||
|
||||
@@ -202,7 +202,6 @@ function createNewlineLineReader(
|
||||
|
||||
export class CodexAppServerClient {
|
||||
private readonly stdoutLineReader: { close(): void };
|
||||
private activeProviderRequests = 0;
|
||||
private readonly pending = new Map<number, PendingRequest>();
|
||||
private readonly requestHandlers = new Map<string, RequestHandler>();
|
||||
private notificationHandler: NotificationHandler | null = null;
|
||||
@@ -256,10 +255,6 @@ export class CodexAppServerClient {
|
||||
this.requestHandlers.set(method, handler);
|
||||
}
|
||||
|
||||
hasPendingRequests(): boolean {
|
||||
return this.pending.size > 0 || this.activeProviderRequests > 0;
|
||||
}
|
||||
|
||||
request(method: string, params?: unknown, timeoutMs = DEFAULT_TIMEOUT_MS): Promise<unknown> {
|
||||
if (this.disposed) {
|
||||
return Promise.reject(new Error("Codex app-server client is closed"));
|
||||
@@ -397,7 +392,6 @@ export class CodexAppServerClient {
|
||||
this.traceRawEvent(request);
|
||||
const handler = this.requestHandlers.get(request.method);
|
||||
try {
|
||||
this.activeProviderRequests++;
|
||||
const result = handler ? await handler(request.params, request.id) : {};
|
||||
this.writeJsonRpcResponse({ id: request.id, result });
|
||||
} catch (error) {
|
||||
@@ -405,8 +399,6 @@ export class CodexAppServerClient {
|
||||
id: request.id,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
} finally {
|
||||
this.activeProviderRequests--;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { OmpAgentMessage } from "./rpc-types.js";
|
||||
import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderOptions } from "./provider-config.js";
|
||||
import { OmpRuntimeEventSchema, OmpSessionStateSchema } from "./rpc-types.js";
|
||||
import { OmpRuntimeEventSchema } from "./rpc-types.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
import { OmpAgentClient } from "./agent.js";
|
||||
import { FakeOmp } from "./test-utils/fake-omp.js";
|
||||
@@ -1544,86 +1544,3 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("omp runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.isSettled = true;
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.queuedMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.queuedMessageCount = 0;
|
||||
native.state.isStreaming = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isStreaming = false;
|
||||
native.state.isCompacting = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isCompacting = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
test("OMP runtime safety retains async work after the foreground turn finishes", async () => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession({
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
native.state.hasPendingAsyncWork = true;
|
||||
native.state.isSettled = false;
|
||||
const prompted = native.nextPrompt();
|
||||
const run = session.run("start a background job");
|
||||
await prompted;
|
||||
native.beginTurn();
|
||||
native.acceptPrompt("start a background job", "async-user");
|
||||
native.streamAssistantText("Job started in background");
|
||||
native.finishTurn();
|
||||
await run;
|
||||
expect(native.state.isStreaming).toBe(false);
|
||||
expect(native.state.queuedMessageCount).toBe(0);
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
// The delivery is drained, but an admitted/scheduled continuation still prevents settlement.
|
||||
native.state.hasPendingAsyncWork = false;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isSettled = true;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ hasPendingAsyncWork: true, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: false, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: true, expected: true },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: true, expected: false },
|
||||
{ hasPendingAsyncWork: false, isSettled: undefined, expected: false },
|
||||
{ hasPendingAsyncWork: undefined, isSettled: undefined, expected: false },
|
||||
])(
|
||||
"OMP runtime safety requires explicit settlement: %j",
|
||||
async ({ hasPendingAsyncWork, isSettled, expected }) => {
|
||||
const runtime = new FakeOmp();
|
||||
const session = await new OmpAgentClient({ logger: createTestLogger(), runtime }).createSession(
|
||||
{
|
||||
provider: "omp",
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
);
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
const response = { ...native.state };
|
||||
if (hasPendingAsyncWork !== undefined) response.hasPendingAsyncWork = hasPendingAsyncWork;
|
||||
if (isSettled !== undefined) response.isSettled = isSettled;
|
||||
// Legacy get_state responses stay valid; missing safety fields never imply settlement.
|
||||
native.state = OmpSessionStateSchema.parse(response);
|
||||
expect(native.state.hasPendingAsyncWork).toBe(hasPendingAsyncWork);
|
||||
expect(native.state.isSettled).toBe(isSettled);
|
||||
expect(await session.canStopRuntime!()).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1172,27 +1172,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.activeToolCalls.clear();
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.activeToolCalls.size ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingClientMessages.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.pendingNoTurnCompletionAbort
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return (
|
||||
state.isSettled === true &&
|
||||
state.hasPendingAsyncWork === false &&
|
||||
!state.isStreaming &&
|
||||
!state.isCompacting &&
|
||||
state.queuedMessageCount === 0
|
||||
);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -152,8 +152,6 @@ export const OmpSessionStateSchema = z
|
||||
thinkingLevel: OmpThinkingLevelSchema.optional(),
|
||||
isStreaming: z.boolean(),
|
||||
isCompacting: z.boolean(),
|
||||
hasPendingAsyncWork: z.boolean().optional(),
|
||||
isSettled: z.boolean().optional(),
|
||||
autoCompactionEnabled: z.boolean().optional(),
|
||||
fastModeEnabled: z.boolean().optional(),
|
||||
fastModeActive: z.boolean().optional(),
|
||||
|
||||
@@ -7010,21 +7010,3 @@ describe("OpenCode session permission rules", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v1 runtime safety retains terminals and busy sessions on its shared server", async () => {
|
||||
const { parent, openCode } = await createParentSession("runtime-safety");
|
||||
try {
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { data: [{ id: "shell", status: "running" }] };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.ptyListResponse = { data: [] };
|
||||
openCode.sessionStatusResponse = { data: { "other-native-session": { type: "busy" } } };
|
||||
expect(await parent.canStopRuntime!()).toBe(false);
|
||||
openCode.sessionStatusResponse = { data: {} };
|
||||
expect(await parent.canStopRuntime!()).toBe(true);
|
||||
openCode.ptyListResponse = { error: "Inventory unavailable" };
|
||||
await expect(parent.canStopRuntime!()).rejects.toThrow("Unable to inspect OpenCode terminals");
|
||||
} finally {
|
||||
await parent.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5055,43 +5055,6 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.serverExited ||
|
||||
this.reconnection ||
|
||||
this.turnState.status !== "idle" ||
|
||||
this.runningToolCalls.size ||
|
||||
this.pendingPermissions.size ||
|
||||
this.pendingSteerSubmissions.length
|
||||
)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
const family = new Map([[this.sessionId, this.config.cwd]]);
|
||||
const inspected = new Set<string>();
|
||||
for (const [sessionId, directory] of family) {
|
||||
if (!inspected.has(directory)) {
|
||||
const statuses = await this.client.session.status({ directory }, request);
|
||||
if (statuses.error || !statuses.data)
|
||||
throw new Error("Unable to inspect OpenCode executions");
|
||||
if (Object.values(statuses.data).some((status) => status.type !== "idle")) return false;
|
||||
const terminals = await this.client.pty.list({ directory }, request);
|
||||
if (terminals.error || !terminals.data)
|
||||
throw new Error("Unable to inspect OpenCode terminals");
|
||||
if (terminals.data.some((terminal) => terminal.status === "running")) return false;
|
||||
inspected.add(directory);
|
||||
}
|
||||
const response = await this.client.session.children(
|
||||
{ sessionID: sessionId, directory },
|
||||
request,
|
||||
);
|
||||
const children = readOpenCodeChildSessionInfosFromResponse(response);
|
||||
if (!children) throw new Error("Unable to inspect OpenCode child sessions");
|
||||
for (const child of children) family.set(child.id, child.directory ?? directory);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
try {
|
||||
this.closed = true;
|
||||
|
||||
-2
@@ -170,7 +170,6 @@ export class TestOpenCodeClient {
|
||||
null;
|
||||
sessionPromptAsyncEvents: unknown[] = [idleEvent()];
|
||||
sessionPromptAsyncResponse: OpenCodeResponse = {};
|
||||
ptyListResponse: OpenCodeResponse = { data: [] };
|
||||
sessionStatusResponse: OpenCodeResponse = { data: {} };
|
||||
sessionStatusImplementation:
|
||||
| ((parameters: unknown, options: unknown) => Promise<OpenCodeResponse>)
|
||||
@@ -196,7 +195,6 @@ export class TestOpenCodeClient {
|
||||
|
||||
asSdkClient(): OpencodeClient {
|
||||
return {
|
||||
pty: { list: async () => this.ptyListResponse },
|
||||
app: {
|
||||
agents: async (parameters: unknown, options: unknown) => {
|
||||
this.calls.appAgents.push(parameters);
|
||||
|
||||
@@ -90,9 +90,6 @@ export class V2Harness {
|
||||
|
||||
readonly api: V2Api = {
|
||||
server: { info: unexpected },
|
||||
shell: {
|
||||
list: async () => ({ location: { directory: this.info.location.directory }, data: [] }),
|
||||
},
|
||||
plugin: {
|
||||
list: async () => ({
|
||||
location: { directory: this.info.location.directory },
|
||||
@@ -134,7 +131,6 @@ export class V2Harness {
|
||||
},
|
||||
permission: { list: async () => [], reply: unexpected },
|
||||
session: {
|
||||
inbox: { list: async () => [] },
|
||||
form: { list: async () => [], reply: unexpected, cancel: unexpected },
|
||||
create: async (input = {}) => {
|
||||
this.creates.push(input);
|
||||
|
||||
@@ -1074,110 +1074,3 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety retains shells, queued reports and shared executions", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => ({
|
||||
location: { directory: "/tmp/project" },
|
||||
data: [
|
||||
{
|
||||
id: "quiet-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: "/tmp/project",
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.shell.list = async () => ({ location: { directory: "/tmp/project" }, data: [] });
|
||||
harness.api.session.inbox.list = async () => [
|
||||
{
|
||||
id: "report",
|
||||
sessionID: session.id!,
|
||||
type: "synthetic",
|
||||
delivery: "queue",
|
||||
time: { created: 1 },
|
||||
payload: { text: "Background child finished" },
|
||||
},
|
||||
];
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.inbox.list = async () => [];
|
||||
harness.api.session.active = async () => ({
|
||||
"unrelated-autonomous-session": { type: "running" },
|
||||
});
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
harness.api.session.active = async () => ({});
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
harness.api.shell.list = async () => {
|
||||
throw new Error("No shell inventory");
|
||||
};
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("No shell inventory");
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode v2 runtime safety follows descendant locations before releasing the last lease", async () => {
|
||||
const harness = new V2Harness();
|
||||
const child = {
|
||||
...harness.info,
|
||||
id: "child",
|
||||
parentID: harness.info.id,
|
||||
location: { directory: "/tmp/child-worktree" },
|
||||
};
|
||||
const locations: string[] = [];
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||
// A completed native child can still own a quiet shell in another location.
|
||||
harness.api.session.list = async (input) => ({
|
||||
data: input.parentID === session.id ? [child] : [],
|
||||
cursor: {},
|
||||
});
|
||||
let running = true;
|
||||
harness.api.shell.list = async (input) => {
|
||||
const directory = input?.location?.directory ?? "";
|
||||
locations.push(directory);
|
||||
return {
|
||||
location: { directory },
|
||||
data:
|
||||
directory === child.location.directory && running
|
||||
? [
|
||||
{
|
||||
id: "child-shell",
|
||||
status: "running",
|
||||
command: "sleep 7200",
|
||||
cwd: directory,
|
||||
shell: "sh",
|
||||
file: "/tmp/out",
|
||||
metadata: {},
|
||||
time: { started: 1 },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
};
|
||||
try {
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
expect(locations).toEqual(["/tmp/project", "/tmp/child-worktree"]);
|
||||
running = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
await session.close();
|
||||
expect(harness.releases).toBe(1);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { OpenCodeClient } from "@opencode/client";
|
||||
|
||||
export interface V2Api {
|
||||
server: OpenCodeClient["server"];
|
||||
shell: Pick<OpenCodeClient["shell"], "list">;
|
||||
session: Pick<
|
||||
OpenCodeClient["session"],
|
||||
| "create"
|
||||
@@ -21,7 +20,6 @@ export interface V2Api {
|
||||
| "revert"
|
||||
| "log"
|
||||
> & {
|
||||
inbox: Pick<OpenCodeClient["session"]["inbox"], "list">;
|
||||
form: Pick<OpenCodeClient["session"]["form"], "list" | "reply" | "cancel">;
|
||||
};
|
||||
plugin: Pick<OpenCodeClient["plugin"], "list">;
|
||||
|
||||
@@ -516,36 +516,6 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
await delay(100, undefined, { signal }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (this.closed || this.exited || this.reconnecting || this.turns.id || this.dirty)
|
||||
return false;
|
||||
const request = { signal: AbortSignal.timeout(10_000) };
|
||||
// Active executions are server-wide. Releasing the last lease must not stop an
|
||||
// autonomous execution merely because it belongs to another session or location.
|
||||
if (Object.keys(await this.client.session.active(request)).length > 0) return false;
|
||||
const family = new Map([[this.info.id, this.info]]);
|
||||
const directories = new Set<string>();
|
||||
for (const info of family.values()) {
|
||||
if ((await this.client.session.inbox.list({ sessionID: info.id }, request)).length)
|
||||
return false;
|
||||
directories.add(info.location.directory);
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await this.client.session.list(
|
||||
{ ...(cursor ? { cursor } : { parentID: info.id }), limit: 100 },
|
||||
request,
|
||||
);
|
||||
for (const child of page.data) family.set(child.id, child);
|
||||
cursor = page.cursor.next ?? undefined;
|
||||
} while (cursor);
|
||||
}
|
||||
for (const directory of directories) {
|
||||
const shells = await this.client.shell.list({ location: { directory } }, request);
|
||||
if (shells.data.some((shell) => shell.status === "running")) return false;
|
||||
}
|
||||
return !this.turns.id && !this.dirty;
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed) return;
|
||||
this.closed = true;
|
||||
|
||||
@@ -3488,23 +3488,3 @@ describe("transformPiModels", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("pi runtime safety protects queued replies and runtime-owned activity", async () => {
|
||||
const runtime = new FakePi();
|
||||
const session = await createClient(runtime).createSession(createConfig());
|
||||
onTestFinished(() => session.close());
|
||||
const native = runtime.latestSession();
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.state.pendingMessageCount = 1;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.pendingMessageCount = 0;
|
||||
native.state.isStreaming = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isStreaming = false;
|
||||
native.state.isCompacting = true;
|
||||
expect(await session.canStopRuntime!()).toBe(false);
|
||||
native.state.isCompacting = false;
|
||||
expect(await session.canStopRuntime!()).toBe(true);
|
||||
native.getStateError = new Error("Unable to prove idle");
|
||||
await expect(session.canStopRuntime!()).rejects.toThrow("Unable to prove idle");
|
||||
});
|
||||
|
||||
@@ -1581,22 +1581,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
return await resultPromise;
|
||||
}
|
||||
|
||||
async canStopRuntime(): Promise<boolean> {
|
||||
if (
|
||||
this.closed ||
|
||||
this.activeTurnId ||
|
||||
this.pendingExtensionUiRequests.size ||
|
||||
this.pendingExtensionResults.size ||
|
||||
this.pendingExtensionHydrations.size ||
|
||||
this.pendingSteerSubmissions.length ||
|
||||
this.pendingNoTurnOutputs.length ||
|
||||
this.interruptingTurn
|
||||
)
|
||||
return false;
|
||||
const state = await this.runtimeSession.getState();
|
||||
return !state.isStreaming && !state.isCompacting && state.pendingMessageCount === 0;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
return;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { createIdleRuntimeCleanup } from "./agent/idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
createFinishSubscriptions,
|
||||
createFinishObservation,
|
||||
@@ -454,7 +453,6 @@ export interface PaseoDaemonConfig {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
idleRuntimeCleanup?: boolean;
|
||||
providerCatalogRefreshTimeoutMs?: number;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
@@ -1400,7 +1398,6 @@ export async function createPaseoDaemon(
|
||||
archiveWorkspace: archiveScheduleWorkspaceExternal,
|
||||
});
|
||||
await scheduleService.start();
|
||||
|
||||
agentManager.setAgentArchivedCallback(async (agentId) => {
|
||||
try {
|
||||
await scheduleService.completeForAgent(agentId);
|
||||
@@ -1448,15 +1445,6 @@ export async function createPaseoDaemon(
|
||||
}),
|
||||
});
|
||||
|
||||
const idleRuntimeCleanup = createIdleRuntimeCleanup({
|
||||
enabled: config.idleRuntimeCleanup !== false,
|
||||
runtimes: () => agentManager.getRuntimeCleanupCandidates(),
|
||||
isProtected: (agentId) =>
|
||||
finishSubscriptions.hasPendingForCaller(agentId) ||
|
||||
(wsServer?.agentMessages.hasPending(agentId) ?? false),
|
||||
logger,
|
||||
});
|
||||
|
||||
const createAgentToolHostDependencies = (
|
||||
runtime: PaseoToolRuntimeContext,
|
||||
): PaseoToolHostDependencies => ({
|
||||
@@ -1919,7 +1907,6 @@ export async function createPaseoDaemon(
|
||||
await hubRelationships.stop();
|
||||
workspaceReconciliation.dispose();
|
||||
scriptHealthMonitor.stop();
|
||||
await idleRuntimeCleanup.close();
|
||||
// Freeze both ingress and registration before taking the agent closure snapshot.
|
||||
wsServer?.prepareForShutdown();
|
||||
await finishSubscriptions.close();
|
||||
|
||||
@@ -27,17 +27,6 @@ describe("server config", () => {
|
||||
expect(standaloneConfig.desktopManaged).toBe(false);
|
||||
});
|
||||
|
||||
test("idle runtime cleanup defaults on and supports a host-wide opt-out", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-idle-cleanup-"));
|
||||
roots.push(paseoHome);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(true);
|
||||
await writeFile(
|
||||
path.join(paseoHome, "config.json"),
|
||||
JSON.stringify({ agents: { idleRuntimeCleanup: false } }),
|
||||
);
|
||||
expect(loadConfig(paseoHome, { env: {} }).idleRuntimeCleanup).toBe(false);
|
||||
});
|
||||
|
||||
test("loads the provider catalog refresh timeout", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(os.tmpdir(), "paseo-config-provider-timeout-"));
|
||||
roots.push(paseoHome);
|
||||
|
||||
@@ -660,7 +660,6 @@ export function resolveConfigFromPersisted(
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
idleRuntimeCleanup: persisted.agents?.idleRuntimeCleanup ?? true,
|
||||
providerCatalogRefreshTimeoutMs: persisted.agents?.catalogRefreshTimeoutMs,
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
|
||||
@@ -339,7 +339,6 @@ export const PersistedConfigSchema = z
|
||||
agents: z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
idleRuntimeCleanup: z.boolean().optional(),
|
||||
catalogRefreshTimeoutMs: z.number().int().positive().max(2_147_483_647).optional(),
|
||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||
skills: z.object({ selection: AgentSkillSelectionSchema.optional() }).strict().optional(),
|
||||
|
||||
@@ -552,9 +552,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"idleRuntimeCleanup": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"catalogRefreshTimeoutMs": {
|
||||
"type": "integer",
|
||||
"exclusiveMinimum": 0,
|
||||
|
||||
@@ -74,7 +74,7 @@ Runtime-safe settings include relay enablement, MCP settings, browser tools, hos
|
||||
|
||||
New homes keep relay disabled when you remove `daemon.relay.enabled`. A daemon whose config already omitted this field when it started keeps the legacy relay-enabled behavior for compatibility. Set `daemon.relay.enabled` explicitly when editing an older config.
|
||||
|
||||
Idle runtime cleanup, listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
|
||||
Listen addresses, authentication, relay endpoints and TLS, worktree allocation, service-proxy addresses, the bundled web UI, logging, speech, voice, credentials, and local model settings require a restart. Reload applies other valid edits in the same file before reporting those paths.
|
||||
|
||||
Deployment environment variables and legacy supervisor flags remain authoritative. Reload reports a changed file setting under `overrideControlledPaths` when a launch override prevents it from taking effect. This includes startup settings such as listen addresses, passwords, relay endpoints and TLS, service-proxy and web UI settings, logging, speech, and voice configuration. List settings such as hostnames and CORS origins still append across sources, so values from `config.json` continue to apply. Stop and relaunch the deployment without the override to make the file value authoritative.
|
||||
|
||||
@@ -84,20 +84,6 @@ Agent providers, both the first-class ones Paseo ships with and custom entries y
|
||||
|
||||
See [Providers](/docs/providers) for the mental model and [Supported providers](/docs/supported-providers) for the full list of agents Paseo can launch. For pointing Claude at Anthropic-compatible endpoints (Z.AI, Alibaba/Qwen), multiple profiles, custom binaries, ACP agents, and the `additionalModels` merge behavior, see [Custom providers](/docs/custom-providers). The full field reference lives on GitHub at [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md).
|
||||
|
||||
## Idle runtimes
|
||||
|
||||
Paseo releases resumable idle agent processes after one hour when its daemon baseline allows
|
||||
cleanup and any implemented provider check agrees. Your next prompt resumes the same conversation. Disable this
|
||||
for the entire host with:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
This setting defaults to `true` and requires a daemon restart to change. There is no per-agent
|
||||
setting. Providers without a runtime safety check use the same daemon baseline and one-hour idle
|
||||
period. Active work, descendants and pending finish notifications prevent cleanup.
|
||||
|
||||
## Worktrees
|
||||
|
||||
New worktrees are created under `$PASEO_HOME/worktrees` by default. To place new worktrees somewhere else, set `worktrees.root`:
|
||||
|
||||
@@ -72,7 +72,7 @@ paseo daemon restart
|
||||
|
||||
当你删除 `daemon.relay.enabled` 时,新的主目录会保持中继关闭。如果 daemon 启动时其配置就已经省略了这个字段,它会为了兼容而保持旧版的中继开启行为。编辑较旧的配置时,请显式设置 `daemon.relay.enabled`。
|
||||
|
||||
空闲运行时清理、监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
|
||||
监听地址、身份验证、中继端点和 TLS、worktree 分配、服务代理地址、内置的 Web UI、日志、语音合成与识别、语音、凭据以及本地模型设置都需要重启。重新加载会先应用同一文件中的其他有效编辑,再报告这些路径。
|
||||
|
||||
部署环境变量和旧版进程管理器参数仍然具有最终决定权。当某个启动覆盖项阻止了文件中已更改的设置生效时,重新加载会在 `overrideControlledPaths` 下报告它。这包括启动设置,例如监听地址、密码、中继端点和 TLS、服务代理和 Web UI 设置、日志、语音合成与识别以及语音配置。主机名和 CORS 来源等列表设置仍然在各个来源之间追加,因此 `config.json` 中的值继续生效。要让文件中的值具有决定权,请停止该部署并在不带覆盖项的情况下重新启动。
|
||||
|
||||
@@ -82,16 +82,6 @@ Agent Provider,包括 Paseo 自带的一等 Provider 和你在 `agents.provide
|
||||
|
||||
心智模型见 [Provider](/docs/providers),Paseo 可以启动的全部 Agent 见[支持的 Provider](/docs/supported-providers)。要让 Claude 指向兼容 Anthropic 的端点(Z.AI、Alibaba/Qwen)、使用多个配置、自定义二进制文件、ACP Agent,以及了解 `additionalModels` 的合并行为,见[自定义 Provider](/docs/custom-providers)。完整的字段参考在 GitHub 上的 [docs/custom-providers.md](https://github.com/getpaseo/paseo/blob/main/docs/custom-providers.md)。
|
||||
|
||||
## 空闲运行时 {#idle-runtimes}
|
||||
|
||||
当 daemon 的基础条件允许清理,且 Provider 已实现的安全检查也允许时,Paseo 会在可恢复的 Agent 空闲一小时后释放其进程。你的下一条提示词会恢复同一个会话。要为整个主机禁用此功能,请设置:
|
||||
|
||||
```json
|
||||
{ "agents": { "idleRuntimeCleanup": false } }
|
||||
```
|
||||
|
||||
此设置默认为 `true`,更改后需要重启 daemon。没有单个 Agent 的设置。不提供运行时安全检查的 Provider 使用相同的 daemon 基础条件和一小时空闲期。活动中的工作、后代 Agent 和待发送的完成通知都会阻止清理。
|
||||
|
||||
## worktree {#worktrees}
|
||||
|
||||
新的 worktree 默认创建在 `$PASEO_HOME/worktrees` 下。要把新的 worktree 放到其他位置,请设置 `worktrees.root`:
|
||||
|
||||
@@ -306,52 +306,6 @@ turn or finish as a side effect with a `completed` prompt result.
|
||||
Advertise only capabilities you implement. If steering is unsupported, omit `prompt.steer`; Paseo
|
||||
can replace the active turn instead.
|
||||
|
||||
## Permit idle runtime cleanup
|
||||
|
||||
Paseo can release an idle resumable session after one hour when its daemon baseline allows cleanup.
|
||||
Add a provider-owned veto by advertising `session.can_stop_runtime` on the connection and the
|
||||
session's `session.opened` capabilities. Without this capability, the same baseline and one-hour
|
||||
idle period apply, and the plugin receives no safety request.
|
||||
|
||||
Handle the safety query in your existing `send(input)` handler. It addresses the provider's own
|
||||
session ID, including when one connection serves several sessions:
|
||||
|
||||
```ts
|
||||
case "session.can_stop_runtime": {
|
||||
try {
|
||||
const session = sessions.get(input.sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
canStop: await session.canStopRuntime(),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`canStopRuntime(): Promise<boolean>` belongs to your provider's session implementation. Return
|
||||
`true` only when releasing the runtime loses no background shells, monitors, native children,
|
||||
queued completion reports, or pending autonomous replies. This query must not stop or mutate the
|
||||
session. Return `false` while work remains. A rejected query, invalid response, or timeout also
|
||||
keeps the runtime alive. Never respond with `request.completed` for this query.
|
||||
|
||||
Paseo always checks foreground work, permissions, queued deliveries, lifecycle operations,
|
||||
working managed/native descendants, and outstanding local or remote finish subscriptions for the
|
||||
caller. Subscription protection lasts through registration, recovery and notification dispatch
|
||||
acknowledgment. A positive provider response never overrides these blockers. Paseo rechecks
|
||||
activity before the existing `session.close` operation. After protected work finishes, a fresh
|
||||
one-hour idle period starts. The saved provider persistence must resume the same conversation.
|
||||
The host setting `agents.idleRuntimeCleanup: false` disables collection for every provider.
|
||||
|
||||
## Publish timeline items
|
||||
|
||||
Publish complete snapshots through `timeline.item`. Reuse an item `id` when updating streamed text,
|
||||
|
||||
@@ -269,38 +269,6 @@ Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,然后用当
|
||||
|
||||
只声明你实现了的能力。如果不支持引导,请省略 `prompt.steer`;Paseo 可以改为替换当前轮次。
|
||||
|
||||
## 允许清理空闲运行时 {#permit-idle-runtime-cleanup}
|
||||
|
||||
当 daemon 的基础条件允许清理时,Paseo 可以在可恢复的会话空闲一小时后释放其运行时。在连接和会话的 `session.opened` capabilities 中声明 `session.can_stop_runtime`,即可添加 Provider 自己的否决检查。没有此能力时,会话仍使用相同的基础条件和一小时空闲期,插件不会收到安全查询。
|
||||
|
||||
在现有的 `send(input)` 处理函数中处理安全查询。请求使用 Provider 自己的会话 ID,包括同一连接服务多个会话的情况:
|
||||
|
||||
```ts
|
||||
case "session.can_stop_runtime": {
|
||||
try {
|
||||
const session = sessions.get(input.sessionId);
|
||||
if (!session) throw new Error("Session not found");
|
||||
emit({
|
||||
type: "session.can_stop_runtime",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
canStop: await session.canStopRuntime(),
|
||||
});
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: error instanceof Error ? error.message : String(error) },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`canStopRuntime(): Promise<boolean>` 由你的 Provider 会话实现。只有释放运行时不会丢失后台 shell、监控任务、原生子 Agent、排队的完成报告或待处理的自主回复时,才返回 `true`。此查询不得停止或修改会话。仍有工作时返回 `false`。查询拒绝、响应无效或超时也会保留运行时。不要用 `request.completed` 响应此查询。
|
||||
|
||||
Paseo 始终检查前台工作、权限、排队投递、生命周期操作、工作中的托管或原生后代 Agent,以及调用方尚未完成的本地或远程完成通知订阅。订阅保护覆盖注册、恢复,直到通知投递获确认。Provider 返回 `true` 也不能绕过这些阻止条件。Paseo 会在调用现有的 `session.close` 操作之前重新检查活动。受保护的工作完成后,会重新开始一小时的空闲期。保存的 Provider 持久化数据必须能恢复同一个会话。主机设置 `agents.idleRuntimeCleanup: false` 会禁用所有 Provider 的清理。
|
||||
|
||||
## 发布时间线条目 {#publish-timeline-items}
|
||||
|
||||
通过 `timeline.item` 发布完整的快照。更新流式文本、运行中的工具或待办列表时,复用条目的 `id`。Paseo 会推导出实时增量,并保持正常的时间线。
|
||||
|
||||
Reference in New Issue
Block a user