feat: track compaction count + verbose notice
This commit is contained in:
@@ -555,6 +555,10 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
compactionInFlight = true;
|
||||
ensureCompactionPromise();
|
||||
log.debug(`embedded run compaction start: runId=${params.runId}`);
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "auto_compaction_end") {
|
||||
@@ -567,6 +571,10 @@ export function subscribeEmbeddedPiSession(params: {
|
||||
} else {
|
||||
maybeResolveCompactionWait();
|
||||
}
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry },
|
||||
});
|
||||
}
|
||||
|
||||
if (evt.type === "agent_end") {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
runEmbeddedPiAgent,
|
||||
} from "../agents/pi-embedded.js";
|
||||
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
|
||||
import { resolveSessionKey } from "../config/sessions.js";
|
||||
import { loadSessionStore, resolveSessionKey } from "../config/sessions.js";
|
||||
import { getReplyFromConfig } from "./reply.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
@@ -731,6 +731,10 @@ describe("trigger handling", () => {
|
||||
|
||||
it("runs /compact as a gated command", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = join(
|
||||
tmpdir(),
|
||||
`clawdbot-session-test-${Date.now()}.json`,
|
||||
);
|
||||
vi.mocked(compactEmbeddedPiSession).mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
@@ -757,7 +761,7 @@ describe("trigger handling", () => {
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
session: {
|
||||
store: join(tmpdir(), `clawdbot-session-test-${Date.now()}.json`),
|
||||
store: storePath,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -765,6 +769,13 @@ describe("trigger handling", () => {
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(compactEmbeddedPiSession).toHaveBeenCalledOnce();
|
||||
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
|
||||
const store = loadSessionStore(storePath);
|
||||
const sessionKey = resolveSessionKey("per-sender", {
|
||||
Body: "/compact focus on decisions",
|
||||
From: "+1003",
|
||||
To: "+2000",
|
||||
});
|
||||
expect(store[sessionKey]?.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import type { GetReplyOptions } from "../types.js";
|
||||
import type { FollowupRun, QueueSettings } from "./queue.js";
|
||||
@@ -54,7 +58,14 @@ type EmbeddedPiAgentParams = {
|
||||
onPartialReply?: (payload: { text?: string }) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
function createMinimalRun(params?: {
|
||||
opts?: GetReplyOptions;
|
||||
resolvedVerboseLevel?: "off" | "on";
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
}) {
|
||||
const typing = createTyping();
|
||||
const opts = params?.opts;
|
||||
const sessionCtx = {
|
||||
@@ -62,13 +73,14 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
const resolvedQueue = { mode: "interrupt" } as unknown as QueueSettings;
|
||||
const sessionKey = params?.sessionKey ?? "main";
|
||||
const followupRun = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
sessionKey,
|
||||
surface: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
@@ -77,7 +89,7 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "off",
|
||||
verboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
@@ -104,9 +116,13 @@ function createMinimalRun(params?: { opts?: GetReplyOptions }) {
|
||||
isStreaming: false,
|
||||
opts,
|
||||
typing,
|
||||
sessionEntry: params?.sessionEntry,
|
||||
sessionStore: params?.sessionStore,
|
||||
sessionKey,
|
||||
storePath: params?.storePath,
|
||||
sessionCtx,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
resolvedVerboseLevel: "off",
|
||||
resolvedVerboseLevel: params?.resolvedVerboseLevel ?? "off",
|
||||
isNewSession: false,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
@@ -153,4 +169,42 @@ describe("runReplyAgent typing (heartbeat)", () => {
|
||||
expect(typing.startTypingOnText).not.toHaveBeenCalled();
|
||||
expect(typing.startTypingLoop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("announces auto-compaction in verbose mode and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry = { sessionId: "session", updatedAt: Date.now() };
|
||||
const sessionStore = { main: sessionEntry };
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const { run } = createMinimalRun({
|
||||
resolvedVerboseLevel: "on",
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
});
|
||||
const res = await run();
|
||||
expect(Array.isArray(res)).toBe(true);
|
||||
const payloads = res as { text?: string }[];
|
||||
expect(payloads[0]?.text).toContain("Auto-compaction complete");
|
||||
expect(payloads[0]?.text).toContain("count 1");
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
scheduleFollowupDrain,
|
||||
} from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export async function runReplyAgent(params: {
|
||||
@@ -167,6 +168,7 @@ export async function runReplyAgent(params: {
|
||||
};
|
||||
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
try {
|
||||
const runId = crypto.randomUUID();
|
||||
if (sessionKey) {
|
||||
@@ -233,6 +235,14 @@ export async function runReplyAgent(params: {
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
onBlockReply:
|
||||
blockStreamingEnabled && opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
@@ -478,6 +488,21 @@ export async function runReplyAgent(params: {
|
||||
|
||||
// If verbose is enabled and this is a new session, prepend a session hint.
|
||||
let finalPayloads = filteredPayloads;
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (resolvedVerboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
finalPayloads = [
|
||||
{ text: `🧹 Auto-compaction complete${suffix}.` },
|
||||
...finalPayloads,
|
||||
];
|
||||
}
|
||||
}
|
||||
if (resolvedVerboseLevel === "on" && isNewSession) {
|
||||
finalPayloads = [
|
||||
{ text: `🧭 New session: ${followupRun.run.sessionId}` },
|
||||
|
||||
@@ -44,6 +44,7 @@ import type { ReplyPayload } from "../types.js";
|
||||
import { isAbortTrigger, setAbortMemory } from "./abort.js";
|
||||
import type { InlineDirectives } from "./directive-handling.js";
|
||||
import { stripMentions, stripStructuralPrefixes } from "./mentions.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
|
||||
export type CommandContext = {
|
||||
surface: string;
|
||||
@@ -444,6 +445,14 @@ export async function handleCommands(params: {
|
||||
: "Compacted"
|
||||
: "Compaction skipped"
|
||||
: "Compaction failed";
|
||||
if (result.ok && result.compacted) {
|
||||
await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
}
|
||||
const reason = result.reason?.trim();
|
||||
const line = reason
|
||||
? `${compactLabel}: ${reason} • ${contextSummary}`
|
||||
|
||||
119
src/auto-reply/reply/followup-runner.compaction.test.ts
Normal file
119
src/auto-reply/reply/followup-runner.compaction.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
const runEmbeddedPiAgentMock = vi.fn();
|
||||
|
||||
vi.mock("../../agents/model-fallback.js", () => ({
|
||||
runWithModelFallback: async ({
|
||||
provider,
|
||||
model,
|
||||
run,
|
||||
}: {
|
||||
provider: string;
|
||||
model: string;
|
||||
run: (provider: string, model: string) => Promise<unknown>;
|
||||
}) => ({
|
||||
result: await run(provider, model),
|
||||
provider,
|
||||
model,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../agents/pi-embedded.js", () => ({
|
||||
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
|
||||
}));
|
||||
|
||||
import { createFollowupRunner } from "./followup-runner.js";
|
||||
|
||||
function createTyping(): TypingController {
|
||||
return {
|
||||
onReplyStart: vi.fn(async () => {}),
|
||||
startTypingLoop: vi.fn(async () => {}),
|
||||
startTypingOnText: vi.fn(async () => {}),
|
||||
refreshTypingTtl: vi.fn(),
|
||||
cleanup: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createFollowupRunner compaction", () => {
|
||||
it("adds verbose auto-compaction notice and tracks count", async () => {
|
||||
const storePath = path.join(
|
||||
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
|
||||
"sessions.json",
|
||||
);
|
||||
const sessionEntry: SessionEntry = {
|
||||
sessionId: "session",
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const sessionStore: Record<string, SessionEntry> = {
|
||||
main: sessionEntry,
|
||||
};
|
||||
const onBlockReply = vi.fn(async () => {});
|
||||
|
||||
runEmbeddedPiAgentMock.mockImplementationOnce(
|
||||
async (params: {
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void;
|
||||
}) => {
|
||||
params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry: false },
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
},
|
||||
);
|
||||
|
||||
const runner = createFollowupRunner({
|
||||
opts: { onBlockReply },
|
||||
typing: createTyping(),
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: "main",
|
||||
storePath,
|
||||
defaultModel: "anthropic/claude-opus-4-5",
|
||||
});
|
||||
|
||||
const queued = {
|
||||
prompt: "hello",
|
||||
summaryLine: "hello",
|
||||
enqueuedAt: Date.now(),
|
||||
run: {
|
||||
sessionId: "session",
|
||||
sessionKey: "main",
|
||||
surface: "whatsapp",
|
||||
sessionFile: "/tmp/session.jsonl",
|
||||
workspaceDir: "/tmp",
|
||||
config: {},
|
||||
skillsSnapshot: {},
|
||||
provider: "anthropic",
|
||||
model: "claude",
|
||||
thinkLevel: "low",
|
||||
verboseLevel: "on",
|
||||
elevatedLevel: "off",
|
||||
bashElevated: {
|
||||
enabled: false,
|
||||
allowed: false,
|
||||
defaultLevel: "off",
|
||||
},
|
||||
timeoutMs: 1_000,
|
||||
blockReplyBreak: "message_end",
|
||||
},
|
||||
} as FollowupRun;
|
||||
|
||||
await runner(queued);
|
||||
|
||||
expect(onBlockReply).toHaveBeenCalled();
|
||||
expect(onBlockReply.mock.calls[0][0].text).toContain(
|
||||
"Auto-compaction complete",
|
||||
);
|
||||
expect(sessionStore.main.compactionCount).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { extractReplyToTag } from "./reply-tags.js";
|
||||
import { incrementCompactionCount } from "./session-updates.js";
|
||||
import type { TypingController } from "./typing.js";
|
||||
|
||||
export function createFollowupRunner(params: {
|
||||
@@ -61,6 +62,7 @@ export function createFollowupRunner(params: {
|
||||
if (queued.run.sessionKey) {
|
||||
registerAgentRunContext(runId, { sessionKey: queued.run.sessionKey });
|
||||
}
|
||||
let autoCompactionCompleted = false;
|
||||
let runResult: Awaited<ReturnType<typeof runEmbeddedPiAgent>>;
|
||||
let fallbackProvider = queued.run.provider;
|
||||
let fallbackModel = queued.run.model;
|
||||
@@ -91,6 +93,14 @@ export function createFollowupRunner(params: {
|
||||
timeoutMs: queued.run.timeoutMs,
|
||||
runId,
|
||||
blockReplyBreak: queued.run.blockReplyBreak,
|
||||
onAgentEvent: (evt) => {
|
||||
if (evt.stream !== "compaction") return;
|
||||
const phase = String(evt.data.phase ?? "");
|
||||
const willRetry = Boolean(evt.data.willRetry);
|
||||
if (phase === "end" && !willRetry) {
|
||||
autoCompactionCompleted = true;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
runResult = fallbackResult.result;
|
||||
@@ -132,6 +142,21 @@ export function createFollowupRunner(params: {
|
||||
|
||||
if (replyTaggedPayloads.length === 0) return;
|
||||
|
||||
if (autoCompactionCompleted) {
|
||||
const count = await incrementCompactionCount({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
});
|
||||
if (queued.run.verboseLevel === "on") {
|
||||
const suffix = typeof count === "number" ? ` (count ${count})` : "";
|
||||
replyTaggedPayloads.unshift({
|
||||
text: `🧹 Auto-compaction complete${suffix}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionStore && sessionKey) {
|
||||
const usage = runResult.meta.agentMeta?.usage;
|
||||
const modelUsed =
|
||||
|
||||
@@ -122,3 +122,32 @@ export async function ensureSkillSnapshot(params: {
|
||||
|
||||
return { sessionEntry: nextEntry, skillsSnapshot, systemSent };
|
||||
}
|
||||
|
||||
export async function incrementCompactionCount(params: {
|
||||
sessionEntry?: SessionEntry;
|
||||
sessionStore?: Record<string, SessionEntry>;
|
||||
sessionKey?: string;
|
||||
storePath?: string;
|
||||
now?: number;
|
||||
}): Promise<number | undefined> {
|
||||
const {
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey,
|
||||
storePath,
|
||||
now = Date.now(),
|
||||
} = params;
|
||||
if (!sessionStore || !sessionKey) return undefined;
|
||||
const entry = sessionStore[sessionKey] ?? sessionEntry;
|
||||
if (!entry) return undefined;
|
||||
const nextCount = (entry.compactionCount ?? 0) + 1;
|
||||
sessionStore[sessionKey] = {
|
||||
...entry,
|
||||
compactionCount: nextCount,
|
||||
updatedAt: now,
|
||||
};
|
||||
if (storePath) {
|
||||
await saveSessionStore(storePath, sessionStore);
|
||||
}
|
||||
return nextCount;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("buildStatusMessage", () => {
|
||||
contextTokens: 32_000,
|
||||
thinkingLevel: "low",
|
||||
verboseLevel: "on",
|
||||
compactionCount: 2,
|
||||
},
|
||||
sessionKey: "main",
|
||||
sessionScope: "per-sender",
|
||||
@@ -39,6 +40,7 @@ describe("buildStatusMessage", () => {
|
||||
expect(text).toContain("Runtime: direct");
|
||||
expect(text).toContain("Context: 16k/32k (50%)");
|
||||
expect(text).toContain("Session: main");
|
||||
expect(text).toContain("compactions 2");
|
||||
expect(text).toContain("Web: linked");
|
||||
expect(text).toContain("heartbeat 45s");
|
||||
expect(text).toContain("thinking=medium");
|
||||
|
||||
@@ -217,6 +217,9 @@ export function buildStatusMessage(args: StatusArgs): string {
|
||||
entry?.updatedAt
|
||||
? `updated ${formatAge(now - entry.updatedAt)}`
|
||||
: "no activity",
|
||||
typeof entry?.compactionCount === "number"
|
||||
? `compactions ${entry.compactionCount}`
|
||||
: undefined,
|
||||
args.storePath ? `store ${shortenHomePath(args.storePath)}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -55,6 +55,7 @@ export type SessionEntry = {
|
||||
modelProvider?: string;
|
||||
model?: string;
|
||||
contextTokens?: number;
|
||||
compactionCount?: number;
|
||||
displayName?: string;
|
||||
surface?: string;
|
||||
subject?: string;
|
||||
|
||||
Reference in New Issue
Block a user