From c48e973c66fcc8a03e41b6dc790a9b9660f78e7e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 23 Aug 2026 14:09:39 -0700 Subject: [PATCH] fix(sessions): keep admin events out of model context (#128346) --- docs/gateway/config-agents.md | 4 +- .../compaction/branch-summarization.test.ts | 106 ++++++++------ .../src/harness/compaction/compaction.test.ts | 54 ++++--- .../src/harness/compaction/compaction.ts | 6 +- .../harness/session/session-context.test.ts | 46 +++++- .../agent-core/src/harness/session/session.ts | 4 +- .../compaction-real-conversation.test.ts | 28 ++++ src/agents/compaction-real-conversation.ts | 7 +- .../run/attempt-execution-settle.test.ts | 3 + .../run/compaction-timeout.test.ts | 23 +++ .../run/compaction-timeout.ts | 8 +- .../preemptive-compaction.bashexec.test.ts | 45 +++++- .../run/preemptive-compaction.ts | 6 +- .../tool-result-char-estimator.test.ts | 43 ++++-- .../tool-result-char-estimator.ts | 9 +- src/agents/tools/sessions-tool.test.ts | 8 ++ src/gateway/server-methods/session-audit.ts | 32 ----- .../server-methods/sessions-sharing.test.ts | 114 +++++++-------- .../server-methods/sessions-sharing.ts | 48 ------- .../sessions-suggestions.test-mocks.ts | 2 - .../sessions-suggestions.test-support.ts | 2 - .../sessions-suggestions.test.ts | 136 +++++++++++++----- .../server-methods/sessions-suggestions.ts | 37 ----- .../server.sessions.archive-lifecycle.test.ts | 66 ++++----- 24 files changed, 478 insertions(+), 359 deletions(-) delete mode 100644 src/gateway/server-methods/session-audit.ts diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md index fe8f33dbfcbf..a8b142e4fae0 100644 --- a/docs/gateway/config-agents.md +++ b/docs/gateway/config-agents.md @@ -1285,10 +1285,10 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden - `defaultSpawnContext`: default native subagent context for thread-bound spawns (`"fork"` or `"isolated"`). Defaults to `"fork"`. - **`sharing`**: controls which per-session collaboration modes owners and `operator.admin` connections may select. Every flag defaults to `true`; setting one to `false` removes that choice from the Control UI and makes create-time visibility or `session.visibility.set` reject it. New sessions start `shared` unless the Control UI starts one as a draft. - `readOnly`: allow `read-only`, where non-members can watch but cannot send, steer, abort, approve, or mutate session state. - - `suggest`: allow `suggest`. In this phase it enforces the same admission behavior as `read-only`; the suggestion queue is a later feature. + - `suggest`: allow `suggest`, where viewers can submit suggestions for the session owner or an `operator.admin` connection to send, queue, edit, or dismiss without granting direct access to send or manage the session. - `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts. -Membership and visibility changes are written into the session transcript as system notes. These controls coordinate operators sharing one agent; they are not a security boundary between tenants. Use separate Gateways or agents when work requires isolation. +Session visibility and membership are maintained as canonical sharing state. Structured `session.sharing` and `session.suggestion` change events refresh connected clients without adding administrative narration to conversation transcripts. These controls coordinate operators sharing one agent; they are not a security boundary between tenants. Use separate Gateways or agents when work requires isolation. diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.test.ts b/packages/agent-core/src/harness/compaction/branch-summarization.test.ts index 60c0024b5fe4..a367c7f0abd7 100644 --- a/packages/agent-core/src/harness/compaction/branch-summarization.test.ts +++ b/packages/agent-core/src/harness/compaction/branch-summarization.test.ts @@ -231,55 +231,71 @@ src/write.ts expect(preparation.messages.map((message) => message.role)).toEqual(["user", "toolResult"]); }); - it("preserves earlier branch context while excluding private shell output", async () => { - const model = createModel(8192); - const capture = createCapturingStream(model); - const shellMessage: AgentMessage = { - role: "bashExecution", - command: "private command", - output: `private output marker ${"x".repeat(80_000)}`, - exitCode: 0, - cancelled: false, - truncated: false, - timestamp: 2, - excludeFromContext: true, - }; - const entries: SessionTreeEntry[] = [ - createMessageEntry({ role: "user", content: "important original request", timestamp: 1 }, 0), - createMessageEntry(shellMessage, 1), - createMessageEntry({ role: "user", content: "continue branch", timestamp: 3 }, 2), - ]; + it.each(["shell", "custom"] as const)( + "preserves earlier branch context while excluding private %s activity", + async (kind) => { + const model = createModel(8192); + const capture = createCapturingStream(model); + const excludedMessage: AgentMessage = + kind === "shell" + ? { + role: "bashExecution", + command: "private command", + output: `private output marker ${"x".repeat(80_000)}`, + exitCode: 0, + cancelled: false, + truncated: false, + timestamp: 2, + excludeFromContext: true, + } + : { + role: "custom", + customType: "openclaw.operator-activity", + content: `private output marker ${"x".repeat(80_000)}`, + display: true, + timestamp: 2, + excludeFromContext: true, + }; + const entries: SessionTreeEntry[] = [ + createMessageEntry( + { role: "user", content: "important original request", timestamp: 1 }, + 0, + ), + createMessageEntry(excludedMessage, 1), + createMessageEntry({ role: "user", content: "continue branch", timestamp: 3 }, 2), + ]; - const preparation = prepareBranchEntries(entries, 100); - expect(preparation.messages).toMatchObject([ - { role: "user", content: "important original request" }, - { role: "user", content: "continue branch" }, - ]); - expect(preparation.totalTokens).toBeLessThan(100); + const preparation = prepareBranchEntries(entries, 100); + expect(preparation.messages).toMatchObject([ + { role: "user", content: "important original request" }, + { role: "user", content: "continue branch" }, + ]); + expect(preparation.totalTokens).toBeLessThan(100); - const visibleEntries = entries.map((entry, index) => - index === 1 - ? createMessageEntry({ ...shellMessage, excludeFromContext: false }, index) - : entry, - ); - expect(prepareBranchEntries(visibleEntries, 100).messages).toMatchObject([ - { role: "user", content: "continue branch" }, - ]); + const visibleEntries = entries.map((entry, index) => + index === 1 + ? createMessageEntry({ ...excludedMessage, excludeFromContext: false }, index) + : entry, + ); + expect(prepareBranchEntries(visibleEntries, 100).messages).toMatchObject([ + { role: "user", content: "continue branch" }, + ]); - const result = await generateBranchSummary(entries, { - model, - apiKey: "test-key", - signal: new AbortController().signal, - streamFn: capture.streamFn, - }); + const result = await generateBranchSummary(entries, { + model, + apiKey: "test-key", + signal: new AbortController().signal, + streamFn: capture.streamFn, + }); - expect(result.ok).toBe(true); - expect(capture.readCapture().prompt).toContain("important original request"); - expect(capture.readCapture().prompt).toContain("continue branch"); - expect(capture.readCapture().prompt).not.toContain("private command"); - expect(capture.readCapture().prompt).not.toContain("private output marker"); - expect(JSON.stringify(entries)).toContain("private output marker"); - }); + expect(result.ok).toBe(true); + expect(capture.readCapture().prompt).toContain("important original request"); + expect(capture.readCapture().prompt).toContain("continue branch"); + expect(capture.readCapture().prompt).not.toContain("private command"); + expect(capture.readCapture().prompt).not.toContain("private output marker"); + expect(JSON.stringify(entries)).toContain("private output marker"); + }, + ); it("summarizes tool failures without exposing private result details", async () => { const model = createModel(128_000); diff --git a/packages/agent-core/src/harness/compaction/compaction.test.ts b/packages/agent-core/src/harness/compaction/compaction.test.ts index 8ad1b6690e31..91546a5f167f 100644 --- a/packages/agent-core/src/harness/compaction/compaction.test.ts +++ b/packages/agent-core/src/harness/compaction/compaction.test.ts @@ -283,24 +283,44 @@ describe("calculateContextTokens", () => { }); describe("session-entry compaction budgeting", () => { - it("counts visible shell output while ignoring private output after provider usage", () => { - const hidden = createBashMessage("x".repeat(80_000), 2, true); - const visible = createBashMessage("x".repeat(80_000), 2, false); - const assistant = createAssistant("done", createUsage(42), 1); - const latest: AgentMessage = { role: "user", content: "continue", timestamp: 3 }; + it.each([ + { + kind: "shell", + createMessage: (excludeFromContext: boolean) => + createBashMessage("x".repeat(80_000), 2, excludeFromContext), + }, + { + kind: "custom", + createMessage: (excludeFromContext: boolean): AgentMessage => ({ + role: "custom", + customType: "openclaw.operator-activity", + content: "x".repeat(80_004), + display: excludeFromContext, + excludeFromContext, + timestamp: 2, + }), + }, + ])( + "counts visible $kind activity while ignoring excluded activity after provider usage", + ({ createMessage }) => { + const hidden = createMessage(true); + const visible = createMessage(false); + const assistant = createAssistant("done", createUsage(42), 1); + const latest: AgentMessage = { role: "user", content: "continue", timestamp: 3 }; - expect(estimateTokens(hidden)).toBe(0); - expect(estimateTokens(visible)).toBeGreaterThan(20_000); - expect(estimateContextTokens([assistant, hidden, latest])).toMatchObject({ - tokens: 44, - usageTokens: 42, - trailingTokens: 2, - lastUsageIndex: 0, - }); - expect(estimateContextTokens([assistant, visible, latest]).trailingTokens).toBeGreaterThan( - 20_000, - ); - }); + expect(estimateTokens(hidden)).toBe(0); + expect(estimateTokens(visible)).toBeGreaterThan(20_000); + expect(estimateContextTokens([assistant, hidden, latest])).toMatchObject({ + tokens: 44, + usageTokens: 42, + trailingTokens: 2, + lastUsageIndex: 0, + }); + expect(estimateContextTokens([assistant, visible, latest]).trailingTokens).toBeGreaterThan( + 20_000, + ); + }, + ); it("never rewinds a retained visible turn onto an excluded shell-history row", () => { const entries: SessionTreeEntry[] = [ diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts index 8b2fd7c23a32..fc0f29b1744d 100644 --- a/packages/agent-core/src/harness/compaction/compaction.ts +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -294,6 +294,9 @@ function countContentBlockChars( /** Estimate token count for one message using a conservative character heuristic. */ export function estimateTokens(message: AgentMessage): number { + if ("excludeFromContext" in message && message.excludeFromContext === true) { + return 0; + } let chars = 0; const harnessMessage = message as HarnessMessage; @@ -334,9 +337,6 @@ export function estimateTokens(message: AgentMessage): number { return Math.ceil(chars / CHARS_PER_TOKEN_ESTIMATE); } case "bashExecution": { - if (harnessMessage.excludeFromContext === true) { - return 0; - } chars = estimateStringChars(harnessMessage.command) + estimateStringChars(harnessMessage.output); return Math.ceil(chars / CHARS_PER_TOKEN_ESTIMATE); diff --git a/packages/agent-core/src/harness/session/session-context.test.ts b/packages/agent-core/src/harness/session/session-context.test.ts index 176cddee30b0..4eca5f3c5349 100644 --- a/packages/agent-core/src/harness/session/session-context.test.ts +++ b/packages/agent-core/src/harness/session/session-context.test.ts @@ -137,13 +137,57 @@ describe("buildSessionContext", () => { const activity = { role: "custom" as const, customType: "openclaw.context-compaction", - content: "Context compacted", + content: `Context compacted ${"x".repeat(80_000)}`, display: true, excludeFromContext: true, timestamp: Date.parse(timestamp), }; + const runtimeContext = { + role: "custom" as const, + customType: "openclaw.runtime-context", + content: "Model-visible runtime context", + display: false, + details: { runtimeContextCarrier: true }, + timestamp: Date.parse(timestamp), + }; + const activityEntry: SessionTreeEntry = { + type: "message", + id: "activity", + parentId: "initial", + timestamp, + message: activity, + }; + const runtimeContextEntry: SessionTreeEntry = { + type: "message", + id: "runtime-context", + parentId: "activity", + timestamp, + message: runtimeContext, + }; + const entries = [ + userEntry("initial", null, "original request"), + activityEntry, + runtimeContextEntry, + userEntry("latest", "runtime-context", "continue"), + ]; + const messages = buildSessionContext(entries).messages; expect(convertToLlm([activity])).toEqual([]); + expect(projectSessionEntryMessage(activityEntry)).toBeUndefined(); + expect(projectSessionEntryMessage(runtimeContextEntry)).toBe(runtimeContext); + expect(messages.map((message) => message.role)).toEqual(["user", "custom", "user"]); + expect(JSON.stringify(messages)).toContain("Model-visible runtime context"); + expect(JSON.stringify(messages)).not.toContain("Context compacted"); + expect(convertToLlm(messages)).toMatchObject([ + { role: "user", content: "original request" }, + { + role: "user", + content: [{ type: "text", text: "Model-visible runtime context" }], + runtimeContextCarrier: true, + }, + { role: "user", content: "continue" }, + ]); + expect(JSON.stringify(entries)).toContain("Context compacted"); }); it("keeps private shell executions in history without projecting them into context", () => { diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts index 027dee7e9d52..85f7d7d8b8c5 100644 --- a/packages/agent-core/src/harness/session/session.ts +++ b/packages/agent-core/src/harness/session/session.ts @@ -16,8 +16,8 @@ const SESSION_HISTORY_PRELUDE = Symbol.for("openclaw.sessionHistoryPrelude"); export function projectSessionEntryMessage(entry: SessionTreeEntry): AgentMessage | undefined { switch (entry.type) { case "message": - // Private shell history stays persisted but never enters replay or summarization. - return entry.message.role === "bashExecution" && entry.message.excludeFromContext === true + // Display-only history stays persisted but never enters replay or summarization. + return "excludeFromContext" in entry.message && entry.message.excludeFromContext === true ? undefined : entry.message; case "custom_message": diff --git a/src/agents/compaction-real-conversation.test.ts b/src/agents/compaction-real-conversation.test.ts index 16ade06ec31f..e9cc75183206 100644 --- a/src/agents/compaction-real-conversation.test.ts +++ b/src/agents/compaction-real-conversation.test.ts @@ -40,6 +40,34 @@ describe("compaction real conversation classification", () => { }, ); + it.each([ + { excludeFromContext: true, expected: false }, + { excludeFromContext: false, expected: true }, + ])( + "classifies custom conversation according to context eligibility ($excludeFromContext)", + ({ excludeFromContext, expected }) => { + const custom = { + role: "custom", + customType: "display-note", + content: "A visible administrative event.", + display: true, + excludeFromContext, + timestamp: 1, + } satisfies AgentMessage; + const toolResult = { + role: "toolResult", + toolCallId: "call-1", + toolName: "exec", + content: [{ type: "text", text: "audit output" }], + } as AgentMessage; + const messages = [custom, toolResult]; + + expect(hasMeaningfulConversationContent(custom)).toBe(expected); + expect(isRealConversationMessage(custom, messages, 0)).toBe(expected); + expect(isRealConversationMessage(toolResult, messages, 1)).toBe(expected); + }, + ); + it("rejects tool-call-only messages and orphan tool results", () => { const toolCall = { role: "assistant", diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index c2e9cd162f89..1bde08d04e70 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -30,6 +30,9 @@ function isSummaryRole(role: unknown): boolean { /** Returns whether a message has content worth preserving as conversation. */ export function hasMeaningfulConversationContent(message: AgentMessage): boolean { + if ("excludeFromContext" in message && message.excludeFromContext === true) { + return false; + } if ((message as { role?: unknown }).role === "custom") { const custom = message as { content?: unknown; display?: unknown }; return custom.display !== false && hasMeaningfulMessageContent(custom.content); @@ -38,11 +41,7 @@ export function hasMeaningfulConversationContent(message: AgentMessage): boolean const bash = message as { command?: unknown; output?: unknown; - excludeFromContext?: unknown; }; - if (bash.excludeFromContext === true) { - return false; - } const command = typeof bash.command === "string" ? bash.command : ""; const output = typeof bash.output === "string" ? bash.output : ""; return hasMeaningfulText(`${command}\n${output}`); diff --git a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts index d43190292f8b..d74be8dc22ee 100644 --- a/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts @@ -417,6 +417,9 @@ describe("runEmbeddedAttemptSettledPhase", () => { content: expect.stringMatching(/1.*image contents.*unavailable.*resend.*not claim/is), }), ); + expect(fixture.sessionManager.appendMessage.mock.calls[0]?.[0]).not.toHaveProperty( + "excludeFromContext", + ); expect(mocks.completeResult).toHaveBeenCalledWith( expect.objectContaining({ state: expect.objectContaining({ diff --git a/src/agents/embedded-agent-runner/run/compaction-timeout.test.ts b/src/agents/embedded-agent-runner/run/compaction-timeout.test.ts index 9545b4310d01..f79b9440ab77 100644 --- a/src/agents/embedded-agent-runner/run/compaction-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/compaction-timeout.test.ts @@ -232,6 +232,29 @@ describe("compaction-timeout helpers", () => { }), expectedLength: 1, }, + { + name: "excluded custom tail", + tail: castAgentMessage({ + role: "custom", + customType: "display-note", + content: "display-only activity", + display: true, + excludeFromContext: true, + timestamp: 2, + }), + expectedLength: 1, + }, + { + name: "undisplayed model-visible custom tail", + tail: castAgentMessage({ + role: "custom", + customType: "openclaw-runtime-context", + content: "runtime context", + display: false, + timestamp: 2, + }), + expectedLength: 2, + }, { name: "tool result tail", tail: castAgentMessage({ diff --git a/src/agents/embedded-agent-runner/run/compaction-timeout.ts b/src/agents/embedded-agent-runner/run/compaction-timeout.ts index ed2bcc5df8ff..a8fb00dc46d4 100644 --- a/src/agents/embedded-agent-runner/run/compaction-timeout.ts +++ b/src/agents/embedded-agent-runner/run/compaction-timeout.ts @@ -51,15 +51,17 @@ type SnapshotSelection = { }; export function canContinueFromMessage(message: AgentMessage | undefined): boolean { - switch (message?.role) { + if (!message || ("excludeFromContext" in message && message.excludeFromContext === true)) { + return false; + } + switch (message.role) { case "user": case "toolResult": case "branchSummary": case "compactionSummary": case "custom": - return true; case "bashExecution": - return message.excludeFromContext !== true; + return true; default: return false; } diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.bashexec.test.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.bashexec.test.ts index f5d8d4c18e7e..bf1f5d5f6778 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.bashexec.test.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.bashexec.test.ts @@ -61,13 +61,46 @@ describe("preemptive precheck counts bashExecution and summary turns", () => { expect(precheckTokens).toBeGreaterThanOrEqual(realProviderTokens); }); - it("drops a bash turn excluded from context", () => { - const excluded = { - ...(bashExecMessage() as unknown as Record), - excludeFromContext: true, - } as unknown as AgentMessage; + it.each([ + { + role: "bashExecution", + message: { ...bashExecMessage(), excludeFromContext: true } as AgentMessage, + }, + { + role: "custom", + message: { + role: "custom", + customType: "display-note", + content: BIG_OUTPUT, + display: true, + excludeFromContext: true, + timestamp: 1, + } satisfies AgentMessage, + }, + ])("drops excluded $role turns from token pressure", ({ message }) => { + expect(estimateLlmBoundaryTokenPressure({ messages: [message], prompt: "" })).toBeLessThan(50); + expect( + shouldPreemptivelyCompactBeforePrompt({ + messages: [message], + prompt: "continue", + contextTokenBudget: 128000, + reserveTokens: 16384, + }).route, + ).toBe("fits"); + }); - expect(estimateLlmBoundaryTokenPressure({ messages: [excluded], prompt: "" })).toBeLessThan(50); + it("counts undisplayed custom context when it remains model-visible", () => { + const runtimeContext = { + role: "custom", + customType: "openclaw-runtime-context", + content: BIG_OUTPUT, + display: false, + timestamp: 1, + } satisfies AgentMessage; + + expect( + estimateLlmBoundaryTokenPressure({ messages: [runtimeContext], prompt: "" }), + ).toBeGreaterThan(50000); }); it("routes an oversized bash transcript to compaction", () => { diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts index f41a1d943e49..90220b66d053 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -156,6 +156,9 @@ function estimateContentTokenPressure( } function estimateMessageTokenPressure(message: AgentMessage): number { + if ("excludeFromContext" in message && message.excludeFromContext === true) { + return 0; + } // Provider replay can carry legacy aliases outside the canonical AgentMessage union. const legacy: Record = isRecord(message) ? message : {}; let tokens = MESSAGE_BOUNDARY_OVERHEAD_TOKENS; @@ -169,9 +172,6 @@ function estimateMessageTokenPressure(message: AgentMessage): number { } if (message.role === "bashExecution") { - if (message.excludeFromContext === true) { - return 0; - } const bashMessage: BashExecutionMessage = message; tokens += estimateStringTokenPressure(bashExecutionToText(bashMessage)); return tokens; diff --git a/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts b/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts index d9fe64641ad0..44bd152fc1dc 100644 --- a/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-char-estimator.test.ts @@ -98,21 +98,34 @@ describe("tool-result-char-estimator", () => { expect(chars).toBeGreaterThan(500_000); }); - it("returns 0 for bashExecution with excludeFromContext", () => { - const msg = { + it.each([ + { role: "bashExecution", - command: "npm run build", - output: "huge output ".repeat(50000), - exitCode: 0, - cancelled: false, - truncated: false, - excludeFromContext: true, - timestamp: 1, - } as unknown as AgentMessage; - + message: { + role: "bashExecution", + command: "npm run build", + output: "huge output ".repeat(50000), + exitCode: 0, + cancelled: false, + truncated: false, + excludeFromContext: true, + timestamp: 1, + }, + }, + { + role: "custom", + message: { + role: "custom", + customType: "display-note", + content: "huge output ".repeat(50000), + display: true, + excludeFromContext: true, + timestamp: 1, + }, + }, + ])("returns 0 for excluded $role messages", ({ message }) => { const cache = createMessageCharEstimateCache(); - const chars = estimateMessageCharsCached(msg, cache); - expect(chars).toBe(0); + expect(estimateMessageCharsCached(message as AgentMessage, cache)).toBe(0); }); it("estimates compactionSummary with prefix/suffix", () => { @@ -145,13 +158,13 @@ describe("tool-result-char-estimator", () => { expect(chars).toBeGreaterThan(256); }); - it("estimates custom message with string content", () => { + it.each([true, false])("estimates custom message with display=%s", (display) => { const text = "custom data ".repeat(5000); const msg = { role: "custom", customType: "test", content: text, - display: true, + display, timestamp: 1, } as unknown as AgentMessage; diff --git a/src/agents/embedded-agent-runner/tool-result-char-estimator.ts b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts index ed564edbb247..1e290b5c5cc6 100644 --- a/src/agents/embedded-agent-runner/tool-result-char-estimator.ts +++ b/src/agents/embedded-agent-runner/tool-result-char-estimator.ts @@ -105,7 +105,11 @@ export function getToolResultText(msg: AgentMessage): string { } function estimateMessageChars(msg: AgentMessage): number { - if (!msg || typeof msg !== "object") { + if ( + !msg || + typeof msg !== "object" || + ("excludeFromContext" in msg && msg.excludeFromContext === true) + ) { return 0; } @@ -161,9 +165,6 @@ function estimateMessageChars(msg: AgentMessage): number { const role: unknown = Reflect.get(msg, "role"); if (role === "bashExecution") { - if (Reflect.get(msg, "excludeFromContext") === true) { - return 0; - } return bashExecutionToText(msg as Parameters[0]).length; } diff --git a/src/agents/tools/sessions-tool.test.ts b/src/agents/tools/sessions-tool.test.ts index db7a728366bf..20bd551f9bdf 100644 --- a/src/agents/tools/sessions-tool.test.ts +++ b/src/agents/tools/sessions-tool.test.ts @@ -533,6 +533,14 @@ describe("sessions tool", () => { }), }), ); + expect(events).not.toContainEqual( + expect.objectContaining({ + message: expect.objectContaining({ + customType: "openclaw.system-note", + excludeFromContext: expect.anything(), + }), + }), + ); }); }); diff --git a/src/gateway/server-methods/session-audit.ts b/src/gateway/server-methods/session-audit.ts deleted file mode 100644 index 1ac15854623e..000000000000 --- a/src/gateway/server-methods/session-audit.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { SessionManager } from "../../agents/sessions/session-manager.js"; -import type { SessionEntry } from "../../config/sessions.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; - -export async function appendSessionAudit(params: { - cfg: OpenClawConfig; - target: { - agentId: string; - entry: Pick; - sessionKey: string; - storePath: string; - }; - text: string; - now: number; -}): Promise { - const identity = { - agentId: params.target.agentId, - sessionId: params.target.entry.sessionId, - storePath: params.target.storePath, - }; - SessionManager.appendMessageToTranscript( - { ...identity, sessionKey: params.target.sessionKey }, - { - role: "custom", - customType: "openclaw.system-note", - content: `System note: ${params.text}`, - display: true, - timestamp: params.now, - }, - { config: params.cfg }, - ); -} diff --git a/src/gateway/server-methods/sessions-sharing.test.ts b/src/gateway/server-methods/sessions-sharing.test.ts index fa807337a3ca..a0388f6ab7b3 100644 --- a/src/gateway/server-methods/sessions-sharing.test.ts +++ b/src/gateway/server-methods/sessions-sharing.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { SessionManager } from "../../agents/sessions/session-manager.js"; import { loadSessionEntry, loadTranscriptEvents, @@ -9,7 +8,6 @@ import { import { addSessionMember, listSessionMembers, - removeSessionMember, } from "../../config/sessions/session-sharing-store.js"; import { closeOpenClawAgentDatabasesForTest, @@ -107,7 +105,11 @@ function context( } async function call( - method: "session.visibility.set" | "session.members.list" | "session.members.add", + method: + | "session.visibility.set" + | "session.members.list" + | "session.members.add" + | "session.members.remove", params: Record, requestContext: GatewayRequestContext, requestClient: GatewayClient = soloClient(), @@ -709,7 +711,7 @@ describe("session sharing handlers", () => { }); }); - it("persists visibility and membership changes as transcript system notes", async () => { + it("publishes canonical visibility and membership changes without changing the transcript", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { const sessionKey = "agent:main:main"; await upsertSessionEntryCore( @@ -718,6 +720,11 @@ describe("session sharing handlers", () => { ); const broadcast = vi.fn(); const requestContext = context(broadcast); + const transcriptBefore = await loadTranscriptEvents({ + agentId: "main", + sessionId: "session-main", + sessionKey, + }); expect( await call( @@ -739,32 +746,51 @@ describe("session sharing handlers", () => { expect.objectContaining({ identityId: "local-operator", addedBy: "local-operator" }), ]); - const events = await loadTranscriptEvents({ - agentId: "main", - sessionId: "session-main", - sessionKey, - }); - expect(events).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - message: expect.objectContaining({ - customType: "openclaw.system-note", - content: expect.stringContaining("changed session visibility"), - }), + expect( + await call( + "session.members.remove", + { sessionKey, identityId: "local-operator" }, + requestContext, + ), + ).toEqual([[true, { ok: true, sessionKey, identityId: "local-operator" }, undefined]]); + expect(listSessionMembers({ agentId: "main", sessionKey })).toEqual([]); + + expect( + await loadTranscriptEvents({ + agentId: "main", + sessionId: "session-main", + sessionKey, + }), + ).toEqual(transcriptBefore); + const sharingEvents = broadcast.mock.calls + .filter(([event]) => event === "session.sharing") + .map(([, payload, options]) => ({ payload, options })); + expect(sharingEvents).toEqual([ + { + payload: expect.objectContaining({ + action: "visibility", + sessionKey, + visibility: "read-only", }), - expect.objectContaining({ - message: expect.objectContaining({ - customType: "openclaw.system-note", - content: expect.stringContaining("added local-operator"), - }), + options: { sessionKeys: [sessionKey] }, + }, + { + payload: expect.objectContaining({ + action: "member-added", + sessionKey, + identityId: "local-operator", }), - ]), - ); - expect(broadcast).toHaveBeenCalledWith( - "session.sharing", - expect.objectContaining({ sessionKey }), - { sessionKeys: [sessionKey] }, - ); + options: { sessionKeys: [sessionKey] }, + }, + { + payload: expect.objectContaining({ + action: "member-removed", + sessionKey, + identityId: "local-operator", + }), + options: { sessionKeys: [sessionKey] }, + }, + ]); const restrictedKey = "agent:main:restricted"; await upsertSessionEntryCore( @@ -821,38 +847,6 @@ describe("session sharing handlers", () => { agentId: "main", }), ).toBe(false); - - await patchSessionEntryCore({ agentId: "main", sessionKey }, () => ({ - visibility: "shared", - })); - const append = vi - .spyOn(SessionManager, "appendMessageToTranscript") - .mockImplementationOnce(() => { - throw new Error("audit unavailable"); - }); - const concurrent = await Promise.allSettled([ - call("session.visibility.set", { sessionKey, visibility: "read-only" }, requestContext), - call("session.visibility.set", { sessionKey, visibility: "draft" }, requestContext), - ]); - append.mockRestore(); - expect(concurrent.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); - expect(loadSessionEntry({ agentId: "main", sessionKey })?.visibility).toBe("draft"); - - removeSessionMember({ agentId: "main", sessionKey }, "local-operator"); - const memberAppend = vi - .spyOn(SessionManager, "appendMessageToTranscript") - .mockImplementationOnce(() => { - throw new Error("audit unavailable"); - }); - const concurrentAdds = await Promise.allSettled([ - call("session.members.add", { sessionKey, identityId: "local-operator" }, requestContext), - call("session.members.add", { sessionKey, identityId: "local-operator" }, requestContext), - ]); - memberAppend.mockRestore(); - expect(concurrentAdds.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); - expect(listSessionMembers({ agentId: "main", sessionKey })).toEqual([ - expect.objectContaining({ identityId: "local-operator" }), - ]); }); }); }); diff --git a/src/gateway/server-methods/sessions-sharing.ts b/src/gateway/server-methods/sessions-sharing.ts index 9fd7ecd4296a..3a2a6ede191d 100644 --- a/src/gateway/server-methods/sessions-sharing.ts +++ b/src/gateway/server-methods/sessions-sharing.ts @@ -30,7 +30,6 @@ import { resolveSessionVisibility, } from "../session-sharing.js"; import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; -import { appendSessionAudit } from "./session-audit.js"; import { emitSessionsChanged } from "./session-change-event.js"; import type { GatewayClient, GatewayRequestContext, GatewayRequestHandlers } from "./types.js"; import { assertValidParams } from "./validation.js"; @@ -236,28 +235,8 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { if (sessionChanged) { throw new Error("session changed before sharing mutation"); } - invalidateSessionSharingSnapshot(current.canonicalKey); const now = Date.now(); const actor = actorIdentity(client); - try { - await appendSessionAudit({ - cfg, - target: { ...current, sessionKey: current.storeKey }, - text: `${actor.label ?? actor.id} changed session visibility from ${previous} to ${visibility}.`, - now, - }); - } catch (error) { - // Roll back only the exact instance and value we patched; an unexpected - // storage-owner replacement must not inherit the old visibility. - await patchSessionEntryCore(scope, (entry) => - entry.sessionId === current.entry.sessionId && - resolveSessionVisibility(entry) === visibility - ? { visibility: previous } - : null, - ); - invalidateSessionSharingSnapshot(current.canonicalKey); - throw error; - } publishSharingChange({ context, agentId: current.agentId, @@ -370,17 +349,6 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { if (!added.inserted) { return; } - try { - await appendSessionAudit({ - cfg, - target: { ...current, sessionKey: current.storeKey }, - text: `${actor.label ?? actor.id} added ${params.identityId} as a session member.`, - now, - }); - } catch (error) { - removeSessionMember(scope, params.identityId, added.member, current.entry.sessionId); - throw error; - } publishSharingChange({ context, agentId: current.agentId, @@ -441,22 +409,6 @@ export const sessionSharingHandlers: GatewayRequestHandlers = { } const now = Date.now(); const actor = actorIdentity(client); - try { - await appendSessionAudit({ - cfg, - target: { ...current, sessionKey: current.storeKey }, - text: `${actor.label ?? actor.id} removed ${params.identityId} from session members.`, - now, - }); - } catch (error) { - addSessionMember(scope, { - identityId: removed.identityId, - addedBy: removed.addedBy, - addedAt: removed.addedAt, - expectedSessionId: current.entry.sessionId, - }); - throw error; - } publishSharingChange({ context, agentId: current.agentId, diff --git a/src/gateway/server-methods/sessions-suggestions.test-mocks.ts b/src/gateway/server-methods/sessions-suggestions.test-mocks.ts index 0219921897bb..a9794b7f80e6 100644 --- a/src/gateway/server-methods/sessions-suggestions.test-mocks.ts +++ b/src/gateway/server-methods/sessions-suggestions.test-mocks.ts @@ -1,7 +1,6 @@ import { vi } from "vitest"; const mocks = vi.hoisted(() => ({ - appendSessionAudit: vi.fn(async () => undefined), handleChatSend: vi.fn(), suggestionMutationFailure: undefined as | "claim" @@ -13,7 +12,6 @@ const mocks = vi.hoisted(() => ({ })); vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend })); -vi.mock("./session-audit.js", () => ({ appendSessionAudit: mocks.appendSessionAudit })); vi.mock("../../infra/system-presence.js", () => ({ listSystemPresence: () => mocks.presence, })); diff --git a/src/gateway/server-methods/sessions-suggestions.test-support.ts b/src/gateway/server-methods/sessions-suggestions.test-support.ts index 699c02acc4df..538764f00165 100644 --- a/src/gateway/server-methods/sessions-suggestions.test-support.ts +++ b/src/gateway/server-methods/sessions-suggestions.test-support.ts @@ -82,13 +82,11 @@ export function responseSuggestionId(result: Awaited>): } export function registerSessionSuggestionTestLifecycle(mocks: { - appendSessionAudit: ReturnType; handleChatSend: ReturnType; suggestionMutationFailure?: string; presence: unknown[]; }): void { beforeEach(() => { - mocks.appendSessionAudit.mockClear(); mocks.handleChatSend.mockReset(); mocks.handleChatSend.mockImplementation(({ respond }: { respond: RespondFn }) => { respond(true, { runId: "suggestion-run", status: "started" }); diff --git a/src/gateway/server-methods/sessions-suggestions.test.ts b/src/gateway/server-methods/sessions-suggestions.test.ts index 12cfa026f8d3..d85ef142fc16 100644 --- a/src/gateway/server-methods/sessions-suggestions.test.ts +++ b/src/gateway/server-methods/sessions-suggestions.test.ts @@ -1,13 +1,19 @@ import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; -import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js"; +import { SessionManager } from "../../agents/sessions/session-manager.js"; +import { + readSessionTranscriptMessageEvents, + upsertSessionEntryCore, +} from "../../config/sessions/session-accessor.js"; import { addSessionMember } from "../../config/sessions/session-sharing-store.js"; import { addSessionSuggestion, listSessionSuggestions, SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, } from "../../config/sessions/session-suggestion-store.js"; +import { buildPersistedUserTurnMessage } from "../../sessions/user-turn-transcript.js"; import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { resolveSessionSharingTarget } from "../session-sharing.js"; import { getSessionSuggestionTestMocks } from "./sessions-suggestions.test-mocks.js"; import { call, @@ -136,7 +142,9 @@ describe("session suggestion handlers", () => { expect.objectContaining({ action: "added" }), expect.objectContaining({ sessionKeys: [sessionKey, "main"] }), ); - expect(mocks.appendSessionAudit).not.toHaveBeenCalled(); + expect( + readSessionTranscriptMessageEvents({ agentId: "main", sessionId: "session-main" }), + ).toEqual([]); await call( "session.suggestions.add", @@ -461,44 +469,102 @@ describe("session suggestion handlers", () => { ); expect(owner.responses[0]?.[0]).toBe(true); expect(mocks.handleChatSend).not.toHaveBeenCalled(); - expect(mocks.appendSessionAudit).toHaveBeenCalledWith( - expect.objectContaining({ text: "Owner moved a suggestion into the composer." }), - ); - expect(mocks.appendSessionAudit).not.toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringContaining("forged") }), - ); + expect( + readSessionTranscriptMessageEvents({ agentId: "main", sessionId: "session-main" }), + ).toEqual([]); }); }); - it("publishes a fenced resolution before awaiting the transcript audit", async () => { - await withOpenClawTestState({ scenario: "minimal" }, async () => { - await upsertDefaultSuggestionSession(); - const added = await call( - "session.suggestions.add", - { sessionKey, text: "resolve before audit" }, - client("alice", "Alice"), - ); - const audit = createDeferred(); - mocks.appendSessionAudit.mockImplementationOnce(() => audit.promise); - const broadcast = vi.fn(); - const pending = call( - "session.suggestions.resolve", - { sessionKey, id: responseSuggestionId(added), resolution: "edit" }, - client("owner", "Owner"), - context(broadcast), - ); + it.each([ + ["send", "accepted", true], + ["queue", "accepted", true], + ["edit", "accepted", false], + ["dismiss", "dismissed", false], + ] as const)( + "finalizes and publishes %s without administrative transcript narration", + async (resolution, state, dispatchesConversation) => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertDefaultSuggestionSession(); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "Ship the focused change" }, + client("alice", "Alice"), + ); + const id = responseSuggestionId(added); + const broadcast = vi.fn(); + const transcriptScope = { agentId: "main", sessionId: "session-main" }; + const target = resolveSessionSharingTarget({ cfg: {}, sessionKey, agentId: "main" }); + if (!target) { + throw new Error("Default suggestion session target was not found"); + } + expect(readSessionTranscriptMessageEvents(transcriptScope)).toEqual([]); - await vi.waitFor(() => expect(mocks.appendSessionAudit).toHaveBeenCalledOnce()); - expect(broadcast).toHaveBeenCalledWith( - "session.suggestion", - expect.objectContaining({ action: "resolved" }), - expect.any(Object), - ); + if (dispatchesConversation) { + mocks.handleChatSend.mockImplementationOnce( + ({ + params, + client: attributedClient, + respond, + }: { + params: { message: string; idempotencyKey: string }; + client: { internal?: { senderAttribution?: { id?: string; name?: string } } }; + respond: RespondFn; + }) => { + SessionManager.appendMessageToTranscript( + { ...transcriptScope, sessionKey, storePath: target.storePath }, + buildPersistedUserTurnMessage({ + text: params.message, + idempotencyKey: params.idempotencyKey, + sender: attributedClient.internal?.senderAttribution, + }), + ); + respond(true, { runId: "suggestion-run", status: "started" }); + }, + ); + } - audit.resolve(undefined); - expect((await pending).responses[0]?.[0]).toBe(true); - }); - }); + const resolved = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution }, + client("owner", "Owner"), + context(broadcast), + ); + + expect(resolved.responses[0]).toMatchObject([ + true, + { suggestion: { id, state, text: "Ship the focused change" } }, + ]); + expect(listSessionSuggestions({ agentId: "main", sessionKey })).toMatchObject([ + { id, state, text: "Ship the focused change" }, + ]); + expect(broadcast).toHaveBeenCalledWith( + "session.suggestion", + expect.objectContaining({ + action: "resolved", + suggestion: expect.objectContaining({ id, state }), + }), + expect.objectContaining({ sessionKeys: [sessionKey] }), + ); + + const events = readSessionTranscriptMessageEvents(transcriptScope); + if (dispatchesConversation) { + expect(events).toHaveLength(1); + expect(events[0]?.event).toMatchObject({ + message: { + role: "user", + content: "Ship the focused change", + idempotencyKey: `session-suggestion:${id}`, + __openclaw: { senderId: "alice", senderName: "Suggested by Alice" }, + }, + }); + expect(mocks.handleChatSend).toHaveBeenCalledOnce(); + } else { + expect(events).toEqual([]); + expect(mocks.handleChatSend).not.toHaveBeenCalled(); + } + }); + }, + ); it("keeps typing dormant for one identity and broadcasts for two live viewers", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts index ff063a759c78..c1d28e0bc569 100644 --- a/src/gateway/server-methods/sessions-suggestions.ts +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -7,7 +7,6 @@ import { validateSessionTypingParams, type SessionSuggestion, type SessionSuggestionResolution, - type SessionSharingIdentity, type SessionTypingEvent, } from "../../../packages/gateway-protocol/src/index.js"; import { @@ -33,7 +32,6 @@ import { import { resolveSessionSubscriptionKeys as subscriptionKeys } from "../session-subscription-keys.js"; import { handleChatSend } from "./chat-send-handler.js"; import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; -import { appendSessionAudit } from "./session-audit.js"; import { broadcastTypingThrottled, liveViewerIdentities, @@ -115,30 +113,6 @@ function runSessionSuggestionMutation(params: { } } -function resolutionAuditAction(resolution: SessionSuggestionResolution): string { - switch (resolution) { - case "send": - return "sent a suggestion immediately"; - case "queue": - return "queued a suggestion"; - case "edit": - return "moved a suggestion into the composer"; - case "dismiss": - return "dismissed a suggestion"; - } - throw new Error(`unsupported suggestion resolution: ${String(resolution)}`); -} - -function actorIdentity(client: GatewayClient | null): SessionSharingIdentity { - return ( - gatewayClientSessionCreator(client) ?? { - type: "system", - id: "operator.admin", - label: "Administrator", - } - ); -} - function attributedSuggestionClient( client: GatewayClient, suggestion: StoredSessionSuggestion, @@ -518,17 +492,6 @@ export const sessionSuggestionHandlers: GatewayRequestHandlers = { action: "resolved", suggestion: projected, }); - const actor = actorIdentity(client); - try { - await appendSessionAudit({ - cfg: context.getRuntimeConfig(), - target: { ...target, sessionKey: target.canonicalKey }, - text: `${actor.label ?? actor.id} ${resolutionAuditAction(resolution)}.`, - now: Date.now(), - }); - } catch (error) { - context.logGateway.warn(`failed to append suggestion resolution audit: ${String(error)}`); - } respond(true, { suggestion: projected }); }, diff --git a/src/gateway/server.sessions.archive-lifecycle.test.ts b/src/gateway/server.sessions.archive-lifecycle.test.ts index 3183ccfe08e0..9b3bc0f6b71c 100644 --- a/src/gateway/server.sessions.archive-lifecycle.test.ts +++ b/src/gateway/server.sessions.archive-lifecycle.test.ts @@ -30,27 +30,6 @@ import { import { registerWorkerInferenceSessionDrain } from "./worker-environments/inference-control-internal.js"; import type { WorkerSessionPlacementRecord } from "./worker-environments/placement-record.js"; -const sessionAuditGate = vi.hoisted(() => ({ - entered: vi.fn(), - wait: undefined as Promise | undefined, -})); - -vi.mock("./server-methods/session-audit.js", async () => { - const actual = await vi.importActual( - "./server-methods/session-audit.js", - ); - return { - ...actual, - appendSessionAudit: async (...args: Parameters) => { - if (sessionAuditGate.wait) { - sessionAuditGate.entered(); - await sessionAuditGate.wait; - } - await actual.appendSessionAudit(...args); - }, - }; -}); - const { createConfiguredGlobalAgentSessionStore, createSessionStoreDir, @@ -419,28 +398,39 @@ test("sharing revocation fences archive before cancellation and forces fresh aut throw new Error("expected resolved sharing target"); } - const releaseAudit = createDeferredCore(); - sessionAuditGate.entered.mockClear(); - sessionAuditGate.wait = releaseAudit.promise; + const sharingCommitted = createDeferredCore(); + const releaseSharingMutation = createDeferredCore(); let sharing: Promise | undefined; let archive: Promise | undefined; try { let sharingSettled = false; - sharing = invokeVisibilityHandler({ - client: owner, - context: requestContext, - sessionKey, - visibility: "draft", + sharing = runExclusiveSessionLifecycleMutation({ + scope: sharingTarget.storePath, + identities: [ + sharingTarget.canonicalKey, + sharingTarget.storeKey, + ...sharingTarget.storeKeys, + sharingTarget.entry.sessionId, + ], + run: async () => { + const response = await invokeVisibilityHandler({ + client: owner, + context: requestContext, + sessionKey, + visibility: "draft", + }); + sharingCommitted.resolve(); + await releaseSharingMutation.promise; + return response; + }, }).finally(() => { sharingSettled = true; }); - await vi.waitFor(() => { - expect(sessionAuditGate.entered).toHaveBeenCalledOnce(); - expect( - isSessionLifecycleMutationActive(sharingTarget.storePath, [sessionKey, sessionId]), - ).toBe(true); - }); + await sharingCommitted.promise; + expect(isSessionLifecycleMutationActive(sharingTarget.storePath, [sessionKey, sessionId])).toBe( + true, + ); expect(sharingSettled).toBe(false); expect(loadSessionEntry({ storePath, sessionKey })?.visibility).toBe("draft"); @@ -462,7 +452,7 @@ test("sharing revocation fences archive before cancellation and forces fresh aut expect(reclaim).not.toHaveBeenCalled(); expect(loadSessionEntry({ storePath, sessionKey })?.archivedAt).toBeUndefined(); - releaseAudit.resolve(); + releaseSharingMutation.resolve(); expect(await sharing).toMatchObject({ ok: true }); expect(loadSessionEntry({ storePath, sessionKey })?.visibility).toBe("draft"); @@ -473,10 +463,10 @@ test("sharing revocation fences archive before cancellation and forces fresh aut expect(interrupted).toBe(false); expect(active.controller.signal.aborted).toBe(false); expectNoSessionQueueCleanup(); + expect(reclaim).not.toHaveBeenCalled(); expect(loadSessionEntry({ storePath, sessionKey })?.archivedAt).toBeUndefined(); } finally { - sessionAuditGate.wait = undefined; - releaseAudit.resolve(); + releaseSharingMutation.resolve(); admission.release(); await Promise.allSettled([...(sharing ? [sharing] : []), ...(archive ? [archive] : [])]); active.unsubscribe();