feat: add session thread management (#98510)

* feat: add session thread management

Squash of codex/thread-management (025aefc3ad1) onto origin/main:
pin/archive/rename sessions via sessions.patch, archived-aware
sessions.list, lifecycle fencing, read-only archived chat, SDK +
Swift protocol support, Control UI session management.

* refactor(ui): minimal session rows with hover-revealed management

Chat picker and sidebar recents share session-row primitives: single-line
rows, relative timestamps, rename/archive/pin revealed on hover or focus,
accent pin badge for pinned rows, and an active-run spinner in the trail
slot. Sidebar floats pinned sessions above recency via the shared
comparator and gains archive/pin actions through the unified sessions-view
patch fallback. Archive eligibility is one shared policy
(canArchiveSessionRow); the sidebar/picker active-run tooltip now uses the
real sessionsView.activeRun locale key.

* fix: align session admission with mailbox-era main

Integration fixes after rebasing onto current main: sessions_list mailbox
test expectations learn the archived/pinned row fields and archived:false
list param; gateway agent admission treats a session as deleted only when
both the requested and canonical alias sets miss it (legacy bare-main
stores and exec-approval followups read under different spellings); cron
persist tests keep a consistent store across claim-guarded persist calls;
the ACP abort hook test asserts abort propagation instead of signal
identity; drop dead lifecycle writes flagged by no-useless-assignment and
fix the promise-executor return in the codex compact test.

* fix(qa): align UI e2e and shard fixtures with redesigned session rows

Sidebar session rows are wrapper divs with an inner link now: update the
navigation browser tests and chat-flow Playwright selectors. Seed a real
per-test session store for the auto-fallback admission guard instead of
depending on leftover host files at /tmp/sessions.json. Teach the
test-projects routing fixture about the suites that newly import the
shared temp-dir helper. Document the Codex thread-format contract for
archivedAt/pinnedAt (flag derived from server-stamped timestamp, epoch ms
here vs Codex epoch seconds) at the type and in the session docs.

* test: route auto-fallback suite through temp-dir helper plans

The auto-fallback suite now imports the shared temp-dir helper for its
seeded session store, so the top-level helper routing fixture must list
it in the auto-reply plan.
This commit is contained in:
Peter Steinberger
2026-07-04 14:30:47 -04:00
committed by GitHub
parent 3644946230
commit 6df0fb818d
271 changed files with 20697 additions and 4928 deletions
@@ -7,6 +7,7 @@ import OSLog
struct IOSGatewayChatTransport: OpenClawChatTransport {
static let logger = Logger(subsystem: "ai.openclawfoundation.app", category: "ios.chat.transport")
static let defaultChatSendTimeoutMs = 30000
static let compactionRequestTimeoutSeconds = 0
private let gateway: GatewayNodeSession
private struct CreateSessionParams: Codable {
@@ -205,7 +206,11 @@ struct IOSGatewayChatTransport: OpenClawChatTransport {
func compactSession(sessionKey: String) async throws {
let json = try Self.makeSessionKeyParamsJSON(sessionKey)
_ = try await self.gateway.request(method: "sessions.compact", paramsJSON: json, timeoutSeconds: 10)
let response = try await self.gateway.request(
method: "sessions.compact",
paramsJSON: json,
timeoutSeconds: Self.compactionRequestTimeoutSeconds)
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
}
func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload {
@@ -27,6 +27,10 @@ import Testing
#expect(IOSGatewayChatTransport.agentWaitRequestTimeoutSeconds(timeoutMs: 30000) == 35)
}
@Test func compactionLeavesTerminalTimeoutToGateway() {
#expect(IOSGatewayChatTransport.compactionRequestTimeoutSeconds == 0)
}
@Test func agentWaitCompletionDecodesFallbackRunId() throws {
let data = Data(#"{"status":"completed"}"#.utf8)
let completion = try IOSGatewayChatTransport.decodeAgentWaitCompletion(data, fallbackRunId: "run-local")
@@ -245,7 +245,8 @@ final class ControlChannel {
func request(
method: String,
params: [String: AnyHashable]? = nil,
timeoutMs: Double? = nil) async throws -> Data
timeoutMs: Double? = nil,
retryTransportFailures: Bool = true) async throws -> Data
{
do {
let rawParams = params?.reduce(into: [String: OpenClawKit.AnyCodable]()) {
@@ -254,7 +255,8 @@ final class ControlChannel {
let data = try await GatewayConnection.shared.request(
method: method,
params: rawParams,
timeoutMs: timeoutMs)
timeoutMs: timeoutMs,
retryTransportFailures: retryTransportFailures)
self.setStateThrottled(.connected)
return data
} catch {
@@ -163,7 +163,8 @@ actor GatewayConnection {
func request(
method: String,
params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data
timeoutMs: Double? = nil,
retryTransportFailures: Bool = true) async throws -> Data
{
let cfg = try await self.configProvider()
await self.configure(url: cfg.url, token: cfg.token, password: cfg.password)
@@ -174,7 +175,7 @@ actor GatewayConnection {
do {
return try await client.request(method: method, params: params, timeoutMs: timeoutMs)
} catch {
if error is GatewayResponseError || error is GatewayDecodingError {
if !retryTransportFailures || error is GatewayResponseError || error is GatewayDecodingError {
throw error
}
@@ -1,5 +1,6 @@
import AppKit
import Foundation
import OpenClawKit
enum SessionActions {
static func patchSession(
@@ -32,9 +33,12 @@ enum SessionActions {
}
static func compactSession(key: String, maxLines: Int = 400) async throws {
_ = try await ControlChannel.shared.request(
let response = try await ControlChannel.shared.request(
method: "sessions.compact",
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)])
params: ["key": AnyHashable(key), "maxLines": AnyHashable(maxLines)],
timeoutMs: 0,
retryTransportFailures: false)
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
}
@MainActor
@@ -131,10 +131,12 @@ struct MacGatewayChatTransport: OpenClawChatTransport {
}
func compactSession(sessionKey: String) async throws {
_ = try await GatewayConnection.shared.request(
let response = try await GatewayConnection.shared.request(
method: "sessions.compact",
params: ["key": AnyCodable(sessionKey)],
timeoutMs: 10000)
timeoutMs: 0,
retryTransportFailures: false)
try OpenClawSessionsCompactResponse.requireSuccess(from: response)
}
func setActiveSessionKey(_ sessionKey: String) async throws {
@@ -92,6 +92,30 @@ struct GatewayConnectionTests {
#expect(session.snapshotMakeCount() == 1)
}
@Test func `request can disable retries for non idempotent mutations`() async throws {
let session = GatewayTestWebSocketSession(
taskFactory: {
GatewayTestWebSocketTask(sendHook: { _, _, sendIndex in
if sendIndex > 0 {
throw URLError(.timedOut)
}
})
})
let (conn, _) = try self.makeConnection(session: session)
do {
_ = try await conn.request(
method: "sessions.compact",
params: nil,
timeoutMs: 10,
retryTransportFailures: false)
Issue.record("expected sessions.compact transport failure")
} catch {}
#expect(session.snapshotMakeCount() == 1)
#expect(session.latestTask()?.snapshotSendCount() == 2)
}
@Test func `subscribe replays latest snapshot`() async throws {
let session = self.makeSession()
let (conn, _) = try self.makeConnection(session: session)
@@ -175,6 +175,10 @@ final class GatewayTestWebSocketTask: WebSocketTasking, @unchecked Sendable {
self.lock.withLock { self.connectRequestID }
}
func snapshotSendCount() -> Int {
self.lock.withLock { self.sendCount }
}
func resume() {
self.state = .running
}
@@ -241,6 +241,10 @@ private enum GatewayConnectErrorCodes {
}
public actor GatewayChannelActor {
nonisolated static func resolveRequestTimeoutMs(_ timeoutMs: Double?, defaultMs: Double) -> Double? {
timeoutMs == 0 ? nil : (timeoutMs ?? defaultMs)
}
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway")
private var task: WebSocketTaskBox?
private var pending: [String: CheckedContinuation<GatewayFrame, Error>] = [:]
@@ -1174,14 +1178,17 @@ public actor GatewayChannelActor {
timeoutMs: Double? = nil) async throws -> Data
{
try await self.connectOrThrow(context: "gateway connect")
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
// Zero leaves terminal-operation deadlines to the Gateway owner.
let effectiveTimeout = Self.resolveRequestTimeoutMs(timeoutMs, defaultMs: self.defaultRequestTimeoutMs)
let payload = try self.encodeRequest(method: method, params: params, kind: "request")
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[payload.id] = cont
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
if let effectiveTimeout {
Task { [weak self] in
guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout)
}
}
Task {
do {
@@ -0,0 +1,26 @@
import Foundation
public struct OpenClawSessionsCompactResponse: Decodable, Sendable {
public let ok: Bool
public let reason: String?
public static func requireSuccess(from data: Data) throws {
let response = try JSONDecoder().decode(Self.self, from: data)
guard response.ok else {
throw OpenClawSessionsCompactError(reason: response.reason)
}
}
}
public struct OpenClawSessionsCompactError: Error, LocalizedError, Sendable {
public let reason: String?
public var errorDescription: String? {
let detail = self.reason?.trimmingCharacters(in: .whitespacesAndNewlines)
return detail?.isEmpty == false ? detail : "Session compaction failed"
}
public init(reason: String?) {
self.reason = reason
}
}
@@ -1640,6 +1640,7 @@ public struct SessionsListParams: Codable, Sendable {
public let spawnedby: String?
public let agentid: String?
public let search: String?
public let archived: Bool?
public init(
limit: Int?,
@@ -1653,7 +1654,8 @@ public struct SessionsListParams: Codable, Sendable {
label: String?,
spawnedby: String?,
agentid: String? = nil,
search: String?)
search: String?,
archived: Bool? = nil)
{
self.limit = limit
self.offset = offset
@@ -1667,6 +1669,7 @@ public struct SessionsListParams: Codable, Sendable {
self.spawnedby = spawnedby
self.agentid = agentid
self.search = search
self.archived = archived
}
private enum CodingKeys: String, CodingKey {
@@ -1682,6 +1685,7 @@ public struct SessionsListParams: Codable, Sendable {
case spawnedby = "spawnedBy"
case agentid = "agentId"
case search
case archived
}
}
@@ -2433,6 +2437,8 @@ public struct SessionsPatchParams: Codable, Sendable {
public let key: String
public let agentid: String?
public let label: AnyCodable?
public let archived: Bool?
public let pinned: Bool?
public let thinkinglevel: AnyCodable?
public let fastmode: AnyCodable?
public let verboselevel: AnyCodable?
@@ -2460,6 +2466,8 @@ public struct SessionsPatchParams: Codable, Sendable {
key: String,
agentid: String? = nil,
label: AnyCodable?,
archived: Bool? = nil,
pinned: Bool? = nil,
thinkinglevel: AnyCodable?,
fastmode: AnyCodable?,
verboselevel: AnyCodable?,
@@ -2486,6 +2494,8 @@ public struct SessionsPatchParams: Codable, Sendable {
self.key = key
self.agentid = agentid
self.label = label
self.archived = archived
self.pinned = pinned
self.thinkinglevel = thinkinglevel
self.fastmode = fastmode
self.verboselevel = verboselevel
@@ -2514,6 +2524,8 @@ public struct SessionsPatchParams: Codable, Sendable {
case key
case agentid = "agentId"
case label
case archived
case pinned
case thinkinglevel = "thinkingLevel"
case fastmode = "fastMode"
case verboselevel = "verboseLevel"
@@ -2617,17 +2629,26 @@ public struct SessionsDeleteParams: Codable, Sendable {
public let key: String
public let agentid: String?
public let deletetranscript: Bool?
public let expectedsessionid: String?
public let expectedlifecyclerevision: String?
public let expectedsessionupdatedat: Double?
public let emitlifecyclehooks: Bool?
public init(
key: String,
agentid: String? = nil,
deletetranscript: Bool?,
expectedsessionid: String? = nil,
expectedlifecyclerevision: String? = nil,
expectedsessionupdatedat: Double? = nil,
emitlifecyclehooks: Bool?)
{
self.key = key
self.agentid = agentid
self.deletetranscript = deletetranscript
self.expectedsessionid = expectedsessionid
self.expectedlifecyclerevision = expectedlifecyclerevision
self.expectedsessionupdatedat = expectedsessionupdatedat
self.emitlifecyclehooks = emitlifecyclehooks
}
@@ -2635,6 +2656,9 @@ public struct SessionsDeleteParams: Codable, Sendable {
case key
case agentid = "agentId"
case deletetranscript = "deleteTranscript"
case expectedsessionid = "expectedSessionId"
case expectedlifecyclerevision = "expectedLifecycleRevision"
case expectedsessionupdatedat = "expectedSessionUpdatedAt"
case emitlifecyclehooks = "emitLifecycleHooks"
}
}
@@ -916,6 +916,13 @@ struct GatewayNodeSessionTests {
#expect(response.error == nil)
}
@Test
func `gateway request timeout zero disables the client deadline`() {
#expect(GatewayChannelActor.resolveRequestTimeoutMs(0, defaultMs: 15000) == nil)
#expect(GatewayChannelActor.resolveRequestTimeoutMs(nil, defaultMs: 15000) == 15000)
#expect(GatewayChannelActor.resolveRequestTimeoutMs(30000, defaultMs: 15000) == 30000)
}
@Test
func `emits synthetic seq gap after reconnect snapshot`() async throws {
let session = FakeGatewayWebSocketSession()
@@ -0,0 +1,26 @@
import Foundation
import OpenClawKit
import Testing
struct SessionMutationResponsesTests {
@Test
func compactResponseAcceptsSuccess() throws {
try OpenClawSessionsCompactResponse.requireSuccess(
from: Data(#"{"ok":true,"key":"agent:main:main","compacted":true}"#.utf8))
}
@Test
func compactResponseSurfacesGatewayFailureReason() {
let data = Data(
#"{"ok":false,"key":"agent:main:main","compacted":false,"reason":"turn failed"}"#.utf8)
do {
try OpenClawSessionsCompactResponse.requireSuccess(
from: data)
Issue.record("expected failed compaction response to throw")
} catch let error as OpenClawSessionsCompactError {
#expect(error.errorDescription == "turn failed")
} catch {
Issue.record("unexpected error: \(error)")
}
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
acdf418738f79d29f58cb6cfe5b7ab2d353cbcce2252861a4f527f85ea0c54af config-baseline.json
f5e127927b1810ec9769f43ed15fbabde4c79a8778cc486e12119527df9b5ebb config-baseline.json
4c98c716bc78e65c274ec374757357c1dcc9b5ec75c9e00ea4c20851531b7d1a config-baseline.core.json
c68853362689981ac1cc1e55b9061286c2002104ff1c10bc44ee99a6080e169e config-baseline.channel.json
859aa272b0dad53b7080c6fefcf775347ae79a1998ec39dd18b732c90d9df90c config-baseline.plugin.json
4721a543fc05a2dee5d6af676a145069adf7849fe50e18c12a8515f9d9fe8b73 config-baseline.plugin.json
@@ -1,2 +1,2 @@
a4f18fb57ec58de32a554b6481190016e421246fef04fc8f8045271f4de843fe plugin-sdk-api-baseline.json
80d3e71a2183c95e0177df970429e639e9702a23a76bd7b852325eef32e7acd1 plugin-sdk-api-baseline.jsonl
95e17039bab1ad1dd3294948868740fb0315151b982e8f5c31859ba68fea2c0b plugin-sdk-api-baseline.json
1c936a4d3ffcf00a16df5aa9bbbba109ed856c4adfd6af6118841557e2e4ec88 plugin-sdk-api-baseline.jsonl
+2 -2
View File
@@ -179,11 +179,11 @@ openclaw sessions compact "agent:main:main" --max-lines 200
openclaw sessions compact "agent:work:main" --agent work --json
```
- Without `--max-lines`, the gateway LLM-summarizes the transcript. This can be slow, so the default `--timeout` is `180000` ms.
- Without `--max-lines`, the gateway LLM-summarizes the transcript. The CLI does not impose a client deadline by default; the gateway owns the configured compaction lifecycle.
- With `--max-lines <n>`, it truncates to the last `n` transcript lines and archives the prior transcript as a `.bak` sidecar.
- `--agent <id>`: agent that owns the session; required for `global` keys.
- `--url` / `--token` / `--password`: gateway connection overrides.
- `--timeout <ms>`: RPC timeout in milliseconds.
- `--timeout <ms>`: optional client-side RPC timeout in milliseconds.
- `--json`: print the raw RPC payload.
The command exits non-zero when the gateway reports a failed compaction or is unreachable, so crons and scripts never mistake a silent no-op for success.
+4 -2
View File
@@ -14,7 +14,7 @@ orchestrate sub-agents.
| Tool | What it does |
| ------------------ | --------------------------------------------------------------------------- |
| `sessions_list` | List sessions with optional filters (kind, label, agent, recency, preview) |
| `sessions_list` | List sessions with optional filters (kind, label, agent, archive, preview) |
| `sessions_history` | Read the transcript of a specific session |
| `sessions_send` | Send a message to another session and optionally wait |
| `sessions_spawn` | Spawn an isolated sub-agent session for background work |
@@ -48,7 +48,9 @@ effective tool list.
`sessions_list` returns sessions with their key, agentId, kind, channel, model,
token counts, and timestamps. Filter by kind (`main`, `group`, `cron`, `hook`,
`node`), exact `label`, exact `agentId`, search text, or recency
(`activeMinutes`). When you need mailbox-style triage, it can also ask for a
(`activeMinutes`). Active sessions are returned by default; pass `archived: true`
to inspect archived sessions. Rows include their pinned and archived state. When
you need mailbox-style triage, it can also ask for a
visibility-scoped derived title, a last-message preview snippet, or bounded recent
messages on each row. Derived titles and previews are produced only for sessions
the caller can already see under the configured session tool visibility policy, so
+15 -7
View File
@@ -225,9 +225,17 @@ history, search, `/new`, `/reset`, and future model or harness switching.
Explicit compaction requests, such as `/compact` or a plugin-requested manual
compact operation, start native Codex compaction with `thread/compact/start`.
OpenClaw returns after starting that native operation. It does not wait for
completion, impose a separate OpenClaw timeout, restart the shared Codex
app-server, or record the operation as an OpenClaw-completed compaction.
OpenClaw keeps the request and shared-client lease open until Codex emits the
matching `contextCompaction` completion item and then reports the compaction turn
as completed. If that terminal turn exceeds the configured compaction timeout,
OpenClaw requests a native turn interrupt. The lease and per-thread compaction
fence remain held until Codex reports terminal state or confirms the interrupt RPC.
If Codex does not confirm within the interrupt grace period, OpenClaw retires
the connection before releasing the fence. Remote connections also detach the
matching thread binding so later work cannot overlap an unconfirmed remote
turn. Other turns on a retired connection fail and can retry on a fresh client.
Client closure, request cancellation, or a failed compaction turn returns a
failed operation.
When a context engine requests Codex thread-bootstrap projection, OpenClaw
projects tool-call names and ids, input shapes, and redacted tool-result content
@@ -235,10 +243,10 @@ into the fresh Codex thread. It does not copy raw tool-call argument values into
that projection.
The mirror includes the user prompt, final assistant text, and lightweight Codex
reasoning or plan records when the app-server emits them. Today, OpenClaw only
records explicit native compaction start signals when it requests compaction. It
does not expose a human-readable compaction summary or an auditable list of
which entries Codex kept after compaction.
reasoning or plan records when the app-server emits them. OpenClaw records the
native compaction start and terminal status, but it does not expose a
human-readable compaction summary or an auditable list of which entries Codex
kept after compaction.
Because Codex owns the canonical native thread, `tool_result_persist` does not
currently rewrite Codex-native tool result records. It only applies when
+10
View File
@@ -160,10 +160,20 @@ two-party event loops that do not go through the shared inbound reply runner.
sessionKey,
update: (entry) => ({ thinkingLevel: "high" }),
});
const storePath = api.runtime.agent.session.resolveStorePath(cfg.session?.store, { agentId });
await api.runtime.agent.session.runWithWorkAdmission(
{ storePath, sessionKey },
async (signal) => {
// Create or update the session, then pass signal to the admitted agent run.
},
);
```
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted.
Use `runWithWorkAdmission(...)` when a plugin starts work on a persisted session. The callback rejects archived or concurrently replaced sessions, keeps archive/reset/delete mutations coordinated through completion, and receives an `AbortSignal` that must be forwarded to the agent run.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Passing `sessionFile`, using `resolveSessionTranscriptLegacyFileTarget(...)`, or importing low-level `appendSessionTranscriptMessage(...)` / `emitSessionTranscriptUpdate(...)` from `openclaw/plugin-sdk/agent-harness-runtime` is deprecated; those paths exist only for legacy code that already receives an active transcript artifact.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, `resolveSessionFilePath(...)`, and `resolveAndPersistSessionFile(...)` are deprecated compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers and transcript identity helpers.
@@ -191,6 +191,18 @@ Key fields (not exhaustive):
time for idle freshness.
- `updatedAt`: last store-row mutation timestamp, used for listing, pruning, and
bookkeeping. It is not the authority for daily/idle reset freshness.
- `archivedAt`: optional archive timestamp. Archived sessions stay in the store
with their transcript intact and are excluded from normal active listings.
- `pinnedAt`: optional pin timestamp. Active pinned sessions sort ahead of
unpinned sessions; archiving a session clears its pin.
- Codex thread interop: both fields follow the Codex thread-management shape —
the `archived`/`pinned` booleans on the wire are always derived from the
timestamp and stamped server-side, matching Codex `threads.archived_at`
semantics and camelCase serialization. OpenClaw timestamps are epoch
milliseconds while Codex uses epoch seconds, so bridges convert at the codex
plugin seam. Codex has no pin API yet (`thread/archive`/`thread/unarchive`
only); pinned state stays OpenClaw-side until one exists, at which point the
matching shape lets bound sessions round-trip pin state mechanically.
- `sessionFile`: optional explicit transcript path override
- `chatType`: `direct | group | room` (helps UIs and send policy)
- `provider`, `subject`, `room`, `space`, `displayName`: metadata for group/channel labeling
+2 -1
View File
@@ -141,7 +141,7 @@ Imported themes are stored only in the current browser profile. They are not wri
- Channels: built-in plus bundled/external plugin channels status, QR login, and per-channel config (`channels.status`, `web.login.*`, `config.patch`).
- Channel probe refreshes keep the previous snapshot visible while slow provider checks finish, and partial snapshots are labeled when a probe or audit exceeds its UI budget.
- Instances: presence list + refresh (`system-presence`).
- Sessions: list configured-agent sessions by default, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`).
- Sessions: list configured-agent sessions by default, pin frequent sessions, rename them, archive or restore inactive sessions, fall back from stale unconfigured agent session keys, and apply per-session model/thinking/fast/verbose/trace/reasoning overrides (`sessions.list`, `sessions.patch`). Pinned sessions sort above recent unpinned sessions; archived sessions live in the Sessions page's archived view and keep their transcripts.
- Dreams: dreaming status, enable/disable toggle, and Dream Diary reader (`doctor.memory.status`, `doctor.memory.dreamDiary`, `config.patch`).
</Accordion>
@@ -222,6 +222,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
- Live `chat` events are delivery state, while `chat.history` is rebuilt from the durable session transcript. After tool-final events the Control UI reloads history and merges only a small optimistic tail; the transcript boundary is documented in [WebChat](/web/webchat).
- `chat.inject` appends an assistant note to the session transcript and broadcasts a `chat` event for UI-only updates (no agent run, no channel delivery).
- The sidebar lists recent sessions with a New Session action, an All Sessions link, and a session search button that opens the full session picker (scoped by the selected agent, with search and pagination). Switching agents shows only sessions tied to that agent and falls back to that agent's main session when it has no saved dashboard sessions yet.
- Each session-picker row can rename, pin, or archive the session. An active run and an agent's main session cannot be archived. Archiving the currently selected session switches Chat back to that agent's main session.
- On desktop widths, chat controls stay on one compact row and collapse while scrolling down the transcript; scrolling up, returning to the top, or reaching the bottom restores the controls.
- Consecutive duplicate text-only messages render as one bubble with a count badge. Messages that carry images, attachments, tool output, or canvas previews are left uncollapsed.
- The chat header model and thinking pickers patch the active session immediately through `sessions.patch`; they are persistent session overrides, not one-turn-only send options.
+2 -2
View File
@@ -345,9 +345,9 @@ export class CodexAppServerClient {
async closeAndWait(options?: {
exitTimeoutMs?: number;
forceKillDelayMs?: number;
}): Promise<void> {
}): Promise<boolean> {
this.markClosed(new Error("codex app-server client is closed"));
await closeCodexAppServerTransportAndWait(this.child, options);
return await closeCodexAppServerTransportAndWait(this.child, options);
}
private writeMessage(message: RpcRequest | RpcResponse, onError?: (error: Error) => void): void {
+709 -46
View File
@@ -8,7 +8,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { maybeCompactCodexAppServerSession as maybeCompactCodexAppServerSessionImpl } from "./compact.js";
import type { CodexServerNotification } from "./protocol.js";
import {
@@ -129,7 +129,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("starts native app-server compaction without waiting for completion", async () => {
it("waits for native app-server compaction completion", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
@@ -139,16 +139,17 @@ describe("maybeCompactCodexAppServerSession", () => {
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(fake.client["addNotificationHandler"]).not.toHaveBeenCalled();
expect(fake.client["addNotificationHandler"]).toHaveBeenCalledTimes(1);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(result.result?.tokensBefore).toBe(123);
expect(result.result?.tokensAfter).toBeUndefined();
const details = compactDetails(result);
expect(details.backend).toBe("codex-app-server");
expect(details.threadId).toBe("thread-1");
expect(details.signal).toBe("thread/compact/start");
expect(details.pending).toBe(true);
expect(details.pending).toBe(false);
expect(details.completed).toBe(true);
});
it("skips native app-server compaction for automatic budget triggers", async () => {
@@ -217,14 +218,15 @@ describe("maybeCompactCodexAppServerSession", () => {
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(result.reason).toBeUndefined();
expect(result.result?.tokensBefore).toBe(456);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
pending: false,
completed: true,
request: "after_context_engine",
trigger: "budget",
});
@@ -397,13 +399,15 @@ describe("maybeCompactCodexAppServerSession", () => {
let externalWriteStarted = false;
let externalWriteFinished = false;
const fake = createFakeCodexClient();
fake.request.mockImplementation(() =>
expectExternalMutationBlockedDuringNativeRequest({
fake.request.mockImplementation(async () => {
const response = await expectExternalMutationBlockedDuringNativeRequest({
releaseExternalMutation: releaseExternalWrite,
isExternalMutationStarted: () => externalWriteStarted,
isExternalMutationFinished: () => externalWriteFinished,
}),
);
});
setImmediate(fake.completeCompaction);
return response;
});
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
contextEngine: {
@@ -459,7 +463,7 @@ describe("maybeCompactCodexAppServerSession", () => {
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-2",
contextEngine: {
@@ -479,13 +483,15 @@ describe("maybeCompactCodexAppServerSession", () => {
let externalClearStarted = false;
let externalClearFinished = false;
const fake = createFakeCodexClient();
fake.request.mockImplementation(() =>
expectExternalMutationBlockedDuringNativeRequest({
fake.request.mockImplementation(async () => {
const response = await expectExternalMutationBlockedDuringNativeRequest({
releaseExternalMutation: releaseExternalClear,
isExternalMutationStarted: () => externalClearStarted,
isExternalMutationFinished: () => externalClearFinished,
}),
);
});
setImmediate(fake.completeCompaction);
return response;
});
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
contextEngine: {
@@ -529,7 +535,7 @@ describe("maybeCompactCodexAppServerSession", () => {
{ timeoutMs: 60_000 },
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
@@ -591,37 +597,509 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(fake.request).not.toHaveBeenCalled();
});
it("does not consume native completion notifications after forwarding the request", async () => {
const fake = createFakeCodexClient();
it("does not finish until the matching native compaction turn completes", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 123 }),
);
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
let settled = false;
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 }).finally(() => {
settled = true;
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
await flushAsyncTasks();
expect(settled).toBe(false);
fake.emit({
method: "thread/tokenUsage/updated",
method: "item/started",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 0,
},
},
turnId: "turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
fake.emit({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
await flushAsyncTasks();
expect(settled).toBe(false);
fake.emit({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: { id: "turn-1", threadId: "thread-1", status: "completed" },
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBeUndefined();
expect(compactDetails(result).tokenUsageSource).toBeUndefined();
expect(compactDetails(result).signal).toBe("thread/compact/start");
});
it("lets terminal interruption win after the compaction item completes", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-hook", threadId: "thread-1", status: "inProgress" },
},
});
for (const method of ["item/started", "item/completed"] as const) {
fake.emit({
method,
params: {
threadId: "thread-1",
turnId: "compact-turn-hook",
item: { id: "compact-item-hook", type: "contextCompaction" },
},
});
}
fake.emit({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-hook", threadId: "thread-1", status: "interrupted" },
},
});
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction turn ended with status interrupted",
});
});
it("fails when the native compaction turn terminates before its item starts", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledOnce();
});
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-failed", threadId: "thread-1", status: "inProgress" },
},
});
fake.emit({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-failed", threadId: "thread-1", status: "failed" },
},
});
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction turn ended with status failed",
});
});
it("accepts the terminal interrupt response when its notification is missing", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
},
{ clientFactory: async () => fake.client, nativeCompletionTimeoutMs: 10 },
);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-stalled", threadId: "thread-1", status: "inProgress" },
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "compact-turn-stalled",
},
{ timeoutMs: 30_000 },
);
});
expect(fake.close).not.toHaveBeenCalled();
expect(fake.closeAndWait).not.toHaveBeenCalled();
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server confirmed native compaction interruption",
});
});
it("accepts an already-terminal interrupt after the completion notification is dropped", async () => {
const fake = createFakeCodexClient({
autoCompleteCompaction: false,
interruptError: new CodexAppServerRpcError(
{ code: -32_600, message: "no active turn to interrupt" },
"turn/interrupt",
),
});
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
},
{ clientFactory: async () => fake.client, nativeCompletionTimeoutMs: 10 },
);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-finished", threadId: "thread-1", status: "inProgress" },
},
});
for (const method of ["item/started", "item/completed"] as const) {
fake.emit({
method,
params: {
threadId: "thread-1",
turnId: "compact-turn-finished",
item: { id: "compact-item-finished", type: "contextCompaction" },
},
});
}
await expect(pendingResult).resolves.toMatchObject({ ok: true, compacted: true });
expect(fake.closeAndWait).not.toHaveBeenCalled();
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeDefined();
});
it("retires a stalled client when interruption cannot be confirmed", async () => {
const fake = createFakeCodexClient({
autoCompleteCompaction: false,
rejectInterrupt: true,
});
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
},
{
clientFactory: async () => fake.client,
nativeCompletionTimeoutMs: 250,
nativeInterruptGraceMs: 10,
},
);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-stuck", threadId: "thread-1", status: "inProgress" },
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "compact-turn-stuck",
},
{ timeoutMs: 10 },
);
expect(fake.closeAndWait).toHaveBeenCalledWith({
exitTimeoutMs: 5_000,
forceKillDelayMs: 250,
});
expect(fake.close).toHaveBeenCalledTimes(1);
});
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction did not reach terminal state after interruption",
});
});
it("uses the configured compaction timeout for native completion", async () => {
const fake = createFakeCodexClient({
autoCompleteCompaction: false,
rejectInterrupt: true,
});
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
},
{
clientFactory: async () => fake.client,
nativeInterruptGraceMs: 10,
},
);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-configured", threadId: "thread-1", status: "inProgress" },
},
});
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction did not reach terminal state after interruption",
});
expect(fake.request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "compact-turn-configured",
},
{ timeoutMs: 10 },
);
});
it("detaches a remote thread when its interrupted turn cannot be confirmed", async () => {
const fake = createFakeCodexClient({
autoCompleteCompaction: false,
rejectInterrupt: true,
});
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
},
{
clientFactory: async () => fake.client,
pluginConfig: {
appServer: { transport: "websocket", url: "ws://127.0.0.1:45001" },
},
nativeCompletionTimeoutMs: 250,
nativeInterruptGraceMs: 10,
},
);
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-remote", threadId: "thread-1", status: "inProgress" },
},
});
await expect(pendingResult).resolves.toMatchObject({ ok: false, compacted: false });
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("cancels a native compaction after the start request", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const abortController = new AbortController();
let settled = false;
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "manual",
abortSignal: abortController.signal,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledOnce();
});
abortController.abort();
await flushAsyncTasks();
expect(settled).toBe(false);
fake.emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-aborted", threadId: "thread-1", status: "inProgress" },
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith(
"turn/interrupt",
{
threadId: "thread-1",
turnId: "compact-turn-aborted",
},
{ timeoutMs: 30_000 },
);
});
await expect(pendingResult).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server confirmed native compaction interruption",
});
});
it("serializes native compaction requests for the same Codex thread", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
setCodexAppServerClientFactoryForTest(async () => fake.client);
const firstSessionFile = await writeTestBinding();
const secondSessionFile = path.join(tempDir, "second-session.jsonl");
await writeCodexAppServerBinding(secondSessionFile, {
threadId: "thread-1",
cwd: tempDir,
});
const first = startCompaction(firstSessionFile);
const second = startCompaction(secondSessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledTimes(1);
});
fake.completeCompaction();
await expect(first).resolves.toMatchObject({ ok: true, compacted: true });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledTimes(2);
});
fake.completeCompaction();
await expect(second).resolves.toMatchObject({ ok: true, compacted: true });
});
it("cancels a queued same-thread compaction before acquiring a client", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
const factory = vi.fn(async () => fake.client);
setCodexAppServerClientFactoryForTest(factory);
const firstSessionFile = await writeTestBinding();
const secondSessionFile = path.join(tempDir, "queued-session.jsonl");
await writeCodexAppServerBinding(secondSessionFile, {
threadId: "thread-1",
cwd: tempDir,
});
const abortController = new AbortController();
const first = startCompaction(firstSessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledTimes(1);
});
const second = maybeCompactCodexAppServerSession({
sessionId: "session-2",
sessionKey: "agent:main:session-2",
sessionFile: secondSessionFile,
workspaceDir: tempDir,
trigger: "manual",
abortSignal: abortController.signal,
});
await flushAsyncTasks();
expect(factory).toHaveBeenCalledTimes(1);
abortController.abort();
await expect(second).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction aborted while waiting to start",
});
expect(factory).toHaveBeenCalledTimes(1);
expect(fake.request).toHaveBeenCalledTimes(1);
fake.completeCompaction();
await expect(first).resolves.toMatchObject({ ok: true, compacted: true });
});
it("keeps later compactions behind an active request after a queued waiter cancels", async () => {
const fake = createFakeCodexClient({ autoCompleteCompaction: false });
const factory = vi.fn(async () => fake.client);
setCodexAppServerClientFactoryForTest(factory);
const firstSessionFile = await writeTestBinding();
const secondSessionFile = path.join(tempDir, "canceled-queued-session.jsonl");
const thirdSessionFile = path.join(tempDir, "later-session.jsonl");
for (const sessionFile of [secondSessionFile, thirdSessionFile]) {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-1",
cwd: tempDir,
});
}
const abortController = new AbortController();
const first = startCompaction(firstSessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledTimes(1);
});
const second = maybeCompactCodexAppServerSession({
sessionId: "session-2",
sessionKey: "agent:main:session-2",
sessionFile: secondSessionFile,
workspaceDir: tempDir,
trigger: "manual",
abortSignal: abortController.signal,
});
abortController.abort();
await expect(second).resolves.toMatchObject({
ok: false,
compacted: false,
reason: "codex app-server compaction aborted while waiting to start",
});
const third = startCompaction(thirdSessionFile);
await flushAsyncTasks();
expect(factory).toHaveBeenCalledTimes(1);
expect(fake.request).toHaveBeenCalledTimes(1);
fake.completeCompaction();
await expect(first).resolves.toMatchObject({ ok: true, compacted: true });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledTimes(2);
});
fake.completeCompaction();
await expect(third).resolves.toMatchObject({ ok: true, compacted: true });
});
it("reuses the bound auth profile for native compaction", async () => {
const fake = createFakeCodexClient();
let seenAuthProfileId: string | undefined;
@@ -653,7 +1131,12 @@ describe("maybeCompactCodexAppServerSession", () => {
it("preserves stale thread binding metadata for recovery and reports failed native compaction", async () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(new Error("thread not found: thread-1"));
fake.request.mockRejectedValueOnce(
new CodexAppServerRpcError(
{ code: -32_602, message: "thread not found: thread-1" },
"thread/compact/start",
),
);
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({
authProfileId: "openai:work",
@@ -680,9 +1163,83 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(result.reason).toBe("thread not found: thread-1");
expect(result.failure?.reason).toBe("stale_thread_binding");
expect(result.result).toBeUndefined();
expect(fake.closeAndWait).not.toHaveBeenCalled();
});
it("does not impose an OpenClaw timeout after Codex accepts native compaction", async () => {
it("retires the client before releasing an unconfirmed compaction start", async () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(new Error("thread/compact/start timed out"));
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(await startCompaction(sessionFile));
expect(result).toMatchObject({
ok: false,
compacted: false,
reason: "thread/compact/start timed out",
});
expect(fake.closeAndWait).toHaveBeenCalledWith({
exitTimeoutMs: 5_000,
forceKillDelayMs: 250,
});
expect(fake.close).toHaveBeenCalledTimes(1);
});
it("keeps the lifecycle fence when an unconfirmed stdio process does not stop", async () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(new Error("thread/compact/start timed out"));
fake.closeAndWait.mockResolvedValueOnce(false);
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding({ threadId: "thread-stuck-stdio" });
const outcome = await Promise.race([
startCompaction(sessionFile).then(() => "settled" as const),
new Promise<"pending">((resolve) => {
setTimeout(() => resolve("pending"), 20);
}),
]);
expect(outcome).toBe("pending");
expect(fake.closeAndWait).toHaveBeenCalledOnce();
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeDefined();
});
it("detaches a guarded remote start after releasing the binding lock", async () => {
const fake = createFakeCodexClient();
fake.request.mockRejectedValueOnce(new Error("thread/compact/start timed out"));
fake.closeAndWait.mockResolvedValueOnce(false);
const sessionFile = await writeTestBinding();
const result = requireCompactResult(
await maybeCompactCodexAppServerSessionImpl(
{
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
trigger: "budget",
},
{
allowNonManualNativeRequest: true,
clientFactory: async () => fake.client,
pluginConfig: {
appServer: { transport: "websocket", url: "ws://127.0.0.1:45001" },
},
},
),
);
expect(result).toMatchObject({
ok: false,
compacted: false,
reason: "thread/compact/start timed out",
});
expect(fake.closeAndWait).toHaveBeenCalledOnce();
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("retains the shared client lease through native compaction completion", async () => {
const fake = createFakeCodexClient();
const factory = vi.fn(async () => fake.client);
setCodexAppServerClientFactoryForTest(factory);
@@ -693,12 +1250,13 @@ describe("maybeCompactCodexAppServerSession", () => {
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
pending: false,
completed: true,
});
expect(factory).toHaveBeenCalledTimes(1);
expect(fake.close).not.toHaveBeenCalled();
@@ -1009,12 +1567,13 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(result.compacted).toBe(true);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
pending: false,
completed: true,
});
expect(compact).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
@@ -1058,15 +1617,116 @@ describe("maybeCompactCodexAppServerSession", () => {
});
});
function createFakeCodexClient(): {
function createFakeCodexClient(
options: {
autoCompleteCompaction?: boolean;
interruptError?: Error;
rejectInterrupt?: boolean;
} = {},
): {
client: CodexAppServerClient;
request: ReturnType<typeof vi.fn<CodexAppServerClient["request"]>>;
close: ReturnType<typeof vi.fn>;
closeAndWait: ReturnType<typeof vi.fn>;
emit: (notification: CodexServerNotification) => void;
completeCompaction: () => void;
} {
const handlers = new Set<(notification: CodexServerNotification) => void>();
const request = vi.fn<CodexAppServerClient["request"]>(async () => ({}));
const close = vi.fn();
const closeHandlers = new Set<() => void>();
const emit = (notification: CodexServerNotification): void => {
for (const handler of handlers) {
handler(notification);
}
};
const completeCompaction = (): void => {
emit({
method: "turn/started",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-1", threadId: "thread-1", status: "inProgress" },
},
});
emit({
method: "item/started",
params: {
threadId: "thread-1",
turnId: "compact-turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
emit({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "compact-turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
emit({
method: "turn/completed",
params: {
threadId: "thread-1",
turn: { id: "compact-turn-1", threadId: "thread-1", status: "completed" },
},
});
};
const request = vi.fn<CodexAppServerClient["request"]>(
async (method: string, params?: unknown) => {
if (method === "turn/interrupt" && options.interruptError) {
throw options.interruptError;
}
if (method === "turn/interrupt" && options.rejectInterrupt) {
throw new Error("interrupt unavailable");
}
if (method === "thread/compact/start" && options.autoCompleteCompaction !== false) {
const threadId = (params as { threadId?: unknown }).threadId;
if (typeof threadId !== "string") {
throw new Error("thread/compact/start requires threadId");
}
// Codex may emit item notifications before acknowledging the start RPC.
emit({
method: "turn/started",
params: {
threadId,
turn: { id: "compact-turn-1", threadId, status: "inProgress" },
},
});
emit({
method: "item/started",
params: {
threadId,
turnId: "compact-turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
emit({
method: "item/completed",
params: {
threadId,
turnId: "compact-turn-1",
item: { id: "compact-item-1", type: "contextCompaction" },
},
});
emit({
method: "turn/completed",
params: {
threadId,
turn: { id: "compact-turn-1", threadId, status: "completed" },
},
});
}
return {};
},
);
const close = vi.fn(() => {
for (const handler of closeHandlers) {
handler();
}
});
const closeAndWait = vi.fn(async () => {
close();
return true;
});
const addNotificationHandler = vi.fn(
(handler: (notification: CodexServerNotification) => void) => {
handlers.add(handler);
@@ -1077,14 +1737,17 @@ function createFakeCodexClient(): {
client: {
request,
close,
closeAndWait,
addNotificationHandler,
addCloseHandler: vi.fn((handler: () => void) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
}),
} as unknown as CodexAppServerClient,
request,
close,
emit(notification: CodexServerNotification): void {
for (const handler of handlers) {
handler(notification);
}
},
closeAndWait,
emit,
completeCompaction,
};
}
+552 -121
View File
@@ -3,18 +3,26 @@
*/
import {
embeddedAgentLog,
resolveCompactionTimeoutMs,
type CompactEmbeddedAgentSessionParams,
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import {
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import { CodexAppServerRpcError, type CodexAppServerClient } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import {
readCodexNotificationThreadId,
readCodexNotificationTurnId,
} from "./notification-correlation.js";
import { isJsonObject, type JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import {
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
clearCodexAppServerBindingForThread,
readCodexAppServerBinding,
withCodexAppServerBindingLock,
writeCodexAppServerBinding,
@@ -23,12 +31,329 @@ import {
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
const codexNativeCompactionQueues = new Map<string, Promise<void>>();
const CODEX_NATIVE_COMPACTION_INTERRUPT_GRACE_MS = 30_000;
const CODEX_NO_ACTIVE_TURN_ERROR_CODE = -32_600;
const CODEX_NO_ACTIVE_TURN_ERROR_MESSAGE = "no active turn to interrupt";
type CodexAppServerCompactOptions = {
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientFactory;
allowNonManualNativeRequest?: boolean;
nativeCompletionTimeoutMs?: number;
nativeInterruptGraceMs?: number;
};
type CodexNativeCompactionCompletion = { completed: true } | { completed: false; reason: string };
function isAlreadyTerminalInterruptError(error: unknown): error is CodexAppServerRpcError {
return (
error instanceof CodexAppServerRpcError &&
error.code === CODEX_NO_ACTIVE_TURN_ERROR_CODE &&
error.message === CODEX_NO_ACTIVE_TURN_ERROR_MESSAGE
);
}
function watchCodexNativeCompactionCompletion(params: {
client: CodexAppServerClient;
threadId: string;
signal?: AbortSignal;
timeoutMs: number;
interruptGraceMs: number;
retireUnconfirmed: () => Promise<void>;
}): {
completion: Promise<CodexNativeCompactionCompletion>;
beginRequest: () => void;
confirmRequestRejected: () => void;
retireUnconfirmedRequest: (reason: string) => Promise<CodexNativeCompactionCompletion>;
cancel: () => void;
} {
let settled = false;
let requestStarted = false;
let abortRequested = false;
let interruptRequested = false;
let retirementStarted = false;
let compactionTurnId: string | undefined;
let compactionItemId: string | undefined;
let compactionItemCompleted = false;
let resolveCompletion = (_result: CodexNativeCompactionCompletion) => {};
const completion = new Promise<CodexNativeCompactionCompletion>((resolve) => {
resolveCompletion = resolve;
});
let removeNotificationHandler = () => {};
let removeCloseHandler = () => {};
let removeAbortHandler = () => {};
let completionTimeout: ReturnType<typeof setTimeout> | undefined;
let interruptGraceTimeout: ReturnType<typeof setTimeout> | undefined;
const finish = (result: CodexNativeCompactionCompletion) => {
if (settled) {
return;
}
settled = true;
removeNotificationHandler();
removeCloseHandler();
removeAbortHandler();
clearTimeout(completionTimeout);
clearTimeout(interruptGraceTimeout);
resolveCompletion(result);
};
const retireUnconfirmed = (reason: string) => {
if (settled || retirementStarted) {
return;
}
retirementStarted = true;
void params
.retireUnconfirmed()
.then(() => finish({ completed: false, reason }))
.catch((error: unknown) => {
embeddedAgentLog.error("failed to retire unconfirmed codex app-server compaction", {
threadId: params.threadId,
turnId: compactionTurnId,
reason: formatCompactionError(error),
});
// Keep the lifecycle fence held when neither terminal state nor thread
// retirement can be proven. Releasing would permit same-thread overlap.
});
};
const requestInterrupt = () => {
if (settled || !requestStarted || !abortRequested || !compactionTurnId || interruptRequested) {
return;
}
interruptRequested = true;
void params.client
.request(
"turn/interrupt",
{
threadId: params.threadId,
turnId: compactionTurnId,
},
{ timeoutMs: Math.max(1, params.interruptGraceMs) },
)
.then(() => {
// Codex answers turn/interrupt only after terminal abort handling, so
// the RPC response is sufficient when its notification was dropped.
finish({
completed: false,
reason: "codex app-server confirmed native compaction interruption",
});
})
.catch((error: unknown) => {
// Codex holds normal interrupt RPCs until TurnAborted. This exact
// InvalidRequest instead proves the target turn was already terminal.
if (isAlreadyTerminalInterruptError(error)) {
finish(
compactionItemCompleted
? { completed: true }
: {
completed: false,
reason:
"codex app-server compaction reached terminal state without a completed compaction item",
},
);
return;
}
embeddedAgentLog.warn("codex app-server compaction interrupt request failed", {
threadId: params.threadId,
turnId: compactionTurnId,
reason: formatCompactionError(error),
});
});
};
const beginInterruptGrace = () => {
if (settled || !requestStarted || interruptGraceTimeout) {
return;
}
requestInterrupt();
interruptGraceTimeout = setTimeout(
() => {
embeddedAgentLog.warn(
"codex app-server compaction did not reach terminal state after interruption",
{
threadId: params.threadId,
turnId: compactionTurnId,
interruptGraceMs: params.interruptGraceMs,
},
);
retireUnconfirmed(
"codex app-server compaction did not reach terminal state after interruption",
);
},
Math.max(1, params.interruptGraceMs),
);
interruptGraceTimeout.unref?.();
};
const beginCompletionTimeout = () => {
completionTimeout = setTimeout(
() => {
abortRequested = true;
beginInterruptGrace();
// Keep the shared client lease and per-thread fence through terminal state or
// forced process retirement; releasing earlier could overlap the same transcript.
embeddedAgentLog.warn("codex app-server compaction exceeded its completion budget", {
threadId: params.threadId,
timeoutMs: params.timeoutMs,
interruptRequested,
});
},
Math.max(1, params.timeoutMs),
);
completionTimeout.unref?.();
};
removeNotificationHandler = params.client.addNotificationHandler((notification) => {
if (!requestStarted) {
return;
}
if (!isJsonObject(notification.params)) {
return;
}
if (readCodexNotificationThreadId(notification.params) !== params.threadId) {
return;
}
const notificationTurnId = readCodexNotificationTurnId(notification.params);
if (notification.method === "turn/started") {
compactionTurnId = notificationTurnId;
requestInterrupt();
return;
}
if (compactionTurnId && notificationTurnId !== compactionTurnId) {
return;
}
const item = readCodexNotificationItem(notification.params);
if (item?.type === "contextCompaction") {
if (notification.method === "item/started") {
compactionTurnId = compactionTurnId ?? notificationTurnId;
compactionItemId = item.id;
requestInterrupt();
return;
}
if (notification.method === "item/completed" && compactionItemId === item.id) {
compactionItemCompleted = true;
return;
}
}
if (
notification.method !== "turn/completed" ||
!compactionTurnId ||
notificationTurnId !== compactionTurnId
) {
return;
}
const turn = isJsonObject(notification.params.turn) ? notification.params.turn : undefined;
const status = typeof turn?.status === "string" ? turn.status : undefined;
if (status !== "completed") {
finish({
completed: false,
reason: `codex app-server compaction turn ended with status ${status ?? "unknown"}`,
});
return;
}
if (!compactionItemId) {
finish({
completed: false,
reason: "codex app-server compaction turn completed without a compaction item",
});
return;
}
if (!compactionItemCompleted) {
finish({
completed: false,
reason: "codex app-server compaction turn completed before its compaction item",
});
return;
}
finish({ completed: true });
});
removeCloseHandler = params.client.addCloseHandler(() => {
retireUnconfirmed("codex app-server closed before native compaction completed");
});
if (params.signal) {
const onAbort = () => {
abortRequested = true;
beginInterruptGrace();
};
params.signal.addEventListener("abort", onAbort, { once: true });
removeAbortHandler = () => params.signal?.removeEventListener("abort", onAbort);
if (params.signal.aborted) {
onAbort();
}
}
return {
completion,
beginRequest: () => {
requestStarted = true;
beginCompletionTimeout();
if (abortRequested) {
beginInterruptGrace();
}
},
confirmRequestRejected: () =>
finish({ completed: false, reason: "codex app-server rejected the compaction request" }),
retireUnconfirmedRequest: async (reason) => {
retireUnconfirmed(reason);
return await completion;
},
cancel: () => {
if (!requestStarted) {
finish({ completed: false, reason: "compaction request did not start" });
}
},
};
}
async function runExclusiveCodexNativeCompaction<T>(
threadId: string,
signal: AbortSignal | undefined,
run: () => Promise<T>,
): Promise<T> {
const previous = codexNativeCompactionQueues.get(threadId) ?? Promise.resolve();
let releaseCurrent!: () => void;
const current = new Promise<void>((resolve) => {
releaseCurrent = resolve;
});
const queued = previous.then(
() => current,
() => current,
);
codexNativeCompactionQueues.set(threadId, queued);
try {
await waitForCodexNativeCompactionQueue(previous, signal);
signal?.throwIfAborted();
return await run();
} finally {
releaseCurrent();
// A canceled waiter must remain in the chain until its predecessor settles;
// otherwise a later request can skip the still-active compaction.
void queued.then(() => {
if (codexNativeCompactionQueues.get(threadId) === queued) {
codexNativeCompactionQueues.delete(threadId);
}
});
}
}
async function waitForCodexNativeCompactionQueue(
previous: Promise<void>,
signal: AbortSignal | undefined,
): Promise<void> {
if (!signal) {
await previous.catch(() => undefined);
return;
}
signal.throwIfAborted();
let removeAbortListener = () => {};
const aborted = new Promise<never>((_, reject) => {
const onAbort = () => {
reject(signal.reason instanceof Error ? signal.reason : new Error("compaction aborted"));
};
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
signal.addEventListener("abort", onAbort, { once: true });
});
try {
await Promise.race([previous.catch(() => undefined), aborted]);
} finally {
removeAbortListener();
}
}
/**
* Starts native Codex compaction for a manually requested bound session, or
* reports why Codex-owned automatic compaction should handle the trigger.
@@ -40,7 +365,7 @@ export async function maybeCompactCodexAppServerSession(
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point starts native Codex compaction for the bound thread and
// returns immediately; Codex applies the compaction inside its app-server.
// retains the lease until Codex reports the context-compaction item complete.
return compactCodexNativeThread(params, options);
}
@@ -202,128 +527,234 @@ async function compactCodexNativeThread(
}
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
try {
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
await client.request(
"thread/compact/start",
{
threadId: binding.threadId,
},
{
timeoutMs: Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
},
return await runExclusiveCodexNativeCompaction(
binding.threadId,
params.abortSignal,
async () => {
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
return { started: true as const };
});
if (!guardedResult.started) {
return guardedResult.result;
}
} else {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
}
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (isCodexThreadNotFoundError(error)) {
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
reason: formatCompactionError(error),
recovery: "stale_thread_binding",
});
}
embeddedAgentLog.warn("codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
threadId: binding.threadId,
reason: formatCompactionError(error),
});
return {
ok: false,
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",
threadId: binding.threadId,
signal: "thread/compact/start",
pending: true,
...(options.allowNonManualNativeRequest
? {
request: "after_context_engine",
trigger: params.trigger ?? "unknown",
const completionWatch = watchCodexNativeCompactionCompletion({
client,
threadId: binding.threadId,
signal: params.abortSignal,
timeoutMs: options.nativeCompletionTimeoutMs ?? resolveCompactionTimeoutMs(params.config),
interruptGraceMs:
options.nativeInterruptGraceMs ?? CODEX_NATIVE_COMPACTION_INTERRUPT_GRACE_MS,
retireUnconfirmed: async () => {
const transportStopped = await client.closeAndWait({
exitTimeoutMs: 5_000,
forceKillDelayMs: 250,
});
if (appServer.start.transport === "stdio") {
if (transportStopped) {
return;
}
// A local thread remains runnable with its stdio process. Keep
// the lifecycle fence held unless process exit is observed.
throw new Error("failed to stop unconfirmed codex app-server process");
}
// Closing a WebSocket proves only that the connection ended, not
// that its remote turn stopped. Detach this exact thread before
// allowing future work to acquire the session lifecycle fence.
const bindingCleared = await clearCodexAppServerBindingForThread(
params.sessionFile,
binding.threadId,
{ config: params.config },
);
if (bindingCleared) {
return;
}
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (currentBinding?.threadId !== binding.threadId) {
return;
}
throw new Error("failed to detach unconfirmed codex app-server thread binding");
},
});
const beginNativeCompactionRequest = async (timeoutMs?: number) => {
completionWatch.beginRequest();
const requestParams = { threadId: binding.threadId };
if (timeoutMs === undefined) {
await client.request("thread/compact/start", requestParams);
} else {
await client.request("thread/compact/start", requestParams, { timeoutMs });
}
};
const settleNativeCompactionRequestError = async (error: unknown) => {
if (error instanceof CodexAppServerRpcError) {
completionWatch.confirmRequestRejected();
} else {
// Transport errors after the write leave the server-side start
// ambiguous. Retire or detach the thread before releasing its fence.
await completionWatch.retireUnconfirmedRequest(
`codex app-server compaction start was unconfirmed: ${formatCompactionError(error)}`,
);
}
};
try {
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(
params.sessionFile,
async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
try {
await beginNativeCompactionRequest(
Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
);
return { started: true as const, accepted: true as const };
} catch (error) {
// Retire outside the binding lock: remote detach acquires this
// same lock and would otherwise deadlock the failure path.
return { started: true as const, accepted: false as const, error };
}
},
);
if (!guardedResult.started) {
return guardedResult.result;
}
if (!guardedResult.accepted) {
await settleNativeCompactionRequestError(guardedResult.error);
throw guardedResult.error;
}
} else {
params.abortSignal?.throwIfAborted();
try {
await beginNativeCompactionRequest();
} catch (error) {
await settleNativeCompactionRequestError(error);
throw error;
}
}
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
const completion = await completionWatch.completion;
if (!completion.completed) {
throw new Error(completion.reason);
}
embeddedAgentLog.info("completed codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (isCodexThreadNotFoundError(error)) {
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
reason: formatCompactionError(error),
recovery: "stale_thread_binding",
});
}
embeddedAgentLog.warn("codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
threadId: binding.threadId,
reason: formatCompactionError(error),
});
return {
ok: false,
compacted: false,
reason: formatCompactionError(error),
};
} finally {
completionWatch.cancel();
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
: {}),
};
return {
ok: true,
compacted: false,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: resultDetails,
},
};
const resultDetails: JsonObject = {
backend: "codex-app-server",
threadId: binding.threadId,
signal: "thread/compact/start",
pending: false,
completed: true,
...(options.allowNonManualNativeRequest
? {
request: "after_context_engine",
trigger: params.trigger ?? "unknown",
}
: {}),
};
return {
ok: true,
compacted: true,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: resultDetails,
},
};
},
);
} catch (error) {
if (params.abortSignal?.aborted) {
if (options.allowNonManualNativeRequest) {
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: initialBinding.threadId,
currentThreadId: binding.threadId,
});
}
return {
ok: false,
compacted: false,
reason: "codex app-server compaction aborted while waiting to start",
};
}
throw error;
}
}
function skippedCodexNativeCompactionResult(
@@ -17,6 +17,7 @@ type TestSessionEntry = {
};
type EmbeddedAgentArgs = {
abortSignal?: AbortSignal;
extraSystemPrompt: string;
provider?: string;
model?: string;
@@ -75,6 +76,12 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
payloads,
meta: { durationMs: 12, aborted: false },
}));
const runWithWorkAdmission = vi.fn(
async (
_params: { storePath: string; sessionKey: string },
run: (signal: AbortSignal) => Promise<unknown>,
) => await run(new AbortController().signal),
);
const resolveAgentDir = vi.fn((_cfg: CoreConfig, agentId: string) => {
return `/tmp/openclaw/agents/${agentId}`;
});
@@ -113,6 +120,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
getSessionEntry,
patchSessionEntry,
upsertSessionEntry,
runWithWorkAdmission,
resolveSessionFilePath,
},
} as unknown as CoreAgentDeps;
@@ -120,6 +128,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
return {
runtime,
runEmbeddedAgent,
runWithWorkAdmission,
saveSessionStore,
updateSessionStore,
patchSessionEntry,
@@ -181,7 +190,7 @@ async function runGenerateVoiceResponse(
describe("generateVoiceResponse", () => {
it("suppresses reasoning payloads and reads structured spoken output", async () => {
const { runtime, runEmbeddedAgent } = createAgentRuntime([
const { runtime, runEmbeddedAgent, runWithWorkAdmission } = createAgentRuntime([
{ text: "Reasoning: hidden", isReasoning: true },
{ text: '{"spoken":"Hello from JSON."}' },
]);
@@ -193,6 +202,29 @@ describe("generateVoiceResponse", () => {
expect(args.extraSystemPrompt).toContain('{"spoken":"..."}');
expect(args.provider).toBe("together");
expect(args.model).toBe("Qwen/Qwen2.5-7B-Instruct-Turbo");
expect(args.abortSignal).toBeInstanceOf(AbortSignal);
expect(runWithWorkAdmission).toHaveBeenCalledWith(
{
storePath: "/tmp/openclaw/main/sessions.json",
sessionKey: "agent:main:voice:15550001111",
},
expect.any(Function),
);
});
it("returns the lifecycle rejection without starting the embedded agent", async () => {
const { runtime, runEmbeddedAgent, runWithWorkAdmission } = createAgentRuntime([]);
runWithWorkAdmission.mockRejectedValueOnce(
new Error('Session "agent:main:voice:15550001111" is archived.'),
);
const { result } = await runGenerateVoiceResponse([], { runtime });
expect(result).toEqual({
text: null,
error: 'Error: Session "agent:main:voice:15550001111" is archived.',
});
expect(runEmbeddedAgent).not.toHaveBeenCalled();
});
it("extracts spoken text from fenced JSON", async () => {
+107 -98
View File
@@ -241,117 +241,126 @@ export async function generateVoiceResponse(
// Resolve paths
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
const agentDir = agentRuntime.resolveAgentDir(cfg, agentId);
const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId);
try {
return await agentRuntime.session.runWithWorkAdmission(
{ storePath, sessionKey: resolvedSessionKey },
async (abortSignal) => {
const agentDir = agentRuntime.resolveAgentDir(cfg, agentId);
const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId);
// Ensure workspace exists
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
// Ensure workspace exists
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
// Load or create session entry
const now = Date.now();
const existingSessionEntry = agentRuntime.session.getSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
});
// Load or create session entry
const now = Date.now();
const existingSessionEntry = agentRuntime.session.getSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
});
// Resolve model from config
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
// Resolve model from config
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
let sessionEntry = existingSessionEntry;
if (!sessionEntry?.sessionId || voiceConfig.responseModel) {
sessionEntry =
(await agentRuntime.session.patchSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
replaceEntry: true,
fallbackEntry: sessionEntry ?? {
sessionId: crypto.randomUUID(),
updatedAt: now,
},
update: (entry) => {
const next = entry.sessionId
? { ...entry }
: {
...entry,
let sessionEntry = existingSessionEntry;
if (!sessionEntry?.sessionId || voiceConfig.responseModel) {
sessionEntry =
(await agentRuntime.session.patchSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
replaceEntry: true,
fallbackEntry: sessionEntry ?? {
sessionId: crypto.randomUUID(),
updatedAt: now,
};
if (voiceConfig.responseModel) {
applyModelOverrideToSessionEntry({
entry: next,
selection: { provider, model },
selectionSource: "auto",
});
}
return next;
},
})) ?? undefined;
}
if (!sessionEntry?.sessionId) {
return { text: null, error: "Voice response session could not be initialized" };
}
const sessionId = sessionEntry.sessionId;
},
update: (entry) => {
const next = entry.sessionId
? { ...entry }
: {
...entry,
sessionId: crypto.randomUUID(),
updatedAt: now,
};
if (voiceConfig.responseModel) {
applyModelOverrideToSessionEntry({
entry: next,
selection: { provider, model },
selectionSource: "auto",
});
}
return next;
},
})) ?? undefined;
}
if (!sessionEntry?.sessionId) {
return { text: null, error: "Voice response session could not be initialized" };
}
const sessionId = sessionEntry.sessionId;
// Resolve thinking level
const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model });
// Resolve thinking level
const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model });
// Resolve agent identity for personalized prompt
const identity = agentRuntime.resolveAgentIdentity(cfg, agentId);
const agentName = identity?.name?.trim() || "assistant";
// Resolve agent identity for personalized prompt
const identity = agentRuntime.resolveAgentIdentity(cfg, agentId);
const agentName = identity?.name?.trim() || "assistant";
// Build system prompt with conversation history
const basePrompt =
voiceConfig.responseSystemPrompt ??
`You are ${agentName}, a helpful voice assistant on a phone call. Keep responses brief and conversational (1-2 sentences max). Be natural and friendly. The caller's phone number is ${from}. You have access to tools - use them when helpful.`;
// Build system prompt with conversation history
const basePrompt =
voiceConfig.responseSystemPrompt ??
`You are ${agentName}, a helpful voice assistant on a phone call. Keep responses brief and conversational (1-2 sentences max). Be natural and friendly. The caller's phone number is ${from}. You have access to tools - use them when helpful.`;
let extraSystemPrompt = basePrompt;
if (transcript.length > 0) {
const history = transcript
.map((entry) => `${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`)
.join("\n");
extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${history}`;
}
extraSystemPrompt = `${extraSystemPrompt}\n\n${VOICE_SPOKEN_OUTPUT_CONTRACT}`;
let extraSystemPrompt = basePrompt;
if (transcript.length > 0) {
const history = transcript
.map((entry) => `${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`)
.join("\n");
extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${history}`;
}
extraSystemPrompt = `${extraSystemPrompt}\n\n${VOICE_SPOKEN_OUTPUT_CONTRACT}`;
// Resolve timeout
const timeoutMs = voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg });
const runId = `voice:${callId}:${Date.now()}`;
// Resolve timeout
const timeoutMs =
voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg });
const runId = `voice:${callId}:${Date.now()}`;
try {
const result = await agentRuntime.runEmbeddedAgent({
sessionId,
sessionKey: resolvedSessionKey,
sessionTarget: {
agentId,
sessionId,
sessionKey: resolvedSessionKey,
storePath,
const result = await agentRuntime.runEmbeddedAgent({
sessionId,
sessionKey: resolvedSessionKey,
sessionTarget: {
agentId,
sessionId,
sessionKey: resolvedSessionKey,
storePath,
},
sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey),
agentId,
messageProvider: "voice",
workspaceDir,
config: cfg,
prompt: userMessage,
provider,
model,
thinkLevel,
verboseLevel: "off",
timeoutMs,
runId,
lane: "voice",
extraSystemPrompt,
agentDir,
toolsAllow,
abortSignal,
});
const text = extractSpokenTextFromPayloads(
(result.payloads ?? []) as VoiceResponsePayload[],
);
if (!text && result.meta?.aborted) {
return { text: null, error: "Response generation was aborted" };
}
return { text };
},
sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey),
agentId,
messageProvider: "voice",
workspaceDir,
config: cfg,
prompt: userMessage,
provider,
model,
thinkLevel,
verboseLevel: "off",
timeoutMs,
runId,
lane: "voice",
extraSystemPrompt,
agentDir,
toolsAllow,
});
const text = extractSpokenTextFromPayloads((result.payloads ?? []) as VoiceResponsePayload[]);
if (!text && result.meta?.aborted) {
return { text: null, error: "Response generation was aborted" };
}
return { text };
);
} catch (err) {
console.error(`[voice-call] Response generation failed:`, err);
return { text: null, error: String(err) };
@@ -185,6 +185,8 @@ export const SessionsListParamsSchema = Type.Object(
spawnedBy: Type.Optional(NonEmptyString),
agentId: Type.Optional(NonEmptyString),
search: Type.Optional(Type.String()),
/** True lists archived sessions; false or omitted lists active sessions. */
archived: Type.Optional(Type.Boolean()),
},
{ additionalProperties: false },
);
@@ -301,6 +303,8 @@ export const SessionsPatchParamsSchema = Type.Object(
key: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
label: Type.Optional(Type.Union([SessionLabelString, Type.Null()])),
archived: Type.Optional(Type.Boolean()),
pinned: Type.Optional(Type.Boolean()),
thinkingLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
fastMode: Type.Optional(Type.Union([Type.Boolean(), Type.Literal("auto"), Type.Null()])),
verboseLevel: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
@@ -382,6 +386,10 @@ export const SessionsDeleteParamsSchema = Type.Object(
key: NonEmptyString,
agentId: Type.Optional(NonEmptyString),
deleteTranscript: Type.Optional(Type.Boolean()),
// Internal compare-and-delete guard for lifecycle-owned cleanup.
expectedSessionId: Type.Optional(NonEmptyString),
expectedLifecycleRevision: Type.Optional(NonEmptyString),
expectedSessionUpdatedAt: Type.Optional(Type.Number({ minimum: 0 })),
// Internal control: when false, still unbind thread bindings but skip hook emission.
emitLifecycleHooks: Type.Optional(Type.Boolean()),
},
+6 -1
View File
@@ -723,7 +723,12 @@ export class Session {
}
async compact(params?: { maxLines?: number }): Promise<unknown> {
return await this.client.request("sessions.compact", { key: this.key, ...params });
return await this.client.request(
"sessions.compact",
{ key: this.key, ...params },
// The server owns the configurable terminal compaction deadline.
{ timeoutMs: null },
);
}
}
+7
View File
@@ -1349,12 +1349,14 @@ describe("OpenClaw SDK", () => {
const transport = new FakeTransport({
"sessions.create": { key: "session-main", label: "Main" },
"sessions.send": { status: "accepted", runId: "run_session" },
"sessions.compact": { ok: true, compacted: true },
});
const oc = new OpenClaw({ transport });
const session = await oc.sessions.create({ key: "session-main" });
const run = await session.send({ message: "continue", thinking: "medium", timeoutMs: 1_500 });
const noTimeoutRun = await session.send({ message: "continue without timeout", timeoutMs: 0 });
await session.compact();
expect(run.id).toBe("run_session");
expect(noTimeoutRun.id).toBe("run_session");
@@ -1374,6 +1376,11 @@ describe("OpenClaw SDK", () => {
options: { expectFinal: true, timeoutMs: null },
params: { key: "session-main", message: "continue without timeout", timeoutMs: 0 },
},
{
method: "sessions.compact",
options: { timeoutMs: null },
params: { key: "session-main" },
},
]);
});
+6 -2
View File
@@ -52,9 +52,13 @@ const DEFAULTED_OPTIONAL_INIT_PARAM_ENTRIES: readonly [string, readonly string[]
["SessionsMessagesSubscribeParams", ["agentId"]],
["SessionsMessagesUnsubscribeParams", ["agentId"]],
["SessionsAbortParams", ["agentId"]],
["SessionsPatchParams", ["agentId"]],
["SessionsListParams", ["archived"]],
["SessionsPatchParams", ["agentId", "archived", "pinned"]],
["SessionsResetParams", ["agentId"]],
["SessionsDeleteParams", ["agentId"]],
[
"SessionsDeleteParams",
["agentId", "expectedSessionId", "expectedLifecycleRevision", "expectedSessionUpdatedAt"],
],
["SessionsCompactParams", ["agentId"]],
["SessionsResolveParams", ["allowMissing"]],
["SessionsUsageParams", ["agentId", "agentScope"]],
@@ -40,6 +40,7 @@ const state = vi.hoisted(() => ({
),
resolveEffectiveModelFallbacksMock: vi.fn().mockReturnValue(undefined),
hasLegacyAutoFallbackWithoutOriginMock: vi.fn((_entry: unknown) => false),
applyModelOverrideToSessionEntryMock: vi.fn((_params: unknown) => ({ updated: false })),
resolveAutoFallbackPrimaryProbeMock: vi.fn((_params: unknown) => undefined as unknown),
resolveChannelModelOverrideMock: vi.fn((_params: unknown) => null as unknown),
assertLifecycleCurrentMock: vi.fn(),
@@ -151,6 +152,8 @@ vi.mock("./command/run-context.js", () => ({
}));
vi.mock("./command/session-store.runtime.js", () => ({
loadSessionEntry: ({ sessionKey }: { sessionKey: string }) =>
(state.sessionStoreMock as Record<string, SessionEntry> | undefined)?.[sessionKey],
updateSessionStoreAfterAgentRun: (...args: unknown[]) =>
state.updateSessionStoreAfterAgentRunMock(...args),
}));
@@ -347,7 +350,8 @@ vi.mock("../sessions/level-overrides.js", () => ({
}));
vi.mock("../sessions/model-overrides.js", () => ({
applyModelOverrideToSessionEntry: () => ({ updated: false }),
applyModelOverrideToSessionEntry: (params: unknown) =>
state.applyModelOverrideToSessionEntryMock(params),
repairProviderWrappedModelOverride: () => ({ updated: false }),
}));
@@ -958,6 +962,7 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
state.resolveAgentSkillsFilterMock.mockReturnValue(undefined);
state.loadManifestModelCatalogMock.mockReturnValue([]);
state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(false);
state.applyModelOverrideToSessionEntryMock.mockReturnValue({ updated: false });
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue(undefined);
state.resolveChannelModelOverrideMock.mockImplementation((params: unknown) => {
const input = params as {
@@ -1259,17 +1264,19 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
it("preserves restart ownership when an aborted attempt resolves normally", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue({
payloads: [],
meta: {
durationMs: 100,
aborted: true,
stopReason: "end_turn",
agentMeta: { provider: "anthropic", model: "claude" },
},
});
const controller = new AbortController();
controller.abort(createAgentRunRestartAbortError());
state.runAgentAttemptMock.mockImplementation(async () => {
controller.abort(createAgentRunRestartAbortError());
return {
payloads: [],
meta: {
durationMs: 100,
aborted: true,
stopReason: "end_turn",
agentMeta: { provider: "anthropic", model: "claude" },
},
};
});
await expect(
agentCommand({
@@ -1348,9 +1355,13 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
expect.objectContaining({
runId: "session-1",
sessionKey: "agent:main:main",
abortSignal: controller.signal,
}),
);
const lifecycleError = state.emitAcpLifecycleErrorMock.mock.calls[0]?.[0] as
| { abortSignal?: AbortSignal }
| undefined;
expect(lifecycleError?.abortSignal?.aborted).toBe(true);
expect(lifecycleError?.abortSignal?.reason).toBe(controller.signal.reason);
expect(state.persistAcpTurnTranscriptMock).toHaveBeenCalledTimes(1);
expect(state.buildAcpResultMock).not.toHaveBeenCalled();
expect(state.deliverAgentCommandResultMock).not.toHaveBeenCalled();
@@ -1676,6 +1687,64 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
expect(fallbackParams.model).toBe("channel-model");
});
it("uses a concurrent user override adopted during legacy fallback repair", async () => {
setupSingleAttemptFallback();
state.applyModelOverrideToSessionEntryMock.mockImplementation((params: unknown) => {
const { entry } = params as { entry: SessionEntry };
delete entry.providerOverride;
delete entry.modelOverride;
delete entry.modelOverrideSource;
return { updated: true };
});
state.hasLegacyAutoFallbackWithoutOriginMock.mockImplementation(
(entry: unknown) =>
(entry as SessionEntry | undefined)?.modelOverride === "stale-fallback-model",
);
state.runtimeConfigMock = {
agents: {
defaults: {
model: "anthropic/default-model",
models: {
"anthropic/default-model": {},
"anthropic/stale-fallback-model": {},
"google/gemini-3-pro": {},
},
},
},
};
const sessionEntry = {
sessionId: "session-1",
updatedAt: 1,
providerOverride: "anthropic",
modelOverride: "stale-fallback-model",
modelOverrideSource: "auto",
skillsSnapshot: { prompt: "", skills: [], version: 0 },
} satisfies SessionEntry;
state.sessionEntryMock = sessionEntry;
state.sessionStoreMock = { "agent:main:main": sessionEntry };
state.storePathMock = "/tmp/openclaw-session-store.json";
state.persistSessionEntryMock.mockImplementation(async (...args: unknown[]) => {
const params = args[0] as { entry?: SessionEntry };
if (params.entry?.modelOverride === "stale-fallback-model") {
return params.entry;
}
return {
...sessionEntry,
updatedAt: 2,
providerOverride: "google",
modelOverride: "gemini-3-pro",
modelOverrideSource: "user",
};
});
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("google", "gemini-3-pro"));
await runBasicAgentCommand();
const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
expect(fallbackParams.provider).toBe("google");
expect(fallbackParams.model).toBe("gemini-3-pro");
});
it("probes the channel primary when a session is pinned to an auto fallback", async () => {
setupSingleAttemptFallback();
state.resolveAutoFallbackPrimaryProbeMock.mockReturnValue({
@@ -2970,8 +3039,10 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
it("marks lifecycle errors aborted when cancellation reaches post-turn handling", async () => {
const abortController = new AbortController();
abortController.abort();
state.runWithModelFallbackMock.mockRejectedValueOnce(new Error("request aborted"));
state.runWithModelFallbackMock.mockImplementationOnce(async () => {
abortController.abort();
throw new Error("request aborted");
});
await expect(
agentCommand({
+1475 -1424
View File
File diff suppressed because it is too large Load Diff
@@ -4,14 +4,23 @@
* updates without loading the real auth store implementation.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
loadSessionEntry,
patchSessionEntry,
replaceSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
type OpenClawTestState,
withOpenClawTestState,
} from "../../test-utils/openclaw-test-state.js";
import { resolveSessionAuthProfileOverride } from "./session-override.js";
import {
clearSessionAuthProfileOverride,
resolveSessionAuthProfileOverride,
} from "./session-override.js";
import type { AuthProfileStore } from "./types.js";
const authStoreMocks = vi.hoisted(() => {
@@ -562,4 +571,100 @@ describe("resolveSessionAuthProfileOverride", () => {
expect(sessionEntry.authProfileOverrideSource).toBe("auto");
});
});
it("clears auth state without restoring concurrent session management fields", async () => {
await withAuthState(async (state) => {
const sessionKey = "agent:main:main";
const storePath = path.join(state.sessionsDir(), "sessions.json");
const scope = { storePath, sessionKey };
await replaceSessionEntry(scope, {
sessionId: "s1",
updatedAt: 1,
label: "before",
pinnedAt: 1,
authProfileOverride: TEST_PRIMARY_PROFILE_ID,
authProfileOverrideSource: "user",
});
const sessionEntry = loadSessionEntry({ ...scope, readConsistency: "latest" });
expect(sessionEntry).toBeDefined();
const sessionStore = { [sessionKey]: sessionEntry! };
await patchSessionEntry(scope, () => ({ label: "renamed", pinnedAt: undefined }));
await clearSessionAuthProfileOverride({
sessionEntry: sessionEntry!,
sessionStore,
sessionKey,
storePath,
});
const persisted = loadSessionEntry({ ...scope, readConsistency: "latest" });
expect(persisted?.label).toBe("renamed");
expect(persisted?.pinnedAt).toBeUndefined();
expect(persisted?.authProfileOverride).toBeUndefined();
expect(sessionStore[sessionKey]?.label).toBe("renamed");
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
});
});
it("rotates auth state without restoring concurrent session management fields", async () => {
await withAuthState(async (state) => {
const agentDir = state.agentDir();
await fs.mkdir(agentDir, { recursive: true });
authStoreMocks.state.hasSource = true;
authStoreMocks.state.store = createAuthStoreWithProfiles({
profiles: {
[TEST_PRIMARY_PROFILE_ID]: {
type: "api_key",
provider: "openai",
key: "sk-primary",
},
[TEST_SECONDARY_PROFILE_ID]: {
type: "api_key",
provider: "openai",
key: "sk-secondary",
},
},
order: {
openai: [TEST_PRIMARY_PROFILE_ID, TEST_SECONDARY_PROFILE_ID],
},
});
const sessionKey = "agent:main:main";
const storePath = path.join(state.sessionsDir(), "sessions.json");
const scope = { storePath, sessionKey };
await replaceSessionEntry(scope, {
sessionId: "s1",
updatedAt: 1,
label: "before",
pinnedAt: 1,
compactionCount: 1,
authProfileOverride: TEST_PRIMARY_PROFILE_ID,
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: 0,
});
const sessionEntry = loadSessionEntry({ ...scope, readConsistency: "latest" });
expect(sessionEntry).toBeDefined();
const sessionStore = { [sessionKey]: sessionEntry! };
await patchSessionEntry(scope, () => ({ label: "renamed", pinnedAt: undefined }));
const resolved = await resolveSessionAuthProfileOverride({
cfg: {} as OpenClawConfig,
provider: "openai",
agentDir,
sessionEntry: sessionEntry!,
sessionStore,
sessionKey,
storePath,
isNewSession: false,
});
expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID);
const persisted = loadSessionEntry({ ...scope, readConsistency: "latest" });
expect(persisted?.label).toBe("renamed");
expect(persisted?.pinnedAt).toBeUndefined();
expect(persisted?.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
expect(sessionStore[sessionKey]?.label).toBe("renamed");
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
});
});
});
+79 -28
View File
@@ -24,6 +24,63 @@ function loadSessionAccessor() {
return sessionAccessorLoader.load();
}
type SessionAuthProfileOverrideState = Pick<
SessionEntry,
"authProfileOverride" | "authProfileOverrideSource" | "authProfileOverrideCompactionCount"
>;
function applySessionAuthProfileOverrideState(
entry: SessionEntry,
state: SessionAuthProfileOverrideState,
updatedAt: number,
): void {
if (state.authProfileOverride === undefined) {
delete entry.authProfileOverride;
} else {
entry.authProfileOverride = state.authProfileOverride;
}
if (state.authProfileOverrideSource === undefined) {
delete entry.authProfileOverrideSource;
} else {
entry.authProfileOverrideSource = state.authProfileOverrideSource;
}
if (state.authProfileOverrideCompactionCount === undefined) {
delete entry.authProfileOverrideCompactionCount;
} else {
entry.authProfileOverrideCompactionCount = state.authProfileOverrideCompactionCount;
}
entry.updatedAt = Math.max(entry.updatedAt ?? 0, updatedAt);
}
async function persistSessionAuthProfileOverrideState(params: {
sessionEntry: SessionEntry;
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
state: SessionAuthProfileOverrideState;
storePath?: string;
}): Promise<void> {
const { sessionEntry, sessionStore, sessionKey, state, storePath } = params;
const updatedAt = Date.now();
applySessionAuthProfileOverrideState(sessionEntry, state, updatedAt);
sessionStore[sessionKey] = sessionEntry;
if (!storePath) {
return;
}
const persisted = await (
await loadSessionAccessor()
).patchSessionEntry(
{ storePath, sessionKey },
(current) => ({
...state,
updatedAt: Math.max(current.updatedAt ?? 0, updatedAt),
}),
{ fallbackEntry: sessionEntry },
);
if (persisted) {
sessionStore[sessionKey] = persisted;
}
}
// Current session overrides are only valid when the selected provider can use
// that profile, including configured aws-sdk profiles without stored secrets.
function isProfileForProvider(params: {
@@ -76,20 +133,17 @@ export async function clearSessionAuthProfileOverride(params: {
storePath?: string;
}) {
const { sessionEntry, sessionStore, sessionKey, storePath } = params;
delete sessionEntry.authProfileOverride;
delete sessionEntry.authProfileOverrideSource;
delete sessionEntry.authProfileOverrideCompactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await (
await loadSessionAccessor()
).patchSessionEntry(
{ storePath, sessionKey },
() => sessionEntry,
{ fallbackEntry: sessionEntry, replaceEntry: true },
);
}
await persistSessionAuthProfileOverrideState({
sessionEntry,
sessionStore,
sessionKey,
state: {
authProfileOverride: undefined,
authProfileOverrideSource: undefined,
authProfileOverrideCompactionCount: undefined,
},
storePath,
});
}
/** Resolves and optionally rotates the session auth-profile override. */
@@ -231,20 +285,17 @@ export async function resolveSessionAuthProfileOverride(params: {
sessionEntry.authProfileOverrideSource !== "auto" ||
sessionEntry.authProfileOverrideCompactionCount !== compactionCount;
if (shouldPersist) {
sessionEntry.authProfileOverride = next;
sessionEntry.authProfileOverrideSource = "auto";
sessionEntry.authProfileOverrideCompactionCount = compactionCount;
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await (
await loadSessionAccessor()
).patchSessionEntry(
{ storePath, sessionKey },
() => sessionEntry,
{ fallbackEntry: sessionEntry, replaceEntry: true },
);
}
await persistSessionAuthProfileOverrideState({
sessionEntry,
sessionStore,
sessionKey,
state: {
authProfileOverride: next,
authProfileOverrideSource: "auto",
authProfileOverrideCompactionCount: compactionCount,
},
storePath,
});
}
return next;
@@ -1,9 +1,14 @@
// Covers shared attempt-execution helpers for prompt materialization and
// guarded session-store persistence.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
} from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
INTERNAL_RUNTIME_CONTEXT_BEGIN,
INTERNAL_RUNTIME_CONTEXT_END,
@@ -15,6 +20,8 @@ import {
} from "./attempt-execution.shared.js";
import type { AgentCommandOpts } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function makeTaskCompletionEvents(): NonNullable<AgentCommandOpts["internalEvents"]> {
// The result deliberately contains internal markers to prove child output
// cannot spoof OpenClaw runtime-context envelopes.
@@ -88,7 +95,7 @@ describe("attempt execution prompt materialization", () => {
describe("persistSessionEntry", () => {
it("clears stale local entries when guarded persistence sees no persisted entry", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-store-"));
const dir = tempDirs.make("openclaw-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const sessionStore = {
@@ -104,6 +111,7 @@ describe("persistSessionEntry", () => {
sessionStore,
sessionKey: "main",
storePath,
initialEntry: sessionStore.main,
entry: {
sessionId: "stale",
updatedAt: 2,
@@ -114,7 +122,112 @@ describe("persistSessionEntry", () => {
expect(persisted).toBeUndefined();
expect(sessionStore.main).toBeUndefined();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
clearSessionStoreCacheForTest();
}
});
it.each([
{
name: "rename and unpin",
current: { label: "Renamed", pinnedAt: undefined },
expected: { label: "Renamed", pinnedAt: undefined },
},
{
name: "label clear and pin",
current: { label: undefined, pinnedAt: 300 },
expected: { label: undefined, pinnedAt: 300 },
},
])("preserves a concurrent $name", async ({ current, expected }) => {
const dir = tempDirs.make("openclaw-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const staleEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
label: "Old label",
pinnedAt: 200,
};
const currentEntry: SessionEntry = {
...staleEntry,
...current,
updatedAt: 400,
};
if (current.label === undefined) {
delete currentEntry.label;
}
if (current.pinnedAt === undefined) {
delete currentEntry.pinnedAt;
}
await saveSessionStore(storePath, { main: currentEntry }, { skipMaintenance: true });
const sessionStore = { main: staleEntry };
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
storePath,
initialEntry: staleEntry,
entry: {
...staleEntry,
model: "gpt-5.5",
updatedAt: 250,
},
});
expect(persisted).toMatchObject({ sessionId: "session-1", model: "gpt-5.5" });
expect(persisted?.label).toBe(expected.label);
expect(persisted?.pinnedAt).toBe(expected.pinnedAt);
expect(persisted?.updatedAt).toBeGreaterThanOrEqual(currentEntry.updatedAt);
expect(sessionStore.main).toEqual(persisted);
expect(loadSessionStore(storePath, { skipCache: true }).main).toEqual(persisted);
} finally {
clearSessionStoreCacheForTest();
}
});
it("does not restore policy fields revoked during an active turn", async () => {
const dir = tempDirs.make("openclaw-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
model: "gpt-5.4",
elevatedLevel: "full",
inheritedToolAllow: ["exec"],
sendPolicy: "allow",
};
const currentEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 400,
model: "gpt-5.4",
sendPolicy: "deny",
};
await saveSessionStore(storePath, { main: currentEntry }, { skipMaintenance: true });
const sessionStore = { main: initialEntry };
const persisted = await persistSessionEntry({
sessionStore,
sessionKey: "main",
storePath,
initialEntry,
entry: {
...initialEntry,
model: "gpt-5.5",
updatedAt: 250,
},
});
expect(persisted).toMatchObject({
sessionId: "session-1",
model: "gpt-5.5",
sendPolicy: "deny",
updatedAt: 400,
});
expect(persisted?.elevatedLevel).toBeUndefined();
expect(persisted?.inheritedToolAllow).toBeUndefined();
expect(loadSessionStore(storePath, { skipCache: true }).main).toEqual(persisted);
} finally {
clearSessionStoreCacheForTest();
}
});
});
+14 -24
View File
@@ -3,7 +3,8 @@
* execution paths.
*/
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import { mergeSessionEntry, type SessionEntry } from "../../config/sessions/types.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import {
formatAgentInternalEventsForPlainPrompt,
formatAgentInternalEventsForPrompt,
@@ -19,18 +20,12 @@ type PersistSessionEntryParams = {
sessionStore: Record<string, SessionEntry>;
sessionKey: string;
storePath: string;
initialEntry: SessionEntry;
entry: SessionEntry;
clearedFields?: string[];
preserveTranscriptMarkerUpdatedAt?: boolean;
shouldPersist?: (entry: SessionEntry | undefined) => boolean;
};
/** Persists one session entry while keeping the caller's in-memory store aligned. */
function normalizeTranscriptMarkerUpdatedAt(value: number | undefined): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
export async function persistSessionEntry(
params: PersistSessionEntryParams,
): Promise<SessionEntry | undefined> {
@@ -42,24 +37,19 @@ export async function persistSessionEntry(
rejectedMissingEntry = !context.existingEntry;
return null;
}
const merged = mergeSessionEntry(context.existingEntry, params.entry);
if (params.preserveTranscriptMarkerUpdatedAt) {
const currentUpdatedAt = normalizeTranscriptMarkerUpdatedAt(
context.existingEntry?.updatedAt,
);
const markerUpdatedAt = normalizeTranscriptMarkerUpdatedAt(params.entry.updatedAt);
if (markerUpdatedAt !== undefined) {
merged.updatedAt = Math.max(currentUpdatedAt ?? 0, markerUpdatedAt);
}
if (!context.existingEntry) {
return params.entry;
}
for (const field of params.clearedFields ?? []) {
// Cleared fields only apply when the replacement entry did not set the
// field again; this preserves explicit false/null updates.
if (!Object.hasOwn(params.entry, field)) {
Reflect.deleteProperty(merged, field);
}
if (context.existingEntry.sessionId !== params.initialEntry.sessionId) {
return null;
}
return merged;
// Agent turns persist broad snapshots. Project only this turn's changes
// so a stale snapshot cannot restore fields changed or cleared meanwhile.
return mergeSessionSnapshotChanges({
initial: params.initialEntry,
next: params.entry,
current: context.existingEntry,
});
},
{
fallbackEntry: params.sessionStore[params.sessionKey] ?? params.entry,
@@ -2,3 +2,4 @@
// config/session persistence until an agent run needs to save state.
export { updateSessionStoreAfterAgentRun } from "./session-store.js";
export { loadSessionStore } from "../../config/sessions.js";
export { loadSessionEntry } from "../../config/sessions/session-accessor.js";
+63
View File
@@ -170,6 +170,69 @@ async function withTempSessionStore<T>(
}
describe("updateSessionStoreAfterAgentRun", () => {
it("preserves a concurrent rename and unpin during final accounting", async () => {
await withTempSessionStore(async ({ storePath }) => {
const sessionKey = "agent:main:explicit:test-management-race";
const sessionId = "test-management-race-session";
const staleEntry: SessionEntry = {
sessionId,
updatedAt: 1,
label: "Old label",
pinnedAt: 100,
chatType: "direct",
elevatedLevel: "full",
inheritedToolAllow: ["exec"],
sendPolicy: "allow",
};
const sessionStore = { [sessionKey]: staleEntry };
const concurrentEntry: SessionEntry = {
...staleEntry,
chatType: "group",
label: "Renamed while running",
sendPolicy: "deny",
updatedAt: 2,
};
delete concurrentEntry.elevatedLevel;
delete concurrentEntry.inheritedToolAllow;
delete concurrentEntry.pinnedAt;
await fs.writeFile(
storePath,
JSON.stringify({
[sessionKey]: concurrentEntry,
}),
);
await updateSessionStoreAfterAgentRun({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
storePath,
sessionStore,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
result: {
meta: {
durationMs: 1,
agentMeta: { sessionId, provider: "openai", model: "gpt-5.5" },
},
},
});
expect(sessionStore[sessionKey]).toMatchObject({
chatType: "group",
label: "Renamed while running",
model: "gpt-5.5",
sendPolicy: "deny",
});
expect(sessionStore[sessionKey]?.elevatedLevel).toBeUndefined();
expect(sessionStore[sessionKey]?.inheritedToolAllow).toBeUndefined();
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(
sessionStore[sessionKey],
);
});
});
it("passes resolved maintenance config to the gateway turn store write", async () => {
sessionStoreMocks.updateSessionStore.mockClear();
await withTempSessionStore(async ({ storePath }) => {
+6 -12
View File
@@ -8,6 +8,7 @@ import {
type SessionEntry,
} from "../../config/sessions.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import { projectSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -37,15 +38,6 @@ function resolvePositiveInteger(value: number | undefined): number | undefined {
return Math.floor(value);
}
function removeLifecycleStateFromMetadataPatch(entry: SessionEntry): SessionEntry {
const next = { ...entry };
delete next.status;
delete next.startedAt;
delete next.endedAt;
delete next.runtimeMs;
return next;
}
/** Applies run result metadata, usage, and CLI bindings to a session entry. */
export async function updateSessionStoreAfterAgentRun(params: {
cfg: OpenClawConfig;
@@ -283,14 +275,14 @@ export async function updateSessionStoreAfterAgentRun(params: {
updatedAt: next.updatedAt,
...(touchInteraction ? { lastInteractionAt: next.lastInteractionAt } : {}),
}
: removeLifecycleStateFromMetadataPatch(next);
: next;
const maintenanceConfig = resolveMaintenanceConfigFromInput(cfg.session?.maintenance);
const persisted = await patchSessionEntry(
{
storePath,
sessionKey,
},
(_currentEntry, context) => {
(currentEntry, context) => {
if (
(!preserveUserFacingRunState &&
context.existingEntry &&
@@ -301,7 +293,9 @@ export async function updateSessionStoreAfterAgentRun(params: {
// Do not merge stale finalizer metadata after a delete or a competing reset.
return null;
}
return metadataPatch;
return preserveUserFacingRunState
? metadataPatch
: projectSessionSnapshotChanges({ initial: entry, next, current: currentEntry });
},
{
...(preserveUserFacingRunState ? {} : { fallbackEntry: entry }),
@@ -173,6 +173,42 @@ export const resolveSandboxContextMock = vi.fn(async () => null);
export const maybeCompactAgentHarnessSessionMock: Mock<
(params?: unknown, options?: unknown) => Promise<unknown>
> = vi.fn(async () => undefined);
async function runCompactWithSafetyTimeoutMock(
compact: () => Promise<unknown>,
_timeoutMs?: number,
opts?: { abortSignal?: AbortSignal; onCancel?: () => void },
): Promise<unknown> {
const abortSignal = opts?.abortSignal;
if (!abortSignal) {
return await compact();
}
const cancelAndCreateError = () => {
opts?.onCancel?.();
const reason = "reason" in abortSignal ? abortSignal.reason : undefined;
if (reason instanceof Error) {
return reason;
}
const err = new Error("aborted");
err.name = "AbortError";
return err;
};
if (abortSignal.aborted) {
throw cancelAndCreateError();
}
return await Promise.race([
compact(),
new Promise<never>((_, reject) => {
abortSignal.addEventListener(
"abort",
() => {
reject(cancelAndCreateError());
},
{ once: true },
);
}),
]);
}
export const compactWithSafetyTimeoutMock = vi.fn(runCompactWithSafetyTimeoutMock);
export const rotateTranscriptAfterCompactionMock: Mock<
(_params?: unknown) => Promise<CompactionTranscriptRotation>
> = vi.fn(async () => ({
@@ -376,6 +412,8 @@ export function resetCompactHooksHarnessMocks(): void {
reason: undefined,
result: { summary: "engine-summary", tokensAfter: 50 },
});
compactWithSafetyTimeoutMock.mockReset();
compactWithSafetyTimeoutMock.mockImplementation(runCompactWithSafetyTimeoutMock);
resolveModelMock.mockReset();
resolveModelMock.mockReturnValue({
@@ -672,45 +710,8 @@ export async function loadCompactHooksHarness(): Promise<{
}));
vi.doMock("./compaction-safety-timeout.js", () => {
const compactWithSafetyTimeout = vi.fn(
async (
compact: () => Promise<unknown>,
_timeoutMs?: number,
opts?: { abortSignal?: AbortSignal; onCancel?: () => void },
) => {
const abortSignal = opts?.abortSignal;
if (!abortSignal) {
return await compact();
}
const cancelAndCreateError = () => {
opts?.onCancel?.();
const reason = "reason" in abortSignal ? abortSignal.reason : undefined;
if (reason instanceof Error) {
return reason;
}
const err = new Error("aborted");
err.name = "AbortError";
return err;
};
if (abortSignal.aborted) {
throw cancelAndCreateError();
}
return await Promise.race([
compact(),
new Promise<never>((_, reject) => {
abortSignal.addEventListener(
"abort",
() => {
reject(cancelAndCreateError());
},
{ once: true },
);
}),
]);
},
);
return {
compactWithSafetyTimeout,
compactWithSafetyTimeout: compactWithSafetyTimeoutMock,
resolveCompactionTimeoutMs: vi.fn(() => 30_000),
// Mirror the real wrapper: bound the engine's compact() with the
// (mocked) safety timeout and thread the abort signal into its params.
@@ -721,7 +722,7 @@ export async function loadCompactHooksHarness(): Promise<{
timeoutMs?: number,
abortSignal?: AbortSignal,
) =>
compactWithSafetyTimeout(
compactWithSafetyTimeoutMock(
() => contextEngine.compact(abortSignal ? { ...params, abortSignal } : params),
timeoutMs,
abortSignal ? { abortSignal } : undefined,
@@ -6,6 +6,7 @@ import {
applyAgentCompactionSettingsFromConfigMock,
buildEmbeddedSystemPromptMock,
contextEngineCompactMock,
compactWithSafetyTimeoutMock,
createAgentSessionMock,
createPreparedEmbeddedAgentSettingsManagerMock,
createOpenClawCodingToolsMock,
@@ -2218,7 +2219,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
} as never);
maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({
ok: true,
compacted: false,
compacted: true,
result: {
summary: "",
firstKeptEntryId: "",
@@ -2226,7 +2227,8 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
details: {
backend: "codex-app-server",
signal: "thread/compact/start",
pending: true,
pending: false,
completed: true,
},
},
});
@@ -2262,18 +2264,66 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => {
| undefined;
expect(details?.codexNativeCompaction).toMatchObject({
ok: true,
compacted: false,
compacted: true,
result: {
tokensBefore: 333,
details: {
backend: "codex-app-server",
signal: "thread/compact/start",
pending: true,
pending: false,
completed: true,
},
},
});
});
it("holds the queued lane until secondary Codex compaction reaches its terminal event", async () => {
resolveAgentHarnessPolicyMock.mockReturnValue({
runtime: "codex",
runtimeSource: "model",
} as never);
const nativeTerminal = createDeferred<{
ok: true;
compacted: true;
result: { summary: string; firstKeptEntryId: string; tokensBefore: number };
}>();
maybeCompactAgentHarnessSessionMock.mockReturnValueOnce(nativeTerminal.promise);
compactWithSafetyTimeoutMock
.mockImplementation(async () => {
throw new Error("Compaction timed out");
})
.mockImplementationOnce(async (compact) => await compact());
let settled = false;
const resultPromise = compactEmbeddedAgentSession(
wrappedCompactionArgs({
provider: "codex",
model: "gpt-5.4",
agentHarnessId: "codex",
trigger: "budget",
}),
).finally(() => {
settled = true;
});
await vi.waitFor(() => {
expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1);
});
expect(settled).toBe(false);
expect(compactWithSafetyTimeoutMock).toHaveBeenCalledTimes(1);
nativeTerminal.resolve({
ok: true,
compacted: true,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: 333,
},
});
await expect(resultPromise).resolves.toMatchObject({ ok: true, compacted: true });
});
it("keeps context-engine compaction successful when the secondary Codex bridge gets a provider 4xx", async () => {
resolveAgentHarnessPolicyMock.mockReturnValue({
runtime: "codex",
@@ -44,7 +44,6 @@ import {
resolveEmbeddedCompactionTarget,
} from "./compaction-runtime-context.js";
import {
compactWithSafetyTimeout,
compactContextEngineWithSafetyTimeout,
resolveCompactionTimeoutMs,
} from "./compaction-safety-timeout.js";
@@ -554,22 +553,19 @@ export async function compactEmbeddedAgentSession(
attemptNativeHarnessCompaction
) {
try {
secondaryNativeHarnessCompaction = await compactWithSafetyTimeout(
(compactAbortSignal) =>
maybeCompactAgentHarnessSession(
{
...params,
sessionId: postCompactionSessionId,
sessionFile: postCompactionSessionFile,
contextEngine,
contextTokenBudget,
contextEngineRuntimeContext,
abortSignal: compactAbortSignal,
},
{ nativeCompactionRequest: "after_context_engine" },
),
resolveCompactionTimeoutMs(params.config),
params.abortSignal ? { abortSignal: params.abortSignal } : undefined,
// The native bridge owns its terminal-event watchdog. Keep this lane held until
// that bridge settles; an outer timeout would release transcript ownership while
// the harness could still be compacting the same session.
secondaryNativeHarnessCompaction = await maybeCompactAgentHarnessSession(
{
...params,
sessionId: postCompactionSessionId,
sessionFile: postCompactionSessionFile,
contextEngine,
contextTokenBudget,
contextEngineRuntimeContext,
},
{ nativeCompactionRequest: "after_context_engine" },
);
if (secondaryNativeHarnessCompaction && !secondaryNativeHarnessCompaction.ok) {
log.warn(
@@ -496,6 +496,7 @@ describe("sessions tools", () => {
params: {
activeMinutes: undefined,
agentId: "main",
archived: false,
includeDerivedTitles: false,
includeLastMessage: false,
includeGlobal: true,
@@ -631,6 +632,10 @@ describe("sessions tools", () => {
channel: "unknown",
origin: undefined,
spawnedBy: undefined,
archived: false,
archivedAt: undefined,
pinned: false,
pinnedAt: undefined,
label: undefined,
displayName: undefined,
derivedTitle: "Visible project kickoff",
+1 -1
View File
@@ -263,7 +263,7 @@ async function callSubagentGateway(
{
expectFinal: request.expectFinal,
...(scopes != null ? { forceSyntheticClient: true } : {}),
timeoutMs: request.timeoutMs,
...(typeof request.timeoutMs === "number" ? { timeoutMs: request.timeoutMs } : {}),
...(scopes != null ? { syntheticScopes: scopes } : {}),
},
);
+1 -1
View File
@@ -14,7 +14,7 @@ export const UPDATE_PLAN_TOOL_DISPLAY_SUMMARY = "Track short work plan.";
/** Describes the sessions_list tool for model-facing instructions. */
export function describeSessionsListTool(): string {
return [
"List visible sessions; filter by kind, label, agentId, search, activity.",
"List visible sessions; filter by kind, label, agentId, search, activity, archive state.",
"Use before sessions_history or sessions_send target selection.",
].join(" ");
}
+4
View File
@@ -57,6 +57,10 @@ export type SessionListRow = {
parentSessionKey?: string;
deliveryContext?: SessionListDeliveryContext;
updatedAt?: number | null;
archived?: boolean;
archivedAt?: number;
pinned?: boolean;
pinnedAt?: number;
sessionId?: string;
model?: string;
contextTokens?: number | null;
@@ -46,6 +46,10 @@ type SessionsListDetails = {
effectiveFastModeSource?: "session" | "agent" | "config" | "default";
fastMode?: boolean | "auto";
fastAutoOnSeconds?: number;
archived?: boolean;
archivedAt?: number;
pinned?: boolean;
pinnedAt?: number;
reasoningLevel?: string;
responseUsage?: string;
thinkingLevel?: string;
@@ -208,6 +212,36 @@ describe("sessions-list-tool", () => {
expect(session?.responseUsage).toBe("full");
});
it("requests archived sessions and keeps management metadata", async () => {
mocks.gatewayCall.mockResolvedValue({
path: "/tmp/sessions.json",
sessions: [
{
key: "agent:main:dashboard:archived",
kind: "direct",
archived: true,
archivedAt: 20,
pinned: false,
},
],
});
const tool = createSessionsListTool({ config: {} as never });
const result = await tool.execute("call-archived", { archived: true });
expect(mocks.gatewayCall).toHaveBeenCalledWith(
expect.objectContaining({
method: "sessions.list",
params: expect.objectContaining({ archived: true }),
}),
);
expect(getSessionsListDetails(result).sessions?.[0]).toMatchObject({
archived: true,
archivedAt: 20,
pinned: false,
});
});
it.each([
[{ limit: 1.5 }, "limit must be a positive integer"],
[{ activeMinutes: 0 }, "activeMinutes must be a positive integer"],
+7
View File
@@ -62,6 +62,7 @@ const SessionsListToolSchema = Type.Object({
label: Type.Optional(Type.String({ minLength: 1 })),
agentId: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
search: Type.Optional(Type.String({ minLength: 1 })),
archived: Type.Optional(Type.Boolean()),
includeDerivedTitles: Type.Optional(Type.Boolean()),
includeLastMessage: Type.Optional(Type.Boolean()),
});
@@ -123,6 +124,7 @@ export function createSessionsListTool(opts?: {
const label = readStringParam(params, "label");
const agentId = readStringParam(params, "agentId");
const search = readStringParam(params, "search");
const archived = params.archived === true;
const includeDerivedTitles = params.includeDerivedTitles === true;
const includeLastMessage = params.includeLastMessage === true;
const gatewayCall = opts?.callGateway ?? callGateway;
@@ -137,6 +139,7 @@ export function createSessionsListTool(opts?: {
label,
agentId,
search,
archived,
includeDerivedTitles: false,
includeLastMessage: false,
includeGlobal: !restrictToSpawned,
@@ -312,6 +315,10 @@ export function createSessionsListTool(opts?: {
}
: undefined,
updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : undefined,
archived: entry.archived === true,
archivedAt: typeof entry.archivedAt === "number" ? entry.archivedAt : undefined,
pinned: entry.pinned === true,
pinnedAt: typeof entry.pinnedAt === "number" ? entry.pinnedAt : undefined,
sessionId,
model: readStringValue(entry.model),
contextTokens: typeof entry.contextTokens === "number" ? entry.contextTokens : undefined,
@@ -465,6 +465,7 @@ function createMockReplyOperation(): {
terminalRecovery: false,
phase: "running",
result: null,
hasOwnedSessionId: vi.fn((sessionId: string) => sessionId === "session"),
setPhase: vi.fn(),
updateSessionId: updateSessionIdMock,
attachBackend: vi.fn(),
@@ -49,6 +49,7 @@ function createReplyOperation(): TestReplyOperation {
terminalRecovery: false,
phase: "queued",
result: null,
hasOwnedSessionId: vi.fn((sessionId: string) => sessionId === "session"),
setPhase: vi.fn<ReplyOperation["setPhase"]>(),
updateSessionId: vi.fn<ReplyOperation["updateSessionId"]>(),
attachBackend: vi.fn(),
+2
View File
@@ -1445,6 +1445,8 @@ export async function runReplyAgent(params: {
const admission = await admitReplyTurn({
sessionId: followupRun.run.sessionId,
sessionKey: replySessionKey ?? "",
expectedSessionId: activeSessionEntry?.sessionId,
storePath,
kind: replyTurnKind,
resetTriggered: effectiveResetTriggered,
routeThreadId: replyRouteThreadId,
+12 -13
View File
@@ -141,6 +141,7 @@ describe("handleCompactCommand", () => {
ok: true,
compacted: false,
});
const abortController = new AbortController();
const result = await handleCompactCommand(
{
@@ -161,6 +162,7 @@ describe("handleCompactCommand", () => {
SenderE164: "+15551234567",
},
agentDir: "/tmp/openclaw-agent-compact",
opts: { abortSignal: abortController.signal },
sessionEntry: {
sessionId: "session-1",
updatedAt: Date.now(),
@@ -179,6 +181,7 @@ describe("handleCompactCommand", () => {
expect(vi.mocked(compactEmbeddedAgentSession)).toHaveBeenCalledOnce();
const call = requireCompactEmbeddedAgentSessionCall();
expect(call.sessionId).toBe("session-1");
expect(call.abortSignal).toBe(abortController.signal);
expect(call.sessionKey).toBe("agent:main:main");
expect(call.allowGatewaySubagentBinding).toBe(true);
expect(call.trigger).toBe("manual");
@@ -465,39 +468,35 @@ describe("handleCompactCommand", () => {
expect(call.tokensAfter).toBe(321);
});
it("reports started Codex native compaction without incrementing completed compaction state", async () => {
it("reports unknown context when terminal compaction omits the post-compaction count", async () => {
vi.mocked(compactEmbeddedAgentSession).mockResolvedValueOnce({
ok: true,
compacted: false,
compacted: true,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: 199_000,
details: {
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
},
tokensBefore: 999,
},
});
const result = await handleCompactCommand(
await handleCompactCommand(
{
...buildCompactParams("/compact", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
sessionEntry: {
sessionId: "live-session",
sessionId: "target-session",
updatedAt: Date.now(),
totalTokens: 999,
totalTokensFresh: true,
},
} as HandleCommandsParams,
true,
);
expect(result?.reply?.text).toContain("Codex compaction started");
expect(vi.mocked(incrementCompactionCount)).not.toHaveBeenCalled();
expect(vi.mocked(incrementCompactionCount)).toHaveBeenCalledOnce();
expect(vi.mocked(formatContextUsageShort)).toHaveBeenLastCalledWith(null, null);
});
it("resolves /compact context budget from the active Codex runtime config instead of stale session metadata", async () => {
+12 -25
View File
@@ -85,19 +85,6 @@ function formatCompactionReason(reason?: string): string | undefined {
}
}
function isCodexNativeCompactionStartedResult(result: { result?: { details?: unknown } }): boolean {
const details = result.result?.details;
if (!details || typeof details !== "object" || Array.isArray(details)) {
return false;
}
const record = details as Record<string, unknown>;
return (
record.backend === "codex-app-server" &&
record.signal === "thread/compact/start" &&
record.pending === true
);
}
function resolveManualCompactContextTokenBudget(params: {
cfg: OpenClawConfig;
provider?: string;
@@ -246,6 +233,7 @@ export const handleCompactCommand: CommandHandler = async (params) => {
persistedContextTokens: targetSessionEntry.contextTokens,
});
const result = await runtime.compactEmbeddedAgentSession({
abortSignal: params.opts?.abortSignal,
sessionId,
sessionKey: params.sessionKey,
allowGatewaySubagentBinding: true,
@@ -287,20 +275,17 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
});
const codexNativeCompactionStarted = isCodexNativeCompactionStartedResult(result);
const compactLabel =
result.ok || isCompactionSkipReason(result.reason)
? codexNativeCompactionStarted
? "Codex compaction started"
: result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} ${runtime.formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
? result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)}${runtime.formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
: "Compaction failed";
if (result.ok && result.compacted && !codexNativeCompactionStarted) {
if (result.ok && result.compacted) {
await runtime.incrementCompactionCount({
cfg: params.cfg,
sessionEntry: targetSessionEntry,
@@ -316,7 +301,9 @@ export const handleCompactCommand: CommandHandler = async (params) => {
// Use the post-compaction token count for context summary if available
const tokensAfterCompaction = result.result?.tokensAfter;
const totalTokens =
tokensAfterCompaction ?? runtime.resolveFreshSessionTotalTokens(targetSessionEntry);
result.ok && result.compacted
? tokensAfterCompaction
: runtime.resolveFreshSessionTotalTokens(targetSessionEntry);
const contextSummary = runtime.formatContextUsageShort(
typeof totalTokens === "number" && totalTokens > 0 ? totalTokens : null,
contextTokenBudget ?? null,
+6 -2
View File
@@ -35,7 +35,11 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
if (HANDLERS === null) {
HANDLERS = (await loadCommandHandlersRuntime()).loadCommandHandlers();
}
const resetResult = await maybeHandleResetCommand(params);
const commandParams: HandleCommandsParams = {
...params,
initialSessionEntry: params.sessionEntry ? { ...params.sessionEntry } : undefined,
};
const resetResult = await maybeHandleResetCommand(commandParams);
if (resetResult) {
return normalizeCommandHandlerResult(resetResult);
}
@@ -47,7 +51,7 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
});
for (const handler of HANDLERS) {
const result = await handler(params, allowTextCommands);
const result = await handler(commandParams, allowTextCommands);
if (result) {
return normalizeCommandHandlerResult(result);
}
+4 -1
View File
@@ -175,7 +175,10 @@ export const handleDockCommand: CommandHandler = async (params, allowTextCommand
sessionEntry.lastTo = target.peerId;
sessionEntry.lastAccountId = resolveTargetChannelAccountId(params, targetChannel);
params.sessionEntry = sessionEntry;
const persisted = await persistSessionEntry(params);
const persisted = await persistSessionEntry({
...params,
touchedFields: ["lastChannel", "lastTo", "lastAccountId"],
});
if (!persisted) {
return {
shouldContinue: false,
@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { loadSessionStore, saveSessionStore } from "../../config/sessions.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { persistAbortTargetEntry, persistSessionEntry } from "./commands-session-store.js";
@@ -16,7 +16,7 @@ async function withTempStore<T>(run: (storePath: string) => Promise<T>): Promise
}
describe("commands session store persistence", () => {
it("persists a single command session entry through the accessor", async () => {
it("persists command state without reverting concurrent session management", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const otherKey = "agent:main:other";
@@ -24,39 +24,175 @@ describe("commands session store persistence", () => {
sessionId: "command-session",
updatedAt: 1,
model: "gpt-5.5",
label: "Before rename",
pinnedAt: 100,
};
const otherEntry: SessionEntry = {
sessionId: "other-session",
updatedAt: 2,
};
const concurrentUpdatedAt = 300;
await saveSessionStore(
storePath,
{
[sessionKey]: { ...entry },
[sessionKey]: {
...entry,
updatedAt: concurrentUpdatedAt,
label: "After rename",
pinnedAt: undefined,
},
[otherKey]: { ...otherEntry },
},
{ skipMaintenance: true },
);
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: entry };
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(200).mockReturnValue(400);
try {
await expect(
persistSessionEntry({
sessionEntry: entry,
sessionStore,
sessionKey,
storePath,
}),
).resolves.toBe(true);
} finally {
nowSpy.mockRestore();
}
const persisted = loadSessionStore(storePath, { skipCache: true });
expect(entry.updatedAt).not.toBe(1);
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "command-session",
label: "After rename",
model: "gpt-5.5",
updatedAt: concurrentUpdatedAt,
});
expect(sessionStore[sessionKey]?.pinnedAt).toBeUndefined();
expect(persisted[sessionKey]).toMatchObject({
sessionId: "command-session",
label: "After rename",
model: "gpt-5.5",
updatedAt: concurrentUpdatedAt,
});
expect(persisted[sessionKey]?.pinnedAt).toBeUndefined();
expect(persisted[otherKey]).toStrictEqual(otherEntry);
});
});
it("rejects command persistence after the session rotates", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
queueMode: "collect",
};
const sessionEntry: SessionEntry = {
...initialEntry,
queueMode: "followup",
};
const rotatedEntry: SessionEntry = {
sessionId: "session-2",
updatedAt: 3,
queueMode: "interrupt",
};
await saveSessionStore(storePath, { [sessionKey]: rotatedEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
sessionEntry: entry,
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
}),
).resolves.toBe(true);
).resolves.toBe(false);
const persisted = loadSessionStore(storePath, { skipCache: true });
expect(sessionStore[sessionKey]).toBe(entry);
expect(entry.updatedAt).not.toBe(1);
expect(persisted[sessionKey]).toMatchObject({
sessionId: "command-session",
model: "gpt-5.5",
updatedAt: entry.updatedAt,
expect(sessionStore[sessionKey]).toEqual(rotatedEntry);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(rotatedEntry);
});
});
it("rejects an explicit same-value command after a concurrent change", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
sendPolicy: "deny",
};
const sessionEntry = { ...initialEntry };
const concurrentEntry: SessionEntry = {
...initialEntry,
updatedAt: 2,
sendPolicy: "allow",
};
await saveSessionStore(
storePath,
{ [sessionKey]: concurrentEntry },
{ skipMaintenance: true },
);
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["sendPolicy"],
}),
).resolves.toBe(false);
expect(sessionStore[sessionKey]).toMatchObject({
sessionId: "session-1",
sendPolicy: "allow",
});
expect(persisted[otherKey]).toStrictEqual(otherEntry);
});
});
it("rejects a grouped command before committing any non-conflicting field", async () => {
await withTempStore(async (storePath) => {
const sessionKey = "agent:main:command";
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 1,
groupActivation: "mention",
groupActivationNeedsSystemIntro: true,
};
const sessionEntry: SessionEntry = {
...initialEntry,
groupActivation: "always",
};
const concurrentEntry: SessionEntry = {
...initialEntry,
updatedAt: 2,
groupActivationNeedsSystemIntro: false,
};
await saveSessionStore(
storePath,
{ [sessionKey]: concurrentEntry },
{ skipMaintenance: true },
);
const sessionStore = { [sessionKey]: sessionEntry };
await expect(
persistSessionEntry({
initialSessionEntry: initialEntry,
sessionEntry,
sessionStore,
sessionKey,
storePath,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
}),
).resolves.toBe(false);
expect(sessionStore[sessionKey]).toEqual(concurrentEntry);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(concurrentEntry);
});
});
+34 -12
View File
@@ -1,14 +1,16 @@
// Shared session-store helpers for command handlers that mutate sessions.
import { resolveSessionStoreEntry, type SessionEntry } from "../../config/sessions.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import { sessionSnapshotChangesApplied } from "../../config/sessions/session-snapshot-merge.js";
import { applyAbortCutoffToSessionEntry, type AbortCutoff } from "./abort-cutoff.js";
import type { CommandHandler } from "./commands-types.js";
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
type CommandParams = Parameters<CommandHandler>[0];
type PersistSessionEntryParams = Pick<
CommandParams,
"sessionEntry" | "sessionStore" | "sessionKey" | "storePath"
>;
"sessionEntry" | "initialSessionEntry" | "sessionStore" | "sessionKey" | "storePath"
> & { touchedFields?: ReadonlyArray<keyof SessionEntry> };
/** Resolves a command target entry through canonical and legacy session keys. */
export function resolveCommandSessionEntryForKey(
@@ -33,25 +35,45 @@ export async function persistSessionEntry(params: PersistSessionEntryParams): Pr
return false;
}
const sessionEntry = params.sessionEntry;
const initialEntry = params.initialSessionEntry ?? { ...sessionEntry };
sessionEntry.updatedAt = Date.now();
params.sessionStore[params.sessionKey] = sessionEntry;
if (params.storePath) {
// Slash commands mutate one known session entry; skipping global session
// maintenance avoids scanning the whole sessions directory for simple
// command-only writes.
await patchSessionEntry(
{ storePath: params.storePath, sessionKey: params.sessionKey },
() => sessionEntry,
{
fallbackEntry: sessionEntry,
replaceEntry: true,
skipMaintenance: true,
},
);
const persistence = await persistReplySessionEntry({
storePath: params.storePath,
sessionKey: params.sessionKey,
initialEntry,
entry: sessionEntry,
skipMaintenance: true,
touchedFields: params.touchedFields,
});
if (persistence.status === "lifecycle-invalidated") {
if (persistence.entry) {
params.sessionStore[params.sessionKey] = persistence.entry;
}
return false;
}
params.sessionStore[params.sessionKey] = persistence.entry;
return sessionSnapshotChangesApplied({
initial: initialEntry,
next: sessionEntry,
current: persistence.entry,
touchedFields: params.touchedFields,
});
}
return true;
}
export function sessionEntryPersistenceConflictReply(): CommandHandlerResult {
return {
shouldContinue: false,
reply: { text: "⚠️ Session changed before this setting could be saved. Retry the command." },
};
}
export async function persistAbortTargetEntry(params: {
entry?: SessionEntry;
key?: string;
@@ -3,9 +3,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HandleCommandsParams } from "./commands-types.js";
const persistSessionEntryMock = vi.hoisted(() => vi.fn(async () => true));
const persistenceConflictReply = vi.hoisted(() => ({
shouldContinue: false,
reply: { text: "retry session command" },
}));
vi.mock("./commands-session-store.js", () => ({
persistSessionEntry: persistSessionEntryMock,
sessionEntryPersistenceConflictReply: () => persistenceConflictReply,
}));
function buildActivationParams(
@@ -64,6 +69,7 @@ function buildActivationParams(
describe("handleActivationCommand", () => {
beforeEach(() => {
persistSessionEntryMock.mockClear();
persistSessionEntryMock.mockResolvedValue(true);
});
it("rejects authorized non-owner senders without changing group activation", async () => {
@@ -90,6 +96,17 @@ describe("handleActivationCommand", () => {
});
expect(params.sessionEntry?.groupActivation).toBe("always");
expect(params.sessionEntry?.groupActivationNeedsSystemIntro).toBe(true);
expect(persistSessionEntryMock).toHaveBeenCalledWith(params);
expect(persistSessionEntryMock).toHaveBeenCalledWith({
...params,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
});
});
it("reports a concurrent session change instead of acknowledging persistence", async () => {
const { handleActivationCommand } = await import("./commands-session.js");
const params = buildActivationParams();
persistSessionEntryMock.mockResolvedValueOnce(false);
await expect(handleActivationCommand(params, true)).resolves.toEqual(persistenceConflictReply);
});
});
+51 -7
View File
@@ -48,7 +48,10 @@ import {
import { resolveCommandSurfaceChannel } from "./channel-context.js";
import { rejectNonOwnerCommand, rejectUnauthorizedCommand } from "./command-gates.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler, HandleCommandsParams } from "./commands-types.js";
import { resolveConversationBindingContextFromAcpCommand } from "./conversation-binding-input.js";
@@ -230,7 +233,14 @@ export const handleActivationCommand: CommandHandler = async (params, allowTextC
if (params.sessionEntry && params.sessionStore && params.sessionKey) {
params.sessionEntry.groupActivation = activationCommand.mode;
params.sessionEntry.groupActivationNeedsSystemIntro = true;
await persistSessionEntry(params);
if (
!(await persistSessionEntry({
...params,
touchedFields: ["groupActivation", "groupActivationNeedsSystemIntro"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -268,7 +278,9 @@ export const handleSendPolicyCommand: CommandHandler = async (params, allowTextC
} else {
params.sessionEntry.sendPolicy = sendPolicyCommand.mode;
}
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["sendPolicy"] }))) {
return sessionEntryPersistenceConflictReply();
}
}
const label =
sendPolicyCommand.mode === "inherit"
@@ -357,7 +369,15 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
delete targetSessionEntry.responseUsage;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["responseUsage"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -377,7 +397,15 @@ export const handleUsageCommand: CommandHandler = async (params, allowTextComman
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
targetSessionEntry.responseUsage = next;
params.sessionStore[params.sessionKey] = targetSessionEntry;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["responseUsage"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
@@ -437,7 +465,15 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
if (resetsToDefault) {
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
delete targetSessionEntry.fastMode;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["fastMode"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
shouldContinue: false,
@@ -452,7 +488,15 @@ export const handleFastCommand: CommandHandler = async (params, allowTextCommand
if (targetSessionEntry && params.sessionStore && params.sessionKey) {
targetSessionEntry.fastMode = nextMode;
await persistSessionEntry({ ...params, sessionEntry: targetSessionEntry });
if (
!(await persistSessionEntry({
...params,
sessionEntry: targetSessionEntry,
touchedFields: ["fastMode"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
}
return {
+21 -5
View File
@@ -33,7 +33,10 @@ import {
} from "../../tts/tts.js";
import { isSilentReplyPayloadText } from "../tokens.js";
import type { ReplyPayload } from "../types.js";
import { persistSessionEntry } from "./commands-session-store.js";
import {
persistSessionEntry,
sessionEntryPersistenceConflictReply,
} from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
type ParsedTtsCommand = {
@@ -228,17 +231,23 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
}
if (requested === "on") {
params.sessionEntry.ttsAuto = "always";
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS enabled for this chat." } };
}
if (requested === "off") {
params.sessionEntry.ttsAuto = "off";
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔇 TTS disabled for this chat." } };
}
if (requested === "default" || requested === "inherit" || requested === "clear") {
delete params.sessionEntry.ttsAuto;
await persistSessionEntry(params);
if (!(await persistSessionEntry({ ...params, touchedFields: ["ttsAuto"] }))) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: { text: "🔊 TTS chat override cleared." } };
}
return { shouldContinue: false, reply: ttsUsage() };
@@ -289,7 +298,14 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand
params.sessionEntry.lastTtsReadLatestHash = hash;
params.sessionEntry.lastTtsReadLatestAt = Date.now();
await persistSessionEntry(params);
if (
!(await persistSessionEntry({
...params,
touchedFields: ["lastTtsReadLatestHash", "lastTtsReadLatestAt"],
}))
) {
return sessionEntryPersistenceConflictReply();
}
return { shouldContinue: false, reply: audio.reply };
}
+3 -1
View File
@@ -1,9 +1,9 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
/** Shared command handler context and result contracts. */
import type { BlockReplyChunking } from "../../agents/embedded-agent-block-chunker.js";
import type { ChannelId } from "../../channels/plugins/types.public.js";
import type { SessionEntry, SessionScope } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SkillCommandSpec } from "../../skills/types.js";
import type { MsgContext } from "../templating.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "../thinking.js";
@@ -49,6 +49,8 @@ export type HandleCommandsParams = {
failures: Array<{ gate: string; key: string }>;
};
sessionEntry?: SessionEntry;
/** Snapshot captured before command handlers mutate the active entry. */
initialSessionEntry?: SessionEntry;
previousSessionEntry?: SessionEntry;
sessionStore?: Record<string, SessionEntry>;
sessionKey: string;
@@ -7,7 +7,12 @@ import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.p
export async function applyInlineDirectivesFastLane(
params: ApplyInlineDirectivesFastLaneParams,
): Promise<{ directiveAck?: ReplyPayload; provider: string; model: string }> {
): Promise<{
directiveAck?: ReplyPayload;
provider: string;
model: string;
sessionChangesApplied: boolean;
}> {
const {
directives,
commandAuthorized,
@@ -45,7 +50,7 @@ export async function applyInlineDirectivesFastLane(
isGroup,
})
) {
return { directiveAck: undefined, provider, model };
return { directiveAck: undefined, provider, model, sessionChangesApplied: true };
}
const agentCfg = params.agentCfg;
@@ -63,6 +68,7 @@ export async function applyInlineDirectivesFastLane(
: async () => undefined,
});
const persistenceState = { sessionChangesApplied: true };
const directiveAck = await handleDirectiveOnly({
cfg,
directives,
@@ -97,6 +103,7 @@ export async function applyInlineDirectivesFastLane(
commandAuthorized,
senderIsOwner: params.senderIsOwner,
workspaceDir: params.workspaceDir,
persistenceState,
});
if (sessionEntry?.providerOverride) {
@@ -106,5 +113,5 @@ export async function applyInlineDirectivesFastLane(
model = sessionEntry.modelOverride;
}
return { directiveAck, provider, model };
return { directiveAck, provider, model, ...persistenceState };
}
@@ -10,7 +10,11 @@ import {
resolveFastModeState,
} from "../../agents/fast-mode.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import {
adoptPersistedSessionSnapshot,
sessionModelOverrideChangesApplied,
sessionSnapshotChangesApplied,
} from "../../config/sessions/session-snapshot-merge.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js";
@@ -34,11 +38,13 @@ import {
formatInternalVerboseCurrentReplyOnlyText,
formatInternalVerbosePersistenceDeniedText,
enqueueModeSwitchEvents,
resolveDirectiveTouchedSessionFields,
withOptions,
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js";
import { refreshQueuedFollowupSession } from "./queue.js";
import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
/** Handles inline directives that can be acknowledged without a model turn. */
export async function handleDirectiveOnly(
@@ -372,6 +378,17 @@ export async function handleDirectiveOnly(
elevatedEnabled &&
elevatedAllowed;
let modelSelectionUpdated = false;
let modelSelectionApplied = true;
let sessionChangesApplied = true;
let appliedSessionEntry = sessionEntry;
const touchedSessionFields = resolveDirectiveTouchedSessionFields({
directives,
allowInternalExecPersistence,
allowInternalVerbosePersistence,
});
if (shouldRemapUnsupportedThinkLevel && !touchedSessionFields.includes("thinkingLevel")) {
touchedSessionFields.push("thinkingLevel");
}
const shouldPersistSessionEntry =
(directives.hasThinkDirective &&
(Boolean(directives.thinkLevel) || directives.clearThinkLevel)) ||
@@ -395,6 +412,7 @@ export async function handleDirectiveOnly(
let reasoningChanged =
directives.hasReasoningDirective && directives.reasoningLevel !== undefined;
if (shouldPersistSessionEntry) {
const initialSessionEntry = { ...sessionEntry };
if (directives.clearThinkLevel) {
delete sessionEntry.thinkingLevel;
} else if (
@@ -485,12 +503,64 @@ export async function handleDirectiveOnly(
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await replaceSessionEntry({ storePath, sessionKey }, sessionEntry);
const persistence = await persistReplySessionEntry({
storePath,
sessionKey,
initialEntry: initialSessionEntry,
entry: sessionEntry,
reassertLiveModelSwitchPending:
modelSelectionUpdated && sessionEntry.liveModelSwitchPending === true,
touchedFields: touchedSessionFields,
});
if (persistence.status === "current") {
const persistedEntry = persistence.entry;
sessionStore[sessionKey] = persistedEntry;
sessionChangesApplied = sessionSnapshotChangesApplied({
initial: initialSessionEntry,
next: sessionEntry,
current: persistedEntry,
touchedFields: touchedSessionFields,
});
if (modelSelection) {
modelSelectionApplied =
sessionChangesApplied &&
sessionModelOverrideChangesApplied({
initial: initialSessionEntry,
next: sessionEntry,
current: persistedEntry,
reassertLiveModelSwitchPending:
modelSelectionUpdated && sessionEntry.liveModelSwitchPending === true,
});
}
adoptPersistedSessionSnapshot(sessionEntry, persistedEntry);
appliedSessionEntry = sessionEntry;
} else {
if (persistence.entry) {
sessionStore[sessionKey] = persistence.entry;
}
sessionChangesApplied = false;
if (modelSelection) {
modelSelectionApplied = false;
}
}
}
if (modelSelection && modelSelectionUpdated && sessionKey) {
if (modelSelection && !modelSelectionApplied) {
sessionChangesApplied = false;
}
if (!sessionChangesApplied) {
if (params.persistenceState) {
params.persistenceState.sessionChangesApplied = false;
}
return {
text: modelSelection
? "Model change was not applied because the session changed. Retry."
: "Session settings were not applied because the session changed. Retry.",
};
}
if (modelSelection && modelSelectionUpdated && modelSelectionApplied && sessionKey) {
triggerSessionPatchHook({
cfg: params.cfg,
sessionEntry,
sessionEntry: appliedSessionEntry,
sessionKey,
patch: {
key: sessionKey,
@@ -511,7 +581,7 @@ export async function handleDirectiveOnly(
});
}
}
if (modelSelection) {
if (modelSelection && modelSelectionApplied) {
const nextLabel = `${modelSelection.provider}/${modelSelection.model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), {
@@ -522,7 +592,7 @@ export async function handleDirectiveOnly(
}
enqueueModeSwitchEvents({
enqueueSystemEvent,
sessionEntry,
sessionEntry: appliedSessionEntry,
sessionKey,
elevatedChanged,
reasoningChanged,
@@ -637,7 +707,7 @@ export async function handleDirectiveOnly(
`Thinking level set to ${remappedUnsupportedThinkLevel} (${nextThinkLevel} not supported for ${resolvedProvider}/${resolvedModel}).`,
);
}
if (modelSelection) {
if (modelSelection && modelSelectionApplied) {
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
parts.push(
@@ -648,6 +718,8 @@ export async function handleDirectiveOnly(
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
} else if (modelSelection) {
parts.push("Model change was not applied because the session changed. Retry.");
}
if (directives.hasQueueDirective && directives.queueMode) {
parts.push(formatDirectiveAck(`Queue mode set to ${directives.queueMode}.`));
@@ -281,6 +281,7 @@ import {
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ModelDefinitionConfig, OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js";
import {
clearInternalHooks,
registerInternalHook,
@@ -1449,7 +1450,6 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
];
const sessionKey = "agent:main:dm:1";
const storePath = "/tmp/sessions.json";
type HandleParams = Parameters<typeof handleDirectiveOnly>[0];
@@ -1464,7 +1464,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
cfg: baseConfig(),
directives: rest.directives ?? parseInlineDirectives(""),
sessionKey,
storePath,
storePath: undefined,
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
@@ -1611,6 +1611,164 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
});
});
it("suppresses model side effects when a concurrent switch wins", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-directive-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionEntry = createSessionEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelOverrideSource: "user",
});
const concurrentEntry: SessionEntry = {
...sessionEntry,
updatedAt: sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-5.5",
};
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
const persistenceState = { sessionChangesApplied: true };
try {
const result = await handleDirectiveOnly(
createHandleParams({
directives: parseInlineDirectives("/model openai/gpt-4o"),
sessionEntry,
sessionStore,
storePath,
persistenceState,
}),
);
expect(result?.text).toContain("Model change was not applied");
expect(persistenceState.sessionChangesApplied).toBe(false);
expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled();
expect(enqueueSystemEvent).not.toHaveBeenCalledWith(
expect.stringContaining("openai/gpt-4o"),
expect.anything(),
);
expect(sessionStore[sessionKey]).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-5.5",
});
expect(sessionStore[sessionKey]?.liveModelSwitchPending).toBeUndefined();
expect(sessionEntry).toEqual(sessionStore[sessionKey]);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(
sessionStore[sessionKey],
);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("reports a rejected non-model directive after session rotation", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-elevated-directive-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionEntry = createSessionEntry({ elevatedLevel: "full" });
const rotatedEntry: SessionEntry = {
sessionId: "s2",
updatedAt: sessionEntry.updatedAt + 1,
elevatedLevel: "full",
};
await saveSessionStore(storePath, { [sessionKey]: rotatedEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
try {
const result = await handleDirectiveOnly(
createHandleParams({
directives: parseInlineDirectives("/elevated off"),
sessionEntry,
sessionStore,
storePath,
elevatedEnabled: true,
elevatedAllowed: true,
currentElevatedLevel: "full",
}),
);
expect(result?.text).toContain("Session settings were not applied");
expect(result?.text).not.toContain("Elevated mode disabled");
expect(enqueueSystemEvent).not.toHaveBeenCalledWith(
expect.stringContaining("Elevated"),
expect.anything(),
);
expect(sessionStore[sessionKey]).toEqual(rotatedEntry);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects an explicit same-value directive after a concurrent change", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-elevated-directive-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionEntry = createSessionEntry({ elevatedLevel: "off" });
const concurrentEntry: SessionEntry = {
...sessionEntry,
updatedAt: sessionEntry.updatedAt + 1,
elevatedLevel: "full",
};
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
try {
const result = await handleDirectiveOnly(
createHandleParams({
directives: parseInlineDirectives("/elevated off"),
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
storePath,
elevatedEnabled: true,
elevatedAllowed: true,
currentElevatedLevel: "off",
}),
);
expect(result?.text).toContain("Session settings were not applied");
expect(sessionEntry).toMatchObject({ sessionId: "s1", elevatedLevel: "full" });
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects a grouped directive when its implicit thinking remap conflicts", async () => {
setDirectiveTestProviders([
{
id: "anthropic",
label: "Anthropic",
auth: [],
resolveThinkingProfile: () => ({
levels: [{ id: "off" }, { id: "low" }, { id: "high" }],
}),
},
]);
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thinking-remap-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionEntry = createSessionEntry({ thinkingLevel: "xhigh" });
const concurrentEntry: SessionEntry = {
...sessionEntry,
updatedAt: sessionEntry.updatedAt + 1,
thinkingLevel: "low",
};
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
try {
const result = await handleDirectiveOnly(
createHandleParams({
directives: parseInlineDirectives("/fast on"),
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
storePath,
}),
);
expect(result?.text).toContain("Session settings were not applied");
expect(sessionEntry).toMatchObject({ thinkingLevel: "low" });
expect(sessionEntry.fastMode).toBeUndefined();
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(concurrentEntry);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("persists auth profile overrides for alias model directives", async () => {
setAuthProfiles({
"anthropic:work": {
@@ -2079,6 +2237,125 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
});
describe("persistInlineDirectives session directive persistence policy", () => {
it("checks an explicit same-value model selection against persisted state", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-inline-model-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionKey = "agent:main:dm:same-model";
const sessionEntry = createSessionEntry({
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
});
const concurrentEntry: SessionEntry = {
...sessionEntry,
updatedAt: sessionEntry.updatedAt + 1,
modelOverride: "gpt-5.5",
};
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
const directives = parseInlineDirectives("hello /model openai/gpt-4o");
try {
const result = await persistInlineDirectives({
directives,
effectiveModelDirective: directives.rawModelDirective,
cfg: baseConfig(),
agentDir: TEST_AGENT_DIR,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
sessionKey,
storePath,
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-6",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["openai/gpt-4o"]),
modelCatalog: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }],
provider: "openai",
model: "gpt-4o",
initialModelLabel: "openai/gpt-4o",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
agentCfg: undefined,
});
expect(result.sessionChangesApplied).toBe(false);
expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" });
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-5.5",
});
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("returns the concurrent model winner without emitting switch side effects", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-inline-model-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const sessionKey = "agent:main:dm:race";
const sessionEntry = createSessionEntry({
providerOverride: "anthropic",
modelOverride: "claude-opus-4-6",
modelOverrideSource: "user",
});
const concurrentEntry: SessionEntry = {
...sessionEntry,
updatedAt: sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-5.5",
};
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
const directives = parseInlineDirectives("hello /model openai/gpt-4o");
const patchEvents: InternalHookEvent[] = [];
registerInternalHook("session:patch", async (event) => {
patchEvents.push(event);
});
try {
const result = await persistInlineDirectives({
directives,
effectiveModelDirective: directives.rawModelDirective,
cfg: baseConfig(),
agentDir: TEST_AGENT_DIR,
sessionEntry,
sessionStore,
sessionKey,
storePath,
elevatedEnabled: false,
elevatedAllowed: false,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-6",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["anthropic/claude-opus-4-6", "openai/gpt-4o"]),
modelCatalog: [
{ provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" },
{ provider: "openai", id: "gpt-4o", name: "GPT-4o" },
],
provider: "anthropic",
model: "claude-opus-4-6",
initialModelLabel: "anthropic/claude-opus-4-6",
formatModelSwitchEvent: (label) => `Switched to ${label}`,
agentCfg: undefined,
});
expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" });
expect(result.sessionChangesApplied).toBe(false);
expect(enqueueSystemEvent).not.toHaveBeenCalledWith(
expect.stringContaining("openai/gpt-4o"),
expect.anything(),
);
expect(patchEvents).toEqual([]);
expect(sessionStore[sessionKey]).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-5.5",
});
expect(sessionEntry).toEqual(sessionStore[sessionKey]);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("skips exec persistence for internal operator.write callers", async () => {
const sessionEntry = await persistInternalOperatorWriteDirective(
"/exec host=node security=allowlist ask=always node=worker-1",
@@ -1,9 +1,9 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
/** Parameter contracts shared by directive-only and fast-lane directive handlers. */
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { MsgContext } from "../templating.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./directives.js";
@@ -49,6 +49,8 @@ export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & {
gatewayClientScopes?: string[];
commandAuthorized?: boolean;
senderIsOwner?: boolean;
/** Internal handoff for mixed inline directives to avoid retrying rejected writes. */
persistenceState?: { sessionChangesApplied: boolean };
};
/** Inputs for applying inline directives before the full reply run is prepared. */
@@ -13,7 +13,11 @@ import {
type ModelAliasIndex,
} from "../../agents/model-selection.js";
import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js";
import { replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import {
adoptPersistedSessionSnapshot,
sessionModelOverrideChangesApplied,
sessionSnapshotChangesApplied,
} from "../../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
@@ -26,9 +30,11 @@ import type { InlineDirectives } from "./directive-handling.parse.js";
import {
canPersistSessionDirectiveDefaults,
enqueueModeSwitchEvents,
resolveDirectiveTouchedSessionFields,
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js";
import { resolveContextTokens } from "./model-selection.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
export type PersistedThinkingLevelRemap = {
from: ThinkLevel;
@@ -109,6 +115,7 @@ export async function persistInlineDirectives(params: {
provider: string;
model: string;
contextTokens: number;
sessionChangesApplied: boolean;
thinkingRemap?: PersistedThinkingLevelRemap;
}> {
const {
@@ -130,6 +137,7 @@ export async function persistInlineDirectives(params: {
} = params;
let { provider, model } = params;
let thinkingRemap: PersistedThinkingLevelRemap | undefined;
let sessionChangesApplied = true;
const allowInternalExecPersistence = canPersistSessionDirectiveDefaults({
messageProvider: params.messageProvider,
surface: params.surface,
@@ -144,6 +152,11 @@ export async function persistInlineDirectives(params: {
commandAuthorized: params.commandAuthorized,
senderIsOwner: params.senderIsOwner,
});
const touchedSessionFields = resolveDirectiveTouchedSessionFields({
directives,
allowInternalExecPersistence,
allowInternalVerbosePersistence,
});
const thinkingCatalog =
params.thinkingCatalog && params.thinkingCatalog.length > 0
? params.thinkingCatalog
@@ -155,6 +168,8 @@ export async function persistInlineDirectives(params: {
const agentDir = resolveAgentDir(cfg, activeAgentId) ?? params.agentDir;
if (sessionEntry && sessionStore && sessionKey) {
const initialSessionEntry = { ...sessionEntry };
let appliedSessionEntry = sessionEntry;
const prevElevatedLevel =
(sessionEntry.elevatedLevel as ElevatedLevel | undefined) ??
(agentCfg?.elevatedDefault as ElevatedLevel | undefined) ??
@@ -250,6 +265,9 @@ export async function persistInlineDirectives(params: {
? params.effectiveModelDirective
: undefined;
let modelUpdated = false;
let modelApplied = true;
let modelRuntimeEvent: { contextKey: string; text: string } | undefined;
let modelSwitchEvent: { alias?: string; label: string } | undefined;
if (modelDirective) {
const modelResolution = resolveModelSelectionFromDirective({
directives: {
@@ -281,32 +299,23 @@ export async function persistInlineDirectives(params: {
if (runtimeOverride?.kind === "clear") {
if (sessionEntry.agentRuntimeOverride) {
delete sessionEntry.agentRuntimeOverride;
updated = true;
}
} else if (runtimeOverride?.kind === "set") {
if (sessionEntry.agentRuntimeOverride) {
delete sessionEntry.agentRuntimeOverride;
updated = true;
}
enqueueSystemEvent(
`Ignored session runtime ${runtimeOverride.runtime}; configure provider or model runtime policy instead.`,
{
sessionKey,
contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}:ignored-session-runtime`,
},
);
modelRuntimeEvent = {
text: `Ignored session runtime ${runtimeOverride.runtime}; configure provider or model runtime policy instead.`,
contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}:ignored-session-runtime`,
};
} else if (runtimeOverride?.kind === "invalid") {
if (sessionEntry.agentRuntimeOverride) {
delete sessionEntry.agentRuntimeOverride;
updated = true;
}
enqueueSystemEvent(
`Ignored unsupported runtime ${runtimeOverride.runtime} for ${modelResolution.modelSelection.provider}.`,
{
sessionKey,
contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}`,
},
);
modelRuntimeEvent = {
text: `Ignored unsupported runtime ${runtimeOverride.runtime} for ${modelResolution.modelSelection.provider}.`,
contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}`,
};
}
modelUpdated = appliedModelOverride.updated;
provider = modelResolution.modelSelection.provider;
@@ -336,20 +345,20 @@ export async function persistInlineDirectives(params: {
provider,
model,
};
updated = true;
}
}
const nextLabel = `${provider}/${model}`;
if (nextLabel !== initialModelLabel) {
enqueueSystemEvent(
formatModelSwitchEvent(nextLabel, modelResolution.modelSelection.alias),
{
sessionKey,
contextKey: `model:${nextLabel}`,
},
);
modelSwitchEvent = {
label: nextLabel,
...(modelResolution.modelSelection.alias
? { alias: modelResolution.modelSelection.alias }
: {}),
};
}
updated = updated || modelUpdated;
// Explicit model selections must still perform the atomic persisted
// winner check when their value matches the local snapshot.
updated = true;
}
}
if (directives.hasQueueDirective && directives.queueReset) {
@@ -364,22 +373,86 @@ export async function persistInlineDirectives(params: {
sessionEntry.updatedAt = Date.now();
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
await replaceSessionEntry({ storePath, sessionKey }, sessionEntry);
const persistence = await persistReplySessionEntry({
storePath,
sessionKey,
initialEntry: initialSessionEntry,
entry: sessionEntry,
reassertLiveModelSwitchPending:
modelUpdated &&
params.markLiveSwitchPending === true &&
sessionEntry.liveModelSwitchPending === true,
touchedFields: touchedSessionFields,
});
if (persistence.status === "current") {
const persistedEntry = persistence.entry;
sessionStore[sessionKey] = persistedEntry;
sessionChangesApplied = sessionSnapshotChangesApplied({
initial: initialSessionEntry,
next: sessionEntry,
current: persistedEntry,
touchedFields: touchedSessionFields,
});
if (modelDirective) {
modelApplied =
sessionChangesApplied &&
sessionModelOverrideChangesApplied({
initial: initialSessionEntry,
next: sessionEntry,
current: persistedEntry,
reassertLiveModelSwitchPending:
modelUpdated &&
params.markLiveSwitchPending === true &&
sessionEntry.liveModelSwitchPending === true,
});
}
adoptPersistedSessionSnapshot(sessionEntry, persistedEntry);
appliedSessionEntry = sessionEntry;
} else {
if (persistence.entry) {
sessionStore[sessionKey] = persistence.entry;
}
sessionChangesApplied = false;
if (modelDirective) {
modelApplied = false;
}
}
}
if (modelDirective && modelUpdated) {
if (modelDirective && !modelApplied) {
sessionChangesApplied = false;
const persistedEntry = sessionStore[sessionKey];
provider = persistedEntry?.providerOverride?.trim() || defaultProvider;
model = persistedEntry?.modelOverride?.trim() || defaultModel;
thinkingRemap = undefined;
}
if (modelDirective && modelUpdated && modelApplied) {
triggerSessionPatchHook({
cfg,
sessionEntry,
sessionEntry: appliedSessionEntry,
sessionKey,
patch: { key: sessionKey, model: modelDirective },
});
}
enqueueModeSwitchEvents({
enqueueSystemEvent,
sessionEntry,
if (sessionChangesApplied) {
enqueueModeSwitchEvents({
enqueueSystemEvent,
sessionEntry: appliedSessionEntry,
sessionKey,
elevatedChanged,
reasoningChanged,
});
}
}
if (modelRuntimeEvent && modelApplied) {
enqueueSystemEvent(modelRuntimeEvent.text, {
sessionKey,
elevatedChanged,
reasoningChanged,
contextKey: modelRuntimeEvent.contextKey,
});
}
if (modelSwitchEvent && modelApplied) {
enqueueSystemEvent(formatModelSwitchEvent(modelSwitchEvent.label, modelSwitchEvent.alias), {
sessionKey,
contextKey: `model:${modelSwitchEvent.label}`,
});
}
}
@@ -391,6 +464,7 @@ export async function persistInlineDirectives(params: {
provider,
model,
thinkingRemap,
sessionChangesApplied,
contextTokens: resolveContextTokens({
cfg,
agentCfg,
@@ -1,8 +1,11 @@
// Shared directive parsing helpers used by model and auth directive handlers.
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { formatCliCommand } from "../../cli/command-format.js";
import { SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS } from "../../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { SYSTEM_MARK, prefixSystemMessage } from "../../infra/system-message.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import type { ElevatedLevel, ReasoningLevel } from "./directives.js";
export const formatDirectiveAck = (text: string): string => {
@@ -47,6 +50,68 @@ export function canPersistSessionDirectiveDefaults(params: {
return params.commandAuthorized === true || params.senderIsOwner === true;
}
/** Names explicit directive writes that snapshot equality cannot infer. */
export function resolveDirectiveTouchedSessionFields(params: {
directives: InlineDirectives;
allowInternalExecPersistence: boolean;
allowInternalVerbosePersistence: boolean;
}): Array<keyof SessionEntry> {
const { directives } = params;
const fields = new Set<keyof SessionEntry>();
if (directives.hasThinkDirective) {
fields.add("thinkingLevel");
}
if (directives.hasFastDirective) {
fields.add("fastMode");
}
if (directives.hasVerboseDirective && params.allowInternalVerbosePersistence) {
fields.add("verboseLevel");
}
if (directives.hasTraceDirective) {
fields.add("traceLevel");
}
if (directives.hasReasoningDirective) {
fields.add("reasoningLevel");
}
if (directives.hasElevatedDirective) {
fields.add("elevatedLevel");
}
if (directives.hasModelDirective) {
for (const field of SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS) {
fields.add(field);
}
}
if (directives.hasExecDirective && params.allowInternalExecPersistence) {
if (directives.execHost) {
fields.add("execHost");
}
if (directives.execSecurity) {
fields.add("execSecurity");
}
if (directives.execAsk) {
fields.add("execAsk");
}
if (directives.execNode) {
fields.add("execNode");
}
}
if (directives.hasQueueDirective) {
if (directives.queueReset || directives.queueMode) {
fields.add("queueMode");
}
if (directives.queueReset || typeof directives.debounceMs === "number") {
fields.add("queueDebounceMs");
}
if (directives.queueReset || typeof directives.cap === "number") {
fields.add("queueCap");
}
if (directives.queueReset || directives.dropPolicy) {
fields.add("queueDrop");
}
}
return [...fields];
}
const formatElevatedEvent = (level: ElevatedLevel) => {
if (level === "full") {
return "Elevated FULL - exec runs on host with auto-approval.";
@@ -196,6 +196,9 @@ describe("dispatchReplyFromConfig ACP abort", () => {
internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload);
internalHookMocks.triggerInternalHook.mockReset();
sessionStoreMocks.currentEntry = undefined;
sessionStoreMocks.loadSessionEntry
.mockReset()
.mockImplementation(() => sessionStoreMocks.currentEntry);
sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({});
sessionStoreMocks.readSessionEntry.mockReset().mockReturnValue(undefined);
sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json");
@@ -514,6 +517,7 @@ describe("dispatchReplyFromConfig ACP abort", () => {
},
};
sessionBindingMocks.resolveByConversation.mockReturnValue(boundConversation);
sessionStoreMocks.currentEntry = sessionStore[sourceSessionKey];
sessionStoreMocks.loadSessionStore.mockReturnValue(sessionStore);
sessionStoreMocks.resolveSessionStoreEntry.mockImplementation((...args: unknown[]) => {
const params = args[0] as { store?: Record<string, unknown>; sessionKey?: string };
@@ -883,8 +887,12 @@ describe("dispatchReplyFromConfig ACP abort", () => {
});
await hookStartedPromise;
expect(hookAbortSignal).toBe(existingOperation.abortSignal);
// The hook signal composes the operation signal with lifecycle/upstream
// signals, so assert propagation instead of instance identity.
expect(hookAbortSignal?.aborted).toBe(false);
expect(replyRunRegistry.abort("agent:already-active-reply-dispatch")).toBe(true);
expect(existingOperation.abortSignal.aborted).toBe(true);
expect(hookAbortSignal?.aborted).toBe(true);
await expect(dispatchPromise).resolves.toMatchObject({
queuedFinal: false,
@@ -104,6 +104,7 @@ const pluginConversationBindingMocks = vi.hoisted(() => ({
}));
const sessionStoreMocks = vi.hoisted(() => ({
currentEntry: undefined as Record<string, unknown> | undefined,
loadSessionEntry: vi.fn((..._args: unknown[]) => sessionStoreMocks.currentEntry),
loadSessionStore: vi.fn(() => ({})),
readSessionEntry: vi.fn(() => sessionStoreMocks.currentEntry),
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
@@ -233,6 +234,13 @@ vi.mock("../../config/sessions/thread-info.js", () => ({
parseSessionThreadInfoFast: (sessionKey: string | undefined) =>
threadInfoMocks.parseSessionThreadInfo(sessionKey),
}));
vi.mock("../../config/sessions/session-accessor.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../config/sessions/session-accessor.js")>();
return {
...actual,
loadSessionEntry: (...args: unknown[]) => sessionStoreMocks.loadSessionEntry(...args),
};
});
vi.mock("./dispatch-from-config.runtime.js", () => ({
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
loadSessionStore: sessionStoreMocks.loadSessionStore,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -1,4 +1,5 @@
// Tests follow-up runner delivery, transcript persistence, and no-reply contracts.
import fsSync from "node:fs";
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -53,6 +54,7 @@ const FOLLOWUP_TEST_QUEUES = new Map<
}
>();
const FOLLOWUP_TEST_SESSION_STORES = new Map<string, Record<string, SessionEntry>>();
const FOLLOWUP_TEST_SESSION_STORE_PATHS = new Set<string>();
function debugFollowupTest(message: string): void {
if (!FOLLOWUP_DEBUG) {
@@ -143,7 +145,10 @@ function registerFollowupTestSessionStore(
storePath: string,
sessionStore: Record<string, SessionEntry>,
): void {
fsSync.mkdirSync(path.dirname(storePath), { recursive: true });
fsSync.writeFileSync(storePath, JSON.stringify(sessionStore));
FOLLOWUP_TEST_SESSION_STORES.set(storePath, sessionStore);
FOLLOWUP_TEST_SESSION_STORE_PATHS.add(storePath);
}
async function incrementRunCompactionCountForFollowupTest(
@@ -381,8 +386,8 @@ async function loadFreshFollowupRunnerModuleForTest() {
completeFollowupRunLifecycle: (run: Pick<FollowupRun, "queuedLifecycle">) =>
run.queuedLifecycle?.onComplete?.(),
enqueueFollowupRun: enqueueFollowupRunForFollowupTest,
isFollowupRunAborted: (run: Pick<FollowupRun, "abortSignal">) =>
run.abortSignal?.aborted === true,
isFollowupRunAborted: (run: Pick<FollowupRun, "abortSignal" | "queueAbortSignal">) =>
run.abortSignal?.aborted === true || run.queueAbortSignal?.aborted === true,
refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest,
}));
vi.doMock("./session-run-accounting.js", () => ({
@@ -579,6 +584,10 @@ afterEach(() => {
clearFollowupQueue("main");
FOLLOWUP_TEST_QUEUES.clear();
FOLLOWUP_TEST_SESSION_STORES.clear();
for (const storePath of FOLLOWUP_TEST_SESSION_STORE_PATHS) {
fsSync.rmSync(storePath, { force: true });
}
FOLLOWUP_TEST_SESSION_STORE_PATHS.clear();
vi.clearAllTimers();
vi.useRealTimers();
clearSessionStoreCacheForTest();
@@ -4115,6 +4124,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
const sessionKey = "main";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
registerFollowupTestSessionStore(storePath, sessionStore);
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
persistSpy.mockImplementationOnce(async (params) => {
const nextEntry: SessionEntry = {
@@ -4172,6 +4182,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
const sessionKey = "main";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
registerFollowupTestSessionStore(storePath, sessionStore);
const cfg = {
messages: {
@@ -4354,6 +4365,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
const sessionKey = "main";
const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() };
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
registerFollowupTestSessionStore(storePath, sessionStore);
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "hello world!" }],
@@ -4419,7 +4431,7 @@ describe("createFollowupRunner messaging delivery and dedupe", () => {
totalTokensFresh: true,
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
FOLLOWUP_TEST_SESSION_STORES.set(storePath, sessionStore);
registerFollowupTestSessionStore(storePath, sessionStore);
const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage");
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "internal announce complete" }],
+12 -1
View File
@@ -99,6 +99,15 @@ import type { TypingController } from "./typing.js";
type EmbeddedAgentRunResult = Awaited<ReturnType<typeof runEmbeddedAgent>>;
function resolveFollowupAbortSignal(
run: Pick<FollowupRun, "abortSignal" | "queueAbortSignal">,
): AbortSignal | undefined {
const signals = [run.abortSignal, run.queueAbortSignal].filter(
(signal): signal is AbortSignal => signal !== undefined,
);
return signals.length > 1 ? AbortSignal.any(signals) : signals[0];
}
type FollowupAgentEvent = { stream: string; data: Record<string, unknown> };
function readApprovalScopeValue(value: unknown): "turn" | "session" | undefined {
@@ -602,10 +611,12 @@ export function createFollowupRunner(params: {
const admission = await admitReplyTurn({
sessionId: effectiveQueued.admissionSessionId ?? run.sessionId,
sessionKey: replySessionKey ?? "",
expectedSessionId: activeSessionEntry?.sessionId,
storePath,
kind: "queued_followup",
resetTriggered: false,
routeThreadId: queued.originatingThreadId,
upstreamAbortSignal: queued.abortSignal,
upstreamAbortSignal: resolveFollowupAbortSignal(queued),
});
if (admission.status === "skipped") {
if (admission.reason === "active-run") {
@@ -1,6 +1,30 @@
// Tests applying parsed directives to get-reply execution options.
import { describe, expect, it } from "vitest";
import { formatModelOverrideResetEvent } from "./get-reply-directives-apply.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { parseInlineDirectives } from "./directive-handling.parse.js";
import {
applyInlineDirectiveOverrides,
formatModelOverrideResetEvent,
} from "./get-reply-directives-apply.js";
import { createFastTestModelSelectionState } from "./model-selection.js";
import { buildTestCtx } from "./test-ctx.js";
const mocks = vi.hoisted(() => ({
fastLane: vi.fn(),
persist: vi.fn(),
}));
vi.mock("./directive-handling.fast-lane.js", () => ({
applyInlineDirectivesFastLane: (...args: unknown[]) => mocks.fastLane(...args),
}));
vi.mock("./directive-handling.persist.runtime.js", () => ({
persistInlineDirectives: (...args: unknown[]) => mocks.persist(...args),
}));
beforeEach(() => {
mocks.fastLane.mockReset();
mocks.persist.mockReset();
});
describe("formatModelOverrideResetEvent", () => {
it("names the rejected model override and allowlist recovery path", () => {
@@ -34,3 +58,83 @@ describe("formatModelOverrideResetEvent", () => {
);
});
});
describe("applyInlineDirectiveOverrides", () => {
it("stops a mixed inline turn when final directive persistence loses", async () => {
const directives = parseInlineDirectives("hello /elevated full");
mocks.fastLane.mockResolvedValue({
directiveAck: { text: "Elevated FULL enabled." },
provider: "openai",
model: "gpt-5.5",
sessionChangesApplied: true,
});
mocks.persist.mockResolvedValue({
provider: "openai",
model: "gpt-5.5",
contextTokens: 8192,
sessionChangesApplied: false,
});
const typing = {
onReplyStart: async () => {},
startTypingLoop: async () => {},
startTypingOnText: async () => {},
refreshTypingTtl: () => {},
isActive: () => false,
markRunComplete: () => {},
markDispatchIdle: () => {},
cleanup: vi.fn(),
};
const sessionEntry = { sessionId: "session-1", updatedAt: 1 };
const result = await applyInlineDirectiveOverrides({
ctx: buildTestCtx({ Body: "hello /elevated full", CommandAuthorized: true }),
cfg: {},
agentId: "main",
agentDir: "/tmp/agent",
workspaceDir: "/tmp/workspace",
agentCfg: {},
sessionEntry,
sessionStore: { "agent:main:main": sessionEntry },
sessionKey: "agent:main:main",
sessionScope: undefined,
isGroup: false,
allowTextCommands: true,
command: {
surface: "webchat",
channel: "webchat",
ownerList: [],
senderIsOwner: true,
isAuthorizedSender: true,
rawBodyNormalized: "hello /elevated full",
commandBodyNormalized: "hello /elevated full",
},
directives,
messageProviderKey: "webchat",
elevatedEnabled: true,
elevatedAllowed: true,
elevatedFailures: [],
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byAlias: new Map(), byKey: new Map() },
provider: "openai",
model: "gpt-5.5",
modelState: createFastTestModelSelectionState({
agentCfg: {},
provider: "openai",
model: "gpt-5.5",
}),
initialModelLabel: "openai/gpt-5.5",
formatModelSwitchEvent: (label) => label,
resolvedElevatedLevel: "full",
defaultActivation: () => "always",
contextTokens: 8192,
typing,
});
expect(result).toEqual({
kind: "reply",
reply: { text: "Session settings were not applied because the session changed. Retry." },
});
expect(typing.cleanup).toHaveBeenCalledOnce();
});
});
@@ -306,6 +306,13 @@ export async function applyInlineDirectiveOverrides(params: {
model,
markLiveSwitchPending: true,
});
if (!persisted.sessionChangesApplied) {
typing.cleanup();
return {
kind: "reply",
reply: { text: "Model change was not applied because the session changed. Retry." },
};
}
const label = `${modelSelection.provider}/${modelSelection.model}`;
const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label;
const parts = [
@@ -431,6 +438,17 @@ export async function applyInlineDirectiveOverrides(params: {
directiveAck = fastLane.directiveAck;
provider = fastLane.provider;
model = fastLane.model;
if (!fastLane.sessionChangesApplied) {
typing.cleanup();
return {
kind: "reply",
reply:
directiveAck ??
({
text: "Session settings were not applied because the session changed. Retry.",
} satisfies ReplyPayload),
};
}
}
const persisted = await (
@@ -443,6 +461,15 @@ export async function applyInlineDirectiveOverrides(params: {
provider = persisted.provider;
model = persisted.model;
contextTokens = persisted.contextTokens;
if (!persisted.sessionChangesApplied) {
typing.cleanup();
return {
kind: "reply",
reply: {
text: "Session settings were not applied because the session changed. Retry.",
},
};
}
const perMessageQueueMode =
directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined;
@@ -1,6 +1,7 @@
/** Tests directive handling for target-session command turns. */
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import { SessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import type { TemplateContext } from "../templating.js";
import { resolveReplyDirectives } from "./get-reply-directives.js";
import { buildTestCtx } from "./test-ctx.js";
@@ -156,20 +157,26 @@ async function resolveHelloWithModelDefaults(params: {
model?: string;
ctx?: Parameters<typeof buildTestCtx>[0];
opts?: Parameters<typeof resolveReplyDirectives>[0]["opts"];
modelError?: unknown;
}) {
const resolveDefaultThinkingLevel = vi.fn(async () => params.defaultThinking);
const resolveDefaultReasoningLevel = vi.fn(async () => params.defaultReasoning);
mocks.listAgentEntries.mockReturnValue(params.agentEntries ?? []);
mocks.createModelSelectionState.mockResolvedValueOnce({
provider: params.selectedProvider ?? "openai",
model: params.selectedModel ?? "gpt-4o-mini",
allowedModelKeys: new Set<string>(),
allowedModelCatalog: [],
resetModelOverride: false,
resolveDefaultThinkingLevel,
hasConfiguredThinkingDefault: params.hasConfiguredThinkingDefault,
resolveDefaultReasoningLevel,
});
if (params.modelError) {
mocks.createModelSelectionState.mockRejectedValueOnce(params.modelError);
} else {
mocks.createModelSelectionState.mockResolvedValueOnce({
provider: params.selectedProvider ?? "openai",
model: params.selectedModel ?? "gpt-4o-mini",
allowedModelKeys: new Set<string>(),
allowedModelCatalog: [],
resetModelOverride: false,
resolveDefaultThinkingLevel,
hasConfiguredThinkingDefault: params.hasConfiguredThinkingDefault,
resolveDefaultReasoningLevel,
});
}
const typing = makeTypingController();
const result = await resolveReplyDirectives({
ctx: buildTestCtx({
@@ -206,12 +213,12 @@ async function resolveHelloWithModelDefaults(params: {
model: params.model ?? "gpt-4o-mini",
hasOneTurnModelOverride: params.hasOneTurnModelOverride,
hasResolvedHeartbeatModelOverride: false,
typing: makeTypingController(),
typing,
opts: params.opts,
skillFilter: undefined,
});
return { result, resolveDefaultReasoningLevel };
return { result, resolveDefaultReasoningLevel, typing };
}
vi.mock("../../agents/agent-scope.js", () => ({
@@ -347,6 +354,21 @@ describe("resolveReplyDirectives", () => {
expect(modelSelectionInput.hasOneTurnModelOverride).toBe(true);
});
it("returns a terminal retry when model preparation sees a rotated session", async () => {
const error = new SessionWorkStartInvalidatedError(
'Session "agent:main:whatsapp:+2000" changed while starting work. Retry.',
);
const { result, typing } = await resolveHelloWithModelDefaults({
defaultThinking: "off",
defaultReasoning: "on",
modelError: error,
});
expect(result).toEqual({ kind: "reply", reply: { text: error.message } });
expect(typing.cleanup).toHaveBeenCalledOnce();
expect(mocks.applyInlineDirectiveOverrides).not.toHaveBeenCalled();
});
it("keeps one-turn fast mode with the resolved fast mode", async () => {
const { result } = await resolveHelloWithModelDefaults({
defaultThinking: "off",
+40 -28
View File
@@ -9,6 +9,7 @@ import { resolveFastModeState } from "../../agents/fast-mode.js";
import { type ModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
import type { SessionEntry } from "../../config/sessions.js";
import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { normalizeAgentId } from "../../routing/session-key.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -516,34 +517,45 @@ export async function resolveReplyDirectives(params: {
aliasIndex: params.aliasIndex,
}));
const modelState = useFastModelSelection
? createFastTestModelSelectionState({
agentCfg,
provider,
model,
})
: await createModelSelectionState({
cfg,
agentId,
agentCfg,
sessionEntry: targetSessionEntry,
sessionStore,
sessionKey,
parentSessionKey:
targetSessionEntry?.parentSessionKey ?? ctx.ModelParentSessionKey ?? ctx.ParentSessionKey,
storePath,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider,
model,
hasModelDirective: directives.hasModelDirective,
hasOneTurnModelOverride,
skipStoredModelOverride,
hasResolvedHeartbeatModelOverride,
isHeartbeat: opts?.isHeartbeat === true,
});
let modelState: Awaited<ReturnType<typeof createModelSelectionState>>;
try {
modelState = useFastModelSelection
? createFastTestModelSelectionState({
agentCfg,
provider,
model,
})
: await createModelSelectionState({
cfg,
agentId,
agentCfg,
sessionEntry: targetSessionEntry,
sessionStore,
sessionKey,
parentSessionKey:
targetSessionEntry?.parentSessionKey ??
ctx.ModelParentSessionKey ??
ctx.ParentSessionKey,
storePath,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider,
model,
hasModelDirective: directives.hasModelDirective,
hasOneTurnModelOverride,
skipStoredModelOverride,
hasResolvedHeartbeatModelOverride,
isHeartbeat: opts?.isHeartbeat === true,
});
} catch (error) {
if (!isSessionWorkStartInvalidatedError(error)) {
throw error;
}
typing.cleanup();
return { kind: "reply", reply: { text: error.message } };
}
provider = modelState.provider;
model = modelState.model;
const resolvedThinkLevelWithDefault =
+6 -2
View File
@@ -9,7 +9,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { resolveSessionTranscriptPath, resolveStorePath } from "../../config/sessions/paths.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { loadSessionStore } from "../../config/sessions/store.js";
import { loadSessionStore, resolveSessionStoreEntry } from "../../config/sessions/store.js";
import type { SessionEntry, SessionScope } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js";
@@ -221,7 +221,10 @@ export function initFastReplySessionState(params: {
skipCache: true,
clone: false,
});
const existingEntry = sessionStore[sessionKey];
const existingEntry = resolveSessionStoreEntry({
store: sessionStore,
sessionKey,
}).existing;
const commandSource = ctx.BodyForCommands ?? ctx.CommandBody ?? ctx.RawBody ?? ctx.Body ?? "";
const triggerBodyNormalized = isFormattedGoalContinuationPrompt(commandSource)
? commandSource.trim()
@@ -300,6 +303,7 @@ export function initFastReplySessionState(params: {
return {
sessionCtx,
sessionEntry,
initialSessionEntry: existingEntry ? { ...existingEntry } : undefined,
sessionEntryHandle,
sessionStore,
sessionKey,
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import type { OpenClawConfig } from "../../config/config.js";
import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import { markCompleteReplyConfig } from "./get-reply-fast-path.js";
import * as sessionPersistence from "./session-entry-persistence.js";
import { buildTestCtx } from "./test-ctx.js";
import type { TypingController } from "./typing.js";
@@ -16,6 +20,8 @@ vi.mock("./commands.runtime.js", () => ({
const { maybeResolveNativeSlashCommandFastReply } =
await import("./get-reply-native-slash-fast-path.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const createTypingController = (): TypingController => ({
onReplyStart: async () => {},
startTypingLoop: async () => {},
@@ -58,7 +64,9 @@ describe("maybeResolveNativeSlashCommandFastReply", () => {
const result = await maybeResolveNativeSlashCommandFastReply({
ctx,
cfg: markCompleteReplyConfig({
session: { store: "/tmp/openclaw-native-slash-sessions.json" },
session: {
store: path.join(tempDirs.make("openclaw-native-slash-"), "sessions.json"),
},
} as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
@@ -120,7 +128,9 @@ describe("maybeResolveNativeSlashCommandFastReply", () => {
const result = await maybeResolveNativeSlashCommandFastReply({
ctx,
cfg: markCompleteReplyConfig({
session: { store: "/tmp/openclaw-text-slash-sessions.json" },
session: {
store: path.join(tempDirs.make("openclaw-text-slash-"), "sessions.json"),
},
} as OpenClawConfig),
agentId: "dev",
agentDir: "/tmp/agent",
@@ -173,7 +183,9 @@ describe("maybeResolveNativeSlashCommandFastReply", () => {
const result = await maybeResolveNativeSlashCommandFastReply({
ctx,
cfg: markCompleteReplyConfig({
session: { store: "/tmp/openclaw-external-text-slash-sessions.json" },
session: {
store: path.join(tempDirs.make("openclaw-external-text-slash-"), "sessions.json"),
},
} as OpenClawConfig),
agentId: "dev",
agentDir: "/tmp/agent",
@@ -192,4 +204,317 @@ describe("maybeResolveNativeSlashCommandFastReply", () => {
expect(handleCommandsMock).not.toHaveBeenCalled();
expect(typing.cleanup).not.toHaveBeenCalled();
});
it("does not create a session for an unauthorized native command", async () => {
const storePath = path.join(
tempDirs.make("openclaw-native-slash-unauthorized-"),
"sessions.json",
);
const sessionKey = "agent:main:telegram:slash:unauthorized";
handleCommandsMock.mockResolvedValueOnce({
shouldContinue: false,
reply: { text: "You are not authorized to use this command." },
});
const result = await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/config show",
CommandBody: "/config show",
CommandSource: "native",
CommandAuthorized: false,
Provider: "telegram",
CommandTargetSessionKey: sessionKey,
CommandTurn: {
kind: "native",
source: "native",
authorized: false,
commandName: "config",
body: "/config show",
},
}),
cfg: markCompleteReplyConfig({ session: { store: storePath } } as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: false,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
expect(result).toEqual({
handled: true,
reply: expect.objectContaining({ text: "You are not authorized to use this command." }),
});
expect(handleCommandsMock).toHaveBeenCalledOnce();
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toBeUndefined();
});
it("marks deleted-session initialization conflicts for delivery", async () => {
vi.spyOn(sessionPersistence, "persistReplySessionEntry").mockResolvedValueOnce({
status: "lifecycle-invalidated",
error: 'Session "agent:main:main" was deleted while starting work. Retry.',
});
const result = await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/compact",
CommandBody: "/compact",
CommandSource: "native",
CommandAuthorized: true,
CommandTargetSessionKey: "agent:main:main",
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
commandName: "compact",
body: "/compact",
},
}),
cfg: markCompleteReplyConfig({
session: {
store: path.join(tempDirs.make("openclaw-native-slash-conflict-"), "sessions.json"),
},
} as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: true,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
expect(result.handled).toBe(true);
if (!result.handled || !result.reply || Array.isArray(result.reply)) {
throw new Error("expected single handled reply");
}
expect(result.reply.text).toContain("was deleted");
expect(getReplyPayloadMetadata(result.reply)?.deliverDespiteSourceReplySuppression).toBe(true);
expect(handleCommandsMock).not.toHaveBeenCalled();
});
it("rejects initialization when the session rotates during persistence", async () => {
vi.spyOn(sessionPersistence, "persistReplySessionEntry").mockResolvedValueOnce({
status: "lifecycle-invalidated",
error: 'Session "agent:main:main" changed while starting work. Retry.',
});
const result = await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/compact",
CommandBody: "/compact",
CommandSource: "native",
CommandAuthorized: true,
CommandTargetSessionKey: "agent:main:main",
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
commandName: "compact",
body: "/compact",
},
}),
cfg: markCompleteReplyConfig({
session: {
store: path.join(tempDirs.make("openclaw-native-slash-rotation-"), "sessions.json"),
},
} as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: true,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
expect(result).toEqual({
handled: true,
reply: expect.objectContaining({ text: expect.stringContaining("changed while") }),
});
expect(handleCommandsMock).not.toHaveBeenCalled();
});
it("adopts a supported legacy alias before native command initialization", async () => {
const storePath = path.join(tempDirs.make("openclaw-native-slash-alias-"), "sessions.json");
const sessionKey = "agent:main:main";
await saveSessionStore(
storePath,
{
"Agent:main:main": {
sessionId: "legacy-session",
updatedAt: 1,
},
},
{ skipMaintenance: true },
);
handleCommandsMock.mockImplementationOnce(async (params: { sessionEntry?: unknown }) => {
expect(params.sessionEntry).toMatchObject({ sessionId: "legacy-session" });
return { shouldContinue: false, reply: { text: "ok" } };
});
const result = await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/compact",
CommandBody: "/compact",
CommandSource: "native",
CommandAuthorized: true,
CommandTargetSessionKey: sessionKey,
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
commandName: "compact",
body: "/compact",
},
}),
cfg: markCompleteReplyConfig({ session: { store: storePath } } as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: true,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
expect(result).toEqual({
handled: true,
reply: expect.objectContaining({ text: "ok" }),
});
expect(handleCommandsMock).toHaveBeenCalledOnce();
});
it("does not mutate an archived session during native command initialization", async () => {
const storePath = path.join(tempDirs.make("openclaw-native-slash-archived-"), "sessions.json");
const sessionKey = "agent:main:main";
const archivedEntry = {
sessionId: "archived-session",
updatedAt: 1,
lastInteractionAt: 1,
archivedAt: 2,
channel: "telegram",
};
await saveSessionStore(storePath, { [sessionKey]: archivedEntry }, { skipMaintenance: true });
const persistedArchivedEntry = loadSessionStore(storePath, { skipCache: true })[sessionKey];
const result = await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/compact",
CommandBody: "/compact",
CommandSource: "native",
CommandAuthorized: true,
Provider: "telegram",
CommandTargetSessionKey: sessionKey,
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
commandName: "compact",
body: "/compact",
},
}),
cfg: markCompleteReplyConfig({ session: { store: storePath } } as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: true,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
expect(result).toEqual({
handled: true,
reply: expect.objectContaining({ text: expect.stringContaining("is archived") }),
});
expect(handleCommandsMock).not.toHaveBeenCalled();
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(
persistedArchivedEntry,
);
});
it("persists fast-path session initialization before command mutation", async () => {
const storePath = path.join(tempDirs.make("openclaw-native-slash-init-"), "sessions.json");
const sessionKey = "agent:main:main";
await saveSessionStore(
storePath,
{
[sessionKey]: {
sessionId: "session-1",
updatedAt: 1,
lastInteractionAt: 1,
channel: "old-channel",
},
},
{ skipMaintenance: true },
);
handleCommandsMock.mockImplementationOnce(async (params: { sessionEntry?: unknown }) => {
const persisted = loadSessionStore(storePath, { skipCache: true })[sessionKey];
expect(params.sessionEntry).toMatchObject({
sessionId: "session-1",
updatedAt: 100,
lastInteractionAt: 100,
channel: "telegram",
});
expect(persisted).toMatchObject(params.sessionEntry as object);
return { shouldContinue: false, reply: { text: "ok" } };
});
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(100);
try {
await maybeResolveNativeSlashCommandFastReply({
ctx: buildTestCtx({
Body: "/compact",
CommandBody: "/compact",
CommandSource: "native",
CommandAuthorized: true,
Provider: "telegram",
CommandTargetSessionKey: sessionKey,
CommandTurn: {
kind: "native",
source: "native",
authorized: true,
commandName: "compact",
body: "/compact",
},
}),
cfg: markCompleteReplyConfig({ session: { store: storePath } } as OpenClawConfig),
agentId: "main",
agentDir: "/tmp/agent",
agentCfg: undefined,
commandAuthorized: true,
defaultProvider: "openai",
defaultModel: "gpt-5.5",
aliasIndex: { byKey: new Map(), byAlias: new Map() },
provider: "openai",
model: "gpt-5.5",
workspaceDir: "/tmp/workspace",
typing: createTypingController(),
});
} finally {
nowSpy.mockRestore();
}
expect(handleCommandsMock).toHaveBeenCalledTimes(1);
});
});
@@ -28,6 +28,7 @@ import { resolveReplyDirectives } from "./get-reply-directives.js";
import { initFastReplySessionState } from "./get-reply-fast-path.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
import { stripStructuralPrefixes } from "./mentions.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
import type { createTypingController } from "./typing.js";
type AgentDefaults = NonNullable<NonNullable<OpenClawConfig["agents"]>["defaults"]> | undefined;
@@ -137,6 +138,34 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
commandAuthorized: params.commandAuthorized,
workspaceDir: params.workspaceDir,
});
if (params.commandAuthorized) {
const creatingSession = sessionState.initialSessionEntry === undefined;
const initializationEntry = sessionState.initialSessionEntry ?? sessionState.sessionEntry;
const persistence = await persistReplySessionEntry({
storePath: sessionState.storePath,
sessionKey: sessionState.sessionKey,
allowCreate: creatingSession,
initialEntry: initializationEntry,
entry: sessionState.sessionEntry,
skipMaintenance: !creatingSession,
});
if (persistence.status === "lifecycle-invalidated") {
params.typing.cleanup();
return {
handled: true,
reply: markCommandReplyForDelivery({
text: persistence.error,
}),
};
}
const persistedInitialEntry = persistence.entry;
// Commit the synthesized activity/channel touch before commands or directives
// capture their own mutation baseline.
sessionState.sessionEntry = persistedInitialEntry;
sessionState.sessionEntryHandle.replaceCurrent(persistedInitialEntry);
sessionState.sessionStore[sessionState.sessionKey] = persistedInitialEntry;
sessionState.sessionId = persistedInitialEntry.sessionId;
}
const command = buildCommandContext({
ctx: params.ctx,
cfg: params.cfg,
@@ -1,5 +1,8 @@
// Tests get-reply behavior while probing an auto-fallback primary model.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import path from "node:path";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import type { ModelDefinitionConfig, OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { ThinkLevel } from "../thinking.js";
@@ -130,6 +133,8 @@ function makePerModelThinkingConfig(
} satisfies OpenClawConfig);
}
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function mockAutoFallbackSession() {
const sessionKey = "agent:main:telegram:123";
const sessionEntry: SessionEntry = {
@@ -141,11 +146,17 @@ function mockAutoFallbackSession() {
modelOverrideFallbackOriginProvider: "openai",
modelOverrideFallbackOriginModel: "gpt-5.5",
};
// Reply-turn admission re-reads the store from disk before starting work;
// seed a real per-test store so the guard sees the same session the mocks
// describe instead of depending on leftover host files.
const storePath = path.join(tempDirs.make("auto-fallback-store"), "sessions.json");
fs.writeFileSync(storePath, JSON.stringify({ [sessionKey]: sessionEntry }));
mocks.initSessionState.mockResolvedValue(
createGetReplySessionState({
sessionKey,
sessionEntry,
sessionStore: { [sessionKey]: sessionEntry },
storePath,
triggerBodyNormalized: "hello",
bodyStripped: "hello",
}),
+57 -39
View File
@@ -16,6 +16,7 @@ import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../../agents/workspace.js";
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import { type OpenClawConfig, getRuntimeConfig } from "../../config/config.js";
import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import { logVerbose } from "../../globals.js";
import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline.js";
import { formatErrorMessage } from "../../infra/errors.js";
@@ -484,6 +485,7 @@ export async function getReplyFromConfig(
commandAuthorized,
requestedSessionId: internalResolvedOpts?.requestedSessionId,
resumeRequestedSession: internalResolvedOpts?.resumeRequestedSession,
signal: internalResolvedOpts?.abortSignal,
}),
);
const {
@@ -559,22 +561,30 @@ export async function getReplyFromConfig(
if (resetTriggered && normalizeOptionalString(bodyStripped)) {
const { applyResetModelOverride } = await loadSessionResetModelRuntime();
await applyResetModelOverride({
cfg,
agentId,
resetTriggered,
bodyStripped,
sessionCtx,
ctx: finalized,
sessionEntry,
sessionEntryHandle,
sessionStore,
sessionKey,
storePath,
defaultProvider,
defaultModel,
aliasIndex,
});
try {
await applyResetModelOverride({
cfg,
agentId,
resetTriggered,
bodyStripped,
sessionCtx,
ctx: finalized,
sessionEntry,
sessionEntryHandle,
sessionStore,
sessionKey,
storePath,
defaultProvider,
defaultModel,
aliasIndex,
});
} catch (error) {
if (!isSessionWorkStartInvalidatedError(error)) {
throw error;
}
typing.cleanup();
return { text: error.message };
}
}
const channelModelOverride = cfg.channels?.modelByChannel
@@ -902,29 +912,37 @@ export async function getReplyFromConfig(
const runModel = runAutoFallbackPrimaryProbe?.model ?? model;
let runModelState = modelState;
if (runAutoFallbackPrimaryProbe) {
runModelState = await createModelSelectionState({
cfg,
agentId,
agentCfg,
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey:
sessionEntry.parentSessionKey ??
sessionCtx.ModelParentSessionKey ??
sessionCtx.ParentSessionKey,
storePath,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider: runProvider,
model: runModel,
hasModelDirective: false,
skipStoredModelOverride: true,
hasResolvedHeartbeatModelOverride,
isHeartbeat: opts?.isHeartbeat === true,
});
try {
runModelState = await createModelSelectionState({
cfg,
agentId,
agentCfg,
sessionEntry,
sessionStore,
sessionKey,
parentSessionKey:
sessionEntry.parentSessionKey ??
sessionCtx.ModelParentSessionKey ??
sessionCtx.ParentSessionKey,
storePath,
defaultProvider,
defaultModel,
primaryProvider,
primaryModel,
provider: runProvider,
model: runModel,
hasModelDirective: false,
skipStoredModelOverride: true,
hasResolvedHeartbeatModelOverride,
isHeartbeat: opts?.isHeartbeat === true,
});
} catch (error) {
if (!isSessionWorkStartInvalidatedError(error)) {
throw error;
}
typing.cleanup();
return { text: error.message };
}
const thinkingLevelOverride = normalizeThinkLevel(resolvedOpts?.thinkingLevelOverride);
const hasTurnOrSessionThinkLevel =
thinkingLevelOverride !== undefined ||
@@ -1,4 +1,7 @@
// Tests model selection resolution from directives, config, and session state.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
MODEL_CONTEXT_TOKEN_CACHE,
@@ -10,6 +13,7 @@ import {
} from "../../agents/model-catalog.runtime.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionStore, saveSessionStore } from "../../config/sessions/store.js";
import { createModelSelectionState, resolveContextTokens } from "./model-selection.js";
vi.mock("../../agents/model-catalog.runtime.js", () => ({
@@ -1145,6 +1149,124 @@ describe("createModelSelectionState respects session model override", () => {
expect(sessionStore[sessionKey]?.providerOverride).toBeUndefined();
});
it("adopts a concurrent valid model while repairing a stale override", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-repair-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-4o" },
models: {
"openai/gpt-4o": {},
"openai/gpt-5.5": {},
},
},
},
} as OpenClawConfig;
const sessionKey = "agent:main:telegram:direct:1";
const sessionEntry = makeEntry({
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
});
const concurrentEntry = makeEntry({
updatedAt: sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-5.5",
modelOverrideSource: "user",
});
await saveSessionStore(storePath, { [sessionKey]: concurrentEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
try {
const state = await createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultProvider: "openai",
defaultModel: "gpt-4o",
provider: "openai",
model: "gpt-4o-mini",
hasModelDirective: false,
});
expect(state).toMatchObject({
provider: "openai",
model: "gpt-5.5",
resetModelOverride: false,
});
expect(sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-5.5",
modelOverrideSource: "user",
});
expect(sessionStore[sessionKey]).toEqual(sessionEntry);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(sessionEntry);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects stale-model repair when the session rotates during persistence", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-repair-rotation-"));
const storePath = path.join(tempRoot, "sessions.json");
const cfg = {
agents: {
defaults: {
model: { primary: "openai/gpt-4o" },
models: {
"openai/gpt-4o": {},
},
},
},
} as OpenClawConfig;
const sessionKey = "agent:main:telegram:direct:1";
const sessionEntry = makeEntry({
sessionId: "s1",
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
});
const rotatedEntry = makeEntry({
sessionId: "s2",
updatedAt: sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-4o",
modelOverrideSource: "user",
});
await saveSessionStore(storePath, { [sessionKey]: rotatedEntry }, { skipMaintenance: true });
const sessionStore = { [sessionKey]: sessionEntry };
try {
await expect(
createModelSelectionState({
cfg,
agentCfg: cfg.agents?.defaults,
sessionEntry,
sessionStore,
sessionKey,
storePath,
defaultProvider: "openai",
defaultModel: "gpt-4o",
provider: "openai",
model: "gpt-4o-mini",
hasModelDirective: false,
}),
).rejects.toThrow(/changed while starting work/i);
expect(sessionEntry).toMatchObject({
sessionId: "s1",
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
});
expect(sessionStore[sessionKey]).toBe(sessionEntry);
expect(loadSessionStore(storePath, { skipCache: true })[sessionKey]).toEqual(rotatedEntry);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("keeps wildcard-provider overrides when configured catalog rows are unavailable", async () => {
const cfg = {
agents: {
+36 -11
View File
@@ -31,6 +31,11 @@ import {
OPENAI_PROVIDER_ID,
listOpenAIAuthProfileProvidersForAgentRuntime,
} from "../../agents/openai-routing.js";
import { SessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import {
adoptPersistedSessionSnapshot,
sessionModelOverrideChangesApplied,
} from "../../config/sessions/session-snapshot-merge.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
@@ -112,8 +117,8 @@ function shouldLogModelSelectionTiming(): boolean {
const modelCatalogRuntimeLoader = createLazyImportLoader(
() => import("../../agents/model-catalog.runtime.js"),
);
const sessionAccessorRuntimeLoader = createLazyImportLoader(
() => import("../../config/sessions/session-accessor.js"),
const sessionPersistenceRuntimeLoader = createLazyImportLoader(
() => import("./session-entry-persistence.js"),
);
function normalizeRuntimeModelRef(provider: string, model: string) {
return normalizeModelRef(provider, model, RUNTIME_MODEL_VISIBILITY_NORMALIZATION);
@@ -123,8 +128,8 @@ function loadModelCatalogRuntime() {
return modelCatalogRuntimeLoader.load();
}
function loadSessionAccessorRuntime() {
return sessionAccessorRuntimeLoader.load();
function loadSessionPersistenceRuntime() {
return sessionPersistenceRuntimeLoader.load();
}
function findSelectedCatalogEntry(params: {
@@ -321,20 +326,40 @@ export async function createModelSelectionState(params: {
);
const key = modelKey(normalizedOverride.provider, normalizedOverride.model);
if (staleDirectStoredOverride || !visibilityPolicy.allowsKey(key)) {
const initialSessionEntry = { ...sessionEntry };
const nextSessionEntry = { ...sessionEntry };
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
entry: nextSessionEntry,
selection: { provider: primaryProvider, model: primaryModel, isDefault: true },
preserveAuthProfileOverride: staleDirectStoredOverride,
});
let resetApplied = updated;
if (updated) {
sessionStore[sessionKey] = sessionEntry;
if (storePath) {
const { replaceSessionEntry } = await loadSessionAccessorRuntime();
await replaceSessionEntry({ storePath, sessionKey }, sessionEntry);
const { persistReplySessionEntry } = await loadSessionPersistenceRuntime();
const persistence = await persistReplySessionEntry({
storePath,
sessionKey,
initialEntry: initialSessionEntry,
entry: nextSessionEntry,
});
if (persistence.status === "lifecycle-invalidated") {
throw new SessionWorkStartInvalidatedError(persistence.error);
}
const persistedEntry = persistence.entry;
resetApplied = sessionModelOverrideChangesApplied({
initial: initialSessionEntry,
next: nextSessionEntry,
current: persistedEntry,
});
adoptPersistedSessionSnapshot(sessionEntry, persistedEntry);
} else {
adoptPersistedSessionSnapshot(sessionEntry, nextSessionEntry);
}
sessionStore[sessionKey] = sessionEntry;
}
resetModelOverride = updated;
if (updated) {
resetModelOverride = resetApplied;
if (resetApplied) {
resetModelOverrideRef = key;
resetModelOverrideReason = staleDirectStoredOverride ? "stale" : "disallowed";
}
@@ -369,7 +394,7 @@ export async function createModelSelectionState(params: {
params.skipStoredModelOverride === true ||
hasOneTurnModelOverride ||
params.hasResolvedHeartbeatModelOverride === true ||
(staleDirectStoredOverride && storedOverride?.source === "session");
(resetModelOverride && staleDirectStoredOverride && storedOverride?.source === "session");
if (storedOverride?.model && !skipStoredOverride) {
const normalizedStoredOverride = normalizeRuntimeModelRef(
+10 -1
View File
@@ -231,6 +231,7 @@ type FollowupRuntimeMetadata = Pick<
| "currentInboundAudio"
| "currentInboundContext"
| "abortSignal"
| "queueAbortSignal"
| "deliveryCorrelations"
| "queuedLifecycle"
>;
@@ -253,7 +254,11 @@ function hasRuntimeOnlyFollowupMetadata(item: FollowupRun): boolean {
}
function combineAbortSignals(items: readonly FollowupRun[]): AbortSignal | undefined {
const signals = items.flatMap((item) => (item.abortSignal ? [item.abortSignal] : []));
const signals = items.flatMap((item) =>
[item.abortSignal, item.queueAbortSignal].filter(
(signal): signal is AbortSignal => signal !== undefined,
),
);
if (signals.length === 0) {
return undefined;
}
@@ -290,6 +295,7 @@ function collectRuntimeMetadata(
? singletonOwner
: items.find(hasCurrentTurnRuntimeMetadata);
const abortSignal = singletonOwner?.abortSignal ?? combineAbortSignals(candidates);
const queueAbortSignal = singletonOwner?.queueAbortSignal;
const deliveryCorrelations = items.flatMap((item) => item.deliveryCorrelations ?? []);
const lifecycleSource = singletonOwner ?? items.find((item) => item.queuedLifecycle);
return {
@@ -297,6 +303,7 @@ function collectRuntimeMetadata(
currentInboundAudio: currentTurnSource?.currentInboundAudio,
currentInboundContext: currentTurnSource?.currentInboundContext,
abortSignal,
queueAbortSignal,
deliveryCorrelations: deliveryCorrelations.length > 0 ? deliveryCorrelations : undefined,
queuedLifecycle:
singletonOwner?.queuedLifecycle ??
@@ -502,6 +509,7 @@ function resolveOverflowSummarySourceGroup(queue: {
export function createOverflowSummaryRetrySource(source: FollowupRun): FollowupRun {
return {
prompt: source.prompt,
queueAbortSignal: source.queueAbortSignal,
transcriptPrompt: source.transcriptPrompt,
messageId: source.messageId,
summaryLine: source.summaryLine,
@@ -594,6 +602,7 @@ async function runSyntheticOverflowSummary(params: {
const currentInboundEventKind = resolveOverflowSummaryInboundEventKind(params.sources);
await params.runFollowup({
prompt: params.prompt,
queueAbortSignal: params.source.queueAbortSignal,
transcriptPrompt: params.prompt,
messageId: params.source.messageId,
userTurnTranscriptRecorder,
+1
View File
@@ -171,6 +171,7 @@ export function enqueueFollowupRun(
return false;
}
run.queueAbortSignal = queue.abortController.signal;
queue.items.push(run);
markFollowupRunEnqueued(run);
if (recentMessageIdKey) {
+14
View File
@@ -1,5 +1,6 @@
// Tests queue state storage, dedupe, and cleanup primitives.
import { afterEach, describe, expect, it } from "vitest";
import { enqueueFollowupRun } from "./enqueue.js";
import { clearFollowupQueue, getFollowupQueue, refreshQueuedFollowupSession } from "./state.js";
import type { FollowupRun } from "./types.js";
@@ -121,6 +122,19 @@ describe("refreshQueuedFollowupSession", () => {
});
describe("getFollowupQueue", () => {
it("aborts work owned by a cleared queue", () => {
const queuedRun: FollowupRun = {
prompt: "queued message",
enqueuedAt: Date.now(),
run: makeRun(),
};
enqueueFollowupRun(QUEUE_KEY, queuedRun, { mode: "followup" });
expect(queuedRun.queueAbortSignal?.aborted).toBe(false);
clearFollowupQueue(QUEUE_KEY);
expect(queuedRun.queueAbortSignal?.aborted).toBe(true);
});
it("trims overflow metadata when a live queue cap shrinks", () => {
const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup", cap: 3 });
for (const [contextKey, count] of [
+3
View File
@@ -11,6 +11,7 @@ import {
} from "./types.js";
export type FollowupQueueState = {
abortController: AbortController;
items: FollowupRun[];
draining: boolean;
lastEnqueuedAt: number;
@@ -75,6 +76,7 @@ export function getFollowupQueue(key: string, settings: QueueSettings): Followup
}
const created: FollowupQueueState = {
abortController: new AbortController(),
items: [],
draining: false,
lastEnqueuedAt: 0,
@@ -108,6 +110,7 @@ export function clearFollowupQueue(key: string): number {
if (!queue) {
return 0;
}
queue.abortController.abort();
const cleared = queue.items.length + queue.droppedCount;
for (const item of queue.items) {
completeFollowupRunLifecycle(item);
+6 -2
View File
@@ -62,6 +62,8 @@ export type FollowupRun = {
currentInboundContext?: CurrentInboundPromptContext;
/** Abort signal for turns that are canceled by their source-channel admission fence. */
abortSignal?: AbortSignal;
/** Queue-owned cancellation fence used when lifecycle cleanup invalidates pending work. */
queueAbortSignal?: AbortSignal;
deliveryCorrelations?: QueuedReplyDeliveryCorrelation[];
queuedLifecycle?: QueuedReplyLifecycle;
/** Provider message ID, when available (for deduplication). */
@@ -160,8 +162,10 @@ export type FollowupRun = {
};
};
export function isFollowupRunAborted(run: Pick<FollowupRun, "abortSignal">): boolean {
return run.abortSignal?.aborted === true;
export function isFollowupRunAborted(
run: Pick<FollowupRun, "abortSignal" | "queueAbortSignal">,
): boolean {
return run.abortSignal?.aborted === true || run.queueAbortSignal?.aborted === true;
}
const enqueuedFollowupLifecycles = new WeakSet<QueuedReplyLifecycle>();
@@ -1,6 +1,6 @@
export type ReplyOperationAdmissionSnapshot =
| { status: "owned" }
| { status: "skipped"; reason: "active-run" | "aborted" };
| { status: "skipped"; reason: "active-run" | "aborted" | "lifecycle-invalidated" };
export type ReplyOperationRunState = {
admission?: ReplyOperationAdmissionSnapshot;
+32 -11
View File
@@ -71,6 +71,8 @@ export type ReplyOperation = {
readonly terminalRecovery: boolean;
readonly phase: ReplyOperationPhase;
readonly result: ReplyOperationResult | null;
/** True when this operation has owned the supplied session ID. */
hasOwnedSessionId(sessionId: string): boolean;
setPhase(next: "queued" | "preflight_compacting" | "memory_flushing" | "running"): void;
/** Mark this operation as an in-flight terminal-session recovery. */
markTerminalRecovery(): void;
@@ -234,6 +236,7 @@ function isReplyRunCompacting(operation: ReplyOperation): boolean {
const attachedBackendByOperation = new WeakMap<ReplyOperation, ReplyBackendHandle>();
const abortFrozenOperations = new WeakSet<ReplyOperation>();
const operationsByUpstreamAbortSignal = new WeakMap<AbortSignal, ReplyOperation>();
const retainStateUntilCompleteOperations = new WeakSet<ReplyOperation>();
const afterClearCallbacksByOperation = new WeakMap<
ReplyOperation,
Set<(sessionId: string) => void>
@@ -263,6 +266,11 @@ export function isReplyRunAbortableForSignal(signal: AbortSignal): boolean {
return operation ? isReplyOperationAbortable(operation) : true;
}
/** Keep terminal state registered until the operation owner exits via complete(). */
export function retainReplyOperationUntilComplete(operation: ReplyOperation): void {
retainStateUntilCompleteOperations.add(operation);
}
function isReplyBackendMessageInjectable(backend: ReplyBackendHandle): boolean {
try {
return backend.isStopped === undefined ? backend.isStreaming() : !backend.isStopped();
@@ -297,16 +305,12 @@ function flushReplyOperationAfterClear(operation: ReplyOperation, sessionId: str
}
}
function registerFollowupAdmissionBarrier(
sessionKey: string,
sessionId: string,
export function waitForReplyBarrierSettlement(
barrier: PromiseLike<unknown>,
timeout: number | ReplyFollowupAdmissionBarrierTimeoutPolicy = REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
): ReplyRunFollowupAdmissionBarrier {
const barriersByKey = replyRunState.followupAdmissionBarriersByKey;
const previous = barriersByKey.get(sessionKey)?.settled;
): Promise<void> {
// Owners may extend this for bounded retry envelopes; all barriers retain a failsafe.
const current = new Promise<void>((resolve) => {
return new Promise<void>((resolve) => {
let settled = false;
let timer: ReturnType<typeof setTimeout>;
const finish = () => {
@@ -352,6 +356,17 @@ function registerFollowupAdmissionBarrier(
}
void Promise.resolve(barrier).then(finish, finish);
});
}
function registerFollowupAdmissionBarrier(
sessionKey: string,
sessionId: string,
barrier: PromiseLike<unknown>,
timeout: number | ReplyFollowupAdmissionBarrierTimeoutPolicy = REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
): ReplyRunFollowupAdmissionBarrier {
const barriersByKey = replyRunState.followupAdmissionBarriersByKey;
const previous = barriersByKey.get(sessionKey)?.settled;
const current = waitForReplyBarrierSettlement(barrier, timeout);
const settled = previous ? Promise.all([previous, current]).then(() => undefined) : current;
const entry = { settled, sessionId };
barriersByKey.set(sessionKey, entry);
@@ -446,6 +461,7 @@ export function createReplyOperation(params: {
upstreamAbortSignal?.removeEventListener("abort", upstreamAbortHandler);
upstreamAbortHandler = undefined;
};
const ownedSessionIds = new Set([sessionId]);
const clearState = (
afterClearBarrier?: PromiseLike<unknown>,
@@ -524,6 +540,10 @@ export function createReplyOperation(params: {
get result() {
return result;
},
hasOwnedSessionId(candidateSessionId) {
const normalizedSessionId = normalizeOptionalString(candidateSessionId);
return normalizedSessionId ? ownedSessionIds.has(normalizedSessionId) : false;
},
setPhase(next) {
if (result) {
return;
@@ -552,6 +572,7 @@ export function createReplyOperation(params: {
replyRunState.activeKeysBySessionId.delete(currentSessionId);
registerWaitSessionId(sessionKey, currentSessionId);
currentSessionId = normalizedNextSessionId;
ownedSessionIds.add(currentSessionId);
updateFollowupAdmissionSessionId(sessionKey, currentSessionId);
replyRunState.activeSessionIdsByKey.set(sessionKey, currentSessionId);
replyRunState.activeKeysBySessionId.set(currentSessionId, sessionKey);
@@ -611,7 +632,7 @@ export function createReplyOperation(params: {
result = { kind: "failed", code, cause };
phase = "failed";
}
if (!retainFailureUntilComplete) {
if (!retainFailureUntilComplete && !retainStateUntilCompleteOperations.has(operation)) {
clearState();
}
},
@@ -623,7 +644,7 @@ export function createReplyOperation(params: {
abortWithReason("user_abort", createUserAbortError(), {
abortedCode: "aborted_by_user",
});
if (phaseBeforeAbort === "queued") {
if (phaseBeforeAbort === "queued" && !retainStateUntilCompleteOperations.has(operation)) {
clearState();
}
return true;
@@ -636,7 +657,7 @@ export function createReplyOperation(params: {
abortWithReason("restart", createAgentRunRestartAbortError(), {
abortedCode: "aborted_for_restart",
});
if (phaseBeforeAbort === "queued") {
if (phaseBeforeAbort === "queued" && !retainStateUntilCompleteOperations.has(operation)) {
clearState();
}
return true;
@@ -659,7 +680,7 @@ export function createReplyOperation(params: {
abortWithReason(restart ? "restart" : "user_abort", upstreamAbortSignal.reason, {
abortedCode: restart ? "aborted_for_restart" : "aborted_by_user",
});
if (phaseBeforeAbort === "queued") {
if (phaseBeforeAbort === "queued" && !retainStateUntilCompleteOperations.has(operation)) {
clearState();
}
};
@@ -1,17 +1,435 @@
// Tests reply turn admission decisions for active, queued, and aborted runs.
import fs from "node:fs";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
interruptSessionWorkAdmissions,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import {
createReplyOperation,
replyRunRegistry,
runAfterReplyOperationClear,
testing,
type ReplyOperation,
} from "./reply-run-registry.js";
import { admitReplyTurn } from "./reply-turn-admission.js";
import { admitReplyTurn, runWithReplyOperationLifecycleAdmission } from "./reply-turn-admission.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
function createDeferred() {
let resolve = () => {};
const promise = new Promise<void>((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
function createSessionStore(entries: Record<string, object>): string {
const root = tempDirs.make("openclaw-reply-admission-");
const storePath = path.join(root, "sessions.json");
fs.writeFileSync(storePath, JSON.stringify(entries));
return storePath;
}
describe("reply turn admission", () => {
afterEach(() => {
testing.resetReplyRunRegistry();
});
it("rejects a reply when an archive commits before admission", async () => {
const sessionKey = "agent:main:telegram:topic:archived";
const sessionId = "session-before-archive";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId, updatedAt: Date.now(), archivedAt: Date.now() },
}),
);
},
});
await mutationStarted.promise;
const admission = admitReplyTurn({
sessionKey,
sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
releaseMutation.resolve();
await mutation;
await expect(admission).rejects.toThrow(
`Session "${sessionKey}" is archived. Restore it before starting new work.`,
);
});
it("rejects a reply when deletion commits before admission", async () => {
const sessionKey = "agent:main:telegram:topic:deleted";
const sessionId = "session-before-delete";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
fs.writeFileSync(storePath, JSON.stringify({}));
},
});
await mutationStarted.promise;
const admission = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
releaseMutation.resolve();
await mutation;
await expect(admission).rejects.toThrow(/deleted while starting work/i);
});
it("uses the persisted session id when reset commits before admission", async () => {
const sessionKey = "agent:main:telegram:topic:reset";
const sessionId = "session-before-reset";
const nextSessionId = "session-after-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
},
});
await mutationStarted.promise;
const admission = admitReplyTurn({
sessionKey,
sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
releaseMutation.resolve();
await mutation;
const result = await admission;
expect(result.status).toBe("owned");
if (result.status === "owned") {
expect(result.operation.sessionId).toBe(nextSessionId);
result.operation.complete();
}
});
it("rejects expected-session work when reset commits before admission", async () => {
const sessionKey = "agent:main:telegram:topic:reset-expected";
const sessionId = "session-before-reset";
const nextSessionId = "session-after-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
},
});
await mutationStarted.promise;
const admission = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
releaseMutation.resolve();
await mutation;
await expect(admission).rejects.toThrow(/changed while starting work/i);
});
it("drops queued work when reset cleanup cancels admission", async () => {
const sessionKey = "agent:main:telegram:topic:queued-reset";
const sessionId = "session-before-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const abortController = new AbortController();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
abortController.abort();
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: "session-after-reset", updatedAt: Date.now() },
}),
);
},
});
await mutationStarted.promise;
const admission = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "queued_followup",
resetTriggered: false,
upstreamAbortSignal: abortController.signal,
});
releaseMutation.resolve();
await mutation;
await expect(admission).resolves.toEqual({
status: "skipped",
reason: "aborted",
});
});
it("drops queued work when the session is archived", async () => {
const sessionKey = "agent:main:telegram:topic:queued-archive";
const sessionId = "session-before-archive";
const storePath = createSessionStore({
[sessionKey]: {
sessionId,
updatedAt: Date.now(),
archivedAt: Date.now(),
},
});
await expect(
admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "queued_followup",
resetTriggered: false,
}),
).resolves.toEqual({
status: "skipped",
reason: "lifecycle-invalidated",
});
});
it("holds lifecycle admission until a running reply operation clears", async () => {
const sessionKey = "agent:main:telegram:topic:running-reset";
const sessionId = "session-before-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const admission = await admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
expect(admission.status).toBe("owned");
if (admission.status !== "owned") {
return;
}
admission.operation.setPhase("running");
let mutationRan = false;
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
prepare: async () => {
await interruptSessionWorkAdmissions({
scope: storePath,
identities: [sessionKey, sessionId],
});
},
run: async () => {
mutationRan = true;
},
});
await vi.waitFor(() => {
expect(admission.operation.abortSignal.aborted).toBe(true);
});
expect(admission.operation.result).toEqual({
kind: "aborted",
code: "aborted_for_restart",
});
expect(mutationRan).toBe(false);
admission.operation.complete();
await mutation;
expect(mutationRan).toBe(true);
});
it("holds interrupted queued reply work until its owner exits", async () => {
const sessionKey = "agent:main:telegram:topic:queued-delete";
const sessionId = "session-before-delete";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const admission = await admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
expect(admission.status).toBe("owned");
if (admission.status !== "owned") {
return;
}
let mutationRan = false;
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
prepare: async () => {
await interruptSessionWorkAdmissions({
scope: storePath,
identities: [sessionKey, sessionId],
});
},
run: async () => {
mutationRan = true;
},
});
await vi.waitFor(() => {
expect(admission.operation.abortSignal.aborted).toBe(true);
});
expect(admission.operation.result).toEqual({
kind: "aborted",
code: "aborted_for_restart",
});
expect(mutationRan).toBe(false);
expect(replyRunRegistry.get(sessionKey)).toBe(admission.operation);
admission.operation.complete();
await mutation;
expect(mutationRan).toBe(true);
expect(replyRunRegistry.get(sessionKey)).toBeUndefined();
});
it("excludes the initiating reply admission from an in-band lifecycle mutation", async () => {
const sessionKey = "agent:main:telegram:topic:in-band-reset";
const sessionId = "session-before-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const admission = await admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
expect(admission.status).toBe("owned");
if (admission.status !== "owned") {
return;
}
await runWithReplyOperationLifecycleAdmission(admission.operation, async () => {
await runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
prepare: async () => {
await interruptSessionWorkAdmissions({
scope: storePath,
identities: [sessionKey, sessionId],
});
},
run: async () => undefined,
});
});
expect(admission.operation.abortSignal.aborted).toBe(false);
admission.operation.complete();
});
it("skips an aborted reply waiting behind a lifecycle mutation", async () => {
const sessionKey = "agent:main:telegram:topic:aborted";
const sessionId = "session-before-abort";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const mutationStarted = createDeferred();
const releaseMutation = createDeferred();
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
mutationStarted.resolve();
await releaseMutation.promise;
},
});
await mutationStarted.promise;
const controller = new AbortController();
const admission = admitReplyTurn({
sessionKey,
sessionId,
storePath,
kind: "visible",
resetTriggered: false,
upstreamAbortSignal: controller.signal,
});
controller.abort();
releaseMutation.resolve();
await mutation;
await expect(admission).resolves.toEqual({ status: "skipped", reason: "aborted" });
});
it("waits for visible turns and reuses the active session id", async () => {
const active = createReplyOperation({
sessionKey: "agent:main:telegram:topic:42",
@@ -31,7 +449,9 @@ describe("reply turn admission", () => {
void admitted.then(() => {
settled = true;
});
await Promise.resolve();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(settled).toBe(false);
active.complete();
@@ -271,6 +691,206 @@ describe("reply turn admission", () => {
}
});
it("accepts an expected session id rotated by the active run", async () => {
const sessionKey = "agent:main:telegram:topic:compaction";
const sessionId = "pre-compact-session";
const nextSessionId = "post-compact-session";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const active = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
active.setPhase("preflight_compacting");
const admitted = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
active.updateSessionId(nextSessionId);
active.complete();
const result = await admitted;
expect(result.status).toBe("owned");
if (result.status === "owned") {
expect(result.operation.sessionId).toBe(nextSessionId);
result.operation.complete();
}
});
it("accepts a rotation already published by the expected active run", async () => {
const sessionKey = "agent:main:telegram:topic:compaction-before-admission";
const sessionId = "pre-compact-session";
const nextSessionId = "post-compact-session";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const active = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
active.setPhase("preflight_compacting");
active.updateSessionId(nextSessionId);
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
active.complete();
const result = await admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
expectedActiveOperation: active,
storePath,
kind: "visible",
resetTriggered: false,
});
expect(result.status).toBe("owned");
if (result.status === "owned") {
expect(result.operation.sessionId).toBe(nextSessionId);
result.operation.complete();
}
});
it("accepts a rotation published by the live owner after the caller snapshot", async () => {
const sessionKey = "agent:main:telegram:topic:late-compaction-owner";
const sessionId = "pre-compact-session";
const nextSessionId = "post-compact-session";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const active = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
active.setPhase("preflight_compacting");
active.updateSessionId(nextSessionId);
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
const admitted = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
waitForActive: true,
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
active.complete();
const result = await admitted;
expect(result.status).toBe("owned");
if (result.status === "owned") {
expect(result.operation.sessionId).toBe(nextSessionId);
result.operation.complete();
}
});
it("rejects a fresh post-reset owner as rotation proof", async () => {
const sessionKey = "agent:main:telegram:topic:fresh-post-reset-owner";
const sessionId = "session-before-reset";
const nextSessionId = "session-after-reset";
const storePath = createSessionStore({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
});
const freshOwner = createReplyOperation({
sessionKey,
sessionId: nextSessionId,
resetTriggered: false,
});
const admitted = admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
storePath,
kind: "visible",
resetTriggered: false,
waitForActive: true,
});
await expect(admitted).rejects.toThrow(/changed while starting work/i);
freshOwner.complete();
});
it.each([
["failed", (operation: ReplyOperation) => operation.fail("run_failed")],
[
"user-aborted",
(operation: ReplyOperation) => {
operation.abortByUser();
operation.complete();
},
],
])("accepts a rotation published before the expected run %s", async (_outcome, finish) => {
const sessionKey = "agent:main:telegram:topic:compaction-terminal-outcome";
const sessionId = "pre-compact-session";
const nextSessionId = "post-compact-session";
const storePath = createSessionStore({
[sessionKey]: { sessionId, updatedAt: Date.now() },
});
const active = createReplyOperation({
sessionKey,
sessionId,
resetTriggered: false,
});
active.setPhase("preflight_compacting");
active.updateSessionId(nextSessionId);
fs.writeFileSync(
storePath,
JSON.stringify({
[sessionKey]: { sessionId: nextSessionId, updatedAt: Date.now() },
}),
);
finish(active);
const result = await admitReplyTurn({
sessionKey,
sessionId,
expectedSessionId: sessionId,
expectedActiveOperation: active,
storePath,
kind: "visible",
resetTriggered: false,
});
expect(result.status).toBe("owned");
if (result.status === "owned") {
expect(result.operation.sessionId).toBe(nextSessionId);
result.operation.complete();
}
});
it("skips heartbeat turns while a visible turn owns the lane", async () => {
const active = createReplyOperation({
sessionKey: "agent:main:telegram:topic:42",
+171 -5
View File
@@ -1,10 +1,18 @@
// Decides whether an inbound turn may start, queue, or abort a reply run.
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import { loadSessionEntry } from "../../config/sessions/session-accessor.js";
import {
beginSessionWorkAdmission,
type SessionWorkAdmissionLease,
} from "../../sessions/session-lifecycle-admission.js";
import {
createReplyOperation,
REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS,
replyRunRegistry,
ReplyRunAlreadyActiveError,
ReplyRunFollowupAdmissionBlockedError,
retainReplyOperationUntilComplete,
runAfterReplyOperationClear,
type ReplyOperation,
waitForReplyRunFollowupAdmission,
} from "./reply-run-registry.js";
@@ -17,10 +25,31 @@ export type ReplyTurnAdmission =
| { status: "owned"; operation: ReplyOperation }
| {
status: "skipped";
reason: "active-run" | "aborted";
reason: "active-run" | "aborted" | "lifecycle-invalidated";
activeOperation?: ReplyOperation;
lifecycleAdmission?: SessionWorkAdmissionLease;
};
class QueuedFollowupLifecycleInvalidatedError extends Error {}
const lifecycleAdmissionByOperation = new WeakMap<ReplyOperation, SessionWorkAdmissionLease>();
/** Runs owner work with its admission marked as the initiating lifecycle context. */
export async function runWithReplyOperationLifecycleAdmission<T>(
operation: ReplyOperation | undefined,
run: () => Promise<T>,
): Promise<T> {
const admission = operation ? lifecycleAdmissionByOperation.get(operation) : undefined;
return admission ? await admission.run(run) : await run();
}
function rejectLifecycleInvalidatedWork(params: { kind: ReplyTurnKind; message: string }): never {
if (params.kind === "queued_followup") {
throw new QueuedFollowupLifecycleInvalidatedError(params.message);
}
throw new Error(params.message);
}
function isAbortSignalAborted(signal: AbortSignal | undefined): boolean {
return signal?.aborted === true;
}
@@ -29,14 +58,20 @@ function isAbortSignalAborted(signal: AbortSignal | undefined): boolean {
export async function admitReplyTurn(params: {
sessionKey: string;
sessionId: string;
expectedSessionId?: string;
expectedActiveOperation?: ReplyOperation;
storePath?: string;
kind: ReplyTurnKind;
resetTriggered: boolean;
routeThreadId?: string | number;
upstreamAbortSignal?: AbortSignal;
waitTimeoutMs?: number;
waitForActive?: boolean;
retainLifecycleAdmissionOnActive?: boolean;
onLifecycleInterrupt?: () => void;
}): Promise<ReplyTurnAdmission> {
let sessionId = params.sessionId;
let expectedSessionId = params.expectedSessionId;
const waitTimeoutMs =
params.waitTimeoutMs ??
(params.kind === "queued_followup" ? REPLY_RUN_IDLE_SETTLE_TIMEOUT_MS : undefined);
@@ -45,9 +80,90 @@ export async function admitReplyTurn(params: {
return { status: "skipped", reason: "aborted" };
}
try {
return {
status: "owned",
operation: createReplyOperation({
const storePath = params.storePath;
let operation: ReplyOperation | undefined;
let interruptedBeforeOperation = false;
const admission = storePath
? await beginSessionWorkAdmission({
scope: storePath,
identities: [params.sessionKey],
signal: params.upstreamAbortSignal,
onInterrupt: () => {
interruptedBeforeOperation = true;
operation?.abortForRestart();
params.onLifecycleInterrupt?.();
},
assertAllowed: () => {
const currentEntry = loadSessionEntry({
storePath,
sessionKey: params.sessionKey,
readConsistency: "latest",
});
if (expectedSessionId && !currentEntry) {
rejectLifecycleInvalidatedWork({
kind: params.kind,
message: `Session "${params.sessionKey}" was deleted while starting work. Retry.`,
});
}
const registeredOperation = replyRunRegistry.get(params.sessionKey);
const rotationOperation = [registeredOperation, params.expectedActiveOperation].find(
(candidate) => {
if (
!candidate ||
!expectedSessionId ||
currentEntry?.sessionId !== candidate.sessionId ||
!candidate.hasOwnedSessionId(expectedSessionId)
) {
return false;
}
if (
candidate.result?.kind === "aborted" &&
candidate.result.code === "aborted_for_restart"
) {
return false;
}
return candidate === registeredOperation || candidate.result !== null;
},
);
const activeOperationRotatedExpectedSession = Boolean(
rotationOperation && currentEntry?.sessionId === rotationOperation.sessionId,
);
if (
expectedSessionId &&
currentEntry?.sessionId !== expectedSessionId &&
!activeOperationRotatedExpectedSession
) {
rejectLifecycleInvalidatedWork({
kind: params.kind,
message: `Session "${params.sessionKey}" changed while starting work. Retry.`,
});
}
if (activeOperationRotatedExpectedSession) {
expectedSessionId = currentEntry?.sessionId;
}
const archivedSessionError = resolveSessionWorkStartError(
params.sessionKey || sessionId,
currentEntry,
);
if (archivedSessionError) {
rejectLifecycleInvalidatedWork({
kind: params.kind,
message: archivedSessionError,
});
}
sessionId = currentEntry?.sessionId ?? sessionId;
},
})
: undefined;
if (interruptedBeforeOperation) {
admission?.release();
rejectLifecycleInvalidatedWork({
kind: params.kind,
message: `Session "${params.sessionKey}" changed while starting work. Retry.`,
});
}
try {
operation = createReplyOperation({
sessionKey: params.sessionKey,
sessionId,
resetTriggered: params.resetTriggered,
@@ -55,9 +171,45 @@ export async function admitReplyTurn(params: {
upstreamAbortSignal: params.upstreamAbortSignal,
respectFollowupAdmissionBarrier:
params.kind === "queued_followup" || params.kind === "heartbeat",
}),
});
} catch (error) {
if (
error instanceof ReplyRunAlreadyActiveError &&
admission &&
params.retainLifecycleAdmissionOnActive
) {
return {
status: "skipped",
reason: "active-run",
activeOperation: replyRunRegistry.get(params.sessionKey),
lifecycleAdmission: admission,
};
}
admission?.release();
throw error;
}
if (admission) {
// The lifecycle fence follows hooks, media work, agent execution, and
// final delivery. Reset/delete interrupts the operation and waits until
// its actual owner clears it before mutating the persisted session.
retainReplyOperationUntilComplete(operation);
lifecycleAdmissionByOperation.set(operation, admission);
runAfterReplyOperationClear(operation, () => {
lifecycleAdmissionByOperation.delete(operation);
admission.release();
});
}
return {
status: "owned",
operation,
};
} catch (error) {
if (isAbortSignalAborted(params.upstreamAbortSignal)) {
return { status: "skipped", reason: "aborted" };
}
if (error instanceof QueuedFollowupLifecycleInvalidatedError) {
return { status: "skipped", reason: "lifecycle-invalidated" };
}
if (error instanceof ReplyRunFollowupAdmissionBlockedError) {
if (params.kind === "heartbeat") {
return { status: "skipped", reason: "active-run" };
@@ -74,6 +226,9 @@ export async function admitReplyTurn(params: {
};
}
sessionId = followupAdmission.sessionId ?? sessionId;
if (expectedSessionId && followupAdmission.sessionId) {
expectedSessionId = followupAdmission.sessionId;
}
continue;
}
if (!(error instanceof ReplyRunAlreadyActiveError)) {
@@ -99,6 +254,17 @@ export async function admitReplyTurn(params: {
}
if (activeOperation) {
sessionId = activeOperation.sessionId;
// In-lane compaction may rotate the active operation's persisted ID.
// Lifecycle reset aborts use a distinct result and must stay invalidated.
if (
expectedSessionId &&
!(
activeOperation.result?.kind === "aborted" &&
activeOperation.result.code === "aborted_for_restart"
)
) {
expectedSessionId = activeOperation.sessionId;
}
}
}
}
@@ -0,0 +1,159 @@
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
} from "../../config/sessions/store.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("persistReplySessionEntry", () => {
it("does not restore policy fields revoked during reply processing", async () => {
const dir = tempDirs.make("openclaw-reply-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
thinkingLevel: "low",
elevatedLevel: "full",
inheritedToolAllow: ["exec"],
sendPolicy: "allow",
};
const currentEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 400,
thinkingLevel: "low",
sendPolicy: "deny",
};
await saveSessionStore(storePath, { main: currentEntry }, { skipMaintenance: true });
const result = await persistReplySessionEntry({
storePath,
sessionKey: "main",
initialEntry,
entry: {
...initialEntry,
thinkingLevel: "high",
updatedAt: 250,
},
});
expect(result.status).toBe("current");
if (result.status !== "current") {
throw new Error("expected current persisted session");
}
expect(result.entry).toMatchObject({
sessionId: "session-1",
thinkingLevel: "high",
sendPolicy: "deny",
updatedAt: 400,
});
expect(result.entry.elevatedLevel).toBeUndefined();
expect(result.entry.inheritedToolAllow).toBeUndefined();
expect(loadSessionStore(storePath, { skipCache: true }).main).toEqual(result.entry);
} finally {
clearSessionStoreCacheForTest();
}
});
it("rejects persistence when the session rotated", async () => {
const dir = tempDirs.make("openclaw-reply-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
thinkingLevel: "low",
};
const currentEntry: SessionEntry = {
sessionId: "session-2",
updatedAt: 400,
thinkingLevel: "medium",
};
await saveSessionStore(storePath, { main: currentEntry }, { skipMaintenance: true });
const result = await persistReplySessionEntry({
storePath,
sessionKey: "main",
initialEntry,
entry: { ...initialEntry, thinkingLevel: "high", updatedAt: 250 },
});
expect(result).toEqual({
status: "lifecycle-invalidated",
error: 'Session "main" changed while starting work. Retry.',
entry: currentEntry,
});
expect(loadSessionStore(storePath, { skipCache: true }).main).toEqual(currentEntry);
} finally {
clearSessionStoreCacheForTest();
}
});
it("does not recreate a row deleted after reply initialization by default", async () => {
const dir = tempDirs.make("openclaw-reply-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
};
await saveSessionStore(storePath, {}, { skipMaintenance: true });
const result = await persistReplySessionEntry({
storePath,
sessionKey: "main",
initialEntry,
entry: { ...initialEntry, updatedAt: 250 },
});
expect(result).toEqual({
status: "lifecycle-invalidated",
error: 'Session "main" was deleted while starting work. Retry.',
});
expect(loadSessionStore(storePath, { skipCache: true }).main).toBeUndefined();
} finally {
clearSessionStoreCacheForTest();
}
});
it("rejects same-value persistence after the session is archived", async () => {
const dir = tempDirs.make("openclaw-reply-session-store-");
try {
const storePath = path.join(dir, "sessions.json");
const initialEntry: SessionEntry = {
sessionId: "session-1",
updatedAt: 100,
modelOverride: "gpt-5.5",
};
const archivedEntry: SessionEntry = {
...initialEntry,
updatedAt: 400,
archivedAt: 300,
};
await saveSessionStore(storePath, { main: archivedEntry }, { skipMaintenance: true });
const result = await persistReplySessionEntry({
storePath,
sessionKey: "main",
initialEntry,
entry: { ...initialEntry, updatedAt: 250 },
touchedFields: ["modelOverride"],
});
expect(result).toEqual({
status: "lifecycle-invalidated",
error: 'Session "main" is archived. Restore it before starting new work.',
entry: archivedEntry,
});
expect(loadSessionStore(storePath, { skipCache: true }).main).toEqual(archivedEntry);
} finally {
clearSessionStoreCacheForTest();
}
});
});
@@ -0,0 +1,89 @@
// Atomic persistence for broad auto-reply session snapshots.
import type { SessionEntry } from "../../config/sessions.js";
import { resolveSessionWorkStartError } from "../../config/sessions/lifecycle.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import {
mergeSessionSnapshotChanges,
sessionSnapshotTouchedFieldsConflict,
} from "../../config/sessions/session-snapshot-merge.js";
type PersistReplySessionEntryParams = {
allowCreate?: boolean;
entry: SessionEntry;
initialEntry: SessionEntry;
reassertLiveModelSwitchPending?: boolean;
sessionKey: string;
skipMaintenance?: boolean;
storePath: string;
touchedFields?: ReadonlyArray<keyof SessionEntry>;
};
export type PersistReplySessionEntryResult =
| { status: "current"; entry: SessionEntry }
| { status: "lifecycle-invalidated"; error: string; entry?: SessionEntry };
/** Persists reply-owned state without reverting concurrent session management. */
export async function persistReplySessionEntry(
params: PersistReplySessionEntryParams,
): Promise<PersistReplySessionEntryResult> {
let lifecycleError: string | undefined;
let lifecycleEntry: SessionEntry | undefined;
const persisted = await patchSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(_entry, context) => {
if (!context.existingEntry) {
if (params.allowCreate !== true) {
lifecycleError = resolveSessionWorkStartError(params.sessionKey, undefined, {
expectedSessionId: params.initialEntry.sessionId,
});
return null;
}
return params.entry;
}
lifecycleError = resolveSessionWorkStartError(params.sessionKey, context.existingEntry, {
expectedSessionId: params.initialEntry.sessionId,
});
if (lifecycleError) {
lifecycleEntry = context.existingEntry;
return null;
}
if (
sessionSnapshotTouchedFieldsConflict({
initial: params.initialEntry,
next: params.entry,
current: context.existingEntry,
touchedFields: params.touchedFields,
})
) {
return null;
}
// Reply flows persist broad snapshots. Project only reply-owned changes
// so concurrent lifecycle, policy, and privacy updates remain authoritative.
return mergeSessionSnapshotChanges({
initial: params.initialEntry,
next: params.entry,
current: context.existingEntry,
reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending,
});
},
{
fallbackEntry: params.entry,
replaceEntry: true,
skipMaintenance: params.skipMaintenance,
},
);
if (lifecycleError) {
return {
status: "lifecycle-invalidated",
error: lifecycleError,
...(lifecycleEntry ? { entry: lifecycleEntry } : {}),
};
}
if (!persisted) {
return {
status: "lifecycle-invalidated",
error: `Session "${params.sessionKey}" changed while starting work. Retry.`,
};
}
return { status: "current", entry: persisted };
}
@@ -1,8 +1,16 @@
// Tests reset model selection and persisted model override cleanup.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import {
clearSessionStoreCacheForTest,
loadSessionStore,
saveSessionStore,
} from "../../config/sessions/store.js";
import type { ModelAliasIndex } from "./model-selection-directive.js";
import { applyResetModelOverride } from "./session-reset-model.js";
@@ -77,6 +85,155 @@ describe("applyResetModelOverride", () => {
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
});
it("adopts a concurrent model winner instead of acknowledging the reset hint", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-model-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const fixture = createResetFixture();
const concurrentEntry: SessionEntry = {
...fixture.sessionEntry,
updatedAt: fixture.sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
modelOverrideSource: "user",
};
await saveSessionStore(
storePath,
{ "agent:main:dm:1": concurrentEntry },
{ skipMaintenance: true },
);
try {
const result = await applyResetModelOverride({
cfg: fixture.cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx: fixture.sessionCtx,
ctx: fixture.ctx,
sessionEntry: fixture.sessionEntry,
sessionStore: fixture.sessionStore,
sessionKey: "agent:main:dm:1",
storePath,
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: fixture.aliasIndex,
modelCatalog,
});
expect(result.selection).toBeUndefined();
expect(result.cleanedBody).toBe("summarize");
expect(fixture.sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
modelOverrideSource: "user",
});
expect(fixture.sessionEntry.updatedAt).toBeGreaterThanOrEqual(concurrentEntry.updatedAt);
expect(fixture.sessionStore["agent:main:dm:1"]).toEqual(fixture.sessionEntry);
expect(loadSessionStore(storePath, { skipCache: true })["agent:main:dm:1"]).toEqual(
fixture.sessionEntry,
);
} finally {
clearSessionStoreCacheForTest();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("checks the persisted winner for an explicit same-value reset hint", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-model-race-"));
const storePath = path.join(tempRoot, "sessions.json");
const fixture = createResetFixture({
providerOverride: "minimax",
modelOverride: "m2.7",
modelOverrideSource: "user",
});
const concurrentEntry: SessionEntry = {
...fixture.sessionEntry,
updatedAt: fixture.sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
};
await saveSessionStore(
storePath,
{ "agent:main:dm:1": concurrentEntry },
{ skipMaintenance: true },
);
try {
const result = await applyResetModelOverride({
cfg: fixture.cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx: fixture.sessionCtx,
ctx: fixture.ctx,
sessionEntry: fixture.sessionEntry,
sessionStore: fixture.sessionStore,
sessionKey: "agent:main:dm:1",
storePath,
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: fixture.aliasIndex,
modelCatalog,
});
expect(result.selection).toBeUndefined();
expect(fixture.sessionEntry).toMatchObject({
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
});
expect(fixture.sessionStore["agent:main:dm:1"]).toEqual(fixture.sessionEntry);
} finally {
clearSessionStoreCacheForTest();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("rejects a reset-model hint when the session rotates during persistence", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reset-model-rotation-"));
const storePath = path.join(tempRoot, "sessions.json");
const fixture = createResetFixture();
const rotatedEntry: SessionEntry = {
sessionId: "s2",
updatedAt: fixture.sessionEntry.updatedAt + 1,
providerOverride: "openai",
modelOverride: "gpt-4o-mini",
modelOverrideSource: "user",
};
await saveSessionStore(
storePath,
{ "agent:main:dm:1": rotatedEntry },
{ skipMaintenance: true },
);
try {
await expect(
applyResetModelOverride({
cfg: fixture.cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx: fixture.sessionCtx,
ctx: fixture.ctx,
sessionEntry: fixture.sessionEntry,
sessionStore: fixture.sessionStore,
sessionKey: "agent:main:dm:1",
storePath,
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex: fixture.aliasIndex,
modelCatalog,
}),
).rejects.toThrow(/changed while starting work/i);
expect(fixture.sessionEntry.sessionId).toBe("s1");
expect(fixture.sessionEntry.modelOverride).toBeUndefined();
expect(fixture.sessionStore["agent:main:dm:1"]).toBe(fixture.sessionEntry);
expect(loadSessionStore(storePath, { skipCache: true })["agent:main:dm:1"]).toEqual(
rotatedEntry,
);
} finally {
clearSessionStoreCacheForTest();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it("skips when resetTriggered is false", async () => {
const { sessionEntry, sessionCtx } = await applyResetFixture({
resetTriggered: false,
+38 -18
View File
@@ -8,6 +8,12 @@ import {
} from "../../agents/model-selection-shared.js";
import { resolveAgentModelFallbackValues } from "../../config/model-input.js";
import type { SessionEntry } from "../../config/sessions.js";
import { SessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import {
adoptPersistedSessionSnapshot,
SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
sessionModelOverrideChangesApplied,
} from "../../config/sessions/session-snapshot-merge.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import type { MsgContext, TemplateContext } from "../templating.js";
@@ -107,40 +113,54 @@ function buildSelectionFromExplicit(params: {
};
}
function applySelectionToSession(params: {
async function applySelectionToSession(params: {
selection: ModelDirectiveSelection;
sessionEntry?: SessionEntry;
sessionEntryHandle?: ReplySessionEntryHandle;
sessionStore?: Record<string, SessionEntry>;
sessionKey?: string;
storePath?: string;
}) {
}): Promise<boolean> {
const { selection, sessionEntryHandle, sessionStore, sessionKey, storePath } = params;
const sessionEntry = sessionEntryHandle?.getCurrent() ?? params.sessionEntry;
if (!sessionEntry || !sessionKey) {
return;
return true;
}
const { updated } = applyModelOverrideToSessionEntry({
entry: sessionEntry,
const initialSessionEntry = { ...sessionEntry };
const nextSessionEntry = { ...sessionEntry };
applyModelOverrideToSessionEntry({
entry: nextSessionEntry,
selection,
});
if (!updated) {
return;
let appliedEntry = nextSessionEntry;
let selectionApplied = true;
if (storePath) {
const { persistReplySessionEntry } = await import("./session-entry-persistence.js");
const persistence = await persistReplySessionEntry({
storePath,
sessionKey,
initialEntry: initialSessionEntry,
entry: nextSessionEntry,
touchedFields: SESSION_MODEL_OVERRIDE_TRANSACTION_FIELDS,
});
if (persistence.status === "lifecycle-invalidated") {
throw new SessionWorkStartInvalidatedError(persistence.error);
}
const persistedEntry = persistence.entry;
appliedEntry = persistedEntry;
selectionApplied = sessionModelOverrideChangesApplied({
initial: initialSessionEntry,
next: nextSessionEntry,
current: persistedEntry,
});
}
adoptPersistedSessionSnapshot(sessionEntry, appliedEntry);
if (sessionEntryHandle) {
sessionEntryHandle.replaceCurrent(sessionEntry);
} else if (sessionStore) {
sessionStore[sessionKey] = sessionEntry;
}
if (storePath) {
void import("../../config/sessions/session-accessor.js")
.then(({ replaceSessionEntry }) =>
replaceSessionEntry({ storePath, sessionKey }, sessionEntry),
)
.catch(() => {
// Ignore persistence errors; session still proceeds.
});
}
return selectionApplied;
}
/** Applies a model override embedded in a reset command body. */
@@ -253,7 +273,7 @@ export async function applyResetModelOverride(params: {
params.sessionCtx.BodyStripped = cleanedBody;
params.sessionCtx.BodyForCommands = cleanedBody;
applySelectionToSession({
const selectionApplied = await applySelectionToSession({
selection,
sessionEntry: params.sessionEntry,
sessionEntryHandle: params.sessionEntryHandle,
@@ -262,5 +282,5 @@ export async function applyResetModelOverride(params: {
storePath: params.storePath,
});
return { selection, cleanedBody };
return { selection: selectionApplied ? selection : undefined, cleanedBody };
}
@@ -1,5 +1,9 @@
// Tests session update fanout and persisted lifecycle records.
import fs from "node:fs/promises";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import type { SessionEntry } from "../../config/sessions/types.js";
const TEST_WORKSPACE_DIR = "/tmp/workspace";
@@ -66,6 +70,7 @@ vi.mock("../../routing/session-key.js", () => ({
}));
const { ensureSkillSnapshot } = await import("./session-updates.js");
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("ensureSkillSnapshot", () => {
beforeEach(() => {
@@ -116,4 +121,57 @@ describe("ensureSkillSnapshot", () => {
expect(snapshotParams.agentId).toBe("writer");
expect(resolveAgentIdFromSessionKeyMock).not.toHaveBeenCalled();
});
it("keeps a concurrent rename and unpin while persisting a skill snapshot", async () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "0");
const root = tempDirs.make("openclaw-session-updates-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:reply";
const staleEntry: SessionEntry = {
sessionId: "reply-session",
updatedAt: 1,
label: "Before rename",
pinnedAt: 100,
};
await fs.writeFile(
storePath,
JSON.stringify({
[sessionKey]: {
sessionId: staleEntry.sessionId,
updatedAt: 2,
label: "After rename",
sendPolicy: "deny",
},
}),
"utf8",
);
const sessionStore = { [sessionKey]: staleEntry };
const result = await ensureSkillSnapshot({
sessionEntry: staleEntry,
sessionStore,
sessionKey,
storePath,
isFirstTurnInSession: true,
workspaceDir: TEST_WORKSPACE_DIR,
cfg: {},
});
expect(result.sessionEntry).toMatchObject({
sessionId: "reply-session",
label: "After rename",
sendPolicy: "deny",
systemSent: true,
});
expect(result.sessionEntry?.pinnedAt).toBeUndefined();
expect(sessionStore[sessionKey]).toBe(result.sessionEntry);
const persisted = JSON.parse(await fs.readFile(storePath, "utf8")) as Record<
string,
SessionEntry
>;
expect(persisted[sessionKey]?.label).toBe("After rename");
expect(persisted[sessionKey]?.pinnedAt).toBeUndefined();
expect(persisted[sessionKey]?.sendPolicy).toBe("deny");
expect(persisted[sessionKey]?.skillsSnapshot).toBeDefined();
});
});
+37 -21
View File
@@ -4,7 +4,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { canExecRequestNode } from "../../agents/exec-defaults.js";
import { resolveCompactionSessionFile, type SessionEntry } from "../../config/sessions.js";
import { patchSessionEntry, upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { patchSessionEntry } from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
forgetActiveSessionForShutdown,
@@ -27,27 +27,32 @@ async function persistSessionEntryUpdate(params: {
sessionKey?: string;
storePath?: string;
nextEntry: SessionEntry;
}) {
if (params.sessionEntryHandle) {
params.sessionEntryHandle.replaceCurrent(params.nextEntry);
} else if (params.sessionStore && params.sessionKey) {
params.sessionStore[params.sessionKey] = {
...params.sessionStore[params.sessionKey],
...params.nextEntry,
};
} else {
return;
updates: Partial<SessionEntry>;
}): Promise<SessionEntry> {
if (!params.sessionEntryHandle && (!params.sessionStore || !params.sessionKey)) {
return params.nextEntry;
}
let persistedEntry = params.nextEntry;
if (!params.storePath || !params.sessionKey) {
return;
if (params.sessionEntryHandle) {
params.sessionEntryHandle.replaceCurrent(persistedEntry);
} else if (params.sessionStore && params.sessionKey) {
params.sessionStore[params.sessionKey] = persistedEntry;
}
return persistedEntry;
}
await upsertSessionEntry(
{
storePath: params.storePath,
sessionKey: params.sessionKey,
},
params.nextEntry,
);
persistedEntry =
(await patchSessionEntry(
{ storePath: params.storePath, sessionKey: params.sessionKey },
() => params.updates,
{ fallbackEntry: params.nextEntry },
)) ?? persistedEntry;
if (params.sessionEntryHandle) {
params.sessionEntryHandle.replaceCurrent(persistedEntry);
} else if (params.sessionStore) {
params.sessionStore[params.sessionKey] = persistedEntry;
}
return persistedEntry;
}
function emitCompactionSessionLifecycleHooks(params: {
@@ -198,12 +203,18 @@ export async function ensureSkillSnapshot(params: {
systemSent: true,
skillsSnapshot: skillSnapshot,
};
await persistSessionEntryUpdate({
nextEntry = await persistSessionEntryUpdate({
sessionEntryHandle,
sessionStore,
sessionKey,
storePath,
nextEntry,
updates: {
sessionId: nextEntry.sessionId,
updatedAt: nextEntry.updatedAt,
systemSent: nextEntry.systemSent,
skillsSnapshot: nextEntry.skillsSnapshot,
},
});
systemSent = true;
}
@@ -234,12 +245,17 @@ export async function ensureSkillSnapshot(params: {
updatedAt: Date.now(),
skillsSnapshot,
};
await persistSessionEntryUpdate({
nextEntry = await persistSessionEntryUpdate({
sessionEntryHandle,
sessionStore,
sessionKey,
storePath,
nextEntry,
updates: {
sessionId: nextEntry.sessionId,
updatedAt: nextEntry.updatedAt,
skillsSnapshot: nextEntry.skillsSnapshot,
},
});
}
+361 -1
View File
@@ -24,6 +24,11 @@ import {
resetSystemEventsForTest,
} from "../../infra/system-events.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
import {
beginSessionWorkAdmission,
isSessionLifecycleMutationActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import {
createChannelTestPluginBase,
createTestRegistry,
@@ -472,6 +477,31 @@ afterEach(async () => {
await sessionMcpTesting.resetSessionMcpRuntimeManager();
});
describe("initSessionState guarded initialization", () => {
it("rejects inbound work for an archived session", async () => {
const storePath = await createStorePath("openclaw-session-init-archived-");
const sessionKey = "agent:main:telegram:chat:archived";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: "archived-session",
archivedAt: Date.now(),
updatedAt: 100,
},
});
await expect(
initSessionState({
ctx: {
Body: "blocked while archived",
SessionKey: sessionKey,
},
cfg: { session: { store: storePath } } as OpenClawConfig,
commandAuthorized: true,
}),
).rejects.toThrow(
'Session "agent:main:telegram:chat:archived" is archived. Restore it before starting new work.',
);
});
it("serializes concurrent initializers before reading the guarded snapshot", async () => {
const storePath = await createStorePath("openclaw-session-init-race-");
const sessionKey = "agent:main:telegram:chat:42";
@@ -1124,7 +1154,7 @@ describe("initSessionState RawBody", () => {
expect(store[sessionKey]?.modelOverrideSource).toBe("user");
});
it("preserves user-set behavior overrides across an implicit daily stale rollover (#92562)", async () => {
it("preserves user-set behavior and pinned state across an implicit daily stale rollover (#92562)", async () => {
// Regression: session-level behavior overrides (/think, /verbose, /reasoning,
// /trace, ttsAuto) survive an explicit /new but were dropped after the
// automatic daily/idle reset, because the carryover was gated on
@@ -1151,6 +1181,7 @@ describe("initSessionState RawBody", () => {
traceLevel: "high",
reasoningLevel: "low",
ttsAuto: "always",
pinnedAt: 123,
},
});
@@ -1179,6 +1210,7 @@ describe("initSessionState RawBody", () => {
expect(result.sessionEntry.traceLevel).toBe("high");
expect(result.sessionEntry.reasoningLevel).toBe("low");
expect(result.sessionEntry.ttsAuto).toBe("always");
expect(result.sessionEntry.pinnedAt).toBe(123);
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
@@ -1188,6 +1220,7 @@ describe("initSessionState RawBody", () => {
traceLevel?: string;
reasoningLevel?: string;
ttsAuto?: string;
pinnedAt?: number;
}
>;
expect(store[sessionKey]?.thinkingLevel).toBe("medium");
@@ -1195,6 +1228,7 @@ describe("initSessionState RawBody", () => {
expect(store[sessionKey]?.traceLevel).toBe("high");
expect(store[sessionKey]?.reasoningLevel).toBe("low");
expect(store[sessionKey]?.ttsAuto).toBe("always");
expect(store[sessionKey]?.pinnedAt).toBe(123);
});
it("preserves usage footer mode across daily rollover", async () => {
@@ -3717,6 +3751,332 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
expect(archived).toHaveLength(1);
});
it("drains foreign work before replacing a reply session", async () => {
const storePath = await createStorePath("openclaw-rollover-admission-");
const sessionKey = "agent:main:telegram:dm:rollover-admission";
const existingSessionId = "session-before-admitted-rollover";
const transcriptPath = path.join(path.dirname(storePath), `${existingSessionId}.jsonl`);
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: existingSessionId, updatedAt: Date.now() },
});
await fs.writeFile(transcriptPath, '{"type":"message"}\n', "utf8");
let signalInterrupted = () => {};
const interrupted = new Promise<void>((resolve) => {
signalInterrupted = resolve;
});
const admission = await beginSessionWorkAdmission({
scope: storePath,
identities: [sessionKey, existingSessionId],
assertAllowed: () => {},
onInterrupt: signalInterrupted,
});
const initialization = initSessionState({
ctx: {
Body: "/new",
RawBody: "/new",
CommandBody: "/new",
From: "user-rollover-admission",
To: "bot",
ChatType: "direct",
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
},
cfg: { session: { store: storePath, idleMinutes: 999 } } as OpenClawConfig,
commandAuthorized: true,
});
try {
await interrupted;
// Foreign owners may need the writer lane to finalize before releasing.
// The rollover must not hold that lane while it drains them.
await runExclusiveSessionStoreWrite(storePath, async () => {});
expect(readSessionStoreForTest(storePath)[sessionKey]?.sessionId).toBe(
existingSessionId,
);
expect(await fs.stat(transcriptPath).catch(() => null)).not.toBeNull();
admission.release();
const result = await initialization;
expect(result.sessionId).not.toBe(existingSessionId);
expect(await fs.stat(transcriptPath).catch(() => null)).toBeNull();
} finally {
admission.release();
await initialization.catch(() => {});
}
});
it("keeps the initiating reply admission during an in-band rollover", async () => {
const storePath = await createStorePath("openclaw-rollover-initiator-");
const sessionKey = "agent:main:telegram:dm:rollover-initiator";
const existingSessionId = "session-before-initiating-rollover";
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: existingSessionId, updatedAt: Date.now() },
});
const onInterrupt = vi.fn();
const admission = await beginSessionWorkAdmission({
scope: storePath,
identities: [sessionKey, existingSessionId],
assertAllowed: () => {},
onInterrupt,
});
try {
const result = await admission.run(
async () =>
await initSessionState({
ctx: {
Body: "/reset",
RawBody: "/reset",
CommandBody: "/reset",
From: "user-rollover-initiator",
To: "bot",
ChatType: "direct",
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
},
cfg: { session: { store: storePath, idleMinutes: 999 } } as OpenClawConfig,
commandAuthorized: true,
}),
);
expect(result.sessionId).not.toBe(existingSessionId);
expect(onInterrupt).not.toHaveBeenCalled();
} finally {
admission.release();
}
});
it("cancels a competing admitted rollover without deadlocking the session", async () => {
const storePath = await createStorePath("openclaw-rollover-contenders-");
const sessionKey = "agent:main:telegram:dm:rollover-contenders";
const existingSessionId = "session-before-competing-rollovers";
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: existingSessionId, updatedAt: Date.now() },
});
const controllers = [new AbortController(), new AbortController()];
const admissions = await Promise.all(
controllers.map(
async (controller) =>
await beginSessionWorkAdmission({
scope: storePath,
identities: [sessionKey, existingSessionId],
assertAllowed: () => {},
onInterrupt: () => controller.abort(new Error("competing rollover interrupted")),
}),
),
);
const runRollover = async (index: number) => {
const admission = admissions[index];
const controller = controllers[index];
try {
return await admission.run(
async () =>
await initSessionState({
ctx: {
Body: "/new",
RawBody: "/new",
CommandBody: "/new",
From: `user-rollover-contender-${index}`,
To: "bot",
ChatType: "direct",
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
},
cfg: { session: { store: storePath, idleMinutes: 999 } } as OpenClawConfig,
commandAuthorized: true,
signal: controller.signal,
}),
);
} finally {
admission.release();
}
};
const outcomes = await Promise.allSettled([runRollover(0), runRollover(1)]);
expect(outcomes.filter((outcome) => outcome.status === "fulfilled")).toHaveLength(1);
expect(outcomes.filter((outcome) => outcome.status === "rejected")).toHaveLength(1);
expect(readSessionStoreForTest(storePath)[sessionKey]?.sessionId).not.toBe(
existingSessionId,
);
});
it.each([
{
name: "reuses a fresh replacement without interrupting its work",
body: "continue",
expectedInterruption: false,
},
{
name: "reacquires a changed identity before an explicit reset",
body: "/new",
expectedInterruption: true,
},
])("$name", async ({ body, expectedInterruption }) => {
const storePath = await createStorePath("openclaw-rollover-revalidation-");
const sessionKey = "agent:main:telegram:dm:rollover-revalidation";
const staleSessionId = "stale-session-before-revalidation";
const replacementSessionId = "fresh-replacement-before-revalidation";
const postDrainSessionId = "replacement-created-during-drain";
const finalGapSessionId = "replacement-created-before-destructive-write";
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: staleSessionId,
updatedAt: Date.now() - 10 * 60_000,
},
});
let replacementInterrupted = false;
let replacementIdentityFenced = false;
let postDrainIdentityFenced = false;
let finalGapIdentityFenced = false;
let postDrainAdmission: Awaited<ReturnType<typeof beginSessionWorkAdmission>> | undefined;
let finalGapAdmission: Awaited<ReturnType<typeof beginSessionWorkAdmission>> | undefined;
let postDrainFinalization = Promise.resolve();
let finalGapRebind = Promise.resolve();
let releaseReplacementAdmission = () => {};
const replacementAdmission = await beginSessionWorkAdmission({
scope: storePath,
identities: [replacementSessionId],
assertAllowed: () => {},
onInterrupt: () => {
replacementInterrupted = true;
replacementIdentityFenced = isSessionLifecycleMutationActive(storePath, [
replacementSessionId,
]);
if (!expectedInterruption) {
releaseReplacementAdmission();
return;
}
postDrainFinalization = runExclusiveSessionStoreWrite(storePath, async () => {
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: postDrainSessionId, updatedAt: Date.now() },
});
})
.then(async () => {
let releasePostDrainAdmission = () => {};
postDrainAdmission = await beginSessionWorkAdmission({
scope: storePath,
identities: [postDrainSessionId],
assertAllowed: () => {},
onInterrupt: () => {
postDrainIdentityFenced = isSessionLifecycleMutationActive(storePath, [
postDrainSessionId,
]);
releasePostDrainAdmission();
finalGapRebind = runExclusiveSessionStoreWrite(storePath, async () => {
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: finalGapSessionId, updatedAt: Date.now() },
});
let releaseFinalGapAdmission = () => {};
finalGapAdmission = await beginSessionWorkAdmission({
scope: storePath,
identities: [finalGapSessionId],
assertAllowed: () => {},
onInterrupt: () => {
finalGapIdentityFenced = isSessionLifecycleMutationActive(storePath, [
finalGapSessionId,
]);
releaseFinalGapAdmission();
},
});
releaseFinalGapAdmission = finalGapAdmission.release;
});
},
});
releasePostDrainAdmission = postDrainAdmission.release;
})
.finally(releaseReplacementAdmission);
},
});
releaseReplacementAdmission = replacementAdmission.release;
let signalMutationStarted = () => {};
const mutationStarted = new Promise<void>((resolve) => {
signalMutationStarted = resolve;
});
let releaseMutation = () => {};
const mutationGate = new Promise<void>((resolve) => {
releaseMutation = resolve;
});
const blockingMutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, staleSessionId],
run: async () => {
signalMutationStarted();
await mutationGate;
},
});
await mutationStarted;
let signalWriterStarted = () => {};
const writerStarted = new Promise<void>((resolve) => {
signalWriterStarted = resolve;
});
let releaseWriter = () => {};
const writerGate = new Promise<void>((resolve) => {
releaseWriter = resolve;
});
const blockingWriter = runExclusiveSessionStoreWrite(storePath, async () => {
signalWriterStarted();
await writerGate;
});
await writerStarted;
const initialization = initSessionState({
ctx: {
Body: body,
RawBody: body,
CommandBody: body,
From: "user-rollover-revalidation",
To: "bot",
ChatType: "direct",
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
},
cfg: { session: { store: storePath, idleMinutes: 1 } } as OpenClawConfig,
commandAuthorized: true,
});
const replaceSession = runExclusiveSessionStoreWrite(storePath, async () => {
await writeSessionStoreFast(storePath, {
[sessionKey]: { sessionId: replacementSessionId, updatedAt: Date.now() },
});
});
try {
releaseWriter();
await blockingWriter;
await replaceSession;
releaseMutation();
await blockingMutation;
const result = await initialization;
await postDrainFinalization;
await finalGapRebind;
expect(replacementInterrupted).toBe(expectedInterruption);
if (expectedInterruption) {
expect(replacementIdentityFenced).toBe(true);
expect(postDrainIdentityFenced).toBe(true);
expect(finalGapIdentityFenced).toBe(true);
expect(result.sessionId).not.toBe(finalGapSessionId);
} else {
expect(result.sessionId).toBe(replacementSessionId);
}
} finally {
releaseWriter();
releaseMutation();
replacementAdmission.release();
postDrainAdmission?.release();
finalGapAdmission?.release();
await Promise.allSettled([blockingWriter, replaceSession, blockingMutation, initialization]);
await postDrainFinalization.catch(() => {});
await finalGapRebind.catch(() => {});
}
});
it("archives the old session transcript on daily/scheduled reset (stale session)", async () => {
// Daily resets occur when the session becomes stale (not via /new or /reset command).
// Previously, previousSessionEntry was only set when resetTriggered=true, leaving

Some files were not shown because too many files have changed in this diff Show More