From f4fcaa09a358347e9715067e1058c00b0f4567a1 Mon Sep 17 00:00:00 2001 From: scoootscooob Date: Mon, 6 Apr 2026 17:27:43 -0700 Subject: [PATCH] feat(gateway): add compaction checkpoints (#62146) Merged via squash. Prepared head SHA: e37542554aea39e23d0eb415f6408fbcfecc80f1 Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Co-authored-by: scoootscooob <167050519+scoootscooob@users.noreply.github.com> Reviewed-by: @scoootscooob --- .../sessions-checkpoints-inline.png | Bin 0 -> 87974 bytes .../sessions-overview-inline.png | Bin 0 -> 44704 bytes CHANGELOG.md | 1 + src/agents/pi-embedded-runner/compact.ts | 108 ++++- .../manual-compaction-boundary.test.ts | 133 ++++++ .../manual-compaction-boundary.ts | 103 +++++ src/config/sessions/types.ts | 28 ++ src/gateway/method-scopes.ts | 4 + src/gateway/protocol/index.ts | 24 + .../protocol/schema/protocol-schemas.ts | 18 + src/gateway/protocol/schema/sessions.ts | 118 +++++ src/gateway/protocol/schema/types.ts | 9 + src/gateway/server-methods-list.ts | 4 + src/gateway/server-methods/agent.ts | 2 + src/gateway/server-methods/sessions.ts | 433 +++++++++++++++++- src/gateway/server.impl.ts | 4 + ...sessions.gateway-server-sessions-a.test.ts | 270 +++++++++++ .../session-compaction-checkpoints.test.ts | 84 ++++ src/gateway/session-compaction-checkpoints.ts | 207 +++++++++ src/gateway/session-reset-service.ts | 2 + src/gateway/session-utils.ts | 15 + src/gateway/session-utils.types.ts | 3 + src/gateway/test-helpers.mocks.ts | 2 + src/gateway/test-helpers.runtime-state.ts | 12 + src/gateway/test-helpers.server.ts | 22 + ui/src/ui/app-render.ts | 29 +- ui/src/ui/app-view-state.ts | 5 + ui/src/ui/app.ts | 6 + ui/src/ui/chat/slash-command-executor.ts | 20 +- ui/src/ui/controllers/sessions.test.ts | 100 +++- ui/src/ui/controllers/sessions.ts | 179 +++++++- ui/src/ui/types.ts | 64 +++ ui/src/ui/views/sessions.test.ts | 8 + ui/src/ui/views/sessions.ts | 207 +++++++-- 34 files changed, 2172 insertions(+), 52 deletions(-) create mode 100644 .github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png create mode 100644 .github/pr-assets/compaction-checkpoints/sessions-overview-inline.png create mode 100644 src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts create mode 100644 src/agents/pi-embedded-runner/manual-compaction-boundary.ts create mode 100644 src/gateway/session-compaction-checkpoints.test.ts create mode 100644 src/gateway/session-compaction-checkpoints.ts diff --git a/.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png b/.github/pr-assets/compaction-checkpoints/sessions-checkpoints-inline.png new file mode 100644 index 0000000000000000000000000000000000000000..ec61f9603b7973b2c2d121b851d1c8f0fbb2c00e GIT binary patch literal 87974 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

|N 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 literal 0 HcmV?d00001 diff --git a/.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png b/.github/pr-assets/compaction-checkpoints/sessions-overview-inline.png new file mode 100644 index 0000000000000000000000000000000000000000..6efc15fc0708ed50a64e177d787e6aa5c9dd7389 GIT binary patch literal 44704 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 void) | undefined; let compactionSessionManager: unknown = null; + let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null; + let checkpointSnapshotRetained = false; try { const { shouldLoadSkillEntries, skillEntries } = resolveEmbeddedRunSkillEntries({ workspaceDir: effectiveWorkspace, @@ -730,6 +740,10 @@ export async function compactEmbeddedPiSessionDirect( allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults, allowedToolNames, }); + checkpointSnapshot = captureCompactionCheckpointSnapshot({ + sessionManager, + sessionFile: params.sessionFile, + }); compactionSessionManager = sessionManager; trackSessionManagerAccess(params.sessionFile); const settingsManager = createPreparedEmbeddedPiSettingsManager({ @@ -960,6 +974,28 @@ export async function compactEmbeddedPiSessionDirect( sessionKey: params.sessionKey, sessionFile: params.sessionFile, }); + let effectiveFirstKeptEntryId = result.firstKeptEntryId; + let postCompactionLeafId = + typeof sessionManager.getLeafId === "function" + ? (sessionManager.getLeafId() ?? undefined) + : undefined; + if (params.trigger === "manual") { + try { + const hardenedBoundary = await hardenManualCompactionBoundary({ + sessionFile: params.sessionFile, + }); + if (hardenedBoundary.applied) { + effectiveFirstKeptEntryId = + hardenedBoundary.firstKeptEntryId ?? effectiveFirstKeptEntryId; + postCompactionLeafId = hardenedBoundary.leafId ?? postCompactionLeafId; + session.agent.state.messages = hardenedBoundary.messages; + } + } catch (err) { + log.warn("[compaction] failed to harden manual compaction boundary", { + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } // Estimate tokens after compaction by summing token estimates for remaining messages const tokensAfter = estimateTokensAfterCompaction({ messagesAfter: session.messages, @@ -969,6 +1005,32 @@ export async function compactEmbeddedPiSessionDirect( }); const messageCountAfter = session.messages.length; const compactedCount = Math.max(0, messageCountCompactionInput - messageCountAfter); + if (params.config && params.sessionKey && checkpointSnapshot) { + try { + const storedCheckpoint = await persistSessionCompactionCheckpoint({ + cfg: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + reason: resolveSessionCompactionCheckpointReason({ + trigger: params.trigger, + }), + snapshot: checkpointSnapshot, + summary: result.summary, + firstKeptEntryId: effectiveFirstKeptEntryId, + tokensBefore: observedTokenCount ?? result.tokensBefore, + tokensAfter, + postSessionFile: params.sessionFile, + postLeafId: postCompactionLeafId, + postEntryId: postCompactionLeafId, + createdAt: compactStartedAt, + }); + checkpointSnapshotRetained = storedCheckpoint !== null; + } catch (err) { + log.warn("failed to persist compaction checkpoint", { + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } const postMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined; @@ -1000,7 +1062,7 @@ export async function compactEmbeddedPiSessionDirect( sessionFile: params.sessionFile, summaryLength: typeof result.summary === "string" ? result.summary.length : undefined, tokensBefore: result.tokensBefore, - firstKeptEntryId: result.firstKeptEntryId, + firstKeptEntryId: effectiveFirstKeptEntryId, }); // Truncate session file to remove compacted entries (#39953) if (params.config?.agents?.defaults?.compaction?.truncateAfterCompaction) { @@ -1032,7 +1094,7 @@ export async function compactEmbeddedPiSessionDirect( compacted: true, result: { summary: result.summary, - firstKeptEntryId: result.firstKeptEntryId, + firstKeptEntryId: effectiveFirstKeptEntryId, tokensBefore: observedTokenCount ?? result.tokensBefore, tokensAfter, details: result.details, @@ -1091,6 +1153,9 @@ export async function compactEmbeddedPiSessionDirect( }); return fail(reason); } finally { + if (!checkpointSnapshotRetained) { + await cleanupCompactionCheckpointSnapshot(checkpointSnapshot); + } restoreSkillEnv?.(); } } @@ -1116,6 +1181,8 @@ export async function compactEmbeddedPiSession( }); ensureContextEnginesInitialized(); const contextEngine = await resolveContextEngine(params.config); + let checkpointSnapshot: CapturedCompactionCheckpointSnapshot | null = null; + let checkpointSnapshotRetained = false; try { const agentDir = params.agentDir ?? resolveOpenClawAgentDir(); const resolvedCompactionTarget = resolveEmbeddedCompactionTarget({ @@ -1150,6 +1217,12 @@ export async function compactEmbeddedPiSession( // Fire before_compaction / after_compaction hooks here so plugin subscribers // are notified regardless of which engine is active. const engineOwnsCompaction = contextEngine.info.ownsCompaction === true; + checkpointSnapshot = engineOwnsCompaction + ? captureCompactionCheckpointSnapshot({ + sessionManager: SessionManager.open(params.sessionFile), + sessionFile: params.sessionFile, + }) + : null; const hookRunner = engineOwnsCompaction ? asCompactionHookRunner(getGlobalHookRunner()) : null; @@ -1222,6 +1295,33 @@ export async function compactEmbeddedPiSession( runtimeContext, }); if (result.ok && result.compacted) { + if (params.config && params.sessionKey && checkpointSnapshot) { + try { + const postCompactionSession = SessionManager.open(params.sessionFile); + const postLeafId = postCompactionSession.getLeafId() ?? undefined; + const storedCheckpoint = await persistSessionCompactionCheckpoint({ + cfg: params.config, + sessionKey: params.sessionKey, + sessionId: params.sessionId, + reason: resolveSessionCompactionCheckpointReason({ + trigger: params.trigger, + }), + snapshot: checkpointSnapshot, + summary: result.result?.summary, + firstKeptEntryId: result.result?.firstKeptEntryId, + tokensBefore: result.result?.tokensBefore, + tokensAfter: result.result?.tokensAfter, + postSessionFile: params.sessionFile, + postLeafId, + postEntryId: postLeafId, + }); + checkpointSnapshotRetained = storedCheckpoint !== null; + } catch (err) { + log.warn("failed to persist compaction checkpoint", { + errorMessage: err instanceof Error ? err.message : String(err), + }); + } + } await runContextEngineMaintenance({ contextEngine, sessionId: params.sessionId, @@ -1275,6 +1375,9 @@ export async function compactEmbeddedPiSession( : undefined, }; } finally { + if (!checkpointSnapshotRetained) { + await cleanupCompactionCheckpointSnapshot(checkpointSnapshot); + } await contextEngine.dispose?.(); } }), @@ -1287,6 +1390,7 @@ export const __testing = { containsRealConversationMessages, estimateTokensAfterCompaction, buildBeforeCompactionHookMetrics, + hardenManualCompactionBoundary, runBeforeCompactionHooks, runAfterCompactionHooks, runPostCompactionSideEffects, diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts new file mode 100644 index 000000000000..eb977385ba49 --- /dev/null +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { afterEach, describe, expect, it } from "vitest"; +import { hardenManualCompactionBoundary } from "./manual-compaction-boundary.js"; + +let tmpDir = ""; + +async function makeTmpDir(): Promise { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "manual-compaction-boundary-")); + return tmpDir; +} + +afterEach(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + tmpDir = ""; + } +}); + +function createAssistantTextMessage(text: string, timestamp: number): AssistantMessage { + return { + role: "assistant", + content: [{ type: "text", text }], + api: "responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp, + }; +} + +function messageText(message: AgentMessage): string { + if (!("content" in message)) { + return ""; + } + const content = message.content; + if (typeof content === "string") { + return content; + } + if (!Array.isArray(content)) { + return ""; + } + return content + .map((block) => + block && typeof block === "object" && "text" in block && typeof block.text === "string" + ? block.text + : "", + ) + .filter(Boolean) + .join(" "); +} + +describe("hardenManualCompactionBoundary", () => { + it("turns manual compaction into a true checkpoint for rebuilt context", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + + session.appendMessage({ role: "user", content: "old question", timestamp: 1 }); + session.appendMessage(createAssistantTextMessage("very long old answer", 2)); + const firstKeepId = session.getBranch().at(-1)?.id; + expect(firstKeepId).toBeTruthy(); + session.appendCompaction("old summary", firstKeepId!, 100); + + session.appendMessage({ role: "user", content: "new question", timestamp: 3 }); + session.appendMessage( + createAssistantTextMessage("detailed new answer that should be summarized away", 4), + ); + const secondKeepId = session.getBranch().at(-1)?.id; + expect(secondKeepId).toBeTruthy(); + const latestCompactionId = session.appendCompaction("fresh summary", secondKeepId!, 200); + const sessionFile = session.getSessionFile(); + expect(sessionFile).toBeTruthy(); + + const before = SessionManager.open(sessionFile!); + const beforeTexts = before + .buildSessionContext() + .messages.map((message) => messageText(message)); + expect(beforeTexts.join("\n")).toContain("detailed new answer"); + + const hardened = await hardenManualCompactionBoundary({ sessionFile: sessionFile! }); + expect(hardened.applied).toBe(true); + expect(hardened.firstKeptEntryId).toBe(latestCompactionId); + expect(hardened.messages.map((message) => message.role)).toEqual(["compactionSummary"]); + + const reopened = SessionManager.open(sessionFile!); + const latest = reopened.getLeafEntry(); + expect(latest?.type).toBe("compaction"); + if (!latest || latest.type !== "compaction") { + throw new Error("expected latest leaf to be a compaction entry"); + } + expect(latest.firstKeptEntryId).toBe(latestCompactionId); + + reopened.appendMessage({ role: "user", content: "what was happening?", timestamp: 5 }); + const after = SessionManager.open(sessionFile!); + const afterTexts = after.buildSessionContext().messages.map((message) => messageText(message)); + expect(after.buildSessionContext().messages.map((message) => message.role)).toEqual([ + "compactionSummary", + "user", + ]); + expect(afterTexts.join("\n")).not.toContain("detailed new answer"); + }); + + it("is a no-op when the latest leaf is not a compaction entry", async () => { + const dir = await makeTmpDir(); + const session = SessionManager.create(dir, dir); + session.appendMessage({ role: "user", content: "hello", timestamp: 1 }); + session.appendMessage(createAssistantTextMessage("hi", 2)); + const sessionFile = session.getSessionFile(); + expect(sessionFile).toBeTruthy(); + + const result = await hardenManualCompactionBoundary({ sessionFile: sessionFile! }); + expect(result.applied).toBe(false); + expect(result.messages.map((message) => message.role)).toEqual(["user", "assistant"]); + }); +}); diff --git a/src/agents/pi-embedded-runner/manual-compaction-boundary.ts b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts new file mode 100644 index 000000000000..2fc8ef5f1eb8 --- /dev/null +++ b/src/agents/pi-embedded-runner/manual-compaction-boundary.ts @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; + +type SessionManagerLike = ReturnType; +type SessionEntry = ReturnType[number]; +type SessionHeader = NonNullable>; +type CompactionEntry = Extract; + +export type HardenedManualCompactionBoundary = { + applied: boolean; + firstKeptEntryId?: string; + leafId?: string; + messages: AgentMessage[]; +}; + +function serializeSessionFile(header: SessionHeader, entries: SessionEntry[]): string { + return ( + [JSON.stringify(header), ...entries.map((entry) => JSON.stringify(entry))].join("\n") + "\n" + ); +} + +function replaceLatestCompactionBoundary(params: { + entries: SessionEntry[]; + compactionEntryId: string; +}): SessionEntry[] { + return params.entries.map((entry) => { + if (entry.type !== "compaction" || entry.id !== params.compactionEntryId) { + return entry; + } + return { + ...entry, + // Manual /compact is an explicit checkpoint request, so make the + // rebuilt context start from the summary itself instead of preserving + // an upstream "recent tail" that can keep large prior turns alive. + firstKeptEntryId: entry.id, + } satisfies CompactionEntry; + }); +} + +export async function hardenManualCompactionBoundary(params: { + sessionFile: string; +}): Promise { + const sessionManager = SessionManager.open(params.sessionFile) as Partial; + if ( + typeof sessionManager.getHeader !== "function" || + typeof sessionManager.getLeafEntry !== "function" || + typeof sessionManager.buildSessionContext !== "function" || + typeof sessionManager.getEntries !== "function" + ) { + return { + applied: false, + messages: [], + }; + } + + const header = sessionManager.getHeader(); + const leaf = sessionManager.getLeafEntry(); + if (!header || leaf?.type !== "compaction") { + const sessionContext = sessionManager.buildSessionContext(); + return { + applied: false, + leafId: + typeof sessionManager.getLeafId === "function" + ? (sessionManager.getLeafId() ?? undefined) + : undefined, + messages: sessionContext.messages, + }; + } + + if (leaf.firstKeptEntryId === leaf.id) { + const sessionContext = sessionManager.buildSessionContext(); + return { + applied: false, + firstKeptEntryId: leaf.id, + leafId: + typeof sessionManager.getLeafId === "function" + ? (sessionManager.getLeafId() ?? undefined) + : undefined, + messages: sessionContext.messages, + }; + } + + const content = serializeSessionFile( + header, + replaceLatestCompactionBoundary({ + entries: sessionManager.getEntries(), + compactionEntryId: leaf.id, + }), + ); + const tmpFile = `${params.sessionFile}.manual-compaction-tmp`; + await fs.writeFile(tmpFile, content, "utf-8"); + await fs.rename(tmpFile, params.sessionFile); + + const refreshed = SessionManager.open(params.sessionFile); + const sessionContext = refreshed.buildSessionContext(); + return { + applied: true, + firstKeptEntryId: leaf.id, + leafId: refreshed.getLeafId() ?? undefined, + messages: sessionContext.messages, + }; +} diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index bc37ee4899f1..c57d31ec8ed7 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -75,6 +75,33 @@ export type CliSessionBinding = { mcpConfigHash?: string; }; +export type SessionCompactionCheckpointReason = + | "manual" + | "auto-threshold" + | "overflow-retry" + | "timeout-retry"; + +export type SessionCompactionTranscriptReference = { + sessionId: string; + sessionFile?: string; + leafId?: string; + entryId?: string; +}; + +export type SessionCompactionCheckpoint = { + checkpointId: string; + sessionKey: string; + sessionId: string; + createdAt: number; + reason: SessionCompactionCheckpointReason; + tokensBefore?: number; + tokensAfter?: number; + summary?: string; + firstKeptEntryId?: string; + preCompaction: SessionCompactionTranscriptReference; + postCompaction: SessionCompactionTranscriptReference; +}; + export type SessionEntry = { /** * Last delivered heartbeat payload (used to suppress duplicate heartbeat notifications). @@ -182,6 +209,7 @@ export type SessionEntry = { fallbackNoticeReason?: string; contextTokens?: number; compactionCount?: number; + compactionCheckpoints?: SessionCompactionCheckpoint[]; memoryFlushAt?: number; memoryFlushCompactionCount?: number; memoryFlushContextHash?: string; diff --git a/src/gateway/method-scopes.ts b/src/gateway/method-scopes.ts index ac2abc110496..64c3702502fe 100644 --- a/src/gateway/method-scopes.ts +++ b/src/gateway/method-scopes.ts @@ -87,6 +87,8 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.get", "sessions.preview", "sessions.resolve", + "sessions.compaction.list", + "sessions.compaction.get", "sessions.subscribe", "sessions.unsubscribe", "sessions.messages.subscribe", @@ -129,6 +131,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.send", "sessions.steer", "sessions.abort", + "sessions.compaction.branch", "push.test", "node.pending.enqueue", ], @@ -149,6 +152,7 @@ const METHOD_SCOPE_GROUPS: Record = { "sessions.reset", "sessions.delete", "sessions.compact", + "sessions.compaction.restore", "connect", "chat.inject", "web.login.start", diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index e18b91df5615..692745d12b78 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -200,6 +200,14 @@ import { SessionsAbortParamsSchema, type SessionsCompactParams, SessionsCompactParamsSchema, + type SessionsCompactionBranchParams, + SessionsCompactionBranchParamsSchema, + type SessionsCompactionGetParams, + SessionsCompactionGetParamsSchema, + type SessionsCompactionListParams, + SessionsCompactionListParamsSchema, + type SessionsCompactionRestoreParams, + SessionsCompactionRestoreParamsSchema, type SessionsCreateParams, SessionsCreateParamsSchema, type SessionsDeleteParams, @@ -376,6 +384,18 @@ export const validateSessionsDeleteParams = ajv.compile( export const validateSessionsCompactParams = ajv.compile( SessionsCompactParamsSchema, ); +export const validateSessionsCompactionListParams = ajv.compile( + SessionsCompactionListParamsSchema, +); +export const validateSessionsCompactionGetParams = ajv.compile( + SessionsCompactionGetParamsSchema, +); +export const validateSessionsCompactionBranchParams = ajv.compile( + SessionsCompactionBranchParamsSchema, +); +export const validateSessionsCompactionRestoreParams = ajv.compile( + SessionsCompactionRestoreParamsSchema, +); export const validateSessionsUsageParams = ajv.compile(SessionsUsageParamsSchema); export const validateConfigGetParams = ajv.compile(ConfigGetParamsSchema); @@ -551,6 +571,10 @@ export { SessionsListParamsSchema, SessionsPreviewParamsSchema, SessionsResolveParamsSchema, + SessionsCompactionListParamsSchema, + SessionsCompactionGetParamsSchema, + SessionsCompactionBranchParamsSchema, + SessionsCompactionRestoreParamsSchema, SessionsCreateParamsSchema, SessionsSendParamsSchema, SessionsAbortParamsSchema, diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 534dcaac816c..e04c18b9bbb0 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -155,6 +155,15 @@ import { import { SessionsAbortParamsSchema, SessionsCompactParamsSchema, + SessionsCompactionBranchParamsSchema, + SessionsCompactionBranchResultSchema, + SessionsCompactionGetParamsSchema, + SessionsCompactionGetResultSchema, + SessionsCompactionListParamsSchema, + SessionsCompactionListResultSchema, + SessionsCompactionRestoreParamsSchema, + SessionsCompactionRestoreResultSchema, + SessionCompactionCheckpointSchema, SessionsCreateParamsSchema, SessionsDeleteParamsSchema, SessionsListParamsSchema, @@ -224,6 +233,15 @@ export const ProtocolSchemas = { SessionsListParams: SessionsListParamsSchema, SessionsPreviewParams: SessionsPreviewParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema, + SessionCompactionCheckpoint: SessionCompactionCheckpointSchema, + SessionsCompactionListParams: SessionsCompactionListParamsSchema, + SessionsCompactionGetParams: SessionsCompactionGetParamsSchema, + SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema, + SessionsCompactionRestoreParams: SessionsCompactionRestoreParamsSchema, + SessionsCompactionListResult: SessionsCompactionListResultSchema, + SessionsCompactionGetResult: SessionsCompactionGetResultSchema, + SessionsCompactionBranchResult: SessionsCompactionBranchResultSchema, + SessionsCompactionRestoreResult: SessionsCompactionRestoreResultSchema, SessionsCreateParams: SessionsCreateParamsSchema, SessionsSendParams: SessionsSendParamsSchema, SessionsMessagesSubscribeParams: SessionsMessagesSubscribeParamsSchema, diff --git a/src/gateway/protocol/schema/sessions.ts b/src/gateway/protocol/schema/sessions.ts index 5252e7c72cf1..7d8945ec1cd2 100644 --- a/src/gateway/protocol/schema/sessions.ts +++ b/src/gateway/protocol/schema/sessions.ts @@ -1,6 +1,40 @@ import { Type } from "@sinclair/typebox"; import { NonEmptyString, SessionLabelString } from "./primitives.js"; +export const SessionCompactionCheckpointReasonSchema = Type.Union([ + Type.Literal("manual"), + Type.Literal("auto-threshold"), + Type.Literal("overflow-retry"), + Type.Literal("timeout-retry"), +]); + +export const SessionCompactionTranscriptReferenceSchema = Type.Object( + { + sessionId: NonEmptyString, + sessionFile: Type.Optional(NonEmptyString), + leafId: Type.Optional(NonEmptyString), + entryId: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, +); + +export const SessionCompactionCheckpointSchema = Type.Object( + { + checkpointId: NonEmptyString, + sessionKey: NonEmptyString, + sessionId: NonEmptyString, + createdAt: Type.Integer({ minimum: 0 }), + reason: SessionCompactionCheckpointReasonSchema, + tokensBefore: Type.Optional(Type.Integer({ minimum: 0 })), + tokensAfter: Type.Optional(Type.Integer({ minimum: 0 })), + summary: Type.Optional(Type.String()), + firstKeptEntryId: Type.Optional(NonEmptyString), + preCompaction: SessionCompactionTranscriptReferenceSchema, + postCompaction: SessionCompactionTranscriptReferenceSchema, + }, + { additionalProperties: false }, +); + export const SessionsListParamsSchema = Type.Object( { limit: Type.Optional(Type.Integer({ minimum: 1 })), @@ -163,6 +197,90 @@ export const SessionsCompactParamsSchema = Type.Object( { additionalProperties: false }, ); +export const SessionsCompactionListParamsSchema = Type.Object( + { + key: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionGetParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionBranchParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionRestoreParamsSchema = Type.Object( + { + key: NonEmptyString, + checkpointId: NonEmptyString, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionListResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + checkpoints: Type.Array(SessionCompactionCheckpointSchema), + }, + { additionalProperties: false }, +); + +export const SessionsCompactionGetResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + }, + { additionalProperties: false }, +); + +export const SessionsCompactionBranchResultSchema = Type.Object( + { + ok: Type.Literal(true), + sourceKey: NonEmptyString, + key: NonEmptyString, + sessionId: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + entry: Type.Object( + { + sessionId: NonEmptyString, + updatedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: false }, +); + +export const SessionsCompactionRestoreResultSchema = Type.Object( + { + ok: Type.Literal(true), + key: NonEmptyString, + sessionId: NonEmptyString, + checkpoint: SessionCompactionCheckpointSchema, + entry: Type.Object( + { + sessionId: NonEmptyString, + updatedAt: Type.Integer({ minimum: 0 }), + }, + { additionalProperties: true }, + ), + }, + { additionalProperties: false }, +); + export const SessionsUsageParamsSchema = Type.Object( { /** Specific session key to analyze; if omitted returns all sessions. */ diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index f3eb9725093a..0515ca140f61 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -41,6 +41,15 @@ export type PushTestResult = SchemaType<"PushTestResult">; export type SessionsListParams = SchemaType<"SessionsListParams">; export type SessionsPreviewParams = SchemaType<"SessionsPreviewParams">; export type SessionsResolveParams = SchemaType<"SessionsResolveParams">; +export type SessionCompactionCheckpoint = SchemaType<"SessionCompactionCheckpoint">; +export type SessionsCompactionListParams = SchemaType<"SessionsCompactionListParams">; +export type SessionsCompactionGetParams = SchemaType<"SessionsCompactionGetParams">; +export type SessionsCompactionBranchParams = SchemaType<"SessionsCompactionBranchParams">; +export type SessionsCompactionRestoreParams = SchemaType<"SessionsCompactionRestoreParams">; +export type SessionsCompactionListResult = SchemaType<"SessionsCompactionListResult">; +export type SessionsCompactionGetResult = SchemaType<"SessionsCompactionGetResult">; +export type SessionsCompactionBranchResult = SchemaType<"SessionsCompactionBranchResult">; +export type SessionsCompactionRestoreResult = SchemaType<"SessionsCompactionRestoreResult">; export type SessionsCreateParams = SchemaType<"SessionsCreateParams">; export type SessionsSendParams = SchemaType<"SessionsSendParams">; export type SessionsMessagesSubscribeParams = SchemaType<"SessionsMessagesSubscribeParams">; diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index ecc6a02d1227..0176150f9f8c 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -68,6 +68,10 @@ const BASE_METHODS = [ "sessions.messages.subscribe", "sessions.messages.unsubscribe", "sessions.preview", + "sessions.compaction.list", + "sessions.compaction.get", + "sessions.compaction.branch", + "sessions.compaction.restore", "sessions.create", "sessions.send", "sessions.abort", diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index 2d3658f09bc2..285002ea7bd2 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -177,6 +177,8 @@ function emitSessionsChanged( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index c865fc7131c3..10e85c19b0fc 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -1,13 +1,15 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; -import { CURRENT_SESSION_VERSION } from "@mariozechner/pi-coding-agent"; -import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { CURRENT_SESSION_VERSION, SessionManager } from "@mariozechner/pi-coding-agent"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { abortEmbeddedPiRun, isEmbeddedPiRunActive, waitForEmbeddedPiRunEnd, } from "../../agents/pi-embedded-runner/runs.js"; +import { compactEmbeddedPiSession } from "../../agents/pi-embedded.js"; +import { normalizeReasoningLevel, normalizeThinkLevel } from "../../auto-reply/thinking.js"; import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js"; import { loadConfig } from "../../config/config.js"; import { @@ -36,6 +38,10 @@ import { errorShape, validateSessionsAbortParams, validateSessionsCompactParams, + validateSessionsCompactionBranchParams, + validateSessionsCompactionGetParams, + validateSessionsCompactionListParams, + validateSessionsCompactionRestoreParams, validateSessionsCreateParams, validateSessionsDeleteParams, validateSessionsListParams, @@ -47,6 +53,10 @@ import { validateSessionsResolveParams, validateSessionsSendParams, } from "../protocol/index.js"; +import { + getSessionCompactionCheckpoint, + listSessionCompactionCheckpoints, +} from "../session-compaction-checkpoints.js"; import { reactivateCompletedSubagentSession } from "../session-subagent-reactivation.js"; import { archiveFileOnDisk, @@ -185,6 +195,8 @@ function emitSessionsChanged( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, @@ -220,6 +232,47 @@ function buildDashboardSessionKey(agentId: string): string { return `agent:${agentId}:dashboard:${randomUUID()}`; } +function cloneCheckpointSessionEntry(params: { + currentEntry: SessionEntry; + nextSessionId: string; + nextSessionFile: string; + label?: string; + parentSessionKey?: string; + totalTokens?: number; + preserveCompactionCheckpoints?: boolean; +}): SessionEntry { + return { + ...params.currentEntry, + sessionId: params.nextSessionId, + sessionFile: params.nextSessionFile, + updatedAt: Date.now(), + systemSent: false, + abortedLastRun: false, + startedAt: undefined, + endedAt: undefined, + runtimeMs: undefined, + status: undefined, + inputTokens: undefined, + outputTokens: undefined, + cacheRead: undefined, + cacheWrite: undefined, + estimatedCostUsd: undefined, + totalTokens: + typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) + ? params.totalTokens + : undefined, + totalTokensFresh: + typeof params.totalTokens === "number" && Number.isFinite(params.totalTokens) + ? true + : undefined, + label: params.label ?? params.currentEntry.label, + parentSessionKey: params.parentSessionKey ?? params.currentEntry.parentSessionKey, + compactionCheckpoints: params.preserveCompactionCheckpoints + ? params.currentEntry.compactionCheckpoints + : undefined, + }; +} + function ensureSessionTranscriptFile(params: { sessionId: string; storePath: string; @@ -644,6 +697,74 @@ export const sessionsHandlers: GatewayRequestHandlers = { } respond(true, { ok: true, key: resolved.key }, undefined); }, + "sessions.compaction.list": ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionListParams, + "sessions.compaction.list", + respond, + ) + ) { + return; + } + const key = requireSessionKey((params as { key?: unknown }).key, respond); + if (!key) { + return; + } + const { entry, canonicalKey } = loadSessionEntry(key); + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoints: listSessionCompactionCheckpoints(entry), + }, + undefined, + ); + }, + "sessions.compaction.get": ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionGetParams, + "sessions.compaction.get", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const { entry, canonicalKey } = loadSessionEntry(key); + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + respond( + true, + { + ok: true, + key: canonicalKey, + checkpoint, + }, + undefined, + ); + }, "sessions.create": async ({ req, params, respond, context, client, isWebchatConnect }) => { if (!assertValidParams(params, validateSessionsCreateParams, "sessions.create", respond)) { return; @@ -831,6 +952,228 @@ export const sessionsHandlers: GatewayRequestHandlers = { }); } }, + "sessions.compaction.branch": async ({ params, respond, context }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionBranchParams, + "sessions.compaction.branch", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const loaded = loadSessionEntry(key); + const { cfg, entry, canonicalKey } = loaded; + const target = resolveGatewaySessionStoreTarget({ cfg, key: canonicalKey }); + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint?.preCompaction.sessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"), + ); + return; + } + + const snapshotSession = SessionManager.open( + checkpoint.preCompaction.sessionFile, + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const branchedSession = SessionManager.forkFrom( + checkpoint.preCompaction.sessionFile, + snapshotSession.getCwd(), + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const branchedSessionFile = branchedSession.getSessionFile(); + if (!branchedSessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to create checkpoint branch transcript"), + ); + return; + } + const nextKey = buildDashboardSessionKey(target.agentId); + const label = entry.label?.trim() ? `${entry.label.trim()} (checkpoint)` : "Checkpoint branch"; + const nextEntry = cloneCheckpointSessionEntry({ + currentEntry: entry, + nextSessionId: branchedSession.getSessionId(), + nextSessionFile: branchedSessionFile, + label, + parentSessionKey: canonicalKey, + totalTokens: checkpoint.tokensBefore, + }); + + await updateSessionStore(target.storePath, (store) => { + store[nextKey] = nextEntry; + }); + + respond( + true, + { + ok: true, + sourceKey: canonicalKey, + key: nextKey, + sessionId: nextEntry.sessionId, + checkpoint, + entry: nextEntry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: canonicalKey, + reason: "checkpoint-branch", + }); + emitSessionsChanged(context, { + sessionKey: nextKey, + reason: "checkpoint-branch", + }); + }, + "sessions.compaction.restore": async ({ + req, + params, + respond, + context, + client, + isWebchatConnect, + }) => { + if ( + !assertValidParams( + params, + validateSessionsCompactionRestoreParams, + "sessions.compaction.restore", + respond, + ) + ) { + return; + } + const p = params; + const key = requireSessionKey(p.key, respond); + if (!key) { + return; + } + const checkpointId = + typeof p.checkpointId === "string" && p.checkpointId.trim() ? p.checkpointId.trim() : ""; + if (!checkpointId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "checkpointId required")); + return; + } + const loaded = loadSessionEntry(key); + const { entry, canonicalKey, storePath } = loaded; + if (!entry?.sessionId) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `session not found: ${key}`), + ); + return; + } + const checkpoint = getSessionCompactionCheckpoint({ entry, checkpointId }); + if (!checkpoint?.preCompaction.sessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `checkpoint not found: ${checkpointId}`), + ); + return; + } + if (!fs.existsSync(checkpoint.preCompaction.sessionFile)) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "checkpoint snapshot transcript is missing"), + ); + return; + } + + const interruptResult = await interruptSessionRunIfActive({ + req, + context, + client, + isWebchatConnect, + requestedKey: key, + canonicalKey, + sessionId: entry.sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const snapshotSession = SessionManager.open( + checkpoint.preCompaction.sessionFile, + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const restoredSession = SessionManager.forkFrom( + checkpoint.preCompaction.sessionFile, + snapshotSession.getCwd(), + path.dirname(checkpoint.preCompaction.sessionFile), + ); + const restoredSessionFile = restoredSession.getSessionFile(); + if (!restoredSessionFile) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "failed to restore checkpoint transcript"), + ); + return; + } + const nextEntry = cloneCheckpointSessionEntry({ + currentEntry: entry, + nextSessionId: restoredSession.getSessionId(), + nextSessionFile: restoredSessionFile, + totalTokens: checkpoint.tokensBefore, + preserveCompactionCheckpoints: true, + }); + + await updateSessionStore(storePath, (store) => { + store[canonicalKey] = nextEntry; + }); + + respond( + true, + { + ok: true, + key: canonicalKey, + sessionId: nextEntry.sessionId, + checkpoint, + entry: nextEntry, + }, + undefined, + ); + emitSessionsChanged(context, { + sessionKey: canonicalKey, + reason: "checkpoint-restore", + }); + }, "sessions.send": async ({ req, params, respond, context, client, isWebchatConnect }) => { await handleSessionSend({ method: "sessions.send", @@ -1122,7 +1465,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const messages = limit < allMessages.length ? allMessages.slice(-limit) : allMessages; respond(true, { messages }, undefined); }, - "sessions.compact": async ({ params, respond, context }) => { + "sessions.compact": async ({ req, params, respond, context, client, isWebchatConnect }) => { if (!assertValidParams(params, validateSessionsCompactParams, "sessions.compact", respond)) { return; } @@ -1135,7 +1478,7 @@ export const sessionsHandlers: GatewayRequestHandlers = { const maxLines = typeof p.maxLines === "number" && Number.isFinite(p.maxLines) ? Math.max(1, Math.floor(p.maxLines)) - : 400; + : undefined; const { cfg, target, storePath } = resolveGatewaySessionTargetFromKey(key); // Lock + read in a short critical section; transcript work happens outside. @@ -1179,6 +1522,88 @@ export const sessionsHandlers: GatewayRequestHandlers = { return; } + if (maxLines === undefined) { + const interruptResult = await interruptSessionRunIfActive({ + req, + context, + client, + isWebchatConnect, + requestedKey: key, + canonicalKey: target.canonicalKey, + sessionId, + }); + if (interruptResult.error) { + respond(false, undefined, interruptResult.error); + return; + } + + const resolvedModel = resolveSessionModelRef(cfg, entry, target.agentId); + const workspaceDir = + entry?.spawnedWorkspaceDir?.trim() || resolveAgentWorkspaceDir(cfg, target.agentId); + const result = await compactEmbeddedPiSession({ + sessionId, + sessionKey: target.canonicalKey, + allowGatewaySubagentBinding: true, + sessionFile: filePath, + workspaceDir, + config: cfg, + provider: resolvedModel.provider, + model: resolvedModel.model, + thinkLevel: normalizeThinkLevel(entry?.thinkingLevel), + reasoningLevel: normalizeReasoningLevel(entry?.reasoningLevel), + bashElevated: { + enabled: false, + allowed: false, + defaultLevel: "off", + }, + trigger: "manual", + }); + + if (result.ok && result.compacted) { + await updateSessionStore(storePath, (store) => { + const entryKey = compactTarget.primaryKey; + const entryToUpdate = store[entryKey]; + if (!entryToUpdate) { + return; + } + entryToUpdate.updatedAt = Date.now(); + entryToUpdate.compactionCount = Math.max(0, entryToUpdate.compactionCount ?? 0) + 1; + delete entryToUpdate.inputTokens; + delete entryToUpdate.outputTokens; + if ( + typeof result.result?.tokensAfter === "number" && + Number.isFinite(result.result.tokensAfter) + ) { + entryToUpdate.totalTokens = result.result.tokensAfter; + entryToUpdate.totalTokensFresh = true; + } else { + delete entryToUpdate.totalTokens; + delete entryToUpdate.totalTokensFresh; + } + }); + } + + respond( + true, + { + ok: result.ok, + key: target.canonicalKey, + compacted: result.compacted, + reason: result.reason, + result: result.result, + }, + undefined, + ); + if (result.ok) { + emitSessionsChanged(context, { + sessionKey: target.canonicalKey, + reason: "compact", + compacted: result.compacted, + }); + } + return; + } + const raw = fs.readFileSync(filePath, "utf-8"); const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0); if (lines.length <= maxLines) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index cd2688d9db7e..040493a22ffc 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -1059,6 +1059,8 @@ export async function startGatewayServer( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}; const message = attachOpenClawTranscriptMeta(update.message, { @@ -1160,6 +1162,8 @@ export async function startGatewayServer( startedAt: sessionRow.startedAt, endedAt: sessionRow.endedAt, runtimeMs: sessionRow.runtimeMs, + compactionCheckpointCount: sessionRow.compactionCheckpointCount, + latestCompactionCheckpoint: sessionRow.latestCompactionCheckpoint, } : {}), }, diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts index 0187529449a9..6a04155f96a9 100644 --- a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts +++ b/src/gateway/server.sessions.gateway-server-sessions-a.test.ts @@ -1,6 +1,9 @@ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; @@ -221,6 +224,68 @@ async function writeSingleLineSession(dir: string, sessionId: string, content: s ); } +function createCheckpointFixture(dir: string) { + const session = SessionManager.create(dir, dir); + const userMessage: UserMessage = { + role: "user", + content: "before compaction", + timestamp: Date.now(), + }; + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "working on it" }], + api: "responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + session.appendMessage(userMessage); + session.appendMessage(assistantMessage); + const preCompactionLeafId = session.getLeafId(); + if (!preCompactionLeafId) { + throw new Error("expected persisted session leaf before compaction"); + } + const sessionFile = session.getSessionFile(); + if (!sessionFile) { + throw new Error("expected persisted session file"); + } + const preCompactionSessionFile = path.join( + dir, + `${path.parse(sessionFile).name}.checkpoint-test.jsonl`, + ); + fsSync.copyFileSync(sessionFile, preCompactionSessionFile); + const preCompactionSession = SessionManager.open(preCompactionSessionFile, dir); + session.appendCompaction("checkpoint summary", preCompactionLeafId, 123, { ok: true }); + const postCompactionLeafId = session.getLeafId(); + if (!postCompactionLeafId) { + throw new Error("expected post-compaction leaf"); + } + return { + session, + sessionId: session.getSessionId(), + sessionFile, + preCompactionSession, + preCompactionSessionFile, + preCompactionLeafId, + postCompactionLeafId, + }; +} + async function seedActiveMainSession() { const { dir, storePath } = await createSessionStoreDir(); await writeSingleLineSession(dir, "sess-main", "hello"); @@ -1227,6 +1292,211 @@ describe("gateway server sessions", () => { ws.close(); }); + test("sessions.compaction.* lists checkpoints and branches or restores from pre-compaction snapshots", async () => { + const { dir, storePath } = await createSessionStoreDir(); + const fixture = createCheckpointFixture(dir); + await writeSessionStore({ + entries: { + main: { + sessionId: fixture.sessionId, + sessionFile: fixture.sessionFile, + updatedAt: Date.now(), + compactionCheckpoints: [ + { + checkpointId: "checkpoint-1", + sessionKey: "agent:main:main", + sessionId: fixture.sessionId, + createdAt: Date.now(), + reason: "manual", + tokensBefore: 123, + tokensAfter: 45, + summary: "checkpoint summary", + firstKeptEntryId: fixture.preCompactionLeafId, + preCompaction: { + sessionId: fixture.preCompactionSession.getSessionId(), + sessionFile: fixture.preCompactionSessionFile, + leafId: fixture.preCompactionLeafId, + }, + postCompaction: { + sessionId: fixture.sessionId, + sessionFile: fixture.sessionFile, + leafId: fixture.postCompactionLeafId, + entryId: fixture.postCompactionLeafId, + }, + }, + ], + }, + }, + }); + + const { ws } = await openClient(); + + const listedSessions = await rpcReq<{ + sessions: Array<{ + key: string; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: { + checkpointId: string; + reason: string; + tokensBefore?: number; + tokensAfter?: number; + }; + }>; + }>(ws, "sessions.list", {}); + expect(listedSessions.ok).toBe(true); + const main = listedSessions.payload?.sessions.find( + (session) => session.key === "agent:main:main", + ); + expect(main?.compactionCheckpointCount).toBe(1); + expect(main?.latestCompactionCheckpoint?.checkpointId).toBe("checkpoint-1"); + expect(main?.latestCompactionCheckpoint?.reason).toBe("manual"); + + const listedCheckpoints = await rpcReq<{ + ok: true; + key: string; + checkpoints: Array<{ checkpointId: string; summary?: string; tokensBefore?: number }>; + }>(ws, "sessions.compaction.list", { key: "main" }); + expect(listedCheckpoints.ok).toBe(true); + expect(listedCheckpoints.payload?.key).toBe("agent:main:main"); + expect(listedCheckpoints.payload?.checkpoints).toHaveLength(1); + expect(listedCheckpoints.payload?.checkpoints[0]).toMatchObject({ + checkpointId: "checkpoint-1", + summary: "checkpoint summary", + tokensBefore: 123, + }); + + const checkpoint = await rpcReq<{ + ok: true; + key: string; + checkpoint: { checkpointId: string; preCompaction: { sessionFile: string } }; + }>(ws, "sessions.compaction.get", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(checkpoint.ok).toBe(true); + expect(checkpoint.payload?.checkpoint.checkpointId).toBe("checkpoint-1"); + expect(checkpoint.payload?.checkpoint.preCompaction.sessionFile).toBe( + fixture.preCompactionSessionFile, + ); + + const branched = await rpcReq<{ + ok: true; + sourceKey: string; + key: string; + entry: { sessionId: string; sessionFile?: string; parentSessionKey?: string }; + }>(ws, "sessions.compaction.branch", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(branched.ok).toBe(true); + expect(branched.payload?.sourceKey).toBe("agent:main:main"); + expect(branched.payload?.entry.parentSessionKey).toBe("agent:main:main"); + const branchedSessionFile = branched.payload?.entry.sessionFile; + expect(branchedSessionFile).toBeTruthy(); + const branchedSession = SessionManager.open(branchedSessionFile!, dir); + expect(branchedSession.getEntries()).toHaveLength( + fixture.preCompactionSession.getEntries().length, + ); + + const storeAfterBranch = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { + parentSessionKey?: string; + compactionCheckpoints?: unknown[]; + sessionId?: string; + } + >; + const branchedEntry = storeAfterBranch[branched.payload!.key]; + expect(branchedEntry?.parentSessionKey).toBe("agent:main:main"); + expect(branchedEntry?.compactionCheckpoints).toBeUndefined(); + + const restored = await rpcReq<{ + ok: true; + key: string; + sessionId: string; + entry: { sessionId: string; sessionFile?: string; compactionCheckpoints?: unknown[] }; + }>(ws, "sessions.compaction.restore", { + key: "main", + checkpointId: "checkpoint-1", + }); + expect(restored.ok).toBe(true); + expect(restored.payload?.key).toBe("agent:main:main"); + expect(restored.payload?.sessionId).not.toBe(fixture.sessionId); + expect(restored.payload?.entry.compactionCheckpoints).toHaveLength(1); + const restoredSessionFile = restored.payload?.entry.sessionFile; + expect(restoredSessionFile).toBeTruthy(); + const restoredSession = SessionManager.open(restoredSessionFile!, dir); + expect(restoredSession.getEntries()).toHaveLength( + fixture.preCompactionSession.getEntries().length, + ); + + const storeAfterRestore = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { compactionCheckpoints?: unknown[]; sessionId?: string } + >; + expect(storeAfterRestore["agent:main:main"]?.sessionId).toBe(restored.payload?.sessionId); + expect(storeAfterRestore["agent:main:main"]?.compactionCheckpoints).toHaveLength(1); + + ws.close(); + }); + + test("sessions.compact without maxLines runs embedded manual compaction for checkpoint-capable flows", async () => { + const { dir, storePath } = await createSessionStoreDir(); + await fs.writeFile( + path.join(dir, "sess-main.jsonl"), + `${JSON.stringify({ role: "user", content: "hello" })}\n`, + "utf-8", + ); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + thinkingLevel: "medium", + reasoningLevel: "stream", + }, + }, + }); + + const { ws } = await openClient(); + const compacted = await rpcReq<{ + ok: true; + key: string; + compacted: boolean; + result?: { tokensAfter?: number }; + }>(ws, "sessions.compact", { + key: "main", + }); + + expect(compacted.ok).toBe(true); + expect(compacted.payload?.key).toBe("agent:main:main"); + expect(compacted.payload?.compacted).toBe(true); + expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledTimes(1); + expect(embeddedRunMock.compactEmbeddedPiSession).toHaveBeenCalledWith( + expect.objectContaining({ + sessionId: "sess-main", + sessionKey: "agent:main:main", + sessionFile: expect.stringMatching(/sess-main\.jsonl$/), + config: expect.any(Object), + provider: expect.any(String), + model: expect.any(String), + thinkLevel: "medium", + reasoningLevel: "stream", + trigger: "manual", + }), + ); + + const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record< + string, + { compactionCount?: number; totalTokens?: number; totalTokensFresh?: boolean } + >; + expect(store["agent:main:main"]?.compactionCount).toBe(1); + expect(store["agent:main:main"]?.totalTokens).toBe(80); + expect(store["agent:main:main"]?.totalTokensFresh).toBe(true); + + ws.close(); + }); + test("sessions.preview returns transcript previews", async () => { const { dir } = await createSessionStoreDir(); const sessionId = "sess-preview"; diff --git a/src/gateway/session-compaction-checkpoints.test.ts b/src/gateway/session-compaction-checkpoints.test.ts new file mode 100644 index 000000000000..63a64804bfe2 --- /dev/null +++ b/src/gateway/session-compaction-checkpoints.test.ts @@ -0,0 +1,84 @@ +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AssistantMessage, UserMessage } from "@mariozechner/pi-ai"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import { afterEach, describe, expect, test } from "vitest"; +import { + captureCompactionCheckpointSnapshot, + cleanupCompactionCheckpointSnapshot, +} from "./session-compaction-checkpoints.js"; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe("session-compaction-checkpoints", () => { + test("capture stores the copied pre-compaction transcript path and cleanup removes only the copy", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-checkpoint-")); + tempDirs.push(dir); + + const session = SessionManager.create(dir, dir); + const userMessage: UserMessage = { + role: "user", + content: "before compaction", + timestamp: Date.now(), + }; + const assistantMessage: AssistantMessage = { + role: "assistant", + content: [{ type: "text", text: "working on it" }], + api: "responses", + provider: "openai", + model: "gpt-test", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + session.appendMessage(userMessage); + session.appendMessage(assistantMessage); + + const sessionFile = session.getSessionFile(); + const leafId = session.getLeafId(); + expect(sessionFile).toBeTruthy(); + expect(leafId).toBeTruthy(); + + const originalBefore = await fs.readFile(sessionFile!, "utf-8"); + const snapshot = captureCompactionCheckpointSnapshot({ + sessionManager: session, + sessionFile: sessionFile!, + }); + + expect(snapshot).not.toBeNull(); + expect(snapshot?.leafId).toBe(leafId); + expect(snapshot?.sessionFile).not.toBe(sessionFile); + expect(snapshot?.sessionFile).toContain(".checkpoint."); + expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(true); + expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + + session.appendCompaction("checkpoint summary", leafId!, 123, { ok: true }); + + expect(await fs.readFile(snapshot!.sessionFile, "utf-8")).toBe(originalBefore); + expect(await fs.readFile(sessionFile!, "utf-8")).not.toBe(originalBefore); + + await cleanupCompactionCheckpointSnapshot(snapshot); + + expect(fsSync.existsSync(snapshot!.sessionFile)).toBe(false); + expect(fsSync.existsSync(sessionFile!)).toBe(true); + }); +}); diff --git a/src/gateway/session-compaction-checkpoints.ts b/src/gateway/session-compaction-checkpoints.ts new file mode 100644 index 000000000000..142db040deab --- /dev/null +++ b/src/gateway/session-compaction-checkpoints.ts @@ -0,0 +1,207 @@ +import { randomUUID } from "node:crypto"; +import fsSync from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { SessionManager } from "@mariozechner/pi-coding-agent"; +import type { OpenClawConfig } from "../config/config.js"; +import { updateSessionStore } from "../config/sessions.js"; +import type { + SessionCompactionCheckpoint, + SessionCompactionCheckpointReason, + SessionEntry, +} from "../config/sessions.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveGatewaySessionStoreTarget } from "./session-utils.js"; + +const log = createSubsystemLogger("gateway/session-compaction-checkpoints"); +const MAX_COMPACTION_CHECKPOINTS_PER_SESSION = 25; + +export type CapturedCompactionCheckpointSnapshot = { + sessionId: string; + sessionFile: string; + leafId: string; +}; + +function trimSessionCheckpoints( + checkpoints: SessionCompactionCheckpoint[] | undefined, +): SessionCompactionCheckpoint[] | undefined { + if (!Array.isArray(checkpoints) || checkpoints.length === 0) { + return undefined; + } + return checkpoints.slice(-MAX_COMPACTION_CHECKPOINTS_PER_SESSION); +} + +function sessionStoreCheckpoints( + entry: Pick | undefined, +): SessionCompactionCheckpoint[] { + return Array.isArray(entry?.compactionCheckpoints) ? [...entry.compactionCheckpoints] : []; +} + +export function resolveSessionCompactionCheckpointReason(params: { + trigger?: "budget" | "overflow" | "manual"; + timedOut?: boolean; +}): SessionCompactionCheckpointReason { + if (params.trigger === "manual") { + return "manual"; + } + if (params.timedOut) { + return "timeout-retry"; + } + if (params.trigger === "overflow") { + return "overflow-retry"; + } + return "auto-threshold"; +} + +export function captureCompactionCheckpointSnapshot(params: { + sessionManager: Pick; + sessionFile: string; +}): CapturedCompactionCheckpointSnapshot | null { + const getLeafId = + params.sessionManager && typeof params.sessionManager.getLeafId === "function" + ? params.sessionManager.getLeafId.bind(params.sessionManager) + : null; + const sessionFile = params.sessionFile.trim(); + if (!getLeafId || !sessionFile) { + return null; + } + const leafId = getLeafId(); + if (!leafId) { + return null; + } + const parsedSessionFile = path.parse(sessionFile); + const snapshotFile = path.join( + parsedSessionFile.dir, + `${parsedSessionFile.name}.checkpoint.${randomUUID()}${parsedSessionFile.ext || ".jsonl"}`, + ); + try { + fsSync.copyFileSync(sessionFile, snapshotFile); + } catch { + return null; + } + let snapshotSession: SessionManager; + try { + snapshotSession = SessionManager.open(snapshotFile, path.dirname(snapshotFile)); + } catch { + try { + fsSync.unlinkSync(snapshotFile); + } catch { + // Best-effort cleanup if the copied transcript cannot be reopened. + } + return null; + } + const getSessionId = + snapshotSession && typeof snapshotSession.getSessionId === "function" + ? snapshotSession.getSessionId.bind(snapshotSession) + : null; + if (!getSessionId) { + return null; + } + return { + sessionId: getSessionId(), + sessionFile: snapshotFile, + leafId, + }; +} + +export async function cleanupCompactionCheckpointSnapshot( + snapshot: CapturedCompactionCheckpointSnapshot | null | undefined, +): Promise { + if (!snapshot?.sessionFile) { + return; + } + try { + await fs.unlink(snapshot.sessionFile); + } catch { + // Best-effort cleanup; retained snapshots are harmless and easier to debug. + } +} + +export async function persistSessionCompactionCheckpoint(params: { + cfg: OpenClawConfig; + sessionKey: string; + sessionId: string; + reason: SessionCompactionCheckpointReason; + snapshot: CapturedCompactionCheckpointSnapshot; + summary?: string; + firstKeptEntryId?: string; + tokensBefore?: number; + tokensAfter?: number; + postSessionFile?: string; + postLeafId?: string; + postEntryId?: string; + createdAt?: number; +}): Promise { + const target = resolveGatewaySessionStoreTarget({ + cfg: params.cfg, + key: params.sessionKey, + }); + const createdAt = params.createdAt ?? Date.now(); + const checkpoint: SessionCompactionCheckpoint = { + checkpointId: randomUUID(), + sessionKey: target.canonicalKey, + sessionId: params.sessionId, + createdAt, + reason: params.reason, + ...(typeof params.tokensBefore === "number" ? { tokensBefore: params.tokensBefore } : {}), + ...(typeof params.tokensAfter === "number" ? { tokensAfter: params.tokensAfter } : {}), + ...(params.summary?.trim() ? { summary: params.summary.trim() } : {}), + ...(params.firstKeptEntryId?.trim() + ? { firstKeptEntryId: params.firstKeptEntryId.trim() } + : {}), + preCompaction: { + sessionId: params.snapshot.sessionId, + sessionFile: params.snapshot.sessionFile, + leafId: params.snapshot.leafId, + }, + postCompaction: { + sessionId: params.sessionId, + ...(params.postSessionFile?.trim() ? { sessionFile: params.postSessionFile.trim() } : {}), + ...(params.postLeafId?.trim() ? { leafId: params.postLeafId.trim() } : {}), + ...(params.postEntryId?.trim() ? { entryId: params.postEntryId.trim() } : {}), + }, + }; + + let stored = false; + await updateSessionStore(target.storePath, (store) => { + const existing = store[target.canonicalKey]; + if (!existing?.sessionId) { + return; + } + const checkpoints = sessionStoreCheckpoints(existing); + checkpoints.push(checkpoint); + store[target.canonicalKey] = { + ...existing, + updatedAt: Math.max(existing.updatedAt ?? 0, createdAt), + compactionCheckpoints: trimSessionCheckpoints(checkpoints), + }; + stored = true; + }); + + if (!stored) { + log.warn("skipping compaction checkpoint persist: session not found", { + sessionKey: params.sessionKey, + }); + return null; + } + return checkpoint; +} + +export function listSessionCompactionCheckpoints( + entry: Pick | undefined, +): SessionCompactionCheckpoint[] { + return sessionStoreCheckpoints(entry).toSorted((a, b) => b.createdAt - a.createdAt); +} + +export function getSessionCompactionCheckpoint(params: { + entry: Pick | undefined; + checkpointId: string; +}): SessionCompactionCheckpoint | undefined { + const checkpointId = params.checkpointId.trim(); + if (!checkpointId) { + return undefined; + } + return listSessionCompactionCheckpoints(params.entry).find( + (checkpoint) => checkpoint.checkpointId === checkpointId, + ); +} diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 93e5f61e4515..bb9cb83e33b7 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -472,6 +472,8 @@ export async function performGatewaySessionReset(params: { model: resolvedModel.model, modelProvider: resolvedModel.provider, contextTokens: resetEntry?.contextTokens, + compactionCount: currentEntry?.compactionCount, + compactionCheckpoints: currentEntry?.compactionCheckpoints, sendPolicy: currentEntry?.sendPolicy, queueMode: currentEntry?.queueMode, queueDebounceMs: currentEntry?.queueDebounceMs, diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 2ce224a47eb4..d844d3a4dddb 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -213,6 +213,18 @@ function resolveNonNegativeNumber(value: number | null | undefined): number | un return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; } +function resolveLatestCompactionCheckpoint( + entry?: Pick | null, +): NonNullable[number] | undefined { + const checkpoints = entry?.compactionCheckpoints; + if (!Array.isArray(checkpoints) || checkpoints.length === 0) { + return undefined; + } + return checkpoints.reduce((latest, checkpoint) => + !latest || checkpoint.createdAt > latest.createdAt ? checkpoint : latest, + ); +} + function resolveEstimatedSessionCostUsd(params: { cfg: OpenClawConfig; provider?: string; @@ -1268,6 +1280,7 @@ export function buildGatewaySessionRow(params: { ? true : transcriptUsage?.totalTokensFresh === true; const childSessions = resolveChildSessionKeys(key, store); + const latestCompactionCheckpoint = resolveLatestCompactionCheckpoint(entry); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg, @@ -1354,6 +1367,8 @@ export function buildGatewaySessionRow(params: { lastTo: deliveryFields.lastTo ?? entry?.lastTo, lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId, + compactionCheckpointCount: entry?.compactionCheckpoints?.length, + latestCompactionCheckpoint, }; } diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 5c1d255edee2..893e3876ec36 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -1,5 +1,6 @@ import type { ChatType } from "../channels/chat-type.js"; import type { SessionEntry } from "../config/sessions.js"; +import type { SessionCompactionCheckpoint } from "../config/sessions.js"; import type { GatewayAgentRow as SharedGatewayAgentRow, SessionsListResultBase, @@ -64,6 +65,8 @@ export type GatewaySessionRow = { lastTo?: string; lastAccountId?: string; lastThreadId?: SessionEntry["lastThreadId"]; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: SessionCompactionCheckpoint; }; export type GatewayAgentRow = SharedGatewayAgentRow; diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index d44e543351e8..68222bdeeea1 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -54,6 +54,8 @@ const gatewayTestHoisted = getGatewayTestHoistedState(); function createEmbeddedRunMockExports() { return { + compactEmbeddedPiSession: (...args: unknown[]) => + embeddedRunMock.compactEmbeddedPiSession(...args), isEmbeddedPiRunActive: (sessionId: string) => embeddedRunMock.activeIds.has(sessionId), abortEmbeddedPiRun: (sessionId: string) => { embeddedRunMock.abortCalls.push(sessionId); diff --git a/src/gateway/test-helpers.runtime-state.ts b/src/gateway/test-helpers.runtime-state.ts index b509801cbcf8..01a2177ee455 100644 --- a/src/gateway/test-helpers.runtime-state.ts +++ b/src/gateway/test-helpers.runtime-state.ts @@ -22,6 +22,7 @@ export type AgentCommandFn = (...args: unknown[]) => Promise; export type SendWhatsAppFn = (...args: unknown[]) => Promise<{ messageId: string; toJid: string }>; export type RunBtwSideQuestionFn = (...args: unknown[]) => Promise; export type DispatchInboundMessageFn = (...args: unknown[]) => Promise; +export type CompactEmbeddedPiSessionFn = (...args: unknown[]) => Promise; const GATEWAY_TEST_CONFIG_ROOT_KEY = Symbol.for("openclaw.gatewayTestHelpers.configRoot"); @@ -49,6 +50,7 @@ export type GatewayTestHoistedState = { abortCalls: string[]; waitCalls: string[]; waitResults: Map; + compactEmbeddedPiSession: Mock; }; testTailscaleWhois: { value: TailscaleWhoisIdentity | null }; getReplyFromConfig: Mock; @@ -99,6 +101,16 @@ const gatewayTestHoisted = vi.hoisted(() => { abortCalls: [], waitCalls: [], waitResults: new Map(), + compactEmbeddedPiSession: vi.fn().mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + tokensAfter: 80, + }, + }), }, testTailscaleWhois: { value: null }, getReplyFromConfig: vi.fn().mockResolvedValue(undefined), diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 21897620a6e6..6e3d0866db8f 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -332,6 +332,17 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); + embeddedRunMock.compactEmbeddedPiSession.mockReset(); + embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + tokensAfter: 80, + }, + }); for (const sessionKey of resolveGatewayTestMainSessionKeys()) { drainSystemEvents(sessionKey); } @@ -411,6 +422,17 @@ async function resetGatewayTestRuntimeOnly() { embeddedRunMock.abortCalls = []; embeddedRunMock.waitCalls = []; embeddedRunMock.waitResults.clear(); + embeddedRunMock.compactEmbeddedPiSession.mockReset(); + embeddedRunMock.compactEmbeddedPiSession.mockResolvedValue({ + ok: true, + compacted: true, + result: { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + tokensAfter: 80, + }, + }); clearSessionStoreCacheForTest(); await persistTestSessionConfig(); for (const sessionKey of resolveGatewayTestMainSessionKeys()) { diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index f5addb5c466a..6298cd680fdf 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -79,7 +79,14 @@ import { import { loadLogs } from "./controllers/logs.ts"; import { loadNodes } from "./controllers/nodes.ts"; import { loadPresence } from "./controllers/presence.ts"; -import { deleteSessionsAndRefresh, loadSessions, patchSession } from "./controllers/sessions.ts"; +import { + branchSessionFromCheckpoint, + deleteSessionsAndRefresh, + loadSessions, + patchSession, + restoreSessionFromCheckpoint, + toggleSessionCompactionCheckpoints, +} from "./controllers/sessions.ts"; import { closeClawHubDetail, installFromClawHub, @@ -840,6 +847,11 @@ export function renderApp(state: AppViewState) { page: state.sessionsPage, pageSize: state.sessionsPageSize, selectedKeys: state.sessionsSelectedKeys, + expandedCheckpointKey: state.sessionsExpandedCheckpointKey, + checkpointItemsByKey: state.sessionsCheckpointItemsByKey, + checkpointLoadingKey: state.sessionsCheckpointLoadingKey, + checkpointBusyKey: state.sessionsCheckpointBusyKey, + checkpointErrorByKey: state.sessionsCheckpointErrorByKey, onFiltersChange: (next) => { state.sessionsFilterActive = next.activeMinutes; state.sessionsFilterLimit = next.limit; @@ -905,6 +917,21 @@ export function renderApp(state: AppViewState) { switchChatSession(state, sessionKey); state.setTab("chat" as import("./navigation.ts").Tab); }, + onToggleCheckpointDetails: (sessionKey) => + toggleSessionCompactionCheckpoints(state, sessionKey), + onBranchFromCheckpoint: async (sessionKey, checkpointId) => { + const nextKey = await branchSessionFromCheckpoint( + state, + sessionKey, + checkpointId, + ); + if (nextKey) { + switchChatSession(state, nextKey); + state.setTab("chat" as import("./navigation.ts").Tab); + } + }, + onRestoreCheckpoint: (sessionKey, checkpointId) => + restoreSessionFromCheckpoint(state, sessionKey, checkpointId), }), ) : nothing} diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index d80270fc77d6..bf159357015c 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -209,6 +209,11 @@ export type AppViewState = { sessionsPage: number; sessionsPageSize: number; sessionsSelectedKeys: Set; + sessionsExpandedCheckpointKey: string | null; + sessionsCheckpointItemsByKey: Record; + sessionsCheckpointLoadingKey: string | null; + sessionsCheckpointBusyKey: string | null; + sessionsCheckpointErrorByKey: Record; usageLoading: boolean; usageResult: SessionsUsageResult | null; usageCostSummary: CostUsageSummary | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 236812755c3e..a0959d27584a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -89,6 +89,7 @@ import type { ModelCatalogEntry, PresenceEntry, ChannelsStatusSnapshot, + SessionCompactionCheckpoint, SessionsListResult, SkillStatusReport, StatusSummary, @@ -312,6 +313,11 @@ export class OpenClawApp extends LitElement { @state() sessionsPage = 0; @state() sessionsPageSize = 25; @state() sessionsSelectedKeys: Set = new Set(); + @state() sessionsExpandedCheckpointKey: string | null = null; + @state() sessionsCheckpointItemsByKey: Record = {}; + @state() sessionsCheckpointLoadingKey: string | null = null; + @state() sessionsCheckpointBusyKey: string | null = null; + @state() sessionsCheckpointErrorByKey: Record = {}; @state() usageLoading = false; @state() usageResult: import("./types.js").SessionsUsageResult | null = null; diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index 49f13614f6fb..7534ca43186f 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -146,8 +146,24 @@ async function executeCompact( sessionKey: string, ): Promise { try { - await client.request("sessions.compact", { key: sessionKey }); - return { content: "Context compacted successfully.", action: "refresh" }; + const result = await client.request<{ + compacted?: boolean; + reason?: string; + result?: { tokensBefore?: number; tokensAfter?: number }; + }>("sessions.compact", { key: sessionKey }); + if (result?.compacted) { + const before = result.result?.tokensBefore; + const after = result.result?.tokensAfter; + const tokenSummary = + typeof before === "number" && typeof after === "number" + ? ` (${before.toLocaleString()} -> ${after.toLocaleString()} tokens)` + : ""; + return { content: `Context compacted successfully${tokenSummary}.`, action: "refresh" }; + } + if (typeof result?.reason === "string" && result.reason.trim()) { + return { content: `Compaction skipped: ${result.reason}`, action: "refresh" }; + } + return { content: "Compaction skipped.", action: "refresh" }; } catch (err) { return { content: `Compaction failed: ${String(err)}` }; } diff --git a/ui/src/ui/controllers/sessions.test.ts b/ui/src/ui/controllers/sessions.test.ts index ad7d8f564855..bfed20ce153a 100644 --- a/ui/src/ui/controllers/sessions.test.ts +++ b/ui/src/ui/controllers/sessions.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { deleteSessionsAndRefresh, subscribeSessions, type SessionsState } from "./sessions.ts"; +import { + deleteSessionsAndRefresh, + loadSessions, + subscribeSessions, + type SessionsState, +} from "./sessions.ts"; type RequestFn = (method: string, params?: unknown) => Promise; @@ -22,6 +27,11 @@ function createState(request: RequestFn, overrides: Partial = {}) sessionsFilterLimit: "0", sessionsIncludeGlobal: true, sessionsIncludeUnknown: true, + sessionsExpandedCheckpointKey: null, + sessionsCheckpointItemsByKey: {}, + sessionsCheckpointLoadingKey: null, + sessionsCheckpointBusyKey: null, + sessionsCheckpointErrorByKey: {}, ...overrides, }; } @@ -120,3 +130,91 @@ describe("deleteSessionsAndRefresh", () => { expect(request).not.toHaveBeenCalled(); }); }); + +describe("loadSessions", () => { + it("refreshes expanded checkpoint cards when the row summary changes", async () => { + const request = vi.fn(async (method: string) => { + if (method === "sessions.list") { + return { + ts: 1, + path: "(multiple)", + count: 1, + defaults: {}, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + updatedAt: 1, + compactionCheckpointCount: 1, + latestCompactionCheckpoint: { + checkpointId: "checkpoint-new", + createdAt: 20, + }, + }, + ], + }; + } + if (method === "sessions.compaction.list") { + return { + ok: true, + key: "agent:main:main", + checkpoints: [ + { + checkpointId: "checkpoint-new", + sessionKey: "agent:main:main", + sessionId: "session-1", + createdAt: 20, + reason: "manual", + }, + ], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + const state = createState(request, { + sessionsExpandedCheckpointKey: "agent:main:main", + sessionsResult: { + ts: 0, + path: "(multiple)", + count: 1, + defaults: {}, + sessions: [ + { + key: "agent:main:main", + kind: "direct", + updatedAt: 0, + compactionCheckpointCount: 3, + latestCompactionCheckpoint: { + checkpointId: "checkpoint-old", + createdAt: 10, + }, + }, + ], + } as never, + sessionsCheckpointItemsByKey: { + "agent:main:main": [ + { + checkpointId: "checkpoint-old", + sessionKey: "agent:main:main", + sessionId: "session-old", + createdAt: 10, + reason: "manual", + }, + ] as never, + }, + }); + + await loadSessions(state); + + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", { + includeGlobal: true, + includeUnknown: true, + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.compaction.list", { + key: "agent:main:main", + }); + expect( + state.sessionsCheckpointItemsByKey["agent:main:main"]?.map((item) => item.checkpointId), + ).toEqual(["checkpoint-new"]); + }); +}); diff --git a/ui/src/ui/controllers/sessions.ts b/ui/src/ui/controllers/sessions.ts index 4821f521b31c..d800579a85fb 100644 --- a/ui/src/ui/controllers/sessions.ts +++ b/ui/src/ui/controllers/sessions.ts @@ -1,6 +1,12 @@ import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { SessionsListResult } from "../types.ts"; +import type { + SessionCompactionCheckpoint, + SessionsCompactionBranchResult, + SessionsCompactionListResult, + SessionsCompactionRestoreResult, + SessionsListResult, +} from "../types.ts"; import { formatMissingOperatorReadScopeMessage, isMissingOperatorReadScopeError, @@ -16,8 +22,74 @@ export type SessionsState = { sessionsFilterLimit: string; sessionsIncludeGlobal: boolean; sessionsIncludeUnknown: boolean; + sessionsExpandedCheckpointKey: string | null; + sessionsCheckpointItemsByKey: Record; + sessionsCheckpointLoadingKey: string | null; + sessionsCheckpointBusyKey: string | null; + sessionsCheckpointErrorByKey: Record; }; +function checkpointSignature( + row: + | { + key: string; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: { checkpointId?: string; createdAt?: number } | null; + } + | undefined, +): string { + return JSON.stringify({ + key: row?.key ?? "", + count: row?.compactionCheckpointCount ?? 0, + latestCheckpointId: row?.latestCompactionCheckpoint?.checkpointId ?? "", + latestCreatedAt: row?.latestCompactionCheckpoint?.createdAt ?? 0, + }); +} + +function invalidateCheckpointCacheForKey(state: SessionsState, key: string) { + if ( + !(key in state.sessionsCheckpointItemsByKey) && + !(key in state.sessionsCheckpointErrorByKey) + ) { + return; + } + const nextItems = { ...state.sessionsCheckpointItemsByKey }; + const nextErrors = { ...state.sessionsCheckpointErrorByKey }; + delete nextItems[key]; + delete nextErrors[key]; + state.sessionsCheckpointItemsByKey = nextItems; + state.sessionsCheckpointErrorByKey = nextErrors; +} + +async function fetchSessionCompactionCheckpoints(state: SessionsState, key: string) { + state.sessionsCheckpointLoadingKey = key; + state.sessionsCheckpointErrorByKey = { + ...state.sessionsCheckpointErrorByKey, + [key]: "", + }; + try { + const result = await state.client?.request( + "sessions.compaction.list", + { key }, + ); + if (result) { + state.sessionsCheckpointItemsByKey = { + ...state.sessionsCheckpointItemsByKey, + [key]: result.checkpoints ?? [], + }; + } + } catch (err) { + state.sessionsCheckpointErrorByKey = { + ...state.sessionsCheckpointErrorByKey, + [key]: String(err), + }; + } finally { + if (state.sessionsCheckpointLoadingKey === key) { + state.sessionsCheckpointLoadingKey = null; + } + } +} + export async function subscribeSessions(state: SessionsState) { if (!state.client || !state.connected) { return; @@ -47,6 +119,9 @@ export async function loadSessions( state.sessionsLoading = true; state.sessionsError = null; try { + const previousRows = new Map( + (state.sessionsResult?.sessions ?? []).map((row) => [row.key, row] as const), + ); const includeGlobal = overrides?.includeGlobal ?? state.sessionsIncludeGlobal; const includeUnknown = overrides?.includeUnknown ?? state.sessionsIncludeUnknown; const activeMinutes = overrides?.activeMinutes ?? toNumber(state.sessionsFilterActive, 0); @@ -64,6 +139,30 @@ export async function loadSessions( const res = await state.client.request("sessions.list", params); if (res) { state.sessionsResult = res; + const nextKeys = new Set(res.sessions.map((row) => row.key)); + for (const key of Object.keys(state.sessionsCheckpointItemsByKey)) { + if (!nextKeys.has(key)) { + invalidateCheckpointCacheForKey(state, key); + } + } + let expandedNeedsRefetch = false; + for (const row of res.sessions) { + const previous = previousRows.get(row.key); + if (checkpointSignature(previous) !== checkpointSignature(row)) { + invalidateCheckpointCacheForKey(state, row.key); + if (state.sessionsExpandedCheckpointKey === row.key) { + expandedNeedsRefetch = true; + } + } + } + const expandedKey = state.sessionsExpandedCheckpointKey; + if ( + expandedKey && + nextKeys.has(expandedKey) && + (expandedNeedsRefetch || !state.sessionsCheckpointItemsByKey[expandedKey]) + ) { + await fetchSessionCompactionCheckpoints(state, expandedKey); + } } } catch (err) { if (isMissingOperatorReadScopeError(err)) { @@ -156,3 +255,81 @@ export async function deleteSessionsAndRefresh( } return deleted; } + +export async function toggleSessionCompactionCheckpoints(state: SessionsState, key: string) { + const trimmedKey = key.trim(); + if (!trimmedKey) { + return; + } + if (state.sessionsExpandedCheckpointKey === trimmedKey) { + state.sessionsExpandedCheckpointKey = null; + return; + } + state.sessionsExpandedCheckpointKey = trimmedKey; + if (state.sessionsCheckpointItemsByKey[trimmedKey]) { + return; + } + await fetchSessionCompactionCheckpoints(state, trimmedKey); +} + +export async function branchSessionFromCheckpoint( + state: SessionsState, + key: string, + checkpointId: string, +): Promise { + if (!state.client || !state.connected) { + return null; + } + const confirmed = window.confirm( + "Create a new child session from this pre-compaction checkpoint?", + ); + if (!confirmed) { + return null; + } + state.sessionsCheckpointBusyKey = checkpointId; + try { + const result = await state.client.request( + "sessions.compaction.branch", + { key, checkpointId }, + ); + await loadSessions(state); + return result?.key ?? null; + } catch (err) { + state.sessionsError = String(err); + return null; + } finally { + if (state.sessionsCheckpointBusyKey === checkpointId) { + state.sessionsCheckpointBusyKey = null; + } + } +} + +export async function restoreSessionFromCheckpoint( + state: SessionsState, + key: string, + checkpointId: string, +) { + if (!state.client || !state.connected) { + return; + } + const confirmed = window.confirm( + "Restore this session to the selected pre-compaction checkpoint?\n\nThis replaces the current active transcript for the session key.", + ); + if (!confirmed) { + return; + } + state.sessionsCheckpointBusyKey = checkpointId; + try { + await state.client.request("sessions.compaction.restore", { + key, + checkpointId, + }); + await loadSessions(state); + } catch (err) { + state.sessionsError = String(err); + } finally { + if (state.sessionsCheckpointBusyKey === checkpointId) { + state.sessionsCheckpointBusyKey = null; + } + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 4b7ce526ab56..c65e0c2086fd 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -369,6 +369,33 @@ export type AgentsFilesSetResult = { export type SessionRunStatus = "running" | "done" | "failed" | "killed" | "timeout"; +export type SessionCompactionCheckpointReason = + | "manual" + | "auto-threshold" + | "overflow-retry" + | "timeout-retry"; + +export type SessionCompactionTranscriptReference = { + sessionId: string; + sessionFile?: string; + leafId?: string; + entryId?: string; +}; + +export type SessionCompactionCheckpoint = { + checkpointId: string; + sessionKey: string; + sessionId: string; + createdAt: number; + reason: SessionCompactionCheckpointReason; + tokensBefore?: number; + tokensAfter?: number; + summary?: string; + firstKeptEntryId?: string; + preCompaction: SessionCompactionTranscriptReference; + postCompaction: SessionCompactionTranscriptReference; +}; + export type GatewaySessionRow = { key: string; spawnedBy?: string; @@ -400,10 +427,47 @@ export type GatewaySessionRow = { model?: string; modelProvider?: string; contextTokens?: number; + compactionCheckpointCount?: number; + latestCompactionCheckpoint?: SessionCompactionCheckpoint; }; export type SessionsListResult = SessionsListResultBase; +export type SessionsCompactionListResult = { + ok: true; + key: string; + checkpoints: SessionCompactionCheckpoint[]; +}; + +export type SessionsCompactionGetResult = { + ok: true; + key: string; + checkpoint: SessionCompactionCheckpoint; +}; + +export type SessionsCompactionBranchResult = { + ok: true; + sourceKey: string; + key: string; + sessionId: string; + checkpoint: SessionCompactionCheckpoint; + entry: { + sessionId: string; + updatedAt: number; + } & Record; +}; + +export type SessionsCompactionRestoreResult = { + ok: true; + key: string; + sessionId: string; + checkpoint: SessionCompactionCheckpoint; + entry: { + sessionId: string; + updatedAt: number; + } & Record; +}; + export type SessionsPatchResult = SessionsPatchResultBase<{ sessionId: string; updatedAt?: number; diff --git a/ui/src/ui/views/sessions.test.ts b/ui/src/ui/views/sessions.test.ts index 0f18a179b1b3..62367dfc2c91 100644 --- a/ui/src/ui/views/sessions.test.ts +++ b/ui/src/ui/views/sessions.test.ts @@ -41,6 +41,11 @@ function buildProps(result: SessionsListResult): SessionsProps { page: 0, pageSize: 10, selectedKeys: new Set(), + expandedCheckpointKey: null, + checkpointItemsByKey: {}, + checkpointLoadingKey: null, + checkpointBusyKey: null, + checkpointErrorByKey: {}, onFiltersChange: () => undefined, onSearchChange: () => undefined, onSortChange: () => undefined, @@ -53,6 +58,9 @@ function buildProps(result: SessionsListResult): SessionsProps { onDeselectPage: () => undefined, onDeselectAll: () => undefined, onDeleteSelected: () => undefined, + onToggleCheckpointDetails: () => undefined, + onBranchFromCheckpoint: () => undefined, + onRestoreCheckpoint: () => undefined, }; } diff --git a/ui/src/ui/views/sessions.ts b/ui/src/ui/views/sessions.ts index 5c8fc9568980..7b5f73368ba5 100644 --- a/ui/src/ui/views/sessions.ts +++ b/ui/src/ui/views/sessions.ts @@ -4,7 +4,11 @@ import { formatRelativeTimestamp } from "../format.ts"; import { icons } from "../icons.ts"; import { pathForTab } from "../navigation.ts"; import { formatSessionTokens } from "../presenter.ts"; -import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; +import type { + GatewaySessionRow, + SessionCompactionCheckpoint, + SessionsListResult, +} from "../types.ts"; export type SessionsProps = { loading: boolean; @@ -21,6 +25,11 @@ export type SessionsProps = { page: number; pageSize: number; selectedKeys: Set; + expandedCheckpointKey: string | null; + checkpointItemsByKey: Record; + checkpointLoadingKey: string | null; + checkpointBusyKey: string | null; + checkpointErrorByKey: Record; onFiltersChange: (next: { activeMinutes: string; limit: string; @@ -48,6 +57,9 @@ export type SessionsProps = { onDeselectAll: () => void; onDeleteSelected: () => void; onNavigateToChat?: (sessionKey: string) => void; + onToggleCheckpointDetails: (sessionKey: string) => void; + onBranchFromCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise; + onRestoreCheckpoint: (sessionKey: string, checkpointId: string) => void | Promise; }; const THINK_LEVELS = ["", "off", "minimal", "low", "medium", "high", "xhigh"] as const; @@ -182,6 +194,36 @@ function paginateRows(rows: T[], page: number, pageSize: number): T[] { return rows.slice(start, start + pageSize); } +function formatCheckpointReason(reason: SessionCompactionCheckpoint["reason"]): string { + switch (reason) { + case "manual": + return "manual"; + case "auto-threshold": + return "auto-threshold"; + case "overflow-retry": + return "overflow retry"; + case "timeout-retry": + return "timeout retry"; + default: + return reason; + } +} + +function formatCheckpointDelta(checkpoint: SessionCompactionCheckpoint): string { + if ( + typeof checkpoint.tokensBefore === "number" && + typeof checkpoint.tokensAfter === "number" && + Number.isFinite(checkpoint.tokensBefore) && + Number.isFinite(checkpoint.tokensAfter) + ) { + return `${checkpoint.tokensBefore.toLocaleString()} → ${checkpoint.tokensAfter.toLocaleString()} tokens`; + } + if (typeof checkpoint.tokensBefore === "number" && Number.isFinite(checkpoint.tokensBefore)) { + return `${checkpoint.tokensBefore.toLocaleString()} tokens before`; + } + return "token delta unavailable"; +} + export function renderSessions(props: SessionsProps) { const rawRows = props.result?.sessions ?? []; const filtered = filterRows(rawRows, props.searchQuery); @@ -349,6 +391,7 @@ export function renderSessions(props: SessionsProps) { Label ${sortHeader("kind", "Kind")} ${sortHeader("updated", "Updated")} ${sortHeader("tokens", "Tokens")} + Compaction Thinking Fast Verbose @@ -360,24 +403,14 @@ export function renderSessions(props: SessionsProps) { ? html` No sessions found. ` - : paginated.map((row) => - renderRow( - row, - props.basePath, - props.onPatch, - props.selectedKeys.has(row.key), - props.onToggleSelect, - props.loading, - props.onNavigateToChat, - ), - )} + : paginated.flatMap((row) => renderRows(row, props))} @@ -416,15 +449,7 @@ export function renderSessions(props: SessionsProps) { `; } -function renderRow( - row: GatewaySessionRow, - basePath: string, - onPatch: SessionsProps["onPatch"], - selected: boolean, - onToggleSelect: SessionsProps["onToggleSelect"], - disabled: boolean, - onNavigateToChat?: (sessionKey: string) => void, -) { +function renderRows(row: GatewaySessionRow, props: SessionsProps) { const updated = row.updatedAt ? formatRelativeTimestamp(row.updatedAt) : t("common.na"); const rawThinking = row.thinkingLevel ?? ""; const isBinaryThinking = isBinaryThinkingProvider(row.modelProvider); @@ -436,6 +461,11 @@ function renderRow( const verboseLevels = withCurrentLabeledOption(VERBOSE_LEVELS, verbose); const reasoning = row.reasoningLevel ?? ""; const reasoningLevels = withCurrentOption(REASONING_LEVELS, reasoning); + const latestCheckpoint = row.latestCompactionCheckpoint; + const checkpointCount = row.compactionCheckpointCount ?? 0; + const isExpanded = props.expandedCheckpointKey === row.key; + const checkpointItems = props.checkpointItemsByKey[row.key] ?? []; + const checkpointError = props.checkpointErrorByKey[row.key]; const displayName = typeof row.displayName === "string" && row.displayName.trim().length > 0 ? row.displayName.trim() @@ -447,7 +477,7 @@ function renderRow( ); const canLink = row.kind !== "global"; const chatUrl = canLink - ? `${pathForTab("chat", basePath)}?session=${encodeURIComponent(row.key)}` + ? `${pathForTab("chat", props.basePath)}?session=${encodeURIComponent(row.key)}` : null; const badgeClass = row.kind === "direct" @@ -458,13 +488,13 @@ function renderRow( ? "data-table-badge--global" : "data-table-badge--unknown"; - return html` - + return [ + html` onToggleSelect(row.key)} + .checked=${props.selectedKeys.has(row.key)} + @change=${() => props.onToggleSelect(row.key)} aria-label="Select session" /> @@ -485,9 +515,9 @@ function renderRow( ) { return; } - if (onNavigateToChat) { + if (props.onNavigateToChat) { e.preventDefault(); - onNavigateToChat(row.key); + props.onNavigateToChat(row.key); } }} >${row.key} { const value = (e.target as HTMLInputElement).value.trim(); - onPatch(row.key, { label: value || null }); + props.onPatch(row.key, { label: value || null }); }} /> @@ -515,13 +545,37 @@ function renderRow( ${updated} ${formatSessionTokens(row)} + +

+ + ${checkpointCount > 0 + ? `${checkpointCount} checkpoint${checkpointCount === 1 ? "" : "s"}` + : "none"} + + ${latestCheckpoint + ? html` + + ${formatCheckpointReason(latestCheckpoint.reason)} · + ${formatRelativeTimestamp(latestCheckpoint.createdAt)} + + ` + : nothing} + +
+ { const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { fastMode: value === "" ? null : value === "on" }); + props.onPatch(row.key, { fastMode: value === "" ? null : value === "on" }); }} > ${fastLevels.map( @@ -553,11 +607,11 @@ function renderRow( { const value = (e.target as HTMLSelectElement).value; - onPatch(row.key, { reasoningLevel: value || null }); + props.onPatch(row.key, { reasoningLevel: value || null }); }} > ${reasoningLevels.map( @@ -585,6 +639,77 @@ function renderRow( )} - - `; + `, + ...(isExpanded + ? [ + html` + +
+ ${props.checkpointLoadingKey === row.key + ? html`
Loading checkpoints…
` + : checkpointError + ? html`
${checkpointError}
` + : checkpointItems.length === 0 + ? html`
+ No compaction checkpoints recorded for this session. +
` + : html` +
+ ${checkpointItems.map( + (checkpoint) => html` +
+
+ + ${formatCheckpointReason(checkpoint.reason)} · + ${formatRelativeTimestamp(checkpoint.createdAt)} + + + ${formatCheckpointDelta(checkpoint)} + +
+ ${checkpoint.summary + ? html`
+ ${checkpoint.summary} +
` + : html`
No summary captured.
`} +
+ + +
+
+ `, + )} +
+ `} +
+ + `, + ] + : []), + ]; }