From 781ded80d62499dc123a97e4eb534ce13791e034 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 09:52:20 -0700 Subject: [PATCH 001/745] fix(plugins): register static node-host commands without activation (#127043) The node host resolves its plugin registry via loadPluginRegistryHandle (activate:false). Since #117587 static definition.nodeHostCommands only registered under runFullActivationOnlyRegistrations, so headless nodes silently lost browser.proxy (and the browser/file caps), breaking the meeting-bot chain with 'No connected Google Meet-capable node with browser proxy'. Register node-host commands in every load mode; each command keeps its own isAvailable gate. reload and security audit collectors stay activation-only. --- src/plugins/loader-runtime-candidate.ts | 10 ++-- src/plugins/loader.node-host-commands.test.ts | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) create mode 100644 src/plugins/loader.node-host-commands.test.ts diff --git a/src/plugins/loader-runtime-candidate.ts b/src/plugins/loader-runtime-candidate.ts index ee139e75563a..92fa090aed09 100644 --- a/src/plugins/loader-runtime-candidate.ts +++ b/src/plugins/loader-runtime-candidate.ts @@ -507,13 +507,17 @@ export function loadRuntimePluginCandidate(params: { } return; } + // Node-host commands register in every load mode: the node host resolves its + // registry without activation (loadPluginRegistryHandle), and each command is + // already availability-gated per invocation. Gating them on full activation + // silently strips static registrations like browser.proxy from headless nodes. + for (const nodeHostCommand of definition?.nodeHostCommands ?? []) { + params.registryBuilder.registerNodeHostCommand(record, nodeHostCommand); + } if (registrationPlan.runFullActivationOnlyRegistrations) { if (definition?.reload) { params.registryBuilder.registerReload(record, definition.reload); } - for (const nodeHostCommand of definition?.nodeHostCommands ?? []) { - params.registryBuilder.registerNodeHostCommand(record, nodeHostCommand); - } for (const collector of definition?.securityAuditCollectors ?? []) { params.registryBuilder.registerSecurityAuditCollector(record, collector); } diff --git a/src/plugins/loader.node-host-commands.test.ts b/src/plugins/loader.node-host-commands.test.ts new file mode 100644 index 000000000000..a98c93e8ddeb --- /dev/null +++ b/src/plugins/loader.node-host-commands.test.ts @@ -0,0 +1,49 @@ +/** Verifies static plugin nodeHostCommands survive non-activating registry loads (node-host path). */ +import { afterAll, afterEach, expect, it } from "vitest"; +import { + cleanupPluginLoaderFixturesForTest, + loadOpenClawPlugins, + resetPluginLoaderTestStateForTest, + useNoBundledPlugins, + writePlugin, +} from "./loader.test-fixtures.js"; + +afterEach(resetPluginLoaderTestStateForTest); +afterAll(cleanupPluginLoaderFixturesForTest); + +// The node host resolves its registry via loadPluginRegistryHandle (activate:false). +// Static nodeHostCommands (e.g. the browser plugin's browser.proxy) must register +// there too, or headless meeting/browser nodes silently lose their surface. +it("registers static nodeHostCommands without activation", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "node-surface", + body: `module.exports = { + id: "node-surface", + nodeHostCommands: [{ + command: "nodesurface.proxy", + cap: "node-surface", + handle: async () => "ok", + }], + register() {}, + };`, + }); + + const registry = loadOpenClawPlugins({ + cache: false, + activate: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: [plugin.id], + }, + }, + onlyPluginIds: [plugin.id], + }); + + expect(registry.plugins.find((entry) => entry.id === plugin.id)?.status).toBe("loaded"); + expect(registry.nodeHostCommands.map((entry) => entry.command.command)).toContain( + "nodesurface.proxy", + ); +}); From 6448550898c2c836e14249213d22c491f7111063 Mon Sep 17 00:00:00 2001 From: Josh Lehman Date: Fri, 21 Aug 2026 10:00:40 -0700 Subject: [PATCH 002/745] fix(codex): preserve project instructions in restricted turns (#126891) * fix(codex): preserve restricted project instructions * fix(codex): preserve ring-zero context isolation * docs(codex): explain restricted turns and ring zero --- docs/plugins/codex-harness-reference.md | 59 ++++++++++++++++++- docs/plugins/codex-harness-runtime.md | 33 ++++++----- docs/plugins/codex-harness.md | 54 ++++++++++++++++- .../src/app-server/attempt-context.test.ts | 34 +++++++++++ .../codex/src/app-server/attempt-context.ts | 11 +++- .../src/app-server/run-attempt-context.ts | 7 ++- .../codex/src/app-server/run-attempt.test.ts | 8 +++ .../codex/src/app-server/session-binding.ts | 4 +- .../src/app-server/thread-lifecycle.test.ts | 48 +++++++++++++++ .../codex/src/app-server/thread-requests.ts | 36 +++++------ 10 files changed, 253 insertions(+), 41 deletions(-) diff --git a/docs/plugins/codex-harness-reference.md b/docs/plugins/codex-harness-reference.md index 2680a2e13451..45fdec3ff0f9 100644 --- a/docs/plugins/codex-harness-reference.md +++ b/docs/plugins/codex-harness-reference.md @@ -808,14 +808,67 @@ the fallback catalog: } ``` +## Restricted turns + +The Codex harness evaluates the effective tool policy for every turn. It marks +the turn policy-restricted when any explicit policy would otherwise leave a +Codex-native capability outside the OpenClaw policy boundary. + +Restriction sources include global, provider, agent, group, sender, sandbox, +subagent, inherited, scheduled/runtime, and per-run tool policies. A finite +allowlist always restricts the native surface. A deny list restricts it when an +expanded entry is unknown or absent from the audited safe-deny set; this includes +wildcards and tool groups containing any unsafe entry. `disableTools` becomes an +empty per-run allowlist and therefore also restricts the native surface. Default +tool-profile narrowing is not an explicit restriction and does not activate this +mode. + +The current audited safe-deny names are: + +```text +automations, canvas, dashboard, gateway, heartbeat_respond, image_generate, +memory_get, memory_search, message, music_generate, show_widget, skill_workshop, +tts, video_generate, web_fetch, x_search +``` + +A policy containing only those denies stays on the normal Codex native surface; +the harness applies the named OpenClaw denial directly. Any other deny fails +closed into the restricted surface. For example, `tools.deny: ["nodes"]` +restricts the native surface because `nodes` is not in the audited set. + +Policy-restricted turns have no Codex environment selection or native Code Mode. +OpenClaw disables inherited and configured MCP servers, attests that they remain +disabled, disables native hook relays, and applies the effective policy to its +dynamic tools. A temporary restriction on an existing session uses a transient +Codex thread and preserves the unrestricted binding for later resume. + +Ring zero is not a configurable policy profile. It is the host-scoped system +agent path used by OpenClaw setup and repair flows. The host must activate the +system-agent authority and provide the exact single-tool allowlist +`["openclaw"]`. Ring zero applies the restricted tool surface plus host-authored +base instructions and zero project-document budget. It also suppresses +OpenClaw's `AGENTS.md` developer-instruction carrier, so ambient workspace +instructions cannot enter the setup/repair turn. + +Message-only source replies also use the restricted tool surface. Lightweight +bootstrap turns and tool-disabled internal turns additionally set the project- +document budget to zero. These modes are separate inputs even when their final +thread configuration overlaps. + ## Workspace bootstrap files -Codex handles `AGENTS.md` itself through native project-doc discovery. +Codex normally handles `AGENTS.md` itself through native project-doc discovery. OpenClaw does not write synthetic Codex project-doc files or depend on Codex fallback filenames for persona files, because Codex fallbacks only apply when -`AGENTS.md` is missing. +`AGENTS.md` is missing. Ordinary policy-restricted turns have no native +filesystem environment, so OpenClaw instead sends the bounded workspace +`AGENTS.md` snapshot as thread-level developer instructions. Ring-zero, +lightweight, message-only, and tool-disabled internal turns suppress that +carrier. -For OpenClaw workspace parity, local tool notes live in the `## Tools` section of `AGENTS.md` and ride Codex's native project-doc discovery. The Codex harness forwards the other bootstrap files as developer instructions: +For OpenClaw workspace parity, local tool notes live in the `## Tools` section +of `AGENTS.md` and normally ride Codex's native project-doc discovery. The +Codex harness forwards the other bootstrap files as developer instructions: - `SOUL.md`, `IDENTITY.md`, and `USER.md` are forwarded as **turn-scoped** collaboration instructions. Native Codex subagents do not inherit them, diff --git a/docs/plugins/codex-harness-runtime.md b/docs/plugins/codex-harness-runtime.md index f61eb3dfb0ea..6eb7d708309b 100644 --- a/docs/plugins/codex-harness-runtime.md +++ b/docs/plugins/codex-harness-runtime.md @@ -45,8 +45,11 @@ it uses Codex-flavored OpenAI auth or transport. OpenClaw starts and resumes native Codex threads with Codex's built-in personality disabled (`personality: "none"`) so workspace personality files and OpenClaw agent identity stay authoritative. Native Codex keeps Codex-owned -base/model instructions and project-doc loading otherwise. Lightweight -OpenClaw runs (for example cron) still suppress project-doc loading. +base/model instructions and project-doc loading otherwise. An ordinary +policy-restricted turn has no native filesystem environment, so OpenClaw carries +the bounded workspace `AGENTS.md` snapshot as thread-level developer +instructions instead. Lightweight, ring-zero, message-only, and tool-disabled +internal turns suppress project-doc loading and that fallback carrier. OpenClaw developer instructions cover OpenClaw runtime concerns: source-channel delivery, OpenClaw dynamic tools, ACP delegation, adapter context, and the @@ -224,19 +227,19 @@ for configuration and local-only transport restrictions. Supported in Codex runtime v1: -| Surface | Support | Why | -| --------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. | -| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. | -| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. | -| Prompt and context plugins | Supported | OpenClaw projects OpenClaw-specific prompt/context into the Codex turn while leaving Codex-owned base, model, and configured project-doc prompts in the native Codex lane. OpenClaw disables Codex's built-in personality for native threads so agent workspace personality files remain authoritative. Native Codex developer instructions accept only command guidance explicitly scoped to `codex_app_server`; legacy global command hints remain for non-Codex prompt surfaces. | -| Context engine lifecycle | Supported | Assemble, ingest, and after-turn maintenance run around Codex turns. Context engines do not replace native Codex compaction. | -| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. | -| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. | -| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. | -| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on the pinned Codex app-server. Blocking is supported; argument rewriting is not. | -| Native permission policy | Supported through Codex app-server approvals and compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. | -| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. | +| Surface | Support | Why | +| --------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| OpenAI model loop through Codex | Supported | Codex app-server owns the OpenAI turn, native thread resume, and native tool continuation. | +| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. | +| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. | +| Prompt and context plugins | Supported | OpenClaw projects OpenClaw-specific prompt/context into the Codex turn while normally leaving Codex-owned base, model, and configured project-doc prompts in the native Codex lane. For ordinary policy-restricted turns without a native filesystem environment, OpenClaw carries the bounded workspace `AGENTS.md` snapshot as thread-level developer instructions. Ring-zero and other context-restricted internal modes suppress both paths. OpenClaw disables Codex's built-in personality for native threads so agent workspace personality files remain authoritative. Native Codex developer instructions accept only command guidance explicitly scoped to `codex_app_server`; legacy global command hints remain for non-Codex prompt surfaces. | +| Context engine lifecycle | Supported | Assemble, ingest, and after-turn maintenance run around Codex turns. Context engines do not replace native Codex compaction. | +| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. | +| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. | +| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. | +| Native shell, patch, and MCP block or observe | Supported through native hook relay | Codex `PreToolUse` and `PostToolUse` are relayed for committed native tool surfaces, including MCP payloads on the pinned Codex app-server. Blocking is supported; argument rewriting is not. | +| Native permission policy | Supported through Codex app-server approvals and compatibility native hook relay | Codex app-server approval requests route through OpenClaw after Codex review. The `PermissionRequest` native hook relay is opt-in for native approval modes because Codex emits it before guardian review. | +| App-server trajectory capture | Supported | OpenClaw records the request it sent to app-server and the app-server notifications it receives. | Not supported in Codex runtime v1: diff --git a/docs/plugins/codex-harness.md b/docs/plugins/codex-harness.md index b4f720ea8f30..5ff04206fcfc 100644 --- a/docs/plugins/codex-harness.md +++ b/docs/plugins/codex-harness.md @@ -271,13 +271,63 @@ Changing auth order does not make a custom, Completions, HTTP, or request-overridden route Codex-compatible. Valid model-scoped Fast-mode and cutoff controls are runtime controls, not request overrides. +### Restricted turns and ring zero + +OpenClaw applies Codex restrictions per turn, not as a permanent session mode. +An existing session can therefore run one restricted turn and return to its +normal Codex thread on the next unrestricted turn. When a restriction is +temporary, OpenClaw preserves the normal thread binding and uses a temporary +restricted thread where necessary. + +An ordinary **policy-restricted turn** occurs when an explicit OpenClaw tool +policy cannot be mapped safely onto Codex's native tool surface. Common +triggers include: + +- a finite `tools.allow` list or an internal per-run allowlist +- `disableTools` or a sender/group policy that denies all tools +- a `tools.deny` entry with a wildcard, tool group, unknown name, or name that + is not in the Codex harness's audited safe-deny set +- an applicable agent, provider, group, sender, sandbox, subagent, inherited, + scheduled, or runtime tool policy with one of those restrictions + +Default tool-profile narrowing alone does not trigger this mode. A deny list +containing only audited OpenClaw-owned tools can also stay on the normal native +surface; the harness enforces those denies without disabling unrelated Codex +capabilities. See [Native tool-policy enforcement](/plugins/sdk-agent-harness#native-tool-policy-enforcement) +for the generic harness contract and [Codex harness reference](/plugins/codex-harness-reference#restricted-turns) +for the current Codex rules. + +For an ordinary policy-restricted turn, OpenClaw disables Codex native Code +Mode, removes environment selections, disables and verifies inherited and +configured MCP servers, disables native hook relays, and filters OpenClaw +dynamic tools through the effective policy. The bounded workspace `AGENTS.md` +snapshot still reaches the model as thread-level developer instructions because +project instructions are context, not tool authority. + +**Ring zero** is stronger and separate. It is the host-owned OpenClaw system +agent used for setup and repair operations. The host activates it with the +single `openclaw` tool; normal agent config cannot opt a chat into ring zero. +Ring-zero turns keep only that host-scoped tool, replace ambient Codex +instructions with host-authored setup instructions, disable native tools and +MCP servers, and suppress workspace project documents, including the +`AGENTS.md` developer-instruction carrier. + +Other narrow internal modes also suppress project documents: lightweight +bootstrap turns, message-only source replies, and tool-disabled internal turns. +They share some isolation settings with policy-restricted turns but are not +synonyms for ring zero. + ### Project instructions Codex loads `AGENTS.md` files through native project-document discovery. For normal app-server threads, OpenClaw raises Codex's aggregate root-to-working- directory budget from the upstream 32 KiB default to a bounded 128 KiB so later -scoped instructions are not silently clipped. Lightweight and restricted turns -set the native project-document budget to zero instead. +scoped instructions are not silently clipped. Ordinary conversation tool-policy +restrictions preserve that budget because project instructions are context, not +tool authority. Their isolated native environment cannot read workspace files, +so OpenClaw supplies the bounded workspace `AGENTS.md` snapshot as thread-level +developer instructions. Lightweight, ring-zero, message-only, and tool-disabled +internal turns set the native project-document budget to zero instead. This byte budget is separate from the character-based workspace bootstrap limits configured through `agents.defaults.bootstrapMaxChars` and diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts index b40665c8fe0f..69500c6074b9 100644 --- a/extensions/codex/src/app-server/attempt-context.test.ts +++ b/extensions/codex/src/app-server/attempt-context.test.ts @@ -141,6 +141,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:main:session-1", sessionAgentId: "main", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, }); expect(context.memoryReferenceFiles).toEqual([]); @@ -181,6 +182,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:marketing-agent:session-1", sessionAgentId: "marketing-agent", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, sandboxed: true, }); @@ -219,6 +221,7 @@ describe("Codex app-server attempt context", () => { sessionKey: "agent:main:session-1", sessionAgentId: "main", memoryToolNames: ["memory_search", "memory_get"], + ringZeroActive: false, }); expect(context.threadDeveloperInstructions).toContain("Canonical agent instructions"); @@ -241,6 +244,37 @@ describe("Codex app-server attempt context", () => { } }); + it("keeps ambient workspace instructions out of overlapping ring-zero restrictions", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ring-zero-workspace-")); + const executionDir = await fs.mkdtemp(path.join(os.tmpdir(), "codex-ring-zero-execution-")); + await fs.writeFile(path.join(workspaceDir, "AGENTS.md"), "Ambient workspace instructions"); + + try { + const context = await buildCodexWorkspaceBootstrapContext({ + params: { + sessionId: "session-1", + sessionKey: "agent:openclaw:session-1", + toolsAllow: ["openclaw"], + pluginHarnessToolPolicyRestricted: true, + config: { agents: { defaults: { workspace: workspaceDir } } }, + } as EmbeddedRunAttemptParams, + resolvedWorkspace: workspaceDir, + executionWorkspace: executionDir, + effectiveWorkspace: executionDir, + sessionKey: "agent:openclaw:session-1", + sessionAgentId: "openclaw", + memoryToolNames: [], + ringZeroActive: true, + }); + + expect(context.threadDeveloperInstructions).toBeUndefined(); + expect(context.threadDeveloperInstructionFiles).toEqual([]); + } finally { + await fs.rm(workspaceDir, { recursive: true, force: true }); + await fs.rm(executionDir, { recursive: true, force: true }); + } + }); + it("reads and compares thread-bootstrap context-engine projections", () => { const projection = readContextEngineThreadBootstrapProjection({ mode: "thread_bootstrap", diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts index 58958eded63e..df98201ead7d 100644 --- a/extensions/codex/src/app-server/attempt-context.ts +++ b/extensions/codex/src/app-server/attempt-context.ts @@ -26,6 +26,7 @@ import type { } from "openclaw/plugin-sdk/session-transcript-runtime"; import { readNonBlankString as readNonEmptyString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js"; +import { isMessageOnlyCodexSourceReply } from "./dynamic-tool-profile.js"; import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js"; import { flattenCodexDynamicToolFunctions, isJsonObject } from "./protocol.js"; import type { CodexAppServerThreadBinding } from "./session-binding.js"; @@ -176,6 +177,7 @@ export async function buildCodexWorkspaceBootstrapContext(params: { sessionKey: string; sessionAgentId: string; memoryToolNames: readonly string[]; + ringZeroActive: boolean; sandboxed?: boolean; }): Promise { try { @@ -244,8 +246,15 @@ export async function buildCodexWorkspaceBootstrapContext(params: { memoryWorkspaceDir: params.effectiveWorkspace, }); const injectOpenClawContext = shouldInjectCodexOpenClawPromptContext(params.params); + const restrictedProjectDocNeedsOpenClawCarrier = + params.params.pluginHarnessToolPolicyRestricted === true && + !params.params.disableTools && + !isMessageOnlyCodexSourceReply(params.params) && + params.params.bootstrapContextMode !== "lightweight"; const threadDeveloperInstructionFiles = - injectOpenClawContext && inheritsAgentWorkspace + injectOpenClawContext && + !params.ringZeroActive && + (inheritsAgentWorkspace || restrictedProjectDocNeedsOpenClawCarrier) ? selectCodexWorkspaceAgentProjectInstructionFiles(contextFiles, params.resolvedWorkspace) : []; const turnScopedDeveloperInstructionFiles = injectOpenClawContext diff --git a/extensions/codex/src/app-server/run-attempt-context.ts b/extensions/codex/src/app-server/run-attempt-context.ts index d153858127f7..57858d771653 100644 --- a/extensions/codex/src/app-server/run-attempt-context.ts +++ b/extensions/codex/src/app-server/run-attempt-context.ts @@ -4,6 +4,7 @@ import { CODEX_APP_SERVER_CONTEXT_ENGINE_HOST, embeddedAgentLog, getAgentHarnessHookRunner, + isHostScopedAgentToolActive, resolveContextEngineOwnerPluginId, runHarnessContextEngineMaintenance, } from "openclaw/plugin-sdk/agent-harness-runtime"; @@ -21,6 +22,7 @@ import { resolveCodexContinuityProjectionMaxChars, type CodexProjectedContextRange, } from "./context-engine-projection.js"; +import { isSystemAgentOnlyCodexDynamicToolAllowlist } from "./dynamic-tool-profile.js"; import type { CodexAttemptRuntime } from "./run-attempt-runtime.js"; import { joinPresentSections } from "./run-attempt-state.js"; import type { CodexAttemptTools } from "./run-attempt-tool-setup.js"; @@ -148,11 +150,14 @@ export async function prepareCodexAttemptContext( sessionKey: contextSessionKey, sessionAgentId, memoryToolNames, + ringZeroActive: + isHostScopedAgentToolActive("openclaw") && + isSystemAgentOnlyCodexDynamicToolAllowlist(runtimeParams.toolsAllow), sandboxed: sandbox?.enabled === true, }); // A thread keeps the bounded agent-workspace snapshot captured at creation. // Workspace edits take effect only in the next session. - const agentWorkspaceDeveloperInstructions = workspaceBootstrapContext.inheritsAgentWorkspace + const agentWorkspaceDeveloperInstructions = workspaceBootstrapContext.threadDeveloperInstructions ? (connection.mutable.startupBinding?.agentWorkspaceDeveloperInstructions ?? workspaceBootstrapContext.threadDeveloperInstructions) : undefined; diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index 1dde07c9ff37..fcc135ee997b 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -429,6 +429,7 @@ async function buildCodexTurnContextForTest( sessionKey: params.sessionKey ?? params.sessionId, sessionAgentId, memoryToolNames, + ringZeroActive: false, }); const threadDeveloperInstructions = testing.buildDeveloperInstructions(params, { dynamicTools }); const openClawPromptContext = buildCodexOpenClawPromptContext({ @@ -2415,6 +2416,10 @@ describe("runCodexAppServerAttempt", () => { deny: ["exec", "process", "write", "edit"], }; params.pluginHarnessToolPolicyRestricted = true; + const agentsGuidance = "Restricted turns keep workspace AGENTS guidance."; + await fs.mkdir(params.workspaceDir, { recursive: true }); + await fs.writeFile(path.join(params.workspaceDir, "AGENTS.md"), agentsGuidance); + setAgentWorkspaceForTest(params, params.workspaceDir); const onAgentEvent = vi.fn(); params.onAgentEvent = onAgentEvent; const harness = createStartedThreadHarness(async (method) => { @@ -2437,6 +2442,7 @@ describe("runCodexAppServerAttempt", () => { | { dynamicTools?: CodexDynamicToolSpec[]; environments?: unknown[]; + developerInstructions?: string; config?: Record; } | undefined; @@ -2445,6 +2451,8 @@ describe("runCodexAppServerAttempt", () => { ); expect(startParams?.environments).toEqual([]); + expect(startParams?.config?.project_doc_max_bytes).toBe(131_072); + expect(startParams?.developerInstructions?.split(agentsGuidance)).toHaveLength(2); expect(startParams?.config?.["tools.update_plan.enabled"]).toBe(false); expect(dynamicToolNames.toSorted()).toEqual(["apply_patch", "progress_card", "read"]); const progressCardSpec = flattenSpecsWithNamespace(startParams?.dynamicTools ?? []).find( diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index d472e9ba2089..be9bee34575b 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -210,8 +210,8 @@ const threadBindingSchema = z connectionScope: z.literal("supervision").optional(), supervisionSourceThreadId: z.string().trim().min(1).optional(), authProfileId: optionalStringSchema, - // Freeze external-cwd AGENTS.md at thread creation; bootstrap refreshes must - // not mutate the inherited policy of a resumed native session. + // Freeze OpenClaw-carried AGENTS.md at thread creation; bootstrap refreshes + // must not mutate the inherited policy of a resumed native session. agentWorkspaceDeveloperInstructions: optionalNonBlankStringSchema, model: optionalStringSchema, // Codex App Server owns selection for supervised and adopted threads. Keep diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts index 0cd57bd4d847..19b212d2c2d9 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.test.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts @@ -355,11 +355,14 @@ describe("Codex ring-zero thread config", () => { it("applies the restriction to both thread start and resume", () => { const params = createAttemptParams({ provider: "openai" }); params.toolsAllow = ["openclaw"]; + params.pluginHarnessToolPolicyRestricted = true; const appServer = createAppServerOptions() as never; + const developerInstructions = "Host-authored ring-zero instructions."; const start = buildThreadStartParams(params, { appServer, cwd: "/repo", dynamicTools: [], + developerInstructions, hostSystemAgentActive: true, nativeCodeModeEnabled: false, config: { project_doc_max_bytes: 64_000 }, @@ -367,6 +370,7 @@ describe("Codex ring-zero thread config", () => { const resume = buildThreadResumeParams(params, { appServer, dynamicTools: [], + developerInstructions, hostSystemAgentActive: true, nativeCodeModeEnabled: false, threadId: "thread-1", @@ -375,6 +379,8 @@ describe("Codex ring-zero thread config", () => { expect(start.environments).toEqual([]); expect(start.baseInstructions).toBe(""); + expect(start.developerInstructions).toBe(developerInstructions); + expect(resume.developerInstructions).toBe(developerInstructions); for (const config of [start.config, resume.config]) { expect(config?.["agents.enabled"]).toBe(false); expect(config?.["tools.experimental_request_user_input.enabled"]).toBe(false); @@ -402,6 +408,47 @@ describe("Codex ring-zero thread config", () => { expect(normal.baseInstructions).toBeUndefined(); expect(normal.config?.["features.goals"]).toBe(false); }); + + it("preserves project documents for ordinary policy-restricted turns", () => { + const params = createAttemptParams({ provider: "openai" }); + params.pluginHarnessToolPolicyRestricted = true; + const appServer = createAppServerOptions() as never; + const start = buildThreadStartParams(params, { + appServer, + cwd: "/repo", + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + }); + const resume = buildThreadResumeParams(params, { + appServer, + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + threadId: "thread-1", + config: { project_doc_max_bytes: 64_000 }, + }); + + expect(start.config?.project_doc_max_bytes).toBe(131_072); + expect(resume.config?.project_doc_max_bytes).toBe(64_000); + for (const threadConfig of [start.config, resume.config]) { + expect(threadConfig?.["features.multi_agent"]).toBe(false); + expect(threadConfig?.["orchestrator.mcp.enabled"]).toBe(false); + } + + const toolsDisabled = createAttemptParams({ provider: "openai" }); + toolsDisabled.disableTools = true; + toolsDisabled.pluginHarnessToolPolicyRestricted = true; + const disabled = buildThreadStartParams(toolsDisabled, { + appServer, + cwd: "/repo", + dynamicTools: [], + hostSystemAgentActive: false, + nativeCodeModeEnabled: false, + config: { project_doc_max_bytes: 64_000 }, + }); + expect(disabled.config?.project_doc_max_bytes).toBe(0); + }); }); describe("Codex delegation capability", () => { @@ -3620,6 +3667,7 @@ describe("Codex app-server supervised branch lifecycle", () => { | { config?: Record } | undefined; expect(threadRequest?.config).toMatchObject({ + project_doc_max_bytes: 0, mcp_servers: { inherited: { enabled: false }, "request-only": { enabled: false }, diff --git a/extensions/codex/src/app-server/thread-requests.ts b/extensions/codex/src/app-server/thread-requests.ts index 05d5597009bc..a89b5b6ff2a3 100644 --- a/extensions/codex/src/app-server/thread-requests.ts +++ b/extensions/codex/src/app-server/thread-requests.ts @@ -56,7 +56,7 @@ const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = { "features.code_mode_only": false, }; -const CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG: JsonObject = { +const CODEX_NO_PROJECT_DOCS_CONFIG: JsonObject = { project_doc_max_bytes: 0, }; @@ -126,7 +126,6 @@ const CODEX_RING_ZERO_THREAD_CONFIG: JsonObject = { SubagentStop: [], Stop: [], }, - project_doc_max_bytes: 0, notify: [], web_search: "disabled", }; @@ -430,6 +429,10 @@ export function buildCodexRuntimeThreadConfigForRun( const messageOnlySourceReply = isMessageOnlyCodexSourceReply(params); const restrictedToolSurface = ringZeroActive || messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true; + const restrictedTurnDisablesProjectDocs = + ringZeroActive || + messageOnlySourceReply || + (params.pluginHarnessToolPolicyRestricted && params.disableTools); const configMcpServers = config?.mcp_servers; if (restrictedToolSurface && configMcpServers !== undefined && !isJsonObject(configMcpServers)) { throw new Error("Codex restricted tool surface received invalid thread mcp_servers config"); @@ -469,23 +472,21 @@ export function buildCodexRuntimeThreadConfigForRun( ? CODEX_DELEGATION_DISABLED_THREAD_CONFIG : undefined, messageOnlySourceReply || params.pluginHarnessToolPolicyRestricted === true - ? buildCodexRestrictedToolThreadConfigPatch(restrictedToolSurfaceMcpServerNames) + ? buildRestrictedToolConfigPatch(restrictedToolSurfaceMcpServerNames) : buildCodexRingZeroThreadConfigPatch( params, options.hostSystemAgentActive, restrictedToolSurfaceMcpServerNames, ), + restrictedTurnDisablesProjectDocs ? CODEX_NO_PROJECT_DOCS_CONFIG : undefined, params.authoredContextTokenCap === undefined ? undefined : { model_context_window: params.authoredContextTokenCap }, ) ?? baseConfig; - const contextConfig = - params.bootstrapContextMode !== "lightweight" - ? runtimeConfig - : (mergeCodexThreadConfigs(runtimeConfig, CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG) ?? { - ...runtimeConfig, - ...CODEX_LIGHTWEIGHT_CONTEXT_THREAD_CONFIG, - }); + const contextConfig = { + ...runtimeConfig, + ...(params.bootstrapContextMode === "lightweight" ? CODEX_NO_PROJECT_DOCS_CONFIG : {}), + }; return applyCodexManagedShellEnvironment( contextConfig, options.shellEnvironment, @@ -501,15 +502,16 @@ export function buildCodexRingZeroThreadConfigPatch( if (!hostSystemAgentActive || !isSystemAgentOnlyCodexDynamicToolAllowlist(params.toolsAllow)) { return undefined; } - return buildCodexRestrictedToolThreadConfigPatch(inheritedMcpServerNames); + return { + ...buildRestrictedToolConfigPatch(inheritedMcpServerNames), + ...CODEX_NO_PROJECT_DOCS_CONFIG, + }; } -function buildCodexRestrictedToolThreadConfigPatch( - inheritedMcpServerNames: readonly string[], -): JsonObject { - // Restricted turns already send environments: [] and disable native code - // mode. Remove every other configurable Codex-owned source so - // native delegation, installed MCP tools, and utilities cannot escape the cap. +function buildRestrictedToolConfigPatch(inheritedMcpServerNames: readonly string[]): JsonObject { + // Restricted turns already send environments: [] and disable native code mode. + // Remove Codex-owned tool sources here; project-document suppression belongs to + // ring-zero, message-only, and tool-disabled context policy at the caller. const mcpServers = Object.fromEntries( [...new Set(inheritedMcpServerNames)].toSorted().map((name) => [name, { enabled: false }]), ); From 081810c9be95aea705ca0dc79b5b95015cbf6f6e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 21 Aug 2026 10:00:52 -0700 Subject: [PATCH 003/745] refactor(gateway): flatten HTTP request stages (#127264) --- src/gateway/server-http.ts | 120 +++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 65 deletions(-) diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 56fef2a249c2..a8e98726cdb5 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -137,15 +137,13 @@ function isWebSocketUpgradeRequest(req: IncomingMessage): boolean { ); } -type GatewayHttpRequestStage = { - run: () => Promise | boolean; -}; +type GatewayHttpRequestStage = () => Promise | boolean; async function runGatewayHttpRequestStages( stages: readonly GatewayHttpRequestStage[], ): Promise { for (const stage of stages) { - if (await stage.run()) { + if (await stage()) { return true; } } @@ -335,34 +333,30 @@ export function createGatewayHttpServer(opts: { return true; }; const requestStages: GatewayHttpRequestStage[] = [ - { - run: () => - handleGatewayProbeRequest( - req, - res, - scopedRequestPath, - resolvedAuthValue, - trustedProxies, - allowRealIpFallback, - rateLimiter, - getReadiness, - getStartup, - ), - }, + () => + handleGatewayProbeRequest( + req, + res, + scopedRequestPath, + resolvedAuthValue, + trustedProxies, + allowRealIpFallback, + rateLimiter, + getReadiness, + getStartup, + ), ]; const addRequestStage = ( enabled: boolean, - run: GatewayHttpRequestStage["run"], + stage: GatewayHttpRequestStage, admitted = false, ) => { if (enabled) { - requestStages.push({ - run: admitted ? () => runWithGatewayHttpWorkAdmission(res, run) : run, - }); + requestStages.push(admitted ? () => runWithGatewayHttpWorkAdmission(res, stage) : stage); } }; - const addAdmittedStage = (enabled: boolean, run: GatewayHttpRequestStage["run"]) => - addRequestStage(enabled, run, true); + const addAdmittedStage = (enabled: boolean, stage: GatewayHttpRequestStage) => + addRequestStage(enabled, stage, true); const workerGatewayRoute = classifyWorkerGatewayPath(scopedRequestPath); addRequestStage(workerGatewayRoute !== "outside", () => { @@ -519,8 +513,8 @@ export function createGatewayHttpServer(opts: { configSnapshot.mcp?.apps?.enabled === true && (mcpAppRoute === "shell" || mcpAppRoute === "view") ) { - requestStages.push({ - run: async () => + requestStages.push( + async () => await runWithGatewayHttpWorkAdmission(res, async () => { const standalone = await getMcpAppStandaloneModule(); return await standalone.handleMcpAppStandaloneHttpRequest(req, res, { @@ -528,7 +522,7 @@ export function createGatewayHttpServer(opts: { sandboxOrigin: configSnapshot.mcp?.apps?.sandboxOrigin, }); }), - }); + ); } // Core and recovery routes run first, then plugin routes, then read-only Control UI // surfaces. Non-GET requests the SPA does not claim reach the startup 503 before final 404. @@ -538,46 +532,42 @@ export function createGatewayHttpServer(opts: { let pluginRequestOperatorScopes: string[] | undefined; // Auth and dispatch stay separate so authorized context reaches the handler. requestStages.push( - { - run: async () => { - if ( - !(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( - pluginPathContext, - ) || - (await getCachedPluginGatewayAuthBypassPaths(configSnapshot)).has(scopedRequestPath) - ) { - return false; - } - // Bypass paths come only from activated channel plugins; every other protected - // route must authorize before runtime scopes are derived. - const { authorizePluginGatewayHttpRequestOrReply } = await getHttpAuthUtilsModule(); - const { resolvePluginRouteRuntimeOperatorScopes } = - await getPluginRouteRuntimeScopesModule(); - const authResult = await authorizePluginGatewayHttpRequestOrReply({ - req, - res, - ...routeAuth, - requestPath: scopedRequestPath, - resolveOperatorScopes: resolvePluginRouteRuntimeOperatorScopes, - }); - if (!authResult) { - return true; - } - pluginGatewayAuthSatisfied = true; - pluginGatewayRequestAuth = authResult.requestAuth; - pluginRequestOperatorScopes = authResult.operatorScopes; + async () => { + if ( + !(shouldEnforcePluginGatewayAuth ?? shouldEnforceDefaultPluginGatewayAuth)( + pluginPathContext, + ) || + (await getCachedPluginGatewayAuthBypassPaths(configSnapshot)).has(scopedRequestPath) + ) { return false; - }, - }, - { - run: () => - handlePluginRequest(req, res, pluginPathContext, { - gatewayAuthSatisfied: pluginGatewayAuthSatisfied, - gatewayRequestAuth: pluginGatewayRequestAuth, - gatewayRequestOperatorScopes: pluginRequestOperatorScopes, - gatewayRequestClientIp: requestClientIp, - }), + } + // Bypass paths come only from activated channel plugins; every other protected + // route must authorize before runtime scopes are derived. + const { authorizePluginGatewayHttpRequestOrReply } = await getHttpAuthUtilsModule(); + const { resolvePluginRouteRuntimeOperatorScopes } = + await getPluginRouteRuntimeScopesModule(); + const authResult = await authorizePluginGatewayHttpRequestOrReply({ + req, + res, + ...routeAuth, + requestPath: scopedRequestPath, + resolveOperatorScopes: resolvePluginRouteRuntimeOperatorScopes, + }); + if (!authResult) { + return true; + } + pluginGatewayAuthSatisfied = true; + pluginGatewayRequestAuth = authResult.requestAuth; + pluginRequestOperatorScopes = authResult.operatorScopes; + return false; }, + () => + handlePluginRequest(req, res, pluginPathContext, { + gatewayAuthSatisfied: pluginGatewayAuthSatisfied, + gatewayRequestAuth: pluginGatewayRequestAuth, + gatewayRequestOperatorScopes: pluginRequestOperatorScopes, + gatewayRequestClientIp: requestClientIp, + }), ); } From 40151953b0abefabc6fe43fbe9b6f8ab5ee7f5b5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 10:10:18 -0700 Subject: [PATCH 004/745] test(cli): use shared temp dir tracker (#127286) --- src/cli/update-cli.test.ts | 76 +++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 42 deletions(-) diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 76787f9267ec..598c9738aa72 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -8,6 +8,7 @@ import { expectDefined } from "@openclaw/normalization-core"; import { Command } from "commander"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { writePackageDistInventory } from "../../scripts/lib/package-dist-inventory.ts"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../config/types.openclaw.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { GATEWAY_SERVICE_RUNTIME_PID_ENV } from "../daemon/constants.js"; @@ -545,6 +546,7 @@ describe("update-cli", () => { fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-update-tests-")), ); let fixtureCount = 0; + const tempDirs = useAutoCleanupTempDirTracker(afterEach); const tempDirsToCleanup = new Set(); const createCaseDir = (prefix: string) => { @@ -553,12 +555,6 @@ describe("update-cli", () => { return dir; }; - const createTrackedTempDir = async (prefix: string) => { - const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), prefix))); - tempDirsToCleanup.add(dir); - return dir; - }; - const baseConfig = {} as OpenClawConfig; const baseSnapshot: ConfigFileSnapshot = { path: "/tmp/openclaw-config.json", @@ -1314,8 +1310,8 @@ describe("update-cli", () => { options: Parameters[0]; beforeUpdate?: () => void | Promise; }) => { - const stateDir = await createTrackedTempDir("openclaw-update-sentinel-state-"); - const metaDir = await createTrackedTempDir("openclaw-update-sentinel-meta-"); + const stateDir = tempDirs.make("openclaw-update-sentinel-state-"); + const metaDir = tempDirs.make("openclaw-update-sentinel-meta-"); const metaPath = path.join(metaDir, "meta.json"); await fs.writeFile(metaPath, JSON.stringify({ version: 1, meta: params.meta })); await params.beforeUpdate?.(); @@ -1758,7 +1754,7 @@ describe("update-cli", () => { it("keeps foreign-service updates in the caller profile", async () => { const personalState = path.join(fixtureRoot, "personal-profile"); const { root, entrypoints } = setupUpdatedRootRefresh(); - const foreignRoot = await createTrackedTempDir("openclaw-update-foreign-profile-"); + const foreignRoot = tempDirs.make("openclaw-update-foreign-profile-"); const foreignEntrypoint = path.join(foreignRoot, "dist", "index.js"); await fs.mkdir(path.dirname(foreignEntrypoint), { recursive: true }); await fs.writeFile( @@ -4473,7 +4469,7 @@ describe("update-cli", () => { }); it("stops package post-update work when staged npm install verification fails", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-staged-fail-"); + const tempDir = tempDirs.make("openclaw-update-staged-fail-"); const prefix = path.join(tempDir, "prefix"); const nodeModules = path.join(prefix, "lib", "node_modules"); const { pkgRoot } = await setupInstalledPackageAtNodeModules(nodeModules, "2026.4.20"); @@ -4524,7 +4520,7 @@ describe("update-cli", () => { }); it("runs old package doctors without fix mode when service ownership is unknown", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-package-"); + const tempDir = tempDirs.make("openclaw-update-package-"); const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageRoot(tempDir); primeServiceCommand(["openclaw-wrapper", "gateway", "run"]); serviceLoaded.mockResolvedValue(true); @@ -4568,7 +4564,7 @@ describe("update-cli", () => { }); it("continues package post-core work for explicit post-update doctor advisories", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-warning-"); + const tempDir = tempDirs.make("openclaw-update-package-doctor-warning-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); @@ -4637,7 +4633,7 @@ describe("update-cli", () => { }); it("fails package updates when the post-update doctor is killed after verification", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-package-doctor-timeout-"); + const tempDir = tempDirs.make("openclaw-update-package-doctor-timeout-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); @@ -4678,7 +4674,7 @@ describe("update-cli", () => { }); it("runs package post-update doctor from the verified package root after a staged swap", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-staged-doctor-"); + const tempDir = tempDirs.make("openclaw-update-staged-doctor-"); const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageAtNodeModules( path.join(tempDir, "lib", "node_modules"), ); @@ -4741,7 +4737,7 @@ describe("update-cli", () => { const processOffSpy = vi.spyOn(process, "off"); suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(true); resumeScheduledTaskAutoStartAfterUpdate.mockResolvedValue(true); - const tempDir = await createTrackedTempDir("openclaw-update-stop-service-"); + const tempDir = tempDirs.make("openclaw-update-stop-service-"); const { nodeModules } = await setupInstalledPackageRoot(tempDir); mockRunningManagedGateway(); mockFileBackedPathExists(); @@ -4815,7 +4811,7 @@ describe("update-cli", () => { "quiesces a stopped loaded managed gateway on $platform before package replacement", async ({ platform, handoff }) => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); - const tempDir = await createTrackedTempDir(`openclaw-update-stopped-loaded-${platform}-`); + const tempDir = tempDirs.make(`openclaw-update-stopped-loaded-${platform}-`); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeServiceCommand(["node", entryPath, "gateway", "run"], { OPENCLAW_SERVICE_MARKER: "openclaw", @@ -4880,7 +4876,7 @@ describe("update-cli", () => { { name: "an ordinary stopped Scheduled Task", platform: "win32" as const, loaded: true }, ])("leaves $name stopped during package replacement", async ({ platform, loaded }) => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); - const tempDir = await createTrackedTempDir(`openclaw-update-stopped-${platform}-`); + const tempDir = tempDirs.make(`openclaw-update-stopped-${platform}-`); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeServiceCommand(["node", entryPath, "gateway", "run"]); serviceLoaded.mockResolvedValue(loaded); @@ -4903,7 +4899,7 @@ describe("update-cli", () => { it("restarts a quiesced stopped LaunchAgent after package replacement fails", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - const tempDir = await createTrackedTempDir("openclaw-update-stopped-launchagent-failure-"); + const tempDir = tempDirs.make("openclaw-update-stopped-launchagent-failure-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeServiceCommand(["node", entryPath, "gateway", "run"]); serviceLoaded.mockResolvedValue(true); @@ -4943,7 +4939,7 @@ describe("update-cli", () => { it("leaves a disabled stopped LaunchAgent disabled when package replacement fails", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("darwin"); - const tempDir = await createTrackedTempDir("openclaw-update-disabled-launchagent-failure-"); + const tempDir = tempDirs.make("openclaw-update-disabled-launchagent-failure-"); const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); primeServiceCommand(["node", entryPath, "gateway", "run"]); serviceLoaded.mockResolvedValue(true); @@ -4974,7 +4970,7 @@ describe("update-cli", () => { it("does not inspect or mutate a Windows host service from an isolated install", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); - const tempDir = await createTrackedTempDir("openclaw-update-isolated-service-"); + const tempDir = tempDirs.make("openclaw-update-isolated-service-"); const { nodeModules } = await setupInstalledPackageRoot(tempDir); mockRunningManagedGateway(); mockFileBackedPathExists(); @@ -5016,7 +5012,7 @@ describe("update-cli", () => { "does not reuse a conflicting $envKey selector from the managed service on $platform", async ({ platform, envKey, value }) => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue(platform); - const tempDir = await createTrackedTempDir(`openclaw-update-${platform}-selector-`); + const tempDir = tempDirs.make(`openclaw-update-${platform}-selector-`); const home = path.join(tempDir, "home"); const stateDir = path.join(home, ".openclaw-work"); const { nodeModules } = await setupInstalledPackageRoot(tempDir); @@ -5303,7 +5299,7 @@ describe("update-cli", () => { it("stops a managed gateway rooted at the git checkout when switching package installs to dev", async () => { const packageRoot = createCaseDir("openclaw-update-package-root"); - const gitRoot = await createTrackedTempDir("openclaw-update-git-service-root-"); + const gitRoot = tempDirs.make("openclaw-update-git-service-root-"); const serviceEntrypoint = path.join(gitRoot, "dist", "index.js"); await fs.mkdir(path.join(gitRoot, ".git"), { recursive: true }); await fs.mkdir(path.dirname(serviceEntrypoint), { recursive: true }); @@ -5336,9 +5332,9 @@ describe("update-cli", () => { }); it("stops a managed gateway rooted at the package install when switching package installs to dev", async () => { - const packageRoot = await createTrackedTempDir("openclaw-update-package-service-root-"); + const packageRoot = tempDirs.make("openclaw-update-package-service-root-"); const packageEntrypoint = path.join(packageRoot, "dist", "index.js"); - const gitRoot = await createTrackedTempDir("openclaw-update-git-service-root-"); + const gitRoot = tempDirs.make("openclaw-update-git-service-root-"); await fs.mkdir(path.join(gitRoot, ".git"), { recursive: true }); await fs.mkdir(path.dirname(packageEntrypoint), { recursive: true }); await fs.writeFile( @@ -5377,7 +5373,7 @@ describe("update-cli", () => { it.runIf(process.platform !== "win32")( "continues package-to-Git updates from the published checkout after its alias is retargeted", async () => { - const root = await createTrackedTempDir("openclaw-update-git-alias-"); + const root = tempDirs.make("openclaw-update-git-alias-"); const nodeModules = path.join(root, "package", "node_modules"); const packageRoot = path.join(nodeModules, "openclaw"); const targetRoot = path.join(root, "checkout-target"); @@ -5427,7 +5423,7 @@ describe("update-cli", () => { ); it("preserves the package and shim when package-to-Git staged activation fails", async () => { - const root = await createTrackedTempDir("openclaw-update-package-to-git-fail-"); + const root = tempDirs.make("openclaw-update-package-to-git-fail-"); const prefix = path.join(root, "prefix"); const nodeModules = path.join(prefix, "lib", "node_modules"); const packageRoot = path.join(nodeModules, "openclaw"); @@ -5481,7 +5477,7 @@ describe("update-cli", () => { }); it("does not stop or restart a managed gateway owned by another git checkout", async () => { - const otherRoot = await createTrackedTempDir("openclaw-update-other-service-root-"); + const otherRoot = tempDirs.make("openclaw-update-other-service-root-"); const otherEntrypoint = path.join(otherRoot, "dist", "index.js"); await fs.mkdir(path.dirname(otherEntrypoint), { recursive: true }); await fs.writeFile( @@ -5548,7 +5544,7 @@ describe("update-cli", () => { }); it("keeps managed service stop output off stdout during json package updates", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-json-stop-service-"); + const tempDir = tempDirs.make("openclaw-update-json-stop-service-"); const { nodeModules } = await setupInstalledPackageRoot(tempDir); const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true); mockRunningManagedGateway(); @@ -5571,7 +5567,7 @@ describe("update-cli", () => { }); it("disarms legacy launchd updater jobs before stopping the gateway", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-launchd-loop-"); + const tempDir = tempDirs.make("openclaw-update-launchd-loop-"); const { nodeModules } = await setupInstalledPackageRoot(tempDir); mockRunningManagedGateway(); launchdUpdateCleanupMocks.disableCurrentOpenClawUpdateLaunchdJob.mockResolvedValue(true); @@ -5589,7 +5585,7 @@ describe("update-cli", () => { }); it("refreshes package installs even when the current version already matches the target", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-current-"); + const tempDir = tempDirs.make("openclaw-update-current-"); const { nodeModules, pkgRoot, entryPath } = await setupInstalledPackageRoot( tempDir, "2026.4.23", @@ -5624,7 +5620,7 @@ describe("update-cli", () => { }); it("retries package updates without optional deps when npm global update fails", async () => { - const tempDir = await createTrackedTempDir("openclaw-update-optional-"); + const tempDir = tempDirs.make("openclaw-update-optional-"); const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw"); mockPackageInstallStatus(pkgRoot); @@ -5873,7 +5869,7 @@ describe("update-cli", () => { it("warns when a package update targets a managed service root outside the shell root", async () => { const shellRoot = createCaseDir("openclaw-shell-root"); - const serviceRoot = await createTrackedTempDir("openclaw-service-root-"); + const serviceRoot = tempDirs.make("openclaw-service-root-"); const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node"); await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true }); await fs.writeFile( @@ -5897,7 +5893,7 @@ describe("update-cli", () => { it("blocks a stale managed service Node before a no-restart package update", async () => { const shellRoot = createCaseDir("openclaw-shell-root"); - const serviceRoot = await createTrackedTempDir("openclaw-service-root-"); + const serviceRoot = tempDirs.make("openclaw-service-root-"); const serviceNode = path.join(path.dirname(serviceRoot), "bin", "node"); await fs.mkdir(path.join(serviceRoot, "dist"), { recursive: true }); await fs.mkdir(path.dirname(serviceNode), { recursive: true }); @@ -5938,7 +5934,7 @@ describe("update-cli", () => { it("runs managed service package follow-up commands with the service Node", async () => { const shellRoot = createCaseDir("openclaw-shell-root"); - const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-"); + const servicePrefix = tempDirs.make("openclaw-service-prefix-"); const nodeModules = path.join(servicePrefix, "lib", "node_modules"); const serviceRoot = path.join(nodeModules, "openclaw"); const serviceNode = path.join(servicePrefix, "bin", "node"); @@ -6031,7 +6027,7 @@ describe("update-cli", () => { }); it("refreshes the managed service to current Node when its baked Node cannot run the target", async () => { - const servicePrefix = await createTrackedTempDir("openclaw-service-prefix-"); + const servicePrefix = tempDirs.make("openclaw-service-prefix-"); const nodeModules = path.join(servicePrefix, "lib", "node_modules"); const root = path.join(nodeModules, "openclaw"); const serviceNode = path.join(servicePrefix, "bin", "node"); @@ -6112,7 +6108,7 @@ describe("update-cli", () => { }); it("pins package install to the service root when nodes differ and no owning npm exists at the prefix", async () => { - const servicePrefix = await createTrackedTempDir("openclaw-no-npm-prefix-"); + const servicePrefix = tempDirs.make("openclaw-no-npm-prefix-"); const nodeModules = path.join(servicePrefix, "lib", "node_modules"); const root = path.join(nodeModules, "openclaw"); const serviceNode = path.join(servicePrefix, "bin", "node"); @@ -6136,11 +6132,7 @@ describe("update-cli", () => { primeNpmChannelTag("latest", "2026.5.20"); mockFileBackedPathExists(); // The PATH npm returns a DIFFERENT global root (simulates Node-B's npm). - const nodeBGlobalRoot = path.join( - await createTrackedTempDir("node-b-global-"), - "lib", - "node_modules", - ); + const nodeBGlobalRoot = path.join(tempDirs.make("node-b-global-"), "lib", "node_modules"); await fs.mkdir(nodeBGlobalRoot, { recursive: true }); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { @@ -8036,7 +8028,7 @@ describe("update-cli", () => { }); it("updateWizardCommand offers dev checkout and forwards selections", async () => { - const root = await createTrackedTempDir("openclaw-update-wizard-"); + const root = tempDirs.make("openclaw-update-wizard-"); const tempDir = path.join(root, "openclaw"); await withEnvAsync({ OPENCLAW_GIT_DIR: tempDir }, async () => { setTty(true); From 98ff2294a93f8f17348ee8c3a7a2f66bc6ef5240 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Fri, 21 Aug 2026 22:43:11 +0530 Subject: [PATCH 005/745] fix(sessions): preserve sessions when age pruning is disabled (#127277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-positive plugin retention now disables age pruning instead of deleting eligible sessions. The disabled path also skips the SQLite stale-row scan and full-store load. Co-authored-by: Ayaan Zaidi Co-authored-by: 曾令彪 0668001395 --- .../session-accessor.sqlite-maintenance.ts | 3 ++ src/config/sessions/store-maintenance.ts | 3 ++ src/plugin-sdk/session-store-runtime.test.ts | 54 +++++++++---------- 3 files changed, 33 insertions(+), 27 deletions(-) diff --git a/src/config/sessions/session-accessor.sqlite-maintenance.ts b/src/config/sessions/session-accessor.sqlite-maintenance.ts index bc571c6b23b7..ba147ded8ad8 100644 --- a/src/config/sessions/session-accessor.sqlite-maintenance.ts +++ b/src/config/sessions/session-accessor.sqlite-maintenance.ts @@ -75,6 +75,9 @@ function hasStaleSqliteSessionEntryCandidate( maxAgeMs: number, isCandidate: (key: string, entry: SessionEntry) => boolean, ): boolean { + if (maxAgeMs <= 0) { + return false; + } const cutoffMs = Date.now() - maxAgeMs; const db = getSessionKysely(database.db); const rows = executeSqliteQuerySync( diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 5b18a542a0fa..c50a22030814 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -298,6 +298,9 @@ export function pruneStaleEntries( } = {}, ): number { const maxAgeMs = overrideMaxAgeMs ?? resolveMaintenanceConfigFromInput().pruneAfterMs; + if (maxAgeMs <= 0) { + return 0; + } const cutoffMs = Date.now() - maxAgeMs; let pruned = 0; for (const [key, entry] of Object.entries(store)) { diff --git a/src/plugin-sdk/session-store-runtime.test.ts b/src/plugin-sdk/session-store-runtime.test.ts index b39bd2fa1651..159e64179938 100644 --- a/src/plugin-sdk/session-store-runtime.test.ts +++ b/src/plugin-sdk/session-store-runtime.test.ts @@ -811,45 +811,45 @@ describe("session-store-runtime compatibility surface", () => { }); }); - it("preserves resolved maintenance settings through entry patches", async () => { - const staleSessionKey = "agent:main:stale"; - const activeSessionKey = "agent:main:active"; - const now = Date.now(); - await seedSessionEntry(staleSessionKey, { - sessionId: "session-stale", - updatedAt: now - 8 * DAY_MS, - }); - await seedSessionEntry(activeSessionKey, { - sessionId: "session-active", - updatedAt: now, - }); + it.each([ + { pruneAfterMs: 7 * DAY_MS, staleSessionPresent: false }, + { pruneAfterMs: 0, staleSessionPresent: true }, + { pruneAfterMs: -DAY_MS, staleSessionPresent: true }, + ])( + "applies age retention $pruneAfterMs through entry patches", + async ({ pruneAfterMs, staleSessionPresent }) => { + const staleSessionKey = "agent:main:stale"; + const activeSessionKey = "agent:main:active"; + const now = Date.now(); + await seedSessionEntry(staleSessionKey, { + sessionId: "session-stale", + updatedAt: now - 8 * DAY_MS, + }); + await seedSessionEntry(activeSessionKey, { + sessionId: "session-active", + updatedAt: now, + }); - await expect( - patchSessionEntry({ + await patchSessionEntry({ sessionKey: activeSessionKey, storePath, maintenanceConfig: { mode: "enforce", - pruneAfterMs: 7 * DAY_MS, + pruneAfterMs, modelRunPruneAfterMs: DAY_MS, - maxEntries: 1, + maxEntries: 100, resetArchiveRetentionMs: 7 * DAY_MS, maxDiskBytes: null, highWaterBytes: null, }, update: () => ({ model: "gpt-5.5" }), - }), - ).resolves.toMatchObject({ - model: "gpt-5.5", - sessionId: "session-active", - }); + }); - expect(getSessionEntry({ sessionKey: activeSessionKey, storePath })).toMatchObject({ - model: "gpt-5.5", - sessionId: "session-active", - }); - expect(getSessionEntry({ sessionKey: staleSessionKey, storePath })).toBeUndefined(); - }); + expect(getSessionEntry({ sessionKey: staleSessionKey, storePath }) != null).toBe( + staleSessionPresent, + ); + }, + ); it("forwards maintenance suppression through entry patches", async () => { const staleSessionKey = "agent:main:stale"; From 0a861b40eef3c859e5f9242c64a4175edad05900 Mon Sep 17 00:00:00 2001 From: ClawSweeper Date: Fri, 21 Aug 2026 10:19:56 -0700 Subject: [PATCH 006/745] fix(gateway): retain safe side-chat context (#127294) Co-authored-by: RoboClaw <309084314+roboclaw-bot@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- src/gateway/session-companion-context.test.ts | 28 +++++++++++++++++++ src/gateway/session-companion-context.ts | 18 ++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/gateway/session-companion-context.test.ts b/src/gateway/session-companion-context.test.ts index 52b1b5979f45..e8fc9651821e 100644 --- a/src/gateway/session-companion-context.test.ts +++ b/src/gateway/session-companion-context.test.ts @@ -130,6 +130,34 @@ describe("session companion context", () => { ); }); + it("keeps complete recent context when older tool rows exhaust the scan byte budget", async () => { + const scope = createScope("companion-context-byte-budget"); + await upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); + const messages = Array.from({ length: 384 }, (_, index) => { + const isContextMessage = index % 9 === 0; + return { + eventId: `message-${index}`, + parentId: index === 0 ? null : `message-${index - 1}`, + message: isContextMessage + ? { role: "user" as const, content: `useful ${index}`, timestamp: index } + : { role: "toolResult" as const, content: "x".repeat(4000), timestamp: index }, + }; + }); + await persistSessionTranscriptTurn(scope, { messages, touchSessionEntry: true }); + + const result = await defaultSessionCompanionContextReader.read(scope); + + expect(result.kind).toBe("ready"); + if (result.kind !== "ready") { + return; + } + expect(result.context.messages.length).toBeGreaterThan(0); + expect(result.context.messages.at(-1)?.text).toBe("useful 378"); + expect(result.context.messages.every((message) => message.text.startsWith("useful"))).toBe( + true, + ); + }); + it.each([ { expectedKind: "ready", unsupportedCount: 4095 }, { expectedKind: "unavailable", unsupportedCount: 4096 }, diff --git a/src/gateway/session-companion-context.ts b/src/gateway/session-companion-context.ts index ee81848baf42..6ef42d05cf05 100644 --- a/src/gateway/session-companion-context.ts +++ b/src/gateway/session-companion-context.ts @@ -142,6 +142,7 @@ async function readSessionCompanionContext(params: { let rawBytes = 0; let scannedMessages = 0; let totalMessages = 0; + let stoppedAtOlderByteBoundary = false; let snapshot: | { activeLeafEntryId?: string | null; @@ -163,9 +164,18 @@ async function readSessionCompanionContext(params: { ), offset, }); - if (params.signal?.aborted || page.events.length !== page.scannedMessages) { + if (params.signal?.aborted) { return { kind: "unavailable" }; } + if (page.events.length !== page.scannedMessages) { + if (contextMessages.length === 0) { + return { kind: "unavailable" }; + } + // A partial older page can contain holes around oversized rows. Keep + // only the complete newer pages, then verify their snapshot below. + stoppedAtOlderByteBoundary = true; + break; + } const pageSnapshot = { activeLeafEntryId: page.activeLeafEntryId, generation: page.snapshot.generation, @@ -193,7 +203,11 @@ async function readSessionCompanionContext(params: { break; } } - if (contextMessages.length < CONTEXT_MAX_MESSAGES && offset < totalMessages) { + if ( + contextMessages.length < CONTEXT_MAX_MESSAGES && + offset < totalMessages && + !stoppedAtOlderByteBoundary + ) { return { kind: "unavailable" }; } const fence = readSessionTranscriptBoundedMessageTailPage(scope, { From f9b5693612ed46b25c2c4b5feaf0c3db628e85cf Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 10:32:36 -0700 Subject: [PATCH 007/745] fix(ui): retire stale plugin lifecycle feedback (#127273) Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp --- ui/src/pages/plugins/plugins-page.test.ts | 51 +++++++++++++++++++++++ ui/src/pages/plugins/plugins-page.ts | 29 +++++++------ ui/src/pages/plugins/plugins.e2e.test.ts | 36 ++++++++++++++++ 3 files changed, 104 insertions(+), 12 deletions(-) diff --git a/ui/src/pages/plugins/plugins-page.test.ts b/ui/src/pages/plugins/plugins-page.test.ts index 5ddb3bbff3d7..cba49169a8d0 100644 --- a/ui/src/pages/plugins/plugins-page.test.ts +++ b/ui/src/pages/plugins/plugins-page.test.ts @@ -760,6 +760,57 @@ describe("PluginsPage", () => { expect(calls).toContainEqual(["plugins.list", {}]); }); + it("does not let an older uninstall republish its page notice after a newer row action", async () => { + const uninstallResult = deferred(); + const enabledPlugin = createPlugin({ enabled: true, state: "enabled" }); + const removable = createPlugin({ + id: "community-thing", + name: "Community Thing", + origin: "global", + removable: true, + featured: false, + }); + const { client, request } = createClient(async (method) => { + if (method === "plugins.uninstall") { + return uninstallResult.promise; + } + if (method === "plugins.setEnabled") { + return { ok: true, plugin: enabledPlugin, restartRequired: false }; + } + if (method === "plugins.list") { + return createResult(enabledPlugin); + } + throw new Error(`Unexpected method ${method}`); + }); + const harness = createGateway(client); + const { page } = await mountPage( + createContext(harness.gateway), + createPluginsRouteData(harness.gateway, { + plugins: [createPlugin(), removable], + diagnostics: [], + mutationAllowed: true, + }), + ); + + const uninstall = page.uninstall("community-thing", "plugin:community-thing"); + await waitForFast(() => + expect(request).toHaveBeenCalledWith("plugins.uninstall", { pluginId: "community-thing" }), + ); + await page.updateEnabled("workboard", true); + + uninstallResult.resolve({ + ok: true, + pluginId: "community-thing", + restartRequired: true, + removed: ["config entry", "install record", "directory"], + }); + await uninstall; + await page.updateComplete; + + expect(page.querySelector(".plugins-page-notice")).toBeNull(); + expect(page.messages["plugin:workboard"]?.text).toContain("Enabled Workboard"); + }); + it("adds an MCP server through the shared config seam", async () => { const { client } = createClient(async (method) => { if (method === "plugins.list") { diff --git a/ui/src/pages/plugins/plugins-page.ts b/ui/src/pages/plugins/plugins-page.ts index 68b55349eb55..7c6326274b31 100644 --- a/ui/src/pages/plugins/plugins-page.ts +++ b/ui/src/pages/plugins/plugins-page.ts @@ -717,6 +717,7 @@ class PluginsPage extends OpenClawLightDomElement { refreshError: string | null, client: GatewayBrowserClient, isCurrent: () => boolean, + isLatest: () => boolean, ) => Promise, onError: (error: unknown) => void = (error) => { this.setMessage(rowKey, { @@ -730,10 +731,12 @@ class PluginsPage extends OpenClawLightDomElement { if (!scope || !this.canMutate() || this.busy[rowKey]) { return; } + this.pageNotice = null; const mutationToken = ++this.mutationToken; this.mutationTokens.set(rowKey, mutationToken); const isCurrent = () => this.gateway.isCurrent(scope) && this.mutationTokens.get(rowKey) === mutationToken; + const isLatest = () => isCurrent() && this.mutationToken === mutationToken; this.setBusy(rowKey, true); if (!options.preserveMessageWhilePending) { this.setMessage(rowKey, null); @@ -747,7 +750,7 @@ class PluginsPage extends OpenClawLightDomElement { if (!isCurrent()) { return; } - await onSuccess(mutation.value, mutation.refreshError, scope.client, isCurrent); + await onSuccess(mutation.value, mutation.refreshError, scope.client, isCurrent, isLatest); } catch (error) { if (isCurrent()) { onError(error); @@ -840,19 +843,21 @@ class PluginsPage extends OpenClawLightDomElement { await this.runPluginMutation( rowKey, (client) => uninstallPlugin(client, pluginId), - async (result, refreshError, client) => { + async (result, refreshError, client, _isCurrent, isLatest) => { this.setPendingRemoval(rowKey, false); // Removal hides its row, so keep the restart reminder on the page. - this.pageNotice = { - kind: "success", - text: [ - t("pluginsPage.removedRestart", { name: result.pluginId }), - ...(result.warnings ?? []).map((warning) => formatUiExternalText(warning)), - refreshError ? t("pluginsPage.configRefreshFailed", { error: refreshError }) : null, - ] - .filter(Boolean) - .join("\n"), - }; + if (isLatest()) { + this.pageNotice = { + kind: "success", + text: [ + t("pluginsPage.removedRestart", { name: result.pluginId }), + ...(result.warnings ?? []).map((warning) => formatUiExternalText(warning)), + refreshError ? t("pluginsPage.configRefreshFailed", { error: refreshError }) : null, + ] + .filter(Boolean) + .join("\n"), + }; + } await this.refreshCatalogAfterMutation(client); }, ); diff --git a/ui/src/pages/plugins/plugins.e2e.test.ts b/ui/src/pages/plugins/plugins.e2e.test.ts index def3964a4f89..cec933712a9f 100644 --- a/ui/src/pages/plugins/plugins.e2e.test.ts +++ b/ui/src/pages/plugins/plugins.e2e.test.ts @@ -676,6 +676,42 @@ describeControlUiE2e("Control UI Plugins mocked Gateway E2E", () => { "Removed calendar-plus", ); + await gateway.setMethodResponse("plugins.list", uninstalledInventory); + await page.getByRole("tab", { name: /^Discover/u }).click(); + const searchCountBeforeReinstall = (await gateway.getRequests("plugins.search")).length; + await page.getByRole("searchbox", { name: "Search plugins" }).fill("calendar"); + await waitForNextRequest(gateway, "plugins.search", searchCountBeforeReinstall); + const reinstallRow = page.locator( + '[data-package-name="calendar-plus"][data-plugin-status="not-installed"]', + ); + await reinstallRow.waitFor({ state: "visible" }); + await gateway.setMethodResponse("plugins.install", installResult); + await gateway.setMethodResponse("plugins.list", finalInventory); + const installCountBeforeReinstall = (await gateway.getRequests("plugins.install")).length; + await reinstallRow + .getByRole("button", { name: "Install Calendar Plus", exact: true }) + .click(); + const reinstallRequest = await waitForNextRequest( + gateway, + "plugins.install", + installCountBeforeReinstall, + ); + expect(requestParams(reinstallRequest)).toEqual({ + source: "clawhub", + packageName: "calendar-plus", + }); + const reinstalledRow = page.locator( + '[data-package-name="calendar-plus"][data-plugin-status="enabled"]', + ); + await reinstalledRow.waitFor({ state: "attached" }); + await captureScreenshot(page, "11-reinstalled-feedback-desktop.png"); + expect(await page.locator(".plugins-page-notice").count()).toBe(0); + expect(await reinstalledRow.getByRole("status").textContent()).toContain( + "Installed Calendar Plus", + ); + + await page.getByRole("tab", { name: /^Installed/u }).click(); + await page.getByRole("searchbox", { name: "Search plugins" }).fill(""); await page.setViewportSize(mobileViewport); await expect .poll(() => From 0c2b224cb07b43eb7811159dd264926c5b9ce039 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 10:37:22 -0700 Subject: [PATCH 008/745] fix(qa): label execution configuration selects (#127299) Amp-Thread-ID: https://ampcode.com/threads/T-01a021f4-b547-7788-a916-d4a94cbd3e3b Co-authored-by: Amp --- extensions/qa-lab/web/src/app.browser.test.ts | 32 +++++++++++++++++++ extensions/qa-lab/web/src/ui-render-shell.ts | 16 +++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/extensions/qa-lab/web/src/app.browser.test.ts b/extensions/qa-lab/web/src/app.browser.test.ts index bb520cbc7084..9467834cd53f 100644 --- a/extensions/qa-lab/web/src/app.browser.test.ts +++ b/extensions/qa-lab/web/src/app.browser.test.ts @@ -205,6 +205,38 @@ afterEach(() => { }); describe("QA Lab runner browser interactions", () => { + it("labels every execution configuration select", async () => { + const root = await mountRunner({ + alternateModel: "mock-openai/gpt-5.6-luna-alt", + channel: null, + channelDriver: "qa-channel", + evidenceMode: "full", + fastMode: false, + primaryModel: "mock-openai/gpt-5.6-luna", + profile: "all", + providerMode: "mock-openai", + runtimePair: null, + runtimePairLane: null, + scenarioIds: ["dm-chat-baseline"], + }); + + root.querySelector("[data-sidebar-panel='config']")?.click(); + const selects = [...root.querySelectorAll(".config-field select")]; + + expect(selects).toHaveLength(9); + expect(selects.map((select) => select.labels?.[0]?.textContent?.trim())).toEqual([ + "Profile", + "Provider lane", + "Channel driver", + "Execution channel", + "Evidence mode", + "Runtime pair", + "Runtime-pair lane", + "Primary model", + "Alternate model", + ]); + }); + it("sends group conversation messages from the interactive chat composer", async () => { const root = await mountRunner( { diff --git a/extensions/qa-lab/web/src/ui-render-shell.ts b/extensions/qa-lab/web/src/ui-render-shell.ts index e760372c9960..1ff93ac76e85 100644 --- a/extensions/qa-lab/web/src/ui-render-shell.ts +++ b/extensions/qa-lab/web/src/ui-render-shell.ts @@ -72,7 +72,7 @@ function renderModelSelect(params: { } return `
- ${esc(params.label)} + ${profiles .map( @@ -128,14 +128,14 @@ export function renderSidebar(state: UiState): string {
- Provider lane +
- Channel driver +
- Execution channel +
- Evidence mode +
- Runtime pair +
- Runtime-pair lane + - props.onRunsFiltersChange({ - cronRunsSortDir: (e.target as HTMLSelectElement).value as CronSortDir, - })} - > - - - +
+ ) => { + const value = event.detail.item.value; + if (value === "asc" || value === "desc") { + void props.onRunsFiltersChange({ cronRunsSortDir: value }); + } + }} + > + + + ${t("cron.runs.newestFirst")} + + + + ${t("cron.runs.oldestFirst")} + + + +
${runs.length === 0 ? hasRunFilters diff --git a/ui/src/pages/cron/view.test.ts b/ui/src/pages/cron/view.test.ts index 2323d70f4e04..00cdb56ad1ab 100644 --- a/ui/src/pages/cron/view.test.ts +++ b/ui/src/pages/cron/view.test.ts @@ -355,9 +355,11 @@ describe("cron view selects", () => { it("shows persisted non-first values in jobs filters and runs sort", () => { const activity = renderView({ listTab: "activity", runsSortDir: "asc" }); - const sort = getElement(activity, "select.cron-run-sort", HTMLSelectElement); - expect(sort.value).toBe("asc"); - expect(sort.querySelector('option[value="asc"]')?.hasAttribute("selected")).toBe(true); + const sort = getElement(activity, ".cron-run-sort", HTMLButtonElement); + expect(sort.textContent).toContain("Oldest first"); + expect( + activity.querySelector('wa-dropdown-item[value="asc"]')?.getAttribute("aria-current"), + ).toBe("true"); const tasks = renderView({ jobsLastStatusFilter: "error" }); const lastStatus = getElement( tasks, diff --git a/ui/src/styles/cron.css b/ui/src/styles/cron.css index 083dc729944e..5eed30abdbd6 100644 --- a/ui/src/styles/cron.css +++ b/ui/src/styles/cron.css @@ -1033,25 +1033,6 @@ } } -/* Match the .btn--sm dropdown triggers beside it instead of the native look. */ -.cron-run-sort { - flex: 0 0 auto; - width: auto; - padding: 6px 10px; - font-size: 12px; - font-weight: 500; - color: var(--text); - border: 1px solid var(--border); - border-radius: var(--radius-md); - background: var(--bg-elevated); - cursor: var(--cursor-action); -} - -.cron-run-sort:hover { - background: var(--bg-hover); - border-color: var(--border-strong); -} - .cron-runs__empty { font-size: 13px; } From 731e9e768152855430b7270802f8808451afbfcc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:18:11 -0700 Subject: [PATCH 054/745] perf(test): speed up browser extension state observations (#127471) --- .../chrome-extension/background.test-harness.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/extensions/browser/chrome-extension/background.test-harness.ts b/extensions/browser/chrome-extension/background.test-harness.ts index bb4808577b3b..f309f632a37a 100644 --- a/extensions/browser/chrome-extension/background.test-harness.ts +++ b/extensions/browser/chrome-extension/background.test-harness.ts @@ -16,6 +16,10 @@ const PAIRING_CONFIG_KEYS = ["relayUrl", "token", "pairingStatus"]; const RETIRED_CUSTODY_BLOCKED_KEY = "retiredCopilotCustodyBlockedV1"; const backgroundCleanups = new Set<() => Promise>(); +function waitForBackgroundState(assertion: () => T | Promise): Promise { + return vi.waitFor(assertion, { interval: 1 }); +} + export async function cleanupBackgroundHarnesses(): Promise { await Promise.all([...backgroundCleanups].map(async (cleanup) => await cleanup())); } @@ -388,7 +392,7 @@ export async function loadBackground({ const backgroundModulePath = "./background.js"; await import(backgroundModulePath); if (!deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingReads = storageGet.mock.calls.filter(([keys]) => PAIRING_CONFIG_KEYS.every((key) => keys.includes(key)), ); @@ -396,7 +400,7 @@ export async function loadBackground({ }); } if (!deferTabAccessInitialization && !deferRetiredStatePreparation) { - await vi.waitFor(() => { + await waitForBackgroundState(() => { const pairingWasCleared = storageRemove.mock.calls.some(([keys]) => keys.includes("relayUrl"), ); @@ -482,7 +486,7 @@ export async function loadBackground({ if (socket.readyState !== FakeWebSocket.OPEN) { socket.open(); } - await vi.waitFor(() => expect(socket.send).toHaveBeenCalled()); + await waitForBackgroundState(() => expect(socket.send).toHaveBeenCalled()); const helloRaw = socket.send.mock.calls.find( ([raw]) => JSON.parse(raw).type === "auth.hello", )?.[0]; @@ -511,7 +515,7 @@ export async function loadBackground({ ...fields, serverProof: await computeRelayAuthProof(String(storageValues.token), "server", fields), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect( socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "auth.response"), ).toBe(true); @@ -534,7 +538,7 @@ export async function loadBackground({ response.clientProof, ), }); - await vi.waitFor(() => { + await waitForBackgroundState(() => { expect(socket.send.mock.calls.some(([raw]) => JSON.parse(raw).type === "hello")).toBe(true); }); }, From 7909ea698374c945cbddb7bb6ffdd07ad88a9072 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:19:06 -0700 Subject: [PATCH 055/745] ci: remove the Test Performance Agent The workflow ran a Codex agent over the full test suite and pushed `test: optimize slow tests` straight to `main` under `contents: write`, with no pull request and no human review. Its gates were a path allowlist, a no-add/delete/rename rule, a non-decreasing total test count, and `pnpm check:changed` -- which covers changed lanes, not the full suite. Test optimization is exactly the class of change where a plausible edit can weaken coverage without moving the test count, so unattended landing is the wrong trade. Autonomous commits to `main` are not something this repo wants. It had also been inert since well before this. The daily-cadence gate excluded prior runs with `select(.status != "cancelled")`, but a finished cancelled run reports `status: "completed"` with `conclusion: "cancelled"` -- verified against run 32506655531, which that filter counts as a prior run. Its `concurrency` block sets `cancel-in-progress: false`, so main's push rate produced dozens of cancelled runs per hour and every trigger skipped, reporting green after ~2 minutes of doing nothing. No `test: optimize slow tests` commit has ever landed on `main`. `pnpm test:perf:groups` and the rest of the performance tooling it drove stay; they are useful by hand and documented in docs/reference/test.md. Repository secret OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY now has no consumer and can be deleted. --- .github/workflows/test-performance-agent.yml | 280 ------------------- docs/ci.md | 5 - docs/reference/test.md | 2 +- 3 files changed, 1 insertion(+), 286 deletions(-) delete mode 100644 .github/workflows/test-performance-agent.yml diff --git a/.github/workflows/test-performance-agent.yml b/.github/workflows/test-performance-agent.yml deleted file mode 100644 index c80c9aeb76aa..000000000000 --- a/.github/workflows/test-performance-agent.yml +++ /dev/null @@ -1,280 +0,0 @@ -name: Test Performance Agent - -on: - workflow_run: # zizmor: ignore[dangerous-triggers] main-only test optimization after trusted CI; job gates repository, event, branch, actor, conclusion, current main SHA, and daily cadence before using write token - workflows: - - CI - types: - - completed - workflow_dispatch: - -permissions: - actions: read - contents: write - -concurrency: - group: test-performance-agent-main - cancel-in-progress: false - -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" - TEST_PERF_BEFORE: .artifacts/test-perf/baseline-before.json - TEST_PERF_AFTER: .artifacts/test-perf/after-agent.json - TEST_PERF_COMPARE: .artifacts/test-perf/agent-compare.json - -jobs: - optimize-tests: - if: > - github.repository == 'openclaw/openclaw' && - (github.event_name == 'workflow_dispatch' || - (github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' && - !endsWith(github.event.workflow_run.actor.login, '[bot]'))) - runs-on: ubuntu-24.04 - timeout-minutes: 240 - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - ref: main - fetch-depth: 0 - persist-credentials: false - submodules: false - - - name: Gate trusted main activity and daily cadence - id: gate - env: - EVENT_NAME: ${{ github.event_name }} - GH_TOKEN: ${{ github.token }} - WORKFLOW_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} - run: | - set -euo pipefail - - if [ "$EVENT_NAME" != "workflow_run" ]; then - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - exit 0 - fi - - for attempt in 1 2 3 4 5; do - if git fetch --no-tags origin main; then - break - fi - if [ "$attempt" = "5" ]; then - echo "Failed to fetch main after retries." >&2 - exit 1 - fi - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - remote_main="$(git rev-parse origin/main)" - if [ "$remote_main" != "$WORKFLOW_HEAD_SHA" ]; then - echo "CI run is superseded by ${remote_main}; skipping test performance agent for ${WORKFLOW_HEAD_SHA}." - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - day_start="$(date -u +%Y-%m-%dT00:00:00Z)" - runs_json="$RUNNER_TEMP/test-performance-agent-runs.json" - gh api --method GET "repos/${GITHUB_REPOSITORY}/actions/workflows/test-performance-agent.yml/runs" \ - -f branch=main \ - -f event=workflow_run \ - -f per_page=50 > "$runs_json" - - prior_runs="$( - jq -r \ - --argjson current_run_id "$GITHUB_RUN_ID" \ - --arg day_start "$day_start" \ - '.workflow_runs[] - | select(.database_id != $current_run_id) - | select(.created_at >= $day_start) - | select(.status != "cancelled") - | select((.conclusion // "") != "skipped") - | [.database_id, .status, (.conclusion // ""), .created_at, .head_sha] - | @tsv' "$runs_json" - )" - - if [ -n "$prior_runs" ]; then - echo "Test performance agent already ran or is running today; skipping." - printf '%s\n' "$prior_runs" - echo "run_agent=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "run_agent=true" >> "$GITHUB_OUTPUT" - echo "base_sha=${remote_main}" >> "$GITHUB_OUTPUT" - - - name: Setup Node environment - if: steps.gate.outputs.run_agent == 'true' - uses: ./.github/actions/setup-node-env - with: - cache-mode: restore - install-bun: "false" - - - name: Ensure test performance agent key exists - if: steps.gate.outputs.run_agent == 'true' - env: - OPENAI_API_KEY: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - run: | - set -euo pipefail - if [ -z "${OPENAI_API_KEY:-}" ]; then - echo "Missing OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2 - exit 1 - fi - - - name: Build baseline full-suite performance report - if: steps.gate.outputs.run_agent == 'true' - run: pnpm test:perf:groups --full-suite --allow-failures --output "$TEST_PERF_BEFORE" --limit 20 --top-files 40 - - - name: Run Codex test performance agent - if: steps.gate.outputs.run_agent == 'true' - uses: openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 - with: - openai-api-key: ${{ secrets.OPENCLAW_TEST_PERF_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }} - prompt-file: .github/codex/prompts/test-performance-agent.md - model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }} - effort: high - sandbox: workspace-write - safety-strategy: drop-sudo - codex-args: '["--full-auto"]' - - - name: Enforce focused test performance patch - if: steps.gate.outputs.run_agent == 'true' - id: patch - run: | - set -euo pipefail - - untracked="$(git ls-files --others --exclude-standard)" - if [ -n "$untracked" ]; then - echo "Test performance agent created untracked files; forbidden:" - printf '%s\n' "$untracked" - exit 1 - fi - - added_deleted_or_renamed="$(git diff --name-status --diff-filter=ADR)" - if [ -n "$added_deleted_or_renamed" ]; then - echo "Test performance agent added, deleted, or renamed tracked files; forbidden:" - printf '%s\n' "$added_deleted_or_renamed" - exit 1 - fi - - bad_paths="$( - git diff --name-only | while IFS= read -r path; do - case "$path" in - apps/*|extensions/*|packages/*|scripts/*|src/*|test/*|ui/*) ;; - *) printf '%s\n' "$path" ;; - esac - done - )" - if [ -n "$bad_paths" ]; then - echo "Test performance agent touched forbidden paths:" - printf '%s\n' "$bad_paths" - exit 1 - fi - - if git diff --quiet; then - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: Restore Node 24 path - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: - | # zizmor: ignore[github-env] NODE_BIN is set by the trusted local setup-node-env action in this same job - set -euo pipefail - export PATH="${NODE_BIN}:${PATH}" - echo "${NODE_BIN}" >> "$GITHUB_PATH" - node -v - corepack enable - pnpm -v - - - name: Run full-suite performance report after agent changes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups --full-suite --output "$TEST_PERF_AFTER" --limit 20 --top-files 40 - - - name: Compare test performance reports - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm test:perf:groups:compare "$TEST_PERF_BEFORE" "$TEST_PERF_AFTER" --output "$TEST_PERF_COMPARE" --limit 20 --top-files 40 - - - name: Enforce coverage-preserving test count - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: | - set -euo pipefail - node <<'NODE' - const fs = require("node:fs"); - const before = JSON.parse(fs.readFileSync(process.env.TEST_PERF_BEFORE, "utf8")); - const after = JSON.parse(fs.readFileSync(process.env.TEST_PERF_AFTER, "utf8")); - - if (before.failed) { - console.log("Baseline had failing configs; skipping total test-count comparison against partial report."); - process.exit(0); - } - - const beforeTests = before.totals?.testCount ?? 0; - const afterTests = after.totals?.testCount ?? 0; - if (afterTests < beforeTests) { - console.error(`Test count decreased from ${beforeTests} to ${afterTests}; refusing coverage-reducing patch.`); - process.exit(1); - } - console.log(`Test count preserved: ${beforeTests} -> ${afterTests}.`); - NODE - - - name: Check changed lanes - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - run: pnpm check:changed - - - name: Commit test performance updates - if: steps.gate.outputs.run_agent == 'true' && steps.patch.outputs.has_changes == 'true' - env: - GITHUB_TOKEN: ${{ github.token }} - TARGET_BRANCH: main - run: | - set -euo pipefail - - if git diff --quiet; then - echo "No test performance changes." - exit 0 - fi - - git config user.name "openclaw-test-performance-agent[bot]" - git config user.email "openclaw-test-performance-agent[bot]@users.noreply.github.com" - git add apps extensions packages scripts src test ui - git commit --no-verify -m "test: optimize slow tests" - - for attempt in 1 2 3 4 5; do - if ! git fetch --no-tags origin "${TARGET_BRANCH}"; then - echo "Fetch attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - continue - fi - if git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" HEAD:"${TARGET_BRANCH}"; then - exit 0 - fi - remote_main="$(git rev-parse "origin/${TARGET_BRANCH}")" - if [ "$remote_main" != "$(git rev-parse HEAD^)" ]; then - echo "main advanced; rebasing test performance update onto ${remote_main}." - if ! git rebase "origin/${TARGET_BRANCH}"; then - echo "Test performance update no longer applies cleanly; skipping stale update." - git rebase --abort || true - exit 0 - fi - pnpm check:changed - fi - echo "Test performance update attempt ${attempt} failed; retrying." - sleep $((attempt * 2)) - done - - echo "Failed to push test performance updates after retries." >&2 - exit 1 - - - name: Upload test performance artifacts - if: steps.gate.outputs.run_agent == 'true' && always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: test-performance-agent-${{ github.run_id }} - path: .artifacts/test-perf/ - if-no-files-found: ignore - retention-days: 14 diff --git a/docs/ci.md b/docs/ci.md index 3cc52aa816d2..17d39df2231f 100644 --- a/docs/ci.md +++ b/docs/ci.md @@ -55,7 +55,6 @@ dispatch. | `ios-build` | Swift lint, Debug and Release builds, focused simulator lifecycle tests, and the full release screenshot matrix when screenshot-pipeline owners changed | iOS/capture changes | | `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes | | `openclaw/ci-gate` | Final aggregate: requires preflight and security; accepts skips only for manifest-disabled downstream lanes | Every non-draft CI run | -| `test-performance-agent` | Separate workflow: daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch | | `openclaw-performance` | Separate workflow: daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.6 live lanes | Scheduled and manual dispatch | The rare path-triggered `docker-seed-e2e` job selects only the executable @@ -726,10 +725,6 @@ Quality stays separate from security so quality findings can be scheduled, measu The `Docs Agent` workflow is an event-driven Codex maintenance lane for keeping existing docs aligned with recently landed changes. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, and manual dispatch can run it directly. Workflow-run invocations skip when `main` has moved on or when another non-skipped Docs Agent run was created in the last hour. When it runs, it reviews the commit range from the previous non-skipped Docs Agent source SHA to current `main`, so one hourly run can cover all main changes accumulated since the last docs pass. -### Test Performance Agent - -The `Test Performance Agent` workflow is an event-driven Codex maintenance lane for slow tests. It has no pure schedule: a successful non-bot push CI run on `main` can trigger it, but it skips if another workflow-run invocation already ran or is running that UTC day. Manual dispatch bypasses that daily activity gate. The lane builds a full-suite grouped Vitest performance report, lets Codex make only small coverage-preserving test performance fixes instead of broad refactors, then reruns the full-suite report and rejects changes that reduce the passing baseline test count. The grouped report records per-config wall time and max RSS on Linux and macOS, so the before/after comparison surfaces test memory deltas beside duration deltas. If the baseline has failing tests, Codex may fix only obvious failures and the after-agent full-suite report must pass before anything is committed. When `main` advances before the bot push lands, the lane rebases the validated patch, reruns `pnpm check:changed`, and retries the push; conflicting stale patches are skipped. It uses GitHub-hosted Ubuntu so the Codex action can keep the same drop-sudo safety posture as the docs agent. - ### Duplicate PRs After Merge The `Duplicate PRs After Merge` workflow is a manual maintainer workflow for post-land duplicate cleanup. It defaults to dry-run and only closes explicitly listed PRs when `apply=true`. Before mutating GitHub, it verifies that the landed PR is merged and that each duplicate has either a shared referenced issue or overlapping changed hunks. diff --git a/docs/reference/test.md b/docs/reference/test.md index b2103d742a10..5f24376016a7 100644 --- a/docs/reference/test.md +++ b/docs/reference/test.md @@ -198,7 +198,7 @@ If `pnpm test` flakes on a loaded host, rerun once before treating it as a regre - `pnpm test:perf:imports`: enables Vitest import-duration + import-breakdown reporting, while still using scoped lane routing for explicit file/directory targets. `pnpm test:perf:imports:changed` scopes the same profiling to files changed since `origin/main`. - `pnpm test:perf:changed:bench -- --ref ` benchmarks the routed changed-mode path against the native root-project run for the same committed git diff; `pnpm test:perf:changed:bench -- --worktree` benchmarks the current worktree change set without committing first. - `pnpm test:perf:profile:main` writes a CPU profile for the Vitest main thread (`.artifacts/vitest-main-profile`); `pnpm test:perf:profile:runner` writes CPU + heap profiles for the unit runner (`.artifacts/vitest-runner-profile`). -- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. The Test Performance Agent uses this as its baseline before attempting slow-test fixes. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. +- `pnpm test:perf:groups --full-suite --allow-failures --output .artifacts/test-perf/baseline-before.json`: runs every full-suite Vitest leaf config serially and writes grouped duration data plus per-config JSON/log artifacts. Full-suite reports isolate files by default so retained module graphs and GC pauses from earlier files are not charged to later assertions; pass `-- --no-isolate` only when intentionally profiling shared-worker accumulation. `pnpm test:perf:groups:compare .artifacts/test-perf/baseline-before.json .artifacts/test-perf/after-agent.json` compares grouped reports after a performance-focused change. - Full, extension, and include-pattern shard runs update local timing data in `.artifacts/vitest-shard-timings.json`; later whole-config runs use those timings to balance slow and fast shards. Include-pattern CI shards append the shard name to the timing key, which keeps filtered shard timings visible without replacing whole-config timing data. Set `OPENCLAW_TEST_PROJECTS_TIMINGS=0` to ignore the local timing artifact. ## Benchmarks From 3ea90bdcdb30f2ad18a68203fee09fa7824170a1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 21 Aug 2026 13:21:43 -0700 Subject: [PATCH 056/745] feat(ui): add Person grouping mode for sessions sidebar and sessions page (#127346) * feat(ui): add Person grouping mode for sessions sidebar and sessions page Sessions can now be grouped by their durable owner identity: the sidebar Group-by menu gains a capability-gated Person mode (self first, humans by label, agent identities after; ownerless rows keep their smart zones), and the sessions page gains the matching person mode. Person section headers render the owner avatar and profile label; the mock dev server now advertises the multi-identity policy and carries explicit row owners the way the gateway projects createdActor fallbacks. * feat(ui): gate sessions-page Person grouping on the identity capability Mirrors the sidebar: the Person option hides without hasMultipleSessionSharingIdentities and a stored Person preference renders as None until the capability returns. --- scripts/control-ui-mock-dev.ts | 9 ++- .../app-sidebar-session-list-render.ts | 49 ++++++++--- .../app-sidebar-session-menu-renderers.ts | 17 ++-- .../app-sidebar-session-navigation-logic.ts | 10 +-- .../app-sidebar-session-navigation.ts | 35 ++++---- ui/src/components/sidebar-menus-controller.ts | 2 + ui/src/components/sidebar-menus-render.ts | 2 +- ui/src/i18n/locales/en.ts | 1 + ui/src/lib/sessions/grouping.test.ts | 81 ++++++++++++++++++- ui/src/lib/sessions/grouping.ts | 57 ++++++++++++- ui/src/pages/sessions/sessions-page.ts | 7 +- ui/src/pages/sessions/view.test.ts | 52 ++++++++++++ ui/src/pages/sessions/view.ts | 15 +++- .../app-sidebar-cases/session-ownership.ts | 75 +++++++++++++++++ 14 files changed, 360 insertions(+), 52 deletions(-) diff --git a/scripts/control-ui-mock-dev.ts b/scripts/control-ui-mock-dev.ts index 2b151fc65302..844d3a2f1c93 100644 --- a/scripts/control-ui-mock-dev.ts +++ b/scripts/control-ui-mock-dev.ts @@ -65,7 +65,7 @@ const MOCK_ACTOR_MIRA: SessionActorFixture = { id: "profile-mira", label: "Mira", }; -// These actors are also the effective owners because the mock rows are unassigned. +// Rows carry explicit owners the way the gateway projects createdActor fallbacks. const MOCK_SESSION_OWNERS: readonly SessionActorFixture[] = [MOCK_ACTOR_PETER, MOCK_ACTOR_MIRA]; const SESSION_PAGE_SIZE = 50; @@ -1531,6 +1531,7 @@ async function createChatPickerScenario( sessionRow(NARRATION_DEMO_SESSION_KEY, "Sidebar narration demo", baseTime - 15_000, { createdActor: MOCK_ACTOR_MIRA, hasActiveRun: true, + owner: { actor: MOCK_ACTOR_MIRA }, startedAt: baseTime - 45_000, status: "running", }), @@ -1544,10 +1545,12 @@ async function createChatPickerScenario( category: "Research", createdActor: MOCK_ACTOR_MIRA, execCwd: "/Users/peter/Projects/clawdbot", + owner: { actor: MOCK_ACTOR_MIRA }, }), sessionRow("agent:main:model-budget", "Model budget review", baseTime - 80_000, { category: "Research", execCwd: "/Users/peter/Projects/openclaw", + owner: { actor: { type: "human", id: "presence-riley", label: "Riley" } }, status: "failed", lastRunError: "Model out of credits: openai/gpt-5.6", }), @@ -1555,6 +1558,7 @@ async function createChatPickerScenario( createdActor: MOCK_ACTOR_PETER, execCwd: "/Users/peter/Work/openclaw", lastReadAt: baseTime - 120_000, + owner: { actor: MOCK_ACTOR_PETER }, observerDigest: { headline: "Done: fixed the flaky retry-window test", health: "done", @@ -1787,6 +1791,9 @@ async function createChatPickerScenario( // Terminal has a second gate beyond the advertised method (see // ui/src/lib/terminal-availability.ts). terminalEnabled: true, + // The mock rows span several owners; advertise the multi-identity policy + // so people-aware UI (People sort, Person grouping) is exercisable here. + hasMultipleSessionSharingIdentities: true, historyMessages, // Lights up the footer facepile and who's-online roster; the email-only // entry keeps the roster's no-display-name row exercised. diff --git a/ui/src/components/app-sidebar-session-list-render.ts b/ui/src/components/app-sidebar-session-list-render.ts index 9289fce9a4c0..94791308b716 100644 --- a/ui/src/components/app-sidebar-session-list-render.ts +++ b/ui/src/components/app-sidebar-session-list-render.ts @@ -59,17 +59,28 @@ function renderSessionSection(params: { const { host, section } = params; const totalRowCount = section.totalRowCount; const group = section.category; + const personOwner = section.personOwner; // Pinned rows render in the nav zone; renderHeader records whether this list // section owns collapse UI or sits directly below the global toolbar. const collapsed = section.renderHeader && host.collapsedSessionSections.has(section.id); - const label = section.groups - ? t("chat.sidebar.groups") - : section.work - ? t("chat.sidebar.coding") - : group - ? group - : t("chat.sidebar.otherSessions"); - const zone = section.groups ? "groups" : section.work ? "coding" : group ? "category" : "threads"; + const label = personOwner + ? personOwner.label || personOwner.id + : section.groups + ? t("chat.sidebar.groups") + : section.work + ? t("chat.sidebar.coding") + : group + ? group + : t("chat.sidebar.otherSessions"); + const zone = personOwner + ? "person" + : section.groups + ? "groups" + : section.work + ? "coding" + : group + ? "category" + : "threads"; // Collapsed Coding still signals live runs so background work stays visible. const collapsedRunningDot = collapsed && @@ -83,6 +94,7 @@ function renderSessionSection(params: { method: "sessions.groups.put", requiredScope: "operator.write", }); + const sectionDropEnabled = groupWriteAccess.allowed && !personOwner; const sectionClass = [ "sidebar-recent-sessions__group", `sidebar-recent-sessions__group--zone-${zone}`, @@ -103,19 +115,21 @@ function renderSessionSection(params: {
host.sectionDragOver(event, section.id, group) : nothing} - @dragleave=${groupWriteAccess.allowed + @dragleave=${sectionDropEnabled ? (event: DragEvent) => host.sectionDragLeave(event, section.id, group) : nothing} - @drop=${groupWriteAccess.allowed + @drop=${sectionDropEnabled ? (event: DragEvent) => host.sectionDrop(event, section.id, group) : nothing} > ${section.renderHeader ? renderSidebarSessionSectionHeader({ sectionId: section.id, + draggable: !personOwner, disabledReason: groupWriteAccess.allowed ? undefined : groupWriteAccess.reason, onStartDrag: (sectionId) => host.startSidebarSectionDrag(sectionId), onFinishDrag: () => host.finishSidebarSectionDrag(), @@ -138,6 +152,19 @@ function renderSessionSection(params: { >${collapsed ? icons.chevronRight : icons.chevronDown} + ${personOwner + ? html`` + : nothing} ${label} ${collapsed && totalRowCount > 0 ? html`${totalRowCount}` diff --git a/ui/src/components/app-sidebar-session-menu-renderers.ts b/ui/src/components/app-sidebar-session-menu-renderers.ts index 3015c9b9cea5..7d5cd4690423 100644 --- a/ui/src/components/app-sidebar-session-menu-renderers.ts +++ b/ui/src/components/app-sidebar-session-menu-renderers.ts @@ -294,6 +294,7 @@ export function renderSidebarSessionSortMenu(params: { } const groupingOptions = [ { grouping: "category", label: t("sessionsView.groupByCategory") }, + { grouping: "person", label: t("sessionsView.groupByPerson") }, { grouping: "none", label: t("sessionsView.groupByNone") }, ] as const satisfies ReadonlyArray<{ grouping: SidebarSessionsGrouping; label: string }>; return keyed( @@ -336,13 +337,15 @@ export function renderSidebarSessionSortMenu(params: { > ${renderSidebarMenuTrigger(position, t("chat.sidebar.sortSessions"))} - ${groupingOptions.map((option) => - renderSidebarMenuRadioItem({ - value: `grouping:${option.grouping}`, - checked: params.grouping === option.grouping, - label: option.label, - }), - )} + ${groupingOptions + .filter((option) => option.grouping !== "person" || params.peopleSortAvailable) + .map((option) => + renderSidebarMenuRadioItem({ + value: `grouping:${option.grouping}`, + checked: params.grouping === option.grouping, + label: option.label, + }), + )} ${SIDEBAR_SESSION_SORT_OPTIONS.filter( diff --git a/ui/src/components/app-sidebar-session-navigation-logic.ts b/ui/src/components/app-sidebar-session-navigation-logic.ts index b22d8beda1ea..8afe28bb7222 100644 --- a/ui/src/components/app-sidebar-session-navigation-logic.ts +++ b/ui/src/components/app-sidebar-session-navigation-logic.ts @@ -308,18 +308,16 @@ export function partitionSidebarVisibleSections(input: { rows: SidebarRecentSession[]; grouping: SidebarSessionsGrouping; knownGroups: string[] | undefined; + selfOwnerId?: string | null; catalogIds?: readonly string[]; sectionOrder?: readonly string[]; collapsedSections: ReadonlySet; hideEmptyOwnerFilteredGroup: (category: string | undefined, rowCount: number) => boolean; visibleSessionLimits: ReadonlyMap; }): SidebarVisibleSections { - const sections = groupSidebarSessionRows(input.rows, { - grouping: input.grouping, - knownGroups: input.knownGroups, - sectionOrder: input.sectionOrder, - catalogIds: input.catalogIds, - }).filter( + const { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds } = input; + const sectionOptions = { grouping, knownGroups, selfOwnerId, sectionOrder, catalogIds }; + const sections = groupSidebarSessionRows(input.rows, sectionOptions).filter( (section) => section.id !== "pinned" && !input.hideEmptyOwnerFilteredGroup(section.category, section.rows.length), diff --git a/ui/src/components/app-sidebar-session-navigation.ts b/ui/src/components/app-sidebar-session-navigation.ts index 9dfc0733d639..e7e0f7b25c43 100644 --- a/ui/src/components/app-sidebar-session-navigation.ts +++ b/ui/src/components/app-sidebar-session-navigation.ts @@ -120,6 +120,13 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { return resolveSidebarSessionSortMode(this.sessionSortMode, this.sessionPeopleSortAvailable()); } + effectiveSessionsGrouping(): SidebarSessionsGrouping { + // Reconnects temporarily hide the capability; retain the stored Person + // preference so it returns when the authoritative identity policy does. + const grouping = this.sessionsGrouping; + return grouping === "person" && !this.sessionPeopleSortAvailable() ? "category" : grouping; + } + setSessionSortMode(mode: SidebarSessionSortMode) { this.sessionSortMode = storeSidebarSessionSortMode(mode, this.sessionPeopleSortCapability()); } @@ -331,10 +338,12 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { /** Collapsed zones keep full rows for true header counts and status dots. */ protected zonedVisibleSections(rows: SidebarRecentSession[]): SidebarVisibleSections { + const grouping = this.effectiveSessionsGrouping(); return partitionSidebarVisibleSections({ rows, - grouping: this.sessionsGrouping, - knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : [], + grouping, + knownGroups: grouping === "category" ? this.knownSessionGroups() : [], + selfOwnerId: this.context?.gateway.snapshot.selfUser?.id ?? null, // Normalize gateway order without dropping catalog-lagging categories. sectionOrder: this.knownSectionOrder(), catalogIds: @@ -381,13 +390,10 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { const rows = this.selectedAgentSessionRows(navigationState); const { visibleRows } = this.zonedVisibleSections(rows); const pinnedByKey = new Map(rows.filter((row) => row.pinned).map((row) => [row.key, row])); - const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => - entry.type === "session" - ? pinnedByKey.get(entry.key) - ? [pinnedByKey.get(entry.key)!] - : [] - : [], - ); + const pinnedRows = this.reconciledSidebarZone().entries.flatMap((entry) => { + const row = entry.type === "session" ? pinnedByKey.get(entry.key) : undefined; + return row ? [row] : []; + }); return [...pinnedRows, ...visibleRows]; } @@ -486,9 +492,7 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { expandedAgentId(): string { const selected = normalizeOptionalString(this.context?.agentSelection.state.selectedId); - return selected - ? normalizeAgentId(selected) - : normalizeAgentId(this.getSessionNavigationState().selectedAgentId); + return normalizeAgentId(selected || this.getSessionNavigationState().selectedAgentId); } activeChipAgent() { @@ -509,11 +513,8 @@ export class AppSidebarSessionNavigationElement extends AppSidebarBase { } private agentResumeKey(agentId: string): string { - return resolveSidebarAgentResumeKey( - this.latestAgentSessionRow(agentId), - agentId, - this.sessionMainKey(), - ); + const latest = this.latestAgentSessionRow(agentId); + return resolveSidebarAgentResumeKey(latest, agentId, this.sessionMainKey()); } /** Offline routes to Settings instead of a dead chat load. */ diff --git a/ui/src/components/sidebar-menus-controller.ts b/ui/src/components/sidebar-menus-controller.ts index f90cf074180d..2bc0308974dc 100644 --- a/ui/src/components/sidebar-menus-controller.ts +++ b/ui/src/components/sidebar-menus-controller.ts @@ -17,6 +17,7 @@ import { sessionPullRequestsForGateway, } from "../lib/session-pull-requests.ts"; import type { CatalogProjectGrouping } from "../lib/sessions/catalog-project-grouping.ts"; +import type { SidebarSessionsGrouping } from "../lib/sessions/grouping.ts"; import { sessionNavigationTarget } from "../lib/sessions/route-navigation.ts"; import { parseAgentSessionKey } from "../lib/sessions/session-key.ts"; import { SidebarCatalogMenuController } from "./app-sidebar-catalog-menu.ts"; @@ -117,6 +118,7 @@ interface SidebarMenusControllerHost hideSessionCatalog(catalogId: string): void; sessionSortMode: SidebarSessionSortMode; effectiveSessionSortMode(): SidebarSessionSortMode; + effectiveSessionsGrouping(): SidebarSessionsGrouping; sessionPeopleSortAvailable(): boolean; setSessionSortMode(mode: SidebarSessionSortMode): void; readonly terminalAvailable: boolean; diff --git a/ui/src/components/sidebar-menus-render.ts b/ui/src/components/sidebar-menus-render.ts index 82d1f7d606ed..23bb0b62d150 100644 --- a/ui/src/components/sidebar-menus-render.ts +++ b/ui/src/components/sidebar-menus-render.ts @@ -380,7 +380,7 @@ export function renderSidebarSessionSortMenuForController(controller: SidebarMen return renderSidebarSessionSortMenu({ position, trigger: controller.sessionSortMenuTrigger, - grouping: host.sessionsGrouping, + grouping: host.effectiveSessionsGrouping(), sortMode: host.effectiveSessionSortMode(), peopleSortAvailable: host.sessionPeopleSortAvailable(), statusFilter: host.sessionsStatusFilter, diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index c33b6123ad13..019940ecdc28 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -1164,6 +1164,7 @@ export const en: TranslationMap = { groupBy: "Group by", groupByNone: "None", groupByCategory: "Custom groups", + groupByPerson: "Person", showSessionPreview: "Show message preview", showCronSessions: "Show automation sessions", showSystemSessions: "Show system sessions", diff --git a/ui/src/lib/sessions/grouping.test.ts b/ui/src/lib/sessions/grouping.test.ts index fdfdd583c48e..551b9b1a73fa 100644 --- a/ui/src/lib/sessions/grouping.test.ts +++ b/ui/src/lib/sessions/grouping.test.ts @@ -82,6 +82,68 @@ describe("groupSidebarSessionRows", () => { expect(sections[1]?.rows.map((item) => item.key)).toEqual(["tg"]); }); + it("orders owner sections before stored zones and leaves ownerless rows in their smart zones", () => { + const sections = groupSidebarSessionRows( + [ + row({ key: "agent", owner: { actor: { type: "agent", id: "agent-z", label: "Zed" } } }), + row({ + key: "owned-group", + kind: "group", + category: "Ignored", + owner: { actor: { type: "human", id: "profile-b", label: "Bea" } }, + }), + row({ key: "thread" }), + row({ key: "group", kind: "group" }), + row({ key: "work", workSession: true }), + row({ key: "human-a", owner: { actor: { type: "human", id: "profile-a", label: "Ada" } } }), + row({ + key: "self", + owner: { + actor: { + type: "human", + id: "profile-self", + label: "Zoe", + avatarUrl: "/avatars/self", + }, + }, + }), + row({ key: "agent-a", owner: { actor: { type: "agent", id: "agent-a", label: "Alpha" } } }), + row({ key: "blank-owner", owner: { actor: { type: "human", id: " " } } }), + row({ key: "pinned", pinned: true, owner: { actor: { type: "human", id: "profile-a" } } }), + ], + { + grouping: "person", + selfOwnerId: "profile-self", + knownGroups: ["Ignored"], + catalogIds: ["catalog"], + sectionOrder: ["work", "person:profile-b", "groups", "ungrouped", "catalog:catalog"], + }, + ); + + expect(sections.map((section) => section.id)).toEqual([ + "pinned", + "person:profile-self", + "person:profile-a", + "person:profile-b", + "person:agent-a", + "person:agent-z", + "work", + "groups", + "ungrouped", + "catalog:catalog", + ]); + expect(sections[1]?.personOwner).toEqual({ + type: "human", + id: "profile-self", + label: "Zoe", + avatarUrl: "/avatars/self", + }); + expect(sections[3]?.rows.map((item) => item.key)).toEqual(["owned-group"]); + expect(sections[6]?.rows.map((item) => item.key)).toEqual(["work"]); + expect(sections[7]?.rows.map((item) => item.key)).toEqual(["group"]); + expect(sections[8]?.rows.map((item) => item.key)).toEqual(["thread", "blank-owner"]); + }); + it("always emits threads and coding so the renderer can host fallbacks and catalogs", () => { expect(groupSidebarSessionRows([row({ key: "a" })]).map((section) => section.id)).toEqual([ "ungrouped", @@ -302,8 +364,9 @@ describe("moveSessionSection", () => { }); describe("normalizeSidebarSessionsGrouping", () => { - it("accepts none and falls back to category grouping", () => { + it("accepts supported modes and falls back to category grouping", () => { expect(normalizeSidebarSessionsGrouping("none")).toBe("none"); + expect(normalizeSidebarSessionsGrouping("person")).toBe("person"); expect(normalizeSidebarSessionsGrouping("category")).toBe("category"); expect(normalizeSidebarSessionsGrouping(null)).toBe("category"); expect(normalizeSidebarSessionsGrouping("bogus")).toBe("category"); @@ -329,6 +392,7 @@ function row( describe("normalizeSessionsGroupBy", () => { it("accepts known modes and falls back to none", () => { expect(normalizeSessionsGroupBy("category")).toBe("category"); + expect(normalizeSessionsGroupBy("person")).toBe("person"); expect(normalizeSessionsGroupBy("date")).toBe("date"); expect(normalizeSessionsGroupBy("bogus")).toBe("none"); expect(normalizeSessionsGroupBy(null)).toBe("none"); @@ -362,6 +426,21 @@ describe("groupSessionRows", () => { expect(groups.map((group) => group.id)).toEqual(["discord", "telegram", UNGROUPED_ID]); }); + it("groups sessions by their durable owner identity and leaves ownerless sessions last", () => { + const groups = groupSessionRows({ + rows: [ + row({ key: "bob", owner: { actor: { type: "human", id: "profile-b", label: "Bob" } } }), + row({ key: "ownerless" }), + row({ key: "ada", owner: { actor: { type: "human", id: " profile-a ", label: "Ada" } } }), + row({ key: "blank", owner: { actor: { type: "human", id: " " } } }), + ], + mode: "person", + }); + + expect(groups.map((group) => group.id)).toEqual(["profile-a", "profile-b", UNGROUPED_ID]); + expect(groups[2]?.rows.map((item) => item.key)).toEqual(["ownerless", "blank"]); + }); + it("preserves row order within a group", () => { const rows = [ row({ key: "agent:main:discord:channel:1" }), diff --git a/ui/src/lib/sessions/grouping.ts b/ui/src/lib/sessions/grouping.ts index ec7c647b1334..501cdb4dc594 100644 --- a/ui/src/lib/sessions/grouping.ts +++ b/ui/src/lib/sessions/grouping.ts @@ -6,6 +6,7 @@ import { parseAgentSessionKey, parseSessionKeyParts } from "./session-key.ts"; export const SESSION_GROUP_MODES = [ "none", "category", + "person", "channel", "kind", "agent", @@ -25,8 +26,16 @@ export type SessionRowGroup = { }; export type SidebarSessionSection = { - id: "pinned" | "ungrouped" | "groups" | "work" | `category:${string}` | `catalog:${string}`; + id: + | "pinned" + | "ungrouped" + | "groups" + | "work" + | `category:${string}` + | `person:${string}` + | `catalog:${string}`; category?: string; + personOwner?: { type: string; id: string; label?: string; avatarUrl?: string }; /** Built-in smart group-conversation section (kind "group" rows). */ groups?: boolean; /** Built-in smart coding section (worktree/exec-node/ACP sessions). */ @@ -128,6 +137,8 @@ function resolveSessionGroupId(row: GatewaySessionRow, mode: SessionsGroupBy, no switch (mode) { case "category": return row.category?.trim() ?? UNGROUPED_ID; + case "person": + return row.owner?.actor.id?.trim() || UNGROUPED_ID; case "channel": return sessionRowChannel(row); case "kind": @@ -169,16 +180,17 @@ export function groupSessionRows(params: { return ids.map((id) => ({ id, rows: byId.get(id) ?? [] })); } -/** How the sidebar buckets non-pinned rows: category sections or one flat list. */ -export type SidebarSessionsGrouping = "category" | "none"; +/** How the sidebar buckets non-pinned rows before its built-in smart zones. */ +export type SidebarSessionsGrouping = "category" | "person" | "none"; export function normalizeSidebarSessionsGrouping(raw: unknown): SidebarSessionsGrouping { - return raw === "none" ? "none" : "category"; + return raw === "none" || raw === "person" ? raw : "category"; } type SidebarGroupableRow = { pinned?: boolean; category?: string | null; + owner?: { actor: { type: string; id?: string; label?: string; avatarUrl?: string } }; /** Session kind from the gateway row; "group" rows form the Groups zone. */ kind?: string; /** Session bound to a managed worktree or exec node (Coding zone). */ @@ -216,6 +228,7 @@ export function groupSidebarSessionRows( options: { knownGroups?: readonly string[]; grouping?: SidebarSessionsGrouping; + selfOwnerId?: string | null; sectionOrder?: readonly string[]; catalogIds?: readonly string[]; } = {}, @@ -226,6 +239,7 @@ export function groupSidebarSessionRows( const groups: Row[] = []; const coding: Row[] = []; const categories = new Map(); + const people = new Map>(); if (grouping === "category") { for (const name of options.knownGroups ?? []) { const trimmed = name.trim(); @@ -239,6 +253,26 @@ export function groupSidebarSessionRows( pinned.push(row); continue; } + const owner = grouping === "person" ? row.owner?.actor : undefined; + const ownerId = owner?.id?.trim(); + if (owner && ownerId) { + const personSection = people.get(ownerId); + if (personSection) { + personSection.rows.push(row); + } else { + people.set(ownerId, { + id: `person:${ownerId}`, + personOwner: { + type: owner.type, + id: ownerId, + ...(owner.label ? { label: owner.label } : {}), + ...(owner.avatarUrl ? { avatarUrl: owner.avatarUrl } : {}), + }, + rows: [row], + }); + } + continue; + } const category = grouping === "category" ? row.category?.trim() : undefined; if (category) { const categoryRows = categories.get(category); @@ -264,6 +298,21 @@ export function groupSidebarSessionRows( if (pinned.length > 0) { sections.push({ id: "pinned", rows: pinned }); } + sections.push( + ...[...people.values()].toSorted((left, right) => { + const leftOwner = left.personOwner!; + const rightOwner = right.personOwner!; + const leftRank = + leftOwner.id === options.selfOwnerId ? 0 : leftOwner.type === "agent" ? 2 : 1; + const rightRank = + rightOwner.id === options.selfOwnerId ? 0 : rightOwner.type === "agent" ? 2 : 1; + return ( + leftRank - rightRank || + (leftOwner.label || leftOwner.id).localeCompare(rightOwner.label || rightOwner.id) || + leftOwner.id.localeCompare(rightOwner.id) + ); + }), + ); const knownGroups = [ ...new Set((options.knownGroups ?? []).map((name) => name.trim()).filter(Boolean)), ]; diff --git a/ui/src/pages/sessions/sessions-page.ts b/ui/src/pages/sessions/sessions-page.ts index c75a5152922d..6d900556ad74 100644 --- a/ui/src/pages/sessions/sessions-page.ts +++ b/ui/src/pages/sessions/sessions-page.ts @@ -1510,6 +1510,8 @@ class SessionsPage extends OpenClawLightDomElement { override render() { const context = this.context; + const personGroupingAvailable = + context?.gateway.snapshot.hello?.policy?.hasMultipleSessionSharingIdentities === true; if (!context) { return html``; } @@ -1559,7 +1561,10 @@ class SessionsPage extends OpenClawLightDomElement { ), sortColumn: this.sortColumn, sortDir: this.sortDir, - groupBy: this.groupBy, + // Same reconnect resilience as the sidebar: the stored Person + // preference survives a temporarily hidden identity capability. + groupBy: personGroupingAvailable || this.groupBy !== "person" ? this.groupBy : "none", + personGroupingAvailable, knownCategories: this.knownCategories(), page: this.page, pageSize: this.pageSize, diff --git a/ui/src/pages/sessions/view.test.ts b/ui/src/pages/sessions/view.test.ts index 26285eef43a8..f70ceae99223 100644 --- a/ui/src/pages/sessions/view.test.ts +++ b/ui/src/pages/sessions/view.test.ts @@ -49,6 +49,7 @@ function buildProps(result: SessionsListResult): SessionsProps { sortColumn: "updated", sortDir: "desc", groupBy: "none", + personGroupingAvailable: true, knownCategories: [], page: 0, pageSize: 10, @@ -482,6 +483,57 @@ describe("sessions view", () => { expect(container.querySelectorAll(".session-data-row")).toHaveLength(1); }); + it("offers person grouping and labels owner sections from their durable profile", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps( + buildMultiResult([ + { + key: "agent:main:ada", + kind: "direct", + updatedAt: 2, + owner: { actor: { type: "human", id: "profile-ada", label: "Ada Lovelace" } }, + }, + { key: "agent:main:ownerless", kind: "direct", updatedAt: 1 }, + ]), + ), + groupBy: "person", + }), + container, + ); + await Promise.resolve(); + + expect( + container + .querySelector('.session-groupby__select option[value="person"]') + ?.textContent?.trim(), + ).toBe("Person"); + expect( + [...container.querySelectorAll(".session-group-row__label")].map((label) => + label.textContent?.trim(), + ), + ).toEqual(["Ada Lovelace", "Ungrouped"]); + }); + + it("hides the person grouping option without the identity capability", async () => { + const container = document.createElement("div"); + render( + renderSessions({ + ...buildProps(buildResult({ key: "agent:main:a", kind: "direct", updatedAt: 1 })), + personGroupingAvailable: false, + }), + container, + ); + await Promise.resolve(); + + const modes = [ + ...container.querySelectorAll(".session-groupby__select option"), + ].map((option) => option.value); + expect(modes).not.toContain("person"); + expect(modes).toContain("category"); + }); + it("selects and names the current page size on first render", async () => { const container = document.createElement("div"); render( diff --git a/ui/src/pages/sessions/view.ts b/ui/src/pages/sessions/view.ts index a4cd25169c9b..3fe4a8b60c76 100644 --- a/ui/src/pages/sessions/view.ts +++ b/ui/src/pages/sessions/view.ts @@ -90,6 +90,8 @@ export type SessionsProps = { sortColumn: "key" | "kind" | "updated" | "tokens"; sortDir: "asc" | "desc"; groupBy: SessionsGroupBy; + /** Multi-identity gateways only; hides the Person mode elsewhere. */ + personGroupingAvailable: boolean; knownCategories: string[]; page: number; pageSize: number; @@ -780,6 +782,7 @@ function sessionsTableColumnCount(props: SessionsProps): number { const SESSION_GROUP_MODE_LABELS = { none: "sessionsView.groupByNone", category: "sessionsView.groupByCategory", + person: "sessionsView.groupByPerson", channel: "sessionsView.groupByChannel", kind: "sessionsView.groupByKind", agent: "sessionsView.groupByAgent", @@ -790,7 +793,8 @@ function groupModeLabel(mode: SessionsGroupBy): string { return t(SESSION_GROUP_MODE_LABELS[mode] ?? SESSION_GROUP_MODE_LABELS.none); } -function sessionGroupLabel(id: string, props: SessionsProps): string { +function sessionGroupLabel(group: SessionRowGroup, props: SessionsProps): string { + const { id } = group; if (props.groupBy === "date") { const labels: Record = { today: "sessionsView.dateToday", @@ -811,6 +815,9 @@ function sessionGroupLabel(id: string, props: SessionsProps): string { return emoji ? `${emoji} ${name}` : name; } } + if (props.groupBy === "person") { + return group.rows[0]?.owner?.actor.label?.trim() || id; + } return id; } @@ -856,7 +863,7 @@ function categoryDropHandlers(props: SessionsProps, category: string | null) { } function renderGroupHeaderRow(group: SessionRowGroup, props: SessionsProps) { - const label = sessionGroupLabel(group.id, props); + const label = sessionGroupLabel(group, props); const count = group.rows.length === 1 ? t("sessionsView.groupRowCountOne", { count: "1" }) @@ -1215,7 +1222,9 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) { @change=${(e: Event) => props.onGroupByChange((e.target as HTMLSelectElement).value as SessionsGroupBy)} > - ${SESSION_GROUP_MODES.map( + ${SESSION_GROUP_MODES.filter( + (mode) => mode !== "person" || props.personGroupingAvailable, + ).map( (mode) => html`