From f2fccfab05f36f400a671026d95263db790affd2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 24 Oct 2026 11:20:11 -0700 Subject: [PATCH] test(core,ui): remove low-value tests (batch d480) (#182734) * test(core,ui): remove low-value tests (batch d480) Retain the reviewed, CI-corrected subset of campaign batch d480: 24 test files, net -4125 lines, and one shrink-only timeout baseline update. Production code is unchanged. Restore incompatible or insufficiently justified reductions to the pinned main. Preserve review and failure dispositions in PR #182734. Local proof is formatting, plain oxlint, and diff checks only; hosted CI owns runtime and type proof. This squashed rebase preserves the reviewed final tree while allowing the line-cap hook to compare the actual batch against main, not an intermediate reduction. Original replayed commit messages follow for provenance; their historical scope and first-pass metrics are superseded by the final PR body. test(gateway): deslop r0083 tests Factor repeated session writes, list reads, dispatch gates, settings, and ACP fixtures without removing lifecycle or authority cases. Remove the HTTP phased-output replay: commentary fallback and cursor identity remain covered by session-history-reads.test.ts; commentary/final sibling projection by chat-display-projection.media.test.ts; output_text normalization and control suppression by server.chat.gateway-server-chat-history.test.ts. Retain HTTP history and SSE boundary tests. Reduce same-path Accept variants while retaining each parser decision path. CI-only campaign: formatting, plain oxlint, and diff checks pass. Runtime tests and coverage intentionally deferred to hosted CI. Reduction is 4964 to 4803 lines (3.2%), below the 35% target because remaining cases protect distinct behavior. test(gateway): remove duplicate r0083 coverage layers Replace randomized selector replay with existing resident resolution and scoped-locator proof in sessions-resolve-projection and session-row-projection. Keep active sentinel precedence, current child ownership, exact pagination, archive-zero, and owner-roster regressions. Search-scope and session-utils-owners cover spawned classification; archived projection owns promote/demote replay. Retire cron handoff/rearm replay covered by server-cron owner integration tests; retain reserved replacement, cleanup uncertainty, and retry races. Retire incognito reset and initiating-admission replay covered by session-lifecycle-incognito and session-controller/session-lifecycle-admission owner tests. Remove HTTP profile, forwarding, hidden-turn, parser-spy, and private-retention replay covered by chat-display-projection, server.chat-history-cursor, session-history-state, and focused accessor tests. Remove chat abort/pre-admission/root replay covered by chat.abort-persistence, sessions.abort-agent-scope, gateway-work-admission, and chat-send-pre-admission. Preserve legacy settings capture, stale rejection, and restart persistence ordering. Trim project credential, attachment custody, failure-history, alias, and fixture-inventory replay covered by clone-identity.e2e, pending-input-media-custody, create.durability, workspace-policy, worktree-base, and service.gc. Keep actual deferred setup, abort, retry, sandbox, corrupt input, and prepublication GC custody. CI-only: plain oxlint, oxfmt, and diff check pass; no test/build/coverage runs. Original shard 4964 lines to 3593 (27.6 percent), below the requested 35 percent; further deletion would remove distinct retained boundary or lifecycle proof. test(infra): deslop r0084 tests test(infra): consolidate r0084 overlapping coverage Remove native TEMP-schema replays covered by sqlite-schema-facts.temp.test.ts: keeps TEMP trigger admission local, revokes admission for ordinary DDL, expires rolled-back TEMP facts, and does not suppress reentrant MAIN DDL. Retain native tracking-schema and read-scope behavior. Consolidate stale-generation event rejection into projects model events only into their current run owner, and retained-routing rejection into reserves exclusive run ids for owner-only delivery and cleanup. Remove SQL call-shape instrumentation and copied backup inventories while retaining observable integrity rejection, exact restored rows, unrelated-session preservation, and live Git scheduling proof. Exact migrated identity/socket bytes remain covered by client-credential-migration.test.ts and client-credential-migration.postgres.test.ts. CI-only: oxfmt, plain oxlint, and diff-check passed; no tests run. Reduction remains below the requested 35 percent because the retained lifecycle, custody, and durability cases cover distinct paths. test(infra): trim r0084 repeated fixture layers Reuse credential maintenance cleanup and compact fixed SQL seeds. Orphan rebuilding remains covered by client-credential-migration.test.ts: leaves copied on source rollback and rebuilds an orphan without merging newer cached tokens. Remove native stale-cursor replay covered by sqlite-native-observer.test.ts: binds iterators immediately and holds custody until exhaustion or explicit return; preserves native cursor behavior when an invalidated iterator is stepped. Keep schema-level settlement and the unique TEMP tracking-write check; ordinary TEMP admission remains in sqlite-schema-facts.temp.test.ts. Keep configured custom-owner and main-override relocation; remove their explicit standard-directory replay and fresh missing-source replay covered by doctor-state-migrations.test.ts. Keep effective credential preservation, selected-env exact transfer, independent order/override cases, and all integrity/authority/crash paths. CI-only: plain oxlint, oxfmt, and diff-check passed. No tests run; 35 percent reduction remains unmet. test(chat): deslop r0081 tests Consolidate repeated browser layout/style measurements and activity/turn fixture markup. Keep every test case, including viewport, engine, focus, scroll, and ordering boundaries. Gateway/history tests remain unchanged because their state and lifecycle paths are distinct. test(chat): reduce r0081 fixture and assertion duplication Consolidate same-path terminal variants and trim repeated layout readers, progress snapshots, closed menu fixtures, and history request payload assertions. Keep both terminal ownership branches and all layout assertions. Configured-main alias replay remains covered by session-key.test.ts (uses advertised main identity) and the cache owner's normalization path. Retain the keyed-commentary final regression from af210e1f3fbf, disabled-commentary delivery, and content-block tool identity extraction after preservation review. test(chat): consolidate r0081 layout and event fixtures Share layout snapshots, composer selectors, geometry assertions, and minimal transcript fixtures. Retain layout-specific image, virtual-row, scrolling, and hit-target checks. Collapse reconnect and late-abort variants into tables while preserving both outcomes and exact transcript assertions. CI-only: plain oxlint, oxfmt and diff-check passed; no tests or coverage run. Original 5168 lines to 4439 (-14.1%); the 35% target remains unmet because no equivalent keeper was found for the distinct layout and event-ordering regressions. test(agents): deslop r0085 tests Consolidate transcript, subscription model, and recovery fixtures while preserving distinct authority, signature, streaming, and persistence paths. Fold equivalent thinking cases into tables without reducing their expanded cases. The fresh-output table covers the removed duplicate frozen-history growth case. Retain explicit untruncated-frozen overflow coverage for the source-versus-replacement path. Validation is static only under the campaign CI-only instruction; no tests or coverage run. test(agents): consolidate r0085 owner-boundary coverage Replace repeated recovery stream and transcript setup with local fixtures, retain exact stream results and retry/notification boundaries, and remove redundant compaction call inventories. Prompt-surface wiring remains covered by system-prompt.test.ts (resolves helper session keys to scoped prompt surfaces), embedded-agent-runner/system-prompt.test.ts (forwards the subagent prompt surface to embedded prompt rendering), and plugins/commands.test.ts (normalizes and filters structured agent prompt guidance by surface). Reconciler behavior remains in provider-stream.lifecycle.test.ts (registers provider streams with the resolved runtime lifecycle handle; first direct-compaction provider request). Remove generic warning-cache, lease-reuse, and multi-result cap replays covered by infra/dedupe.test.ts, session-prompt-state.test.ts, tool-result-truncation.cache-ttl.test.ts, and tool-result-truncation.estimate.test.ts. Keep alias admission, source-versus-replacement frozen history, authority, lifecycle, spill and SQLite contracts. Static lint/format/diff checks only under the CI-only campaign instruction. Cumulative line reduction remains below the 35 percent target; no distinct boundary was removed to force the metric. test(agents): factor r0085 cancellation and projection scenarios Share paused preparation setup across admission, context-engine, model-resolution and budget-cancellation rows without removing their distinct authority or cleanup checks. Consolidate sanitizer and stale-signature fixtures, projection assertions and SQLite recovery setup; remove redundant native argument and prompt-setter echoes. All changes remain in the three shard tests. Static formatting, plain oxlint and diff checks pass; no tests or builds run under CI-only instructions. Cumulative shard lines are 5083 to 4015, still below the requested 35 percent reduction. Keep the remaining distinct authority, persistence, spill and recovery behavior rather than delete it for the metric. test(plugins): deslop r0086 tests Consolidate provider alias and embedding fixtures, dimension assertions, and marketplace temp setup. Retain the same trust, redaction, cleanup, and protocol boundaries. Remove duplicate metadata projection checks covered by projects without rediscovery and completes a planning view. Cross-process exclusion remains in reloads install records after waiting for another process, now asserting that a blocked callback never runs. Nested lease identity remains in the demand test. CI-only campaign: plain oxlint, oxfmt, and git diff --check pass. No test, coverage, build, or Testbox runs. Reduction is below the 35 percent target; retain distinct source-capture, lifecycle recovery, and metadata boundary cases. test(plugins): consolidate r0086 fixture layers Replace fabricated marketplace response objects with real streams and share known-marketplace setup. Remove Git argv replays covered by git-source.test.ts: preserves each caller ref selection against a local repository. Reuse existing HTTP and CLI child-process fixtures; preserve wire bodies, cache identity, cancellation, phase ordering, and process-bound plugin recovery. Consolidate metadata registry setup and remove redundant internal call-shape assertions. The update-volume duplicate remains covered by keeps update source copies in TMPDIR without relocating retained native captures, plus caller-custody tests. Two dimension input variants use the same mismatch branch as retained single-item and late out-of-order batch cases. External-policy fake full/legacy branches are replaced by one observable hook result. CI-only: oxfmt, plain oxlint, and git diff --check pass. No tests, builds, coverage, or Testbox runs. Total shard reduction remains below 35 percent; distinct ownership and recovery boundaries retained. test(plugins): remove duplicate r0086 boundary layers Remove packaged-discovery and exact-source-owner cases duplicated verbatim in provider-discovery.capture-lifetime.test.ts. Provider-policy-ownership.test.ts keeps bundled first-winner precedence; preserve root-basename fallback in the routing table. Remove the embedding oversized-response replay. minimax-vlm.normalizes-api-key.test.ts bounds large successful response bodies through the same default readProviderJsonResponse owner, proving the exact cap, early stop, and cancellation. Keep plugin-specific error and deadline behavior. Trim source-capture worker custody and payload-retry replays covered by plugin-generation-artifact.open-batch.test.ts and plugin-source-capture-recovery.test.ts. Preserve revoked cleanup authority and instance-removal retries. Keep metadata boot test focused on its scheduler contract. Reduce lease outcome combinations to the unique successful-result-with-cleanup-failure case; retained process-cleanup and revocation cases cover operation failures, and demand tests cover default-path serialization. CI-only: plain oxlint, oxfmt, and git diff --check pass. No tests/builds/coverage. Total reduction remains below 35 percent; no further proven duplicate layer found. test(config): deslop r0087 tests Consolidate recovery race fixtures, raw-file assertions, and write scenarios while retaining distinct authority, durability, and environment-provenance cases. Remove the repeated prefix-recovery cap exercise: io.clobber-snapshot.test.ts retains concurrent cap, timestamp rotation, and one-warning coverage. The writer recovery case retains exact forensic bytes and permission hardening. CI-only validation: plain oxlint, oxfmt, and diff-check passed. No tests or coverage run. Reduction remains below 35% because remaining cases cover distinct boundaries. test(config): consolidate r0087 owner-boundary coverage Remove repeated policy, include-provenance, model-map, locking, numeric-key, and recovery-policy exercises. Retain distinct writer authority, rollback, recovery errors, and all input-intent variants; share fixtures and reduce internal argument assertions. Remaining proof: io.meta.test.ts defers unresolved restrictions and honors explicit allow-any; includes.test.ts records arrays, sibling overrides, delegates and allowed roots; io.write-prepare.test.ts covers touched model maps and explicit canonical maps; env-preserve.test.ts covers ambiguous references and escaped moves; io.paired-source-write.test.ts retains real environment-drift writes. Lock and admission proof remains in io.write-lock.test.ts. Numeric-key proof remains in io.write-prepare.numeric-keys.test.ts. Committed revision proof remains in gateway/server.config-patch.test.ts (config.set pairs the committed config and revision while another writer waits). Root nonfinite and comment-warning integration stays in this shard. Recovery duplicates are covered by io.snapshot.recovery.test.ts (replaced-backup identity drift), recovery-policy.test.ts (plugin-local invalid config), and io.observe-freshness.test.ts (newer persisted facts and worker-updated fields). Config journal fingerprint privacy remains in config-journal-snapshot.test.ts; the writer keeps committed-hash slot linkage. Authorized roster deletion remains in io.agent-deletion.test.ts. CI-only: plain oxlint, oxfmt, and diff-check passed; no tests, coverage, or builds run. Total shard 5107 to 3981 lines, below the requested 35 percent reduction because further cuts would remove distinct behavior. test(config): reduce remaining r0087 replay fixtures Consolidate plugin-source persistence around its credential and authored-config outcomes, without repeating default materialization and notification inventories. MCP write behavior remains in mcp-config.test.ts; runtime publication in runtime-snapshot.test.ts; plugin defaults in validation.channel-metadata.test.ts. Keep both direct and MCP writes and both cold and reloaded runtime states. Remove simple invalid-sibling repair replay covered by the retained nested-entry repair. Retire mutation-only revision, preflight-order, path-loss compensation, escaped-root, and shadowed-include replays: keepers are server.config-patch.test.ts committed revision, io.write-preflight.test-support.ts caller guard ordering, io.write-reread.test.ts compensation authority, retained include failed-refresh rollback, io.write-config.test.ts escaped literals, and include-write-boundary.test.ts same-path merges. Retain the only direct write-time tilde restoration assertion and distinct recovery hardening, durability, and publication failures. Plain oxlint, oxfmt, and diff-check pass. No tests, coverage, or builds run under CI-only instructions. The requested overall 35 percent reduction remains unmet without removing distinct retained behavior. test(state): deslop r0088 tests Consolidate repeated fixture setup, exact anchor and migration assertions, and integrity gate setup without dropping any cases. Preserve worker publication, PostgreSQL migration, authority, recovery, and lifecycle interleavings. This already-consolidated shard cannot meet the 35% line target without removing distinct safety coverage. test(state): remove duplicate r0088 coverage layers Consolidate state v25-v28 upgrade proof into the retained v24 real-worker migration, preserving heads, leases, publication intent, index repair and admission metadata. Keep PostgreSQL-specific FTS and FIFO proofs; remove generic catalog, Doctor, auth and search replays covered by database-engine conformance suites. Keeper map: worker idle renewal -> openclaw-agent-execution.idle; pooled factory refusal -> sqlite-worker-open-refusal; pinned WAL close -> openclaw-agent-db.integrity-cache; periodic maintenance -> sqlite-wal.agent-admission; publication cleanup outcomes -> openclaw-agent-worker-store.env-capture. Retain actual publication authority, drain, cancellation and failed lease cleanup checks. GitHub bookkeeping -> github-repository-publication recovered-push and github-shared-publication-events; stale receipt -> state-domain-publication. Keep lazy schema insertion, source guards, closed-owner fencing and batch rollback. Native integrity prepared-existing and invalidated variants remain covered by openclaw-agent-execution.schema-admission; verified host proof remains in the retained two-leases case. PostgreSQL keepers: model-catalog and post-ready conformance; mixed-agent-readers; gateway-session-startup and doctor-session-entry-state; session-search-product.postgres; transcript-projection-flush and transcript-identity-insert. Worker retention remains in store-retention. CI-only: formatting, plain oxlint and diff checks passed; no tests or coverage run. Original shard 4879 -> 3940 lines (19.2%); 35% target remains unmet without dropping distinct safety behavior. test(state): consolidate r0088 lifecycle and integrity proof Fold warm worker reopen into retained shutdown-lease coverage. Remove root-close integration replay: openclaw-agent-db-resources owns draining-root fencing and concurrent sibling settlement; openclaw-agent-db-open-authority and disposal own refusal before new state; the retained worker drain tests own accepted publication settlement. Restrict native integrity matrix to actual failed-authority and live-revocation cases. Valid-database reuse, receipt custody and replacement are covered by openclaw-agent-db-validation-cache; empty-WAL and peer-lease semantics by openclaw-agent-db.integrity-lease. Retain real killed-owner corruption, legacy provenance, PID reuse and interrupted admission. Drop redundant peer return dumps and AbortSignal once-listener counts, preserving persisted results and terminal error identity. Remove PostgreSQL close/reopen metadata replay covered by mixed-agent-readers, duplicate extension JSON covered by engine conformance, and the small FTS matrix replay covered by retained large and second-backend inserts. No production or support changes. Static gates only: formatting, plain oxlint and diff check passed. No tests or coverage run. Original shard 4879 to 3671 lines; the 35 percent target remains unmet rather than dropping distinct security or durability behavior. test(gateway): consolidate r0090 lifecycle proof and fixtures Keep branch representatives for finishing-result failures instead of replaying the stop/error cross product and cleanup wording variants. Preserve normal cancellation separately from drain-owned cancellation. Remove executor outcome-unknown duplication covered by inference.test.ts: preserves independent authority and native failures through execute settlement. Keep exact claim rejection through the real adapter in stops an unconfirmed old launch before dispatching its replacement, replacing the mocked revalidation and same-helper size comparison. Share abort providers, settlement observers, node adapter/workspace fixtures, and committed project setup proof. Retain explicit security, durability, replacement and shutdown interleavings. CI-only: oxlint, oxfmt and diff checks pass; no test runs. test(gateway): deslop r0089 tests Consolidate successful publication RPC assertions and repeated history message matchers without removing cases or changing authority and lifecycle scenarios. Keep separate proof for publication revocation/recovery, dispatch settlement, session durability, wire projection, and product authority boundaries. CI-only campaign: formatting, diff check, and plain oxlint passed; runtime and coverage verification deferred to hosted CI. Reduction is intentionally below the shard target because no safe redundant layer was established. test(gateway): remove redundant r0089 proof layers Retain real websocket reconnect, terminal ordering, publication authority, and early-checkout product proof. Remove duplicate lower-level assertions and exhaustive global order/attachment combinations. No production or shared helper changes. Keepers: chat-send-attachments.owner.test.ts (unsandboxed cleanup before cancellation; managed-PDF pass-through cleanup) plus server.chat.gateway-server-chat-lifecycle.test.ts (sessions.abort terminalizes a restart-safe chat before agent adoption) replace the layered attachment-abort replay. chat-send-profile-binding.test.ts (preserves accepted native input across %s without adopting the retry socket; retains the original caller through initial authorization without an expected profile) owns receipt and profile rejection details. chat-send-admission-authority.test.ts and chat-send-admission.reads.test.ts retain admission revocation cases. Store keepers: server.sessions.store-paths.test.ts (resolves and patches main alias to default agent main key), sessions-resolve-projection.test.ts (bounds exact resident selection without SQLite and sees committed label changes), server.sessions.effort-scopes.test.ts (write-scoped operators change and reset effort in an existing session), sessions-patch.test.ts (normalizes and clears fields; custom SVG persistence; archive attribution), session-row-selection.test.ts (maintains list order across metadata changes and archived-row rematerialization), sessions-patch-model-selection.test.ts (rejected patch preservation), and session-groups.test.ts (ordered catalogs, rename, delete). Retain successful write-scoped group RPCs and implicit model-runtime projection here. Wire keepers: server-chat.stream-text-merge.test.ts (certified raw appends and uncapped boundaries), server-broadcast.live-text.test.ts (one recipient baseline and blocked appends), and remaining real wire assertions. Personal replay validation remains in github-repository-publication.test.ts (original personal selection/content replay); retained-index recovery remains in github-publication-git-index.test.ts (recovers the retained index when ambiguous CAS moved), while same-owner confirmation after restart remains here. CI-only: no test, build, coverage, or Testbox execution. Plain oxlint, oxfmt, and diff check passed. Original shard 4421 -> 3928 lines (11.2%); 35% target remains unmet because further verified redundant layers were not established. test(gateway): deslop r0089 tests Reuse broadcast peers and publication transport fixtures; consolidate native replacement rows, caller setup, settlement helpers, and repeated RPC assertions. Retained owner proof: sessions-github-read.target.test.ts covers visibility and awaited authority loss; sessions-github-read.test.ts covers pending personal-read authority. github-publication.test.ts covers advanced-branch recovery and github-publication-boundaries.test.ts covers changed worktree fingerprints. chat-send-admission-authority.test.ts covers read-only membership revocation; chat-send-attachments.owner.test.ts covers global attachment ownership; attempt.cwd-split.test.ts covers read-tool checkout gating. Preserve distinct WebSocket reconnect/abort ordering, personal recovery effects and confirmation ownership, streamed retirement/cap behavior, global transcript isolation, and real checkout write/send revocation. CI-only: formatting, plain oxlint and diff check passed; no tests or coverage run. Original 4421 lines -> 3597 (18.6%); the requested 35% target remains unmet. test(ui): deslop r0091 tests Reuse the existing session-diff fixture and consolidate repeated audio, widget, and transcript test setup without removing behavior cases. Preserve focus, lifecycle, authority, and ordering assertions. CI-only campaign: formatting, plain oxlint, and diff checks passed; runtime and coverage proof deferred to hosted CI. test(ui): consolidate r0091 cross-layer proof Replace tool outcome cross-products with decision-branch cases and retain only the Raw-tab update contract locally. Per-file patch rendering and disclosure are covered by chat-tool-turn-outcome.e2e tests; item-only failure/blocked/unknown outcomes by chat-tool-item-outcomes.test-support. Keep skipped-only receipts and zero-exit failure regressions. Drop redundant transcript geometry and stream-counter layers covered by chat-transcript-geometry, geometry-updates.browser, end-follow.browser, projection-scale and invalidation suites. Keep remaining restore/teardown/observer races and move row identity assertions into retained cases. Retain the Review preference/native boundary and defer duplicate navigation/rename assertions to session-diff-panel.browser and chat-session-diff.e2e. Consolidate pin focus/toast/membership proof through ToolPreview and preserve long-duration audio formatting in the playback case. No production edits. CI-only: formatting, plain oxlint and diff check pass; no tests run. test(ui): remove redundant r0091 fixture layers Replace the local folding/menu/refresh replay with existing real-Gateway proof in chat-session-diff-folding.real-gateway.e2e.test.ts: folds actual sessions.diff source without concealing edits in expanded files. Keep path-key edge coverage with one representative per decision branch. Delegate rendition retry schedule to chat-media-playback.test.ts: retries at the default delays until the rendition is ready, and stops after the bounded two-minute retry schedule. Consolidate media state setup and retain inline download, duration gating, long-duration formatting, focus and lifecycle assertions. Remove cached-versus-fresh whole-object oracles and a duplicate direct Tool Search render. Retain explicit stream ownership, marker content, immutable prior state, raw sidebar identity and prepared failure behavior. CI-only: plain oxlint, formatting and diff-check passed; no tests run. test(sessions): deslop r0093 tests Share OAuth boundary profile setup, plugin loading, native key resolution, and rotation assertions without changing adapter cases or durable settlement checks. The retained 'forks a catalog with request-isolated auth and provider mutations' test now also proves the inline models.json credentials previously covered by 'preserves models.json provider auth in a catalog fork'. CI-only validation: oxfmt, plain oxlint, and git diff --check. No tests or coverage run. Conservative 112-line reduction; distinct security, migration, and lifecycle cases retained. test(sessions): reduce r0093 duplicate proof and fixtures Remove bounded-session mutation cases duplicated by same-named tests in session-manager-mutation-boundaries.test.ts, plus its bounded-assistant variant. Keep admitted-assistant persistence assertions in the stronger concurrent-reload case. Consolidate manager/serialized provider-change fixtures and shrink model and SDK setup without dropping distinct auth, publication, routing or lifecycle behaviors. Drop SQL/internal call-shape assertions while retaining observable state and effect checks. OAuth discovery recovery remains covered by third-party discovery settlement and xai-oauth.test.ts discovery failure/rediscovery tests; socket fencing remains in the bundled xAI matrix. No production or support-file changes. CI-only proof: plain oxlint exits 0, oxfmt and diff check pass; no test runs. Cumulative shard LOC 4732 to 4132 (12.7%); remaining distinct boundaries prevent the requested 35% cut without coverage loss. * test: remove obsolete truncation line-cap allowance --- config/max-lines-baseline.txt | 1 - config/test-timeout-race-baseline.txt | 2 +- .../compact.hooks.test.ts | 502 ++----- .../embedded-agent-runner/thinking.test.ts | 1254 +++++------------ .../tool-result-truncation.test.ts | 795 ++++------- src/agents/sessions/auth-storage.test.ts | 405 +++--- src/agents/sessions/model-registry.test.ts | 321 ++--- .../sessions/sdk.auth-migration.test.ts | 138 +- ...rver.chat-abort-dispatch-rejection.test.ts | 74 +- .../server.sessions.reset-cleanup.test.ts | 105 +- src/gateway/session-row-selection.test.ts | 732 ++-------- src/gateway/sessions-history-http.test.ts | 316 +---- ...ker-turn-launcher-terminal-results.test.ts | 29 +- src/infra/agent-events.test.ts | 83 +- src/infra/git-worker.lifecycle.test.ts | 29 - ...state-migrations.shared-auth-store.test.ts | 133 +- src/plugins/marketplace.test.ts | 532 +++---- ...enai-compatible-embedding-provider.test.ts | 519 +++---- src/plugins/plugin-lifecycle-lease.test.ts | 286 +--- src/state/github-publication.worker.test.ts | 231 +-- src/state/openclaw-agent-db.postgres.test.ts | 589 ++------ src/state/openclaw-agent-worker-store.test.ts | 418 +----- ui/src/pages/chat/chat-gateway.test.ts | 657 ++++----- .../chat/components/chat-audio-player.test.ts | 379 ++--- .../chat-transcript-controller.test.tsx | 148 +- .../chat-transcript-message-index.test.ts | 250 +--- 26 files changed, 2401 insertions(+), 6527 deletions(-) diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index d90597e7cfb3..501ab74b28a2 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -183,7 +183,6 @@ src/agents/embedded-agent-runner/compact.hooks.test.ts src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts src/agents/embedded-agent-runner/run/attempt-spawn-workspace.test-support.ts src/agents/embedded-agent-runner/runs.ts -src/agents/embedded-agent-runner/tool-result-truncation.test.ts src/agents/embedded-agent-runner/tool-result-truncation.ts src/agents/embedded-agent-subscribe.handlers.tools.test.ts src/agents/harness/native-hook-relay.test.ts diff --git a/config/test-timeout-race-baseline.txt b/config/test-timeout-race-baseline.txt index c8ca9c0d0a73..4849af80df8b 100644 --- a/config/test-timeout-race-baseline.txt +++ b/config/test-timeout-race-baseline.txt @@ -83,7 +83,7 @@ src/infra/push-apns.test.ts 1 src/infra/session-cost-usage-cache.worker.test.ts 5 src/infra/state-migrations.workspace-setup-sandbox.test.ts 1 src/meeting-bot/realtime-node-audio-transport.test.ts 1 -src/plugins/openai-compatible-embedding-provider.test.ts 3 +src/plugins/openai-compatible-embedding-provider.test.ts 2 src/plugins/plugin-metadata-lifecycle.test.ts 1 src/plugins/provider-self-hosted-discovery.test.ts 11 src/plugins/runtime/runtime-agent.ingress.integration.test.ts 1 diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts index 2bc4bfa37551..05313bb279a2 100644 --- a/src/agents/embedded-agent-runner/compact.hooks.test.ts +++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts @@ -1,6 +1,5 @@ // Hook integration coverage for direct and queued embedded compaction. -import { mkdtemp, realpath } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { realpath } from "node:fs/promises"; import { join } from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import type { AgentMessage, StreamFn } from "openclaw/plugin-sdk/agent-core"; @@ -19,7 +18,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { delegateCompactionToRuntime } from "../../context-engine/delegate.js"; import { racePromiseWithAbortSignal } from "../../infra/abort-signal.js"; import { createPluginMetadataSnapshotFixture } from "../../plugins/plugin-metadata.test-support.js"; -import { getModelProviderRuntimePluginHandle } from "../../plugins/provider-hook-runtime.js"; import type { CommandQueueEnqueueOptions } from "../../process/command-queue.types.js"; import type { SessionTurnAdmission } from "../../sessions/session-controller.admission.js"; import type { ReplyOperation } from "../../sessions/session-controller.contracts.js"; @@ -28,7 +26,6 @@ import { waitForSessionRunEnd } from "../../sessions/session-controller.native-r import { isSessionRunActive } from "../../sessions/session-controller.queries.js"; import { createProcessSessionFixture } from "../bash-process-registry.test-helpers.js"; import { recordModelFallbackStop } from "../model-fallback-stop.js"; -import { getModelProviderLocalServiceReconciler } from "../provider-local-service-reconcile.js"; import { createSessionMaintenanceOwner } from "../session-maintenance/coordinator.js"; import { agentSessionAutomaticCompaction } from "../sessions/agent-session-compaction.js"; import { @@ -66,15 +63,12 @@ import { getApiKeyForModelMock, getMemorySearchManagerMock, resolveMemorySearchConfigMock, - resolveSessionAgentIdMock, getHistoryLimitFromSessionKeyMock, hookRunner, limitHistoryTurnsMock, - listRegisteredPluginAgentPromptGuidanceMock, loadCompactHooksHarness, maybeCompactAgentHarnessSessionMock, resolveAgentHarnessPolicyMock, - registerProviderStreamForModelMock, resolveProviderEntryApiKeyProfileReferenceMock, resolveContextWindowInfoMock, resolveCliBackendConfigMock, @@ -125,17 +119,13 @@ const compactionFixture = useCompactHooksSessionFixture(TEST_SESSION_KEY); let TEST_SESSION_FILE: string; let TEST_WORKSPACE_DIR: string; const TEST_CUSTOM_INSTRUCTIONS = "focus on decisions"; -function plannedCompactionPluginSelections( - config: OpenClawConfig, - metadataSnapshot = createPluginMetadataSnapshotFixture({ plugins: [] }), -) { +function plannedCompactionPluginSelections(config: OpenClawConfig) { const derive = expectDefined( acquireAgentRunPreparedModelRuntimeMock.mock.calls[0]?.[1]?.deriveRuntimePluginSelections, "admitted compaction selection recipe", ); - return derive({ config, metadataSnapshot }); + return derive({ config, metadataSnapshot: createPluginMetadataSnapshotFixture({ plugins: [] }) }); } - function mockResolvedModel(supportsTools?: boolean, queued = false) { resolveModelMock.mockReset(); resolveModelMock.mockImplementation( @@ -181,17 +171,21 @@ function directCompactionArgs() { }; } +function defaultSessionTarget() { + return { + agentId: "main", + sessionId: TEST_SESSION_ID, + sessionKey: TEST_SESSION_KEY, + storePath: TEST_STORE_PATH, + }; +} + function wrappedCompactionArgs(overrides: Record = {}) { return { sessionId: TEST_SESSION_ID, sessionKey: TEST_SESSION_KEY, sessionFile: TEST_SESSION_KEY, - sessionTarget: { - agentId: "main", - sessionId: TEST_SESSION_ID, - sessionKey: TEST_SESSION_KEY, - storePath: TEST_STORE_PATH, - }, + sessionTarget: defaultSessionTarget(), workspaceDir: TEST_WORKSPACE_DIR, customInstructions: TEST_CUSTOM_INSTRUCTIONS, ...overrides, @@ -222,6 +216,34 @@ function expectNoCalls(...mocks: Mock[]) { } const abortedCompaction = { ok: false, compacted: false, reason: "compaction aborted" }; +const compacted = { ok: true, compacted: true }; +const notCompacted = { ok: false, compacted: false }; +const unchangedMaintenance = { changed: false, bytesFreed: 0, rewrittenEntries: 0 }; + +function lockedFailure(reason = "model_selection_locked") { + return { ...notCompacted, failure: { reason } }; +} + +function assertActiveWhile(active: () => boolean) { + return () => { + if (!active()) { + throw new Error("queued compaction host authority expired"); + } + }; +} + +function subscriptionModel(provider = "openai", modelId = "fake") { + return { + ...createCompactHooksResolvedModel(provider, modelId), + model: { + provider: "openai", + id: "fake", + api: "openai-chatgpt-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + input: [], + }, + }; +} function useCodexPolicy() { const policy = { runtime: "codex", runtimeSource: "model" }; @@ -419,9 +441,8 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { customInstructions: "preserve the committed state", }); expect(contextEngineCompactMock).not.toHaveBeenCalled(); - expect(createAgentSessionMock).toHaveBeenCalledOnce(); expect(sessionCompactImpl).toHaveBeenCalledOnce(); - expect(result).toMatchObject({ ok: false, compacted: false }); + expect(result).toMatchObject(notCompacted); }); it("restricts endpoint tools and excludes private skills", async () => { @@ -516,7 +537,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }, }, }); - expect(attemptServerEndpointCompactionMock).toHaveBeenCalledOnce(); expectCall(attemptServerEndpointCompactionMock, { context: expect.objectContaining({ messages: [ @@ -568,8 +588,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactDirect({ compactionTimeoutReset }); - expect(result).toMatchObject({ ok: true, compacted: true }); - expect(hookRunner.runAfterCompaction).toHaveBeenCalledOnce(); + expect(result).toMatchObject(compacted); }); it("refreshes the delegated watchdog before delayed fallback setup", async () => { @@ -599,8 +618,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { await pending; } - await expect(pending).resolves.toMatchObject({ ok: true, compacted: true }); - expect(createAgentSessionMock).toHaveBeenCalledTimes(2); + await expect(pending).resolves.toMatchObject(compacted); expect(compactionTimeoutReset).toHaveBeenCalledTimes(6); }); @@ -649,11 +667,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionEntry: { sessionId: TEST_SESSION_ID, updatedAt: 1, pluginOwnerId: "stale-owner" }, }); - expect(result).toMatchObject({ - ok: false, - compacted: false, - failure: { reason: "model_selection_locked" }, - }); + expect(result).toMatchObject(lockedFailure()); expectNoCalls(resolveModelMock, sessionCompactImpl); }); @@ -745,12 +759,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { ([params]) => (params as { profileId?: string }).profileId, ), ).toEqual(["openai:missing", "openai:backup"]); - expect(resolveModelAsyncMock.mock.calls.map((call) => call[4])).toEqual( - expect.arrayContaining([ - expect.objectContaining({ authProfileId: "openai:backup" }), - expect.objectContaining({ authProfileId: "openai:missing" }), - ]), - ); expectCall(resolveEmbeddedAgentStreamMock, { authProfileId: "openai:backup" }); expectCall(buildAgentRuntimePlanMock, { sessionAuthProfileId: "openai:backup" }); }); @@ -788,10 +796,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }, }); - expectCall(resolveSandboxContextMock, { - sessionKey: other.scopeKey, - workspaceDir: join(TEST_WORKSPACE_DIR, "workspace"), - }); expect(sandboxQueries).toEqual([]); expectCall(buildConfiguredAgentSystemPromptMock, { runtimeInfo: expect.objectContaining({ @@ -804,27 +808,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { } }); - it.each([ - ["agent:main:subagent:worker", "subagent", "minimal", "Subagent compact command guidance."], - ["agent:codex:acp:worker", "acp_backend", "full", "ACP compact command guidance."], - ] as const)( - "rebuilds the %s prompt with its guidance", - async (sessionKey, promptSurface, promptMode, guidance) => { - const runtimeCwd = join(TEST_WORKSPACE_DIR, "task-repo"); - await compactWorkspace({ sessionKey, cwd: runtimeCwd }); - expect(listRegisteredPluginAgentPromptGuidanceMock).toHaveBeenCalledWith({ - surface: promptSurface, - }); - expectCall(buildConfiguredAgentSystemPromptMock, { - promptMode, - promptSurface, - runtimeCwd, - workspaceDir: join(TEST_WORKSPACE_DIR, "workspace"), - nativeCommandGuidanceLines: [guidance], - }); - }, - ); - it.each([false, true])( "renders the compaction bootstrap notice only for omitted files (%s)", async (omitted) => { @@ -856,7 +839,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const prompt = created.session.agent.state.systemPrompt ?? ""; expect(prompt.includes("## Bootstrap Context Notice")).toBe(omitted); expect(prompt.includes("Treat Project Context as partial")).toBe(omitted); - expect(created.session.setBaseSystemPrompt).toHaveBeenCalledWith(prompt); }, ); @@ -935,11 +917,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactWorkspace({ config: { tools: { profile: "coding" } } }); expect(result.ok).toBe(true); - const toolOptions = mockCallArg(createOpenClawCodingToolsMock) as { - preparedModelRuntime: { metadataSnapshot: unknown }; - }; - expect(toolOptions.preparedModelRuntime.metadataSnapshot).toBe(metadataSnapshot); - expect(toolOptions).toMatchObject({ + expect(mockCallArg(createOpenClawCodingToolsMock)).toMatchObject({ conversationCapabilityProfile: { policy: { explicitToolAllowlist: expect.arrayContaining([toolName]) }, }, @@ -1192,8 +1170,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const run = await compactWhileStreaming(10, "done"); await vi.advanceTimersByTimeAsync(11 * deltaEveryMs); - await expect(run.pending).resolves.toMatchObject({ ok: true, compacted: true }); - expect(run.stream).toHaveBeenCalledOnce(); + await expect(run.pending).resolves.toMatchObject(compacted); }); it.each([ @@ -1216,7 +1193,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { compacted: false, reason: expect.stringContaining("timed out"), }); - expect(run.stream).toHaveBeenCalledOnce(); }); // #164246: automatic summary timeouts still commit deterministic reduction. @@ -1228,11 +1204,10 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); await vi.advanceTimersByTimeAsync(summaryCutoffMs - prepMs); - await expect(run.pending).resolves.toMatchObject({ ok: true, compacted: true }); + await expect(run.pending).resolves.toMatchObject(compacted); expect( run.sessionManager.getBranch().findLast((entry) => entry.type === "compaction"), ).toMatchObject({ summary: expect.stringContaining("removed without a summary") }); - expect(run.stream).toHaveBeenCalledOnce(); }); }); @@ -1248,23 +1223,16 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { config, }); expect(result.ok, JSON.stringify(result)).toBe(true); + const expectedSelection = { provider: "anthropic", runtime: "codex" }; expect(plannedCompactionPluginSelections(config)).toContainEqual( - expect.objectContaining({ - provider: "anthropic", - modelId: "claude-fallback", - runtime: "codex", - }), + expect.objectContaining({ ...expectedSelection, modelId: "claude-fallback" }), ); const admittedConfig = defaultsConfig({ ...config.agents?.defaults, compaction: { model: "anthropic/reloaded-summary" }, }); expect(plannedCompactionPluginSelections(admittedConfig)).toContainEqual( - expect.objectContaining({ - provider: "anthropic", - modelId: "reloaded-summary", - runtime: "codex", - }), + expect.objectContaining({ ...expectedSelection, modelId: "reloaded-summary" }), ); }); @@ -1333,14 +1301,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); expect(result).toMatchObject({ ok: true, result: { summary: "oauth fallback summary" } }); - findMockCall( - resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.5", - ); - findMockCall( - resolveModelMock, - ([provider, modelId]) => provider === "openai" && modelId === "gpt-5.4-mini", - ); expectRecordFields(mockCallArg(resolveEmbeddedAgentStreamMock, 1), { authProfileId: "openai:default", }); @@ -1393,15 +1353,9 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { }); it("skips sync in await mode when postCompactionForce is false", async () => { - const sync = vi.fn(async () => {}); - getMemorySearchManagerMock.mockResolvedValue({ manager: { sync } }); resolveMemorySearchConfigMock.mockReturnValue({ sources: ["sessions"], - sync: { - sessions: { - postCompactionForce: false, - }, - }, + sync: { sessions: { postCompactionForce: false } }, }); await compactTesting.runPostCompactionSideEffects({ @@ -1410,14 +1364,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionFile: TEST_SESSION_FILE, }); - const resolveAgentArg = mockCallArg(resolveSessionAgentIdMock); - if (typeof resolveAgentArg !== "object" || resolveAgentArg === null) { - throw new Error("Expected session agent arguments"); - } - expect(resolveAgentArg).toMatchObject({ sessionKey: TEST_SESSION_KEY }); - expect(Reflect.get(resolveAgentArg, "config")).toBeTypeOf("object"); expect(getMemorySearchManagerMock).not.toHaveBeenCalled(); - expect(sync).not.toHaveBeenCalled(); }); registerDirectProviderRefreshTests({ @@ -1426,27 +1373,6 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { sessionKey: TEST_SESSION_KEY, sessionFile: () => TEST_SESSION_FILE, }); - it("carries the prepared provider reconciler into direct compaction", async () => { - const reconcile = vi.fn(async () => undefined); - const { resolvePreparedProviderRuntimeHandle } = await import("../runtime-plan/build.js"); - vi.mocked(resolvePreparedProviderRuntimeHandle).mockImplementationOnce((params) => ({ - provider: params.provider, - modelId: params.modelId, - workspaceDir: params.workspaceDir, - prepared: true, - plugin: { id: params.provider, label: "Fixture", auth: [], reconcileLocalService: reconcile }, - })); - - await expect(compactDirect()).resolves.toMatchObject({ ok: true }); - - const streamRegistration = mockCallArg(registerProviderStreamForModelMock) as { - model: object; - }; - expect(getModelProviderLocalServiceReconciler(streamRegistration.model)).toBe(reconcile); - expect(getModelProviderRuntimePluginHandle(streamRegistration.model)).toBe( - buildAgentRuntimePlanMock.mock.calls[0]?.[0].providerRuntimeHandle, - ); - }); it("compacts an overflow transcript anchored by a compaction summary", async () => { sessionMessages.splice( 0, @@ -1466,8 +1392,7 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => { const result = await compactDirect({ trigger: "overflow" }); - expect(result).toMatchObject({ ok: true, compacted: true }); - expect(sessionCompactImpl).toHaveBeenCalledOnce(); + expect(result).toMatchObject(compacted); }); it("never falls back from an explicit compaction.model", async () => { @@ -1716,29 +1641,11 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { it("cancels only the target during runtime admission", async () => { const sourceController = new AbortController(); const admissionStarted = createDeferred(); - acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce((async ( - _input: Record, - options?: { abortSignal?: AbortSignal }, - ): Promise => { - const signal = options?.abortSignal; + acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce(async (_input, options) => { + const signal = expectDefined(options?.abortSignal, "runtime admission abort signal"); admissionStarted.resolve(signal); - if (!signal) { - throw new Error("prepared runtime lease admission did not receive the caller signal"); - } - return await new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - () => { - const error = new Error("Prepared model runtime lease admission aborted", { - cause: signal.reason, - }); - error.name = "AbortError"; - reject(error); - }, - { once: true }, - ); - }); - }) as never); + return await racePromiseWithAbortSignal(createDeferred().promise, signal); + }); const pending = compactQueued({ abortSignal: sourceController.signal, trigger: "manual" }); const admittedSignal = expectDefined( @@ -1754,57 +1661,18 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(resolveContextEngineMock).not.toHaveBeenCalled(); }); - it("releases the prepared runtime lease when host authority expires after admission", async () => { - const { AsyncWorkScope } = await import("../../shared/async-work-scope.js"); - const parent = new AsyncWorkScope(); - const admissionStarted = createDeferred(); - const releaseAdmission = createDeferred(); - const releaseLease = vi.fn(async () => {}); - const defaultAcquire = expectDefined( - acquireAgentRunPreparedModelRuntimeMock.getMockImplementation(), - "default prepared runtime acquisition mock", - ); - let hostActive = true; - acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce((async ( - input: Parameters[0], - ) => { - admissionStarted.resolve(undefined); - await releaseAdmission.promise; - const lease = await defaultAcquire(input); - return { ...lease, [Symbol.asyncDispose]: releaseLease }; - }) as never); - - const pending = parent.run(() => - compactQueued( - {}, - { - assertActive: () => { - if (!hostActive) { - throw new Error("queued compaction host authority expired"); - } - }, - }, - ), - ); - await admissionStarted.promise; - hostActive = false; - releaseAdmission.resolve(undefined); - - try { - await expect(pending).rejects.toThrow("queued compaction host authority expired"); - } finally { - await AsyncWorkScope.runWhenAllIdle( - () => [parent], - () => parent.drain(), - ); - } - expect(releaseLease).toHaveBeenCalledTimes(1); - expect(resolveContextEngineMock).not.toHaveBeenCalled(); - }); - - it.each(["context-engine", "model"] as const)( - "stops preparation when host authority expires during %s resolution", - async (stage) => { + it.each([ + ["admission", false], + ["context-engine", false], + ["model", false], + ["context-engine", true], + ] as const)( + "stops preparation at %s after authority expires (budget cancellation: %s)", + async (stage, cancel) => { + const { AsyncWorkScope } = await import("../../shared/async-work-scope.js"); + const parent = new AsyncWorkScope(); + const controller = new AbortController(); + const releaseLease = vi.fn(async () => {}); const resolutionStarted = createDeferred(); const releaseResolution = createDeferred(); let hostActive = true; @@ -1812,98 +1680,79 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { resolutionStarted.resolve(undefined); await releaseResolution.promise; }; - if (stage === "model") { + if (stage === "admission") { + const acquire = expectDefined( + acquireAgentRunPreparedModelRuntimeMock.getMockImplementation(), + "prepared runtime acquisition", + ); + acquireAgentRunPreparedModelRuntimeMock.mockImplementationOnce(async (input) => { + await waitForResolution(); + return { ...(await acquire(input)), [Symbol.asyncDispose]: releaseLease }; + }); + } else if (stage === "model") { resolveModelAsyncMock.mockImplementationOnce(async (provider, modelId) => { await waitForResolution(); - return { - ...createCompactHooksResolvedModel(provider, modelId), - model: { - provider: "openai", - id: "fake", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - input: [], - }, - }; + return subscriptionModel(provider, modelId); }); } else { resolveContextEngineMock.mockImplementationOnce(async () => { await waitForResolution(); - return { - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - }; + return { info: { ownsCompaction: true }, compact: contextEngineCompactMock }; }); } - const pending = compactQueued( - stage === "model" - ? { - provider: "openai", - model: "fake", - runtimeAuthPlan: { - providerForAuth: "openai", - authProfileProviderForAuth: "openai", - selectedAuthMode: "api-key", - modelRoute: openaiRoute(undefined, "fake"), - }, - } - : {}, - { - assertActive: () => { - if (!hostActive) { - throw new Error("queued compaction host authority expired"); - } - }, - }, + const pending = parent.run(() => + compactQueued( + stage === "model" + ? { + provider: "openai", + model: "fake", + runtimeAuthPlan: { + providerForAuth: "openai", + authProfileProviderForAuth: "openai", + selectedAuthMode: "api-key", + modelRoute: openaiRoute(undefined, "fake"), + }, + } + : cancel + ? { abortSignal: controller.signal, trigger: "budget" } + : {}, + { assertActive: assertActiveWhile(() => hostActive) }, + ), ); await resolutionStarted.promise; - hostActive = false; + if (cancel) { + controller.abort(new Error("request timed out")); + } else { + hostActive = false; + } releaseResolution.resolve(undefined); - - await expect(pending).rejects.toThrow("queued compaction host authority expired"); + try { + if (cancel) { + await expect(pending).resolves.toEqual(abortedCompaction); + } else { + await expect(pending).rejects.toThrow("queued compaction host authority expired"); + } + } finally { + await AsyncWorkScope.runWhenAllIdle( + () => [parent], + () => parent.drain(), + ); + } expect(resolveModelAsyncMock).toHaveBeenCalledTimes(stage === "model" ? 1 : 0); expectNoCalls(selectAgentHarnessForPreparedModelProvidersMock, contextEngineCompactMock); - if (stage === "model") { + if (stage === "admission") { + expect(releaseLease).toHaveBeenCalledOnce(); + expect(resolveContextEngineMock).not.toHaveBeenCalled(); + } + if (stage === "model" || cancel) { expectNoCalls(maybeCompactAgentHarnessSessionMock, enqueueCommandInLaneMock); } }, ); - it("reports budget compaction cancellation during context-engine resolution", async () => { - const sourceController = new AbortController(); - const resolutionStarted = createDeferred(); - const releaseResolution = createDeferred(); - resolveContextEngineMock.mockImplementationOnce(async () => { - resolutionStarted.resolve(undefined); - await releaseResolution.promise; - return { - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - }; - }); - - const pending = compactQueued({ - abortSignal: sourceController.signal, - trigger: "budget", - }); - await resolutionStarted.promise; - sourceController.abort(new Error("request timed out")); - releaseResolution.resolve(undefined); - - await expect(pending).resolves.toEqual({ - ok: false, - compacted: false, - reason: "compaction aborted", - }); - expect(resolveModelAsyncMock).not.toHaveBeenCalled(); - expect(selectAgentHarnessForPreparedModelProvidersMock).not.toHaveBeenCalled(); - expect(contextEngineCompactMock).not.toHaveBeenCalled(); - expect(enqueueCommandInLaneMock).not.toHaveBeenCalled(); - }); - it("resolves the durable session key before invoking an owning context engine", async () => { - const dir = await realpath(await mkdtemp(join(tmpdir(), "openclaw-compaction-session-key-"))); + const dir = await realpath(compactionFixture.makeTempDir("openclaw-compaction-session-key-")); const storePath = join(dir, "sessions.json"); const sessionId = "9d6c8436-7cb2-4bd5-a302-e33305bfc8c4"; const sessionKey = "agent:main:telegram:direct:reporter"; @@ -1966,42 +1815,20 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { }, sessionKey: "agent:marie-clawndo:dashboard:session-1", sessionTarget: { + ...defaultSessionTarget(), agentId: "marie-clawndo", - sessionId: TEST_SESSION_ID, sessionKey: "agent:marie-clawndo:dashboard:session-1", - storePath: TEST_STORE_PATH, }, }); expect(result.ok).toBe(true); - expect(acquireAgentRunPreparedModelRuntimeMock).toHaveBeenCalledWith( - expect.objectContaining({ agentId: "marie-clawndo" }), - expect.objectContaining({ abortSignal: expect.any(AbortSignal) }), - ); }); it("disposes the context engine once when route materialization rejects", async () => { const dispose = vi.fn(async () => {}); - const { authStorage } = createCompactHooksResolvedModel(); - resolveContextEngineMock.mockResolvedValue({ - info: { ownsCompaction: true }, - compact: contextEngineCompactMock, - dispose, - } as never); + mockContextEngine(true, false, { dispose }); resolveModelAsyncMock - .mockImplementationOnce(async (provider, modelId) => ({ - logicalRef: { provider, model: modelId }, - model: { - provider: "openai", - id: "fake", - api: "openai-chatgpt-responses", - baseUrl: "https://chatgpt.com/backend-api/codex", - input: [], - }, - error: null, - authStorage, - modelRegistry: {}, - })) + .mockImplementationOnce(async (provider, modelId) => subscriptionModel(provider, modelId)) .mockRejectedValueOnce(new Error("route materialization failed")); await expect( @@ -2012,14 +1839,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { providerForAuth: "openai", authProfileProviderForAuth: "openai", selectedAuthMode: "api-key", - modelRoute: { - provider: "openai", - modelId: "fake", - api: "openai-responses", - baseUrl: "https://api.openai.com/v1", - authRequirement: "api-key", - requestTransportOverrides: "none", - }, + modelRoute: openaiRoute(undefined, "fake"), }, }), ).rejects.toThrow("route materialization failed"); @@ -2250,25 +2070,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { ); expect(result.ok).toBe(true); if (subscription) { - expect(mockCallArg(resolveModelAsyncMock, 0, 4)).toEqual( - expect.objectContaining({ authProfileId: "openai:token" }), - ); - expect(resolveModelAsyncMock).toHaveBeenLastCalledWith( - "openai", - "gpt-5.5", - expect.any(String), - expect.objectContaining( - providerConfig("openai", expect.objectContaining({ api, baseUrl })), - ), - expect.objectContaining({ authProfileMode: "token" }), - ); expectCall(contextEngineCompactMock, { tokenBudget: 272_000 }); - } else { - expect(mockCallArg(resolveModelMock)).toBe("openai"); - expectRecordFields(mockCallArg(resolveContextWindowInfoMock), { - provider: "openai", - modelId: "gpt-5.5", - }); } expectCall( maybeCompactAgentHarnessSessionMock, @@ -2292,24 +2094,12 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(contextEngineCompactMock.mock.invocationCallOrder[0]).toBeLessThan( maybeCompactAgentHarnessSessionMock.mock.invocationCallOrder[0]!, ); - const compactArg = mockCallArg(contextEngineCompactMock) as { - runtimeContext?: Record; - }; - expectRecordFields(compactArg.runtimeContext, { - provider: "openai", - runtimeProvider: undefined, - model: "gpt-5.5", - }); }, ); it("reports deferred compaction scheduling failures", async () => { const dispose = vi.fn(async () => {}); - const maintain = vi.fn(async () => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - })); + const maintain = vi.fn(async () => unchangedMaintenance); resolveContextEngineMock.mockResolvedValue({ info: { ownsCompaction: true, turnMaintenanceMode: "background" }, compact: contextEngineCompactMock, @@ -2391,15 +2181,9 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { failure: { reason: failureReason }, }, ); - expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1); expectCall( maybeCompactAgentHarnessSessionMock, - { - provider: "openai", - model: "gpt-5.5", - agentHarnessId, - preflightRequired: true, - }, + { preflightRequired: true }, expect.objectContaining({ nativeCompactionRequest: "required_preflight" }), ); expect(contextEngineCompactMock).toHaveBeenCalledTimes(nativeCapabilityUsed ? 1 : 0); @@ -2421,11 +2205,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { agentHarnessId, currentTokenCount: 333, }); - expect(result).toMatchObject({ - ok: false, - compacted: false, - failure: { reason: "model_selection_locked" }, - }); + expect(result).toMatchObject(lockedFailure()); expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(nativeCalls); expect(contextEngineCompactMock).not.toHaveBeenCalled(); }, @@ -2433,7 +2213,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { it("runs native manual compaction before generic auth", async () => { const result = await compactNative({ ...nativeCliArgs(), agentHarnessId: "claude-cli" }); - expect(result).toMatchObject({ ok: true, compacted: true }); + expect(result).toMatchObject(compacted); expectCall(runCliAgentMock, { cliSessionId: "native-session", controlOperation: "compact" }); expectNoCalls(acquireAgentRunPreparedModelRuntimeMock, contextEngineCompactMock); }); @@ -2471,7 +2251,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(settled).toBe(false); nativeTerminal.resolve(nativeResult); - await expect(resultPromise).resolves.toMatchObject({ ok: true, compacted: true }); + await expect(resultPromise).resolves.toMatchObject(compacted); } finally { nativeTerminal.resolve(nativeResult); await resultPromise.catch(() => undefined); @@ -2586,7 +2366,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expect(pending.signal?.aborted).toBe(true); expect(pending.signal?.reason).toBe(replyOperation.abortSignal.reason); pending.release.resolve(undefined); - await expect(resultPromise).resolves.toMatchObject({ ok: false, compacted: false }); + await expect(resultPromise).resolves.toMatchObject(notCompacted); expect(isEmbeddedAgentRunHandleActive(TEST_SESSION_ID)).toBe(false); expect(isSessionRunActive(TEST_SESSION_ID)).toBe(true); } finally { @@ -2642,11 +2422,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { await entered.promise; try { const result = await compactQueued({ trigger: "manual" }); - expect(result).toMatchObject( - active.foreignStore - ? { ok: true, compacted: true } - : { ok: false, compacted: false, failure: { reason: "active_run" } }, - ); + expect(result).toMatchObject(active.foreignStore ? compacted : lockedFailure("active_run")); expect(contextEngineCompactMock).toHaveBeenCalledTimes(active.foreignStore ? 1 : 0); expect(maybeCompactAgentHarnessSessionMock).not.toHaveBeenCalled(); expect(existingHandle.abort).not.toHaveBeenCalled(); @@ -2662,7 +2438,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { async (format) => { const dir = format === "session-key" - ? await realpath(await mkdtemp(join(tmpdir(), "openclaw-compaction-successor-mismatch-"))) + ? await realpath(compactionFixture.makeTempDir("openclaw-compaction-successor-mismatch-")) : undefined; const storePath = dir ? join(dir, "sessions.json") : TEST_STORE_PATH; const activeTarget = { ...wrappedCompactionArgs().sessionTarget, storePath }; @@ -2708,7 +2484,7 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { const result = await compactQueued(); - expect(result).toMatchObject({ ok: true, compacted: true }); + expect(result).toMatchObject(compacted); expect(contextEngineCompactMock).toHaveBeenCalledTimes(1); }); it("prepares native harness auth without a host profile", async () => { @@ -2731,18 +2507,14 @@ describe("compactEmbeddedAgentSession hooks (ownsCompaction engine)", () => { expectCall( maybeCompactAgentHarnessSessionMock, { runtimeAuthPlan: undefined }, - expectedNativeCompactionOptions("after_context_engine"), + expect.objectContaining({ nativeCompactionRequest: "after_context_engine" }), ); }); it.each(["SQLite marker", "partial structured target"] as const)( "keeps a %s successor in the active store for legacy maintenance", async (format) => { - const maintain = vi.fn(async (_params?: unknown) => ({ - changed: false, - bytesFreed: 0, - rewrittenEntries: 0, - })); + const maintain = vi.fn(async (_params?: unknown) => unchangedMaintenance); const delegatedSessionId = format === "SQLite marker" ? "delegated-marker-session" : "delegated-session"; const storePath = diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index 081c913171d3..aa46a697ccef 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -1,17 +1,15 @@ // Thinking sanitization tests cover reasoning-block retention, stripping, and // recovery behavior for provider transcripts and active assistant turns. -import type { AgentMessage } from "openclaw/plugin-sdk/agent-core"; +import type { AgentMessage, StreamFn } from "openclaw/plugin-sdk/agent-core"; import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm"; import { describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../../test/helpers/promise.js"; import { castAgentMessage, - castAgentMessages, makeAgentAssistantMessage, } from "../test-helpers/agent-message-fixtures.js"; import { createQueuedAssistantStream } from "../test-helpers/assistant-event-stream.test-support.js"; -import { sparseAssistant, textAssistant } from "../test-helpers/sparse-transcript.test-support.js"; -import { createZeroUsageFixture } from "../test-helpers/usage-fixtures.js"; +import { sparseAssistant } from "../test-helpers/sparse-transcript.test-support.js"; import { stripStaleThinkingSignaturesForCompactionReplay } from "../thinking-signatures.js"; import { assessLastAssistantMessage, @@ -22,873 +20,419 @@ import { } from "./thinking.js"; type AssistantMessage = Extract; +type ContentCase = [name: string, before: unknown[], after: unknown[]]; const OMITTED_ASSISTANT_REASONING_TEXT = "[assistant reasoning omitted]"; -const noThinkingReferenceCases = [ - { name: "dropThinkingBlocks", drop: dropThinkingBlocks }, - { name: "dropReasoningFromHistory", drop: dropReasoningFromHistory }, -]; - -function createNoThinkingMessages(): AgentMessage[] { - // No-thinking fixtures should keep reference identity to avoid unnecessary - // transcript rewrites in the common path. - return [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "world" }] }), - ]; +function assistant(content: unknown[], timestamp?: number) { + return castAgentMessage({ + ...sparseAssistant(content), + ...(timestamp === undefined ? {} : { timestamp }), + }); +} +const user = (content: string) => castAgentMessage({ role: "user", content }); +const text = (value: string) => ({ type: "text", text: value }); +const thinking = (value: string, thinkingSignature?: string) => ({ + type: "thinking", + thinking: value, + ...(thinkingSignature === undefined ? {} : { thinkingSignature }), +}); +const omitted = [text(OMITTED_ASSISTANT_REASONING_TEXT)]; +function expectContent(messages: AgentMessage[], index: number, content: unknown[]) { + expect(messages[index]).toHaveProperty("content", content); } describe("thinking-free history contract", () => { - it.each(noThinkingReferenceCases)( - "$name returns the original reference when no thinking blocks are present", - ({ drop }) => { - const messages = createNoThinkingMessages(); - - const result = drop(messages); - expect(result).toBe(messages); - }, - ); + it.each([ + { name: "dropThinkingBlocks", drop: dropThinkingBlocks }, + { name: "dropReasoningFromHistory", drop: dropReasoningFromHistory }, + ])("$name returns the original reference when no thinking blocks are present", ({ drop }) => { + const messages = [user("hello"), assistant([text("world")])]; + expect(drop(messages)).toBe(messages); + }); }); describe("dropThinkingBlocks", () => { it("preserves a latest assistant turn even when all content blocks are thinking", () => { - const messages = castAgentMessages([ - { role: "assistant", content: [{ type: "thinking", thinking: "internal-only" }] }, - ]); + const messages = [assistant([thinking("internal-only")])]; expect(dropThinkingBlocks(messages)).toBe(messages); }); - it("preserves thinking blocks in the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "old" }, - { type: "text", text: "old text" }, - ]), - ), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]), - ), + it.each([ + ["mixed", [thinking("old"), text("old text")], [text("old text")]], + ["thinking-only", [thinking("old", "sig_old")], omitted], + ["redacted-only", [{ type: "redacted_thinking", data: "opaque" }], omitted], + ])("strips an older %s turn while preserving the latest thinking", (_name, prior, expected) => { + const messages = [ + user("first"), + assistant(prior), + user("second"), + assistant([thinking("latest", "sig_latest"), text("latest text")]), ]; - const result = dropThinkingBlocks(messages); - const firstAssistant = result[1] as Extract; - const latestAssistant = result[3] as Extract; - - expect(firstAssistant.content).toEqual([{ type: "text", text: "old text" }]); - expect(latestAssistant.content).toEqual([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]); - }); - - it("uses non-empty omitted-reasoning text when an older assistant turn is thinking-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "old", thinkingSignature: "sig_old" }]), - ), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "latest", thinkingSignature: "sig_latest" }, - { type: "text", text: "latest text" }, - ]), - ), - ]; - - const result = dropThinkingBlocks(messages); - const oldAssistant = result[1] as Extract; - const latestAssistant = result[3] as Extract; - const originalLatestAssistant = messages[3] as Extract; - - expect(oldAssistant.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); - expect(latestAssistant.content).toEqual(originalLatestAssistant.content); - }); - - it("uses non-empty omitted-reasoning text when an older assistant turn is redacted-thinking-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage(sparseAssistant([{ type: "redacted_thinking", data: "opaque" }])), - castAgentMessage({ role: "user", content: "second" }), - castAgentMessage(textAssistant("latest text")), - ]; - - const result = dropThinkingBlocks(messages); - const oldAssistant = result[1] as Extract; - - expect(oldAssistant.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); + expectContent(result, 1, expected); + expectContent(result, 3, [thinking("latest", "sig_latest"), text("latest text")]); }); }); +const toolCall = { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }; +function toolTurn() { + return [ + user("look up the answer"), + assistant([thinking("call the tool"), toolCall]), + castAgentMessage({ + role: "toolResult", + toolCallId: "call123456", + toolName: "lookup", + content: "42", + }), + ]; +} + describe("dropReasoningFromHistory", () => { it("uses omitted-reasoning text when a completed assistant turn is reasoning-only", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "first" }), - castAgentMessage(sparseAssistant([{ type: "thinking", thinking: "private" }])), - castAgentMessage({ role: "user", content: "second" }), - ]; - - const result = dropReasoningFromHistory(messages); - const assistant = result[1] as AssistantMessage; - - expect(assistant.content).toEqual([{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }]); + expectContent( + dropReasoningFromHistory([user("first"), assistant([thinking("private")]), user("second")]), + 1, + omitted, + ); }); - it("preserves reasoning for the active tool-call continuation after the latest user turn", () => { - // Active tool-call turns may need reasoning signatures for provider - // continuation, so only completed prior turns are stripped. - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "look up the answer" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "call the tool" }, - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]), - ), - castAgentMessage({ - role: "toolResult", - toolCallId: "call123456", - toolName: "lookup", - content: "42", - }), - ]; - - const result = dropReasoningFromHistory(messages); - - expect(result).toBe(messages); + const messages = toolTurn(); + expect(dropReasoningFromHistory(messages)).toBe(messages); }); - it("strips reasoning from old tool-call turns once a later user turn starts", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "look up the answer" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "call the tool" }, - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]), - ), - castAgentMessage({ - role: "toolResult", - toolCallId: "call123456", - toolName: "lookup", - content: "42", - }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "42" }] }), - castAgentMessage({ role: "user", content: "thanks" }), - ]; - - const result = dropReasoningFromHistory(messages); - const assistant = result[1] as AssistantMessage; - - expect(assistant.content).toEqual([ - { type: "toolCall", id: "call123456", name: "lookup", arguments: {} }, - ]); + const messages = [...toolTurn(), assistant([text("42")]), user("thanks")]; + expectContent(dropReasoningFromHistory(messages), 1, [toolCall]); }); }); +const invalidThinking = () => [ + thinking("missing"), + thinking("empty", ""), + thinking("blank", " "), +]; +const signedAnswer = () => [thinking("signed", "sig"), text("answer")]; +function olderTurn(content: unknown[]) { + return [assistant(content), user("follow up"), assistant([text("latest")])]; +} + describe("stripInvalidThinkingSignatures", () => { it("returns the original reference when no invalid thinking signatures are present", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "internal", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - - const result = stripInvalidThinkingSignatures(messages); - - expect(result).toBe(messages); + const messages = [user("hello"), assistant([thinking("internal", "sig"), text("answer")])]; + expect(stripInvalidThinkingSignatures(messages)).toBe(messages); }); - it("preserves invalid thinking signatures on the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - + const content = [...invalidThinking(), ...signedAnswer()]; + const messages = [user("hello"), assistant(content)]; const result = stripInvalidThinkingSignatures(messages); - const assistant = result[1] as Extract; - expect(result).toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 1, [...invalidThinking(), ...signedAnswer()]); }); - it("can strip invalid thinking signatures from the latest assistant message", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - ]; - + const messages = [user("hello"), assistant([thinking("missing"), ...signedAnswer()])]; const result = stripInvalidThinkingSignatures(messages, { preserveLatestAssistant: false }); - const assistant = result[1] as Extract; - expect(result).not.toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 1, signedAnswer()); }); - - it("strips thinking blocks with missing, empty, or blank signatures from older assistant messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "missing" }, - { type: "thinking", thinking: "empty", thinkingSignature: "" }, - { type: "thinking", thinking: "blank", thinkingSignature: " " }, - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - + it.each([ + [ + "missing, empty, and blank signatures", + [...invalidThinking(), ...signedAnswer()], + signedAnswer(), + ], + ["only invalid signatures", [thinking("reasoning", "")], omitted], + [ + "invalid opaque signatures", + [ + { type: "redacted_thinking", data: "" }, + { type: "redacted_thinking", signature: " " }, + { type: "redacted_thinking", data: "opaque" }, + text("answer"), + ], + [{ type: "redacted_thinking", data: "opaque" }, text("answer")], + ], + ])("strips %s from older assistant messages", (_name, content, expected) => { + const messages = olderTurn(content); const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - expect(result).not.toBe(messages); - expect(assistant.content).toEqual([ - { type: "thinking", thinking: "signed", thinkingSignature: "sig" }, - { type: "text", text: "answer" }, - ]); - }); - - it("uses non-empty omitted-reasoning text when all thinking signatures are invalid", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "reasoning", thinkingSignature: "" }]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - - const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - - expect(assistant.content).toEqual([{ type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }]); - }); - - it("strips redacted thinking blocks with invalid opaque signatures from older assistant messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage( - sparseAssistant([ - { type: "redacted_thinking", data: "" }, - { type: "redacted_thinking", signature: " " }, - { type: "redacted_thinking", data: "opaque" }, - { type: "text", text: "answer" }, - ]), - ), - castAgentMessage({ role: "user", content: "follow up" }), - castAgentMessage({ role: "assistant", content: [{ type: "text", text: "latest" }] }), - ]; - - const result = stripInvalidThinkingSignatures(messages); - const assistant = result[0] as Extract; - - expect(assistant.content).toEqual([ - { type: "redacted_thinking", data: "opaque" }, - { type: "text", text: "answer" }, - ]); + expectContent(result, 0, expected); }); }); describe("assessLastAssistantMessage", () => { - it("marks signed thinking with an empty text block as incomplete text", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: "" }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("incomplete-text"); - }); - - it("treats partial text after signed thinking as valid", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: "Here is my answ" }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("valid"); - }); - - it("treats non-string text blocks as incomplete text when thinking is signed", () => { - const message = castAgentMessage( - sparseAssistant([ - { type: "thinking", thinking: "complete", thinkingSignature: "sig" }, - { type: "text", text: { bad: true } }, - ]), - ); - - expect(assessLastAssistantMessage(message)).toBe("incomplete-text"); + it.each([ + { text: "", assessment: "incomplete-text" }, + { text: "Here is my answ", assessment: "valid" }, + { text: { bad: true }, assessment: "incomplete-text" }, + ])("assesses signed thinking followed by $text as $assessment", ({ text: value, assessment }) => { + expect( + assessLastAssistantMessage( + assistant([thinking("complete", "sig"), { type: "text", text: value }]), + ), + ).toBe(assessment); }); }); +function invoke(wrapped: StreamFn, messages: AgentMessage[] = []) { + return wrapped({} as never, { messages } as never, {} as never); +} +function invokeSync(wrapped: StreamFn) { + const stream = invoke(wrapped); + if (stream instanceof Promise) { + throw new Error("Expected a synchronous event stream"); + } + return stream; +} +async function collect(stream: AsyncIterable) { + const events: unknown[] = []; + for await (const event of stream) { + events.push(event); + } + return events; +} + describe("wrapAnthropicStreamWithRecovery", () => { const anthropicThinkingError = new Error( "thinking or redacted_thinking blocks in the latest assistant message cannot be modified", ); - const genericizedProviderError = - "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; const terminalThinkingSignatureError = "ValidationException: invalid signature on thinking block in message history"; - - function createTestAssistantMessage( - overrides: Partial & Pick, - ): AssistantMessage { + const genericizedProviderError = + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; + const history = () => [assistant([thinking("secret", "sig")])]; + function message(value = "recovered", errorMessage?: string) { return makeAgentAssistantMessage({ api: "anthropic-messages", provider: "anthropic", model: "claude-sonnet-4-6", - ...overrides, + content: [{ type: "text", text: value }], + stopReason: errorMessage ? "error" : "stop", + ...(errorMessage ? { errorMessage } : {}), }); } - - function thinkingHistory(): AgentMessage[] { - return castAgentMessages([ - sparseAssistant([{ type: "thinking", thinking: "secret", thinkingSignature: "sig" }]), - ]); - } - - function createTestStreamErrorMessage(errorMessage: string): AssistantMessage { - return createTestAssistantMessage({ - content: [{ type: "text", text: "stream failed" }], - stopReason: "error", - errorMessage, + type Event = Parameters["push"]>[0]; + function stream(...events: Event[]) { + return createQueuedAssistantStream((push) => { + for (const event of events) { + push({ ...event }); + } }); } - - it("retries once with omitted-reasoning text when the request is rejected before streaming", async () => { - let callCount = 0; - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - callCount += 1; - contexts.push(context as { messages?: AgentMessage[] }); - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect( - wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - ), - ).rejects.toBe(anthropicThinkingError); - expect(callCount).toBe(2); - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); + function done(result: AssistantMessage) { + return stream({ type: "done", reason: "stop", message: result }); + } + function failed(result: AssistantMessage) { + return stream({ type: "error", reason: "error", error: result }); + } + function harness(produce: (attempt: number) => ReturnType, notify = false) { + const inner = vi.fn(); + inner.mockImplementation(() => produce(inner.mock.calls.length)); + const recovered = vi.fn(); + const wrapped = wrapAnthropicStreamWithRecovery(inner, { + id: "test-session", + ...(notify ? { onRecoveredAnthropicThinking: recovered } : {}), + }); + return { inner, recovered, wrapped }; + } + async function expectResult( + response: Awaited>, + result: AssistantMessage, + events?: Event[], + ) { + const actual = await collect(response); + if (events) { + expect(actual).toEqual(events); } - expect(retryMessage.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, - ]); - }); - - it("retries with visible assistant text when stripping thinking leaves content", async () => { - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - contexts.push(context as { messages?: AgentMessage[] }); - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); + await expect(response.result()).resolves.toEqual(result); + } + function expectRetryContent(inner: ReturnType["inner"], content: unknown[]) { + const retry = inner.mock.calls[1]?.[1].messages[0]; + expect(retry).toMatchObject({ role: "assistant" }); + expect(retry).toHaveProperty("content", content); + } + it.each([ + { name: "omitted reasoning", content: [], expected: omitted }, + { name: "visible text", content: [text("visible answer")], expected: [text("visible answer")] }, + ])("retries a rejected request once with $name", async ({ content, expected }) => { + const { wrapped, inner } = harness(() => Promise.reject(anthropicThinkingError)); await expect( - wrapped( - {} as never, - { - messages: castAgentMessages([ - sparseAssistant([ - { type: "thinking", thinking: "secret", thinkingSignature: "sig" }, - { type: "text", text: "visible answer" }, - ]), - ]), - } as never, - {} as never, - ), + invoke(wrapped, [assistant([thinking("secret", "sig"), ...content])]), ).rejects.toBe(anthropicThinkingError); - - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); - } - expect(retryMessage.content).toEqual([{ type: "text", text: "visible answer" }]); + expect(inner).toHaveBeenCalledTimes(2); + expectRetryContent(inner, expected); }); it("notifies recovery only after a rejected request retry succeeds", async () => { - let callCount = 0; - const recovered = vi.fn(); - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered" }], - stopReason: "stop", - }); - const originalMessages = castAgentMessages([ - sparseAssistant([ - { type: "thinking", thinking: "secret", thinkingSignature: "sig" }, - { type: "text", text: "visible answer" }, - ]), - ]); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - if (callCount === 1) { - return Promise.reject(anthropicThinkingError); - } - return createQueuedAssistantStream((push) => { - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const result = message(); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? Promise.reject(anthropicThinkingError) : done(result)), + true, ); - - const response = (await wrapped( - {} as never, - { - messages: originalMessages, - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - for await (const event of response) { - void event; - // Drain the retry stream before reading result(). - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); + await expectResult( + await invoke(wrapped, [assistant([thinking("secret", "sig"), text("visible answer")])]), + result, + ); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).toHaveBeenCalledTimes(1); }); it("does not notify recovery when the stripped-thinking retry also fails", async () => { - const recovered = vi.fn(); - let callCount = 0; const retryError = new Error("retry failed"); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(callCount === 1 ? anthropicThinkingError : retryError); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const { wrapped, recovered } = harness( + (attempt) => Promise.reject(attempt === 1 ? anthropicThinkingError : retryError), + true, ); - - await expect( - wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - ), - ).rejects.toBe(retryError); + await expect(invoke(wrapped, history())).rejects.toBe(retryError); expect(recovered).not.toHaveBeenCalled(); }); it("does not notify recovery when the stripped-thinking retry resolves to a stream error", async () => { - const recovered = vi.fn(); - let callCount = 0; - const errorMessage = createTestStreamErrorMessage("retry stream failed"); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - if (callCount === 1) { - return Promise.reject(anthropicThinkingError); - } - return createQueuedAssistantStream((push) => { - push({ - type: "error", - reason: "error", - error: errorMessage, - }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const result = message("stream failed", "retry stream failed"); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? Promise.reject(anthropicThinkingError) : failed(result)), + true, ); - - const response = (await wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - for await (const event of response) { - void event; - // Drain the retry stream before reading result(). - } - - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(2); + await expectResult(await invoke(wrapped, history()), result); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).not.toHaveBeenCalled(); }); it("retries Bedrock-style invalid thinking signature errors", async () => { - let callCount = 0; - const bedrockThinkingError = new Error( - "ValidationException: invalid signature on thinking block in message history", - ); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(bedrockThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect( - wrapped( - {} as never, - { - messages: castAgentMessages([ - sparseAssistant([{ type: "thinking", thinking: "secret", thinkingSignature: "" }]), - ]), - } as never, - {} as never, - ), - ).rejects.toBe(bedrockThinkingError); - expect(callCount).toBe(2); + const error = new Error(terminalThinkingSignatureError); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped, [assistant([thinking("secret", "")])])).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(2); }); it.each([ - { - name: "failover rawError", - createError: () => - Object.assign(new Error(genericizedProviderError), { - rawError: terminalThinkingSignatureError, + ["failover rawError", () => ({ rawError: terminalThinkingSignatureError })], + [ + "Anthropic SDK error body", + () => ({ error: { error: { message: terminalThinkingSignatureError } } }), + ], + ["direct errorMessage", () => ({ errorMessage: terminalThinkingSignatureError })], + [ + "ProviderHttpError errorBody", + () => ({ + errorBody: JSON.stringify({ + error: { message: terminalThinkingSignatureError, type: "invalid_request_error" }, }), - }, - { - name: "Anthropic SDK error body", - createError: () => - Object.assign(new Error(genericizedProviderError), { - error: { error: { message: terminalThinkingSignatureError } }, - }), - }, - { - name: "direct errorMessage", - createError: () => - Object.assign(new Error(genericizedProviderError), { - errorMessage: terminalThinkingSignatureError, - }), - }, - { - name: "ProviderHttpError errorBody", - createError: () => - Object.assign(new Error(genericizedProviderError), { - errorBody: JSON.stringify({ - error: { - message: terminalThinkingSignatureError, - type: "invalid_request_error", - }, - }), - }), - }, - { - name: "cyclic cause graph", - createError: () => { - const root = new Error(genericizedProviderError) as Error & { cause?: unknown }; - const nested = { cause: root, message: terminalThinkingSignatureError }; - root.cause = nested; - return root; + }), + ], + [ + "cyclic cause graph", + () => { + const cause = { + cause: new Error(genericizedProviderError), + message: terminalThinkingSignatureError, + }; + const error = Object.assign(new Error(genericizedProviderError), { cause }); + cause.cause = error; + return error; }, - }, - ])( - "retries genericized request errors carrying provider detail in $name", - async ({ createError }) => { - const providerError = createError(); - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(providerError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect(wrapped({} as never, { messages: [] } as never, {} as never)).rejects.toBe( - providerError, - ); - expect(callCount).toBe(2); + ], + ] as const)( + "retries genericized request errors carrying provider detail in %s", + async (_name, detail) => { + const details = detail(); + const error = + details instanceof Error + ? details + : Object.assign(new Error(genericizedProviderError), details); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped)).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(2); }, ); it.each([false, true])("recovers terminal stream errors (promised=%s)", async (promised) => { - let callCount = 0; - const contexts: Array<{ messages?: AgentMessage[] }> = []; - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered" }], - stopReason: "stop", + const result = message(); + const { wrapped, inner } = harness((attempt) => { + const response = + attempt === 1 + ? failed(message("stream failed", terminalThinkingSignatureError)) + : done(result); + return promised ? Promise.resolve(response) : response; }); - const wrapped = wrapAnthropicStreamWithRecovery( - ((_model, context) => { - callCount += 1; - const attempt = callCount; - contexts.push(context as { messages?: AgentMessage[] }); - const stream = createQueuedAssistantStream((push) => { - if (attempt === 1) { - push({ - type: "error", - reason: "error", - error: createTestStreamErrorMessage(terminalThinkingSignatureError), - }); - } else { - push({ type: "done", reason: "stop", message: finalMessage }); - } - }); - return promised ? Promise.resolve(stream) : stream; - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = (await wrapped( - {} as never, - { - messages: thinkingHistory(), - } as never, - {} as never, - )) as { result: () => Promise } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([{ type: "done", reason: "stop", message: finalMessage }]); - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); - const retryMessage = contexts[1]?.messages?.[0]; - if (!retryMessage || retryMessage.role !== "assistant") { - throw new Error("Expected Anthropic recovery retry to start with an assistant message"); - } - expect(retryMessage.content).toEqual([ - { type: "text", text: OMITTED_ASSISTANT_REASONING_TEXT }, + await expectResult(await invoke(wrapped, history()), result, [ + { type: "done", reason: "stop", message: result }, ]); + expect(inner).toHaveBeenCalledTimes(2); + expectRetryContent(inner, omitted); }); it("does not retry non-thinking terminal stream-error events", async () => { - let callCount = 0; - const errorMessage = createTestAssistantMessage({ - content: [{ type: "text", text: terminalThinkingSignatureError }], - stopReason: "error", - errorMessage: "rate limit exceeded", - }); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return createQueuedAssistantStream((push) => { - push({ type: "error", reason: "error", error: errorMessage }); - }); - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([{ type: "error", reason: "error", error: errorMessage }]); - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(1); + const result = message(terminalThinkingSignatureError, "rate limit exceeded"); + const { wrapped, inner } = harness(() => failed(result)); + await expectResult(invokeSync(wrapped), result, [ + { type: "error", reason: "error", error: result }, + ]); + expect(inner).toHaveBeenCalledTimes(1); }); it.each([false, true])("does not retry after output (promised=%s)", async (promised) => { - let callCount = 0; - const partialMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "" }], - stopReason: "stop", + const partial = message(""); + const result = message("stream failed", terminalThinkingSignatureError); + const events: Event[] = [ + { type: "start", partial }, + { type: "error", reason: "error", error: result }, + ]; + const { wrapped, inner } = harness(() => { + const response = stream(...events); + return promised ? Promise.resolve(response) : response; }); - const errorMessage = createTestStreamErrorMessage(terminalThinkingSignatureError); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - const stream = createQueuedAssistantStream((push) => { - push({ type: "start", partial: partialMessage }); - push({ type: "error", reason: "error", error: errorMessage }); - }); - return promised ? Promise.resolve(stream) : stream; - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = (await wrapped({} as never, { messages: [] } as never, {} as never)) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - expect(events).toEqual([ - { type: "start", partial: partialMessage }, - { type: "error", reason: "error", error: errorMessage }, - ]); - await expect(response.result()).resolves.toEqual(errorMessage); - expect(callCount).toBe(1); + await expectResult(await invoke(wrapped), result, events); + expect(inner).toHaveBeenCalledTimes(1); }); it("does not retry when the stream fails after yielding a chunk", async () => { - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return (async function* failingStream() { + const { wrapped, inner } = harness( + () => + (async function* () { yield "chunk"; throw anthropicThinkingError; - })(); - }) as unknown as Parameters[0], - { id: "test-session" }, + })() as unknown as ReturnType, ); - - const chunks: unknown[] = []; - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - for await (const chunk of response) { - chunks.push(chunk); - } - - expect(chunks).toEqual(["chunk"]); + const response = invokeSync(wrapped); + expect(await collect(response)).toEqual(["chunk"]); await expect(response.result()).rejects.toBe(anthropicThinkingError); - expect(callCount).toBe(1); + expect(inner).toHaveBeenCalledTimes(1); }); it("does not retry non-Anthropic-thinking errors", async () => { - const rateLimitError = new Error("rate limit exceeded"); - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(rateLimitError); - }) as Parameters[0], - { id: "test-session" }, - ); - - await expect(wrapped({} as never, { messages: [] } as never, {} as never)).rejects.toBe( - rateLimitError, - ); - expect(callCount).toBe(1); + const error = new Error("rate limit exceeded"); + const { wrapped, inner } = harness(() => Promise.reject(error)); + await expect(invoke(wrapped)).rejects.toBe(error); + expect(inner).toHaveBeenCalledTimes(1); }); it("allows each provider call to recover once", async () => { - let callCount = 0; - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - callCount += 1; - return Promise.reject(anthropicThinkingError); - }) as Parameters[0], - { id: "test-session" }, - ); - const context = { - messages: thinkingHistory(), - }; - - await expect(wrapped({} as never, context as never, {} as never)).rejects.toBe( - anthropicThinkingError, - ); - await expect(wrapped({} as never, context as never, {} as never)).rejects.toBe( - anthropicThinkingError, - ); - - expect(callCount).toBe(4); + const { wrapped, inner } = harness(() => Promise.reject(anthropicThinkingError)); + const messages = history(); + await expect(invoke(wrapped, messages)).rejects.toBe(anthropicThinkingError); + await expect(invoke(wrapped, messages)).rejects.toBe(anthropicThinkingError); + expect(inner).toHaveBeenCalledTimes(4); }); it("preserves result() for synchronous event streams", async () => { - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "done" }], - usage: createZeroUsageFixture(), - stopReason: "stop", - timestamp: Date.now(), - }); - - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - return createQueuedAssistantStream((push) => { - push({ type: "start", partial: finalMessage }); - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session" }, - ); - - const response = wrapped({} as never, { messages: [] } as never, {} as never) as { - result: () => Promise; - } & AsyncIterable; - const events: unknown[] = []; - for await (const event of response) { - events.push(event); - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(events).toHaveLength(2); + const result = message("done"); + const events: Event[] = [ + { type: "start", partial: result }, + { type: "done", reason: "stop", message: result }, + ]; + const { wrapped } = harness(() => stream(...events)); + await expectResult(invokeSync(wrapped), result, events); }); it("recovers an error event from a Promise-resolved stream without changing Promise timing", async () => { - const recovered = vi.fn(); - let callCount = 0; - const { promise: firstStreamPromise, resolve: resolveFirstStream } = - createDeferred>(); - const finalMessage = createTestAssistantMessage({ - content: [{ type: "text", text: "recovered answer" }], - stopReason: "stop", - }); - const wrapped = wrapAnthropicStreamWithRecovery( - (() => { - const attempt = ++callCount; - if (attempt === 1) { - return firstStreamPromise; - } - return createQueuedAssistantStream((push) => { - push({ type: "done", reason: "stop", message: finalMessage }); - }); - }) as Parameters[0], - { id: "test-session", onRecoveredAnthropicThinking: recovered }, + const first = createDeferred>(); + const result = message("recovered answer"); + const { wrapped, inner, recovered } = harness( + (attempt) => (attempt === 1 ? first.promise : done(result)), + true, ); - - const responsePromise = wrapped({} as never, { messages: [] } as never, {} as never); + const responsePromise = invoke(wrapped); expect(responsePromise).toBeInstanceOf(Promise); let resolved = false; void Promise.resolve(responsePromise).then(() => { @@ -896,201 +440,61 @@ describe("wrapAnthropicStreamWithRecovery", () => { }); await Promise.resolve(); expect(resolved).toBe(false); - const firstStream = createAssistantMessageEventStream(); - resolveFirstStream(firstStream); + first.resolve(firstStream); const response = await responsePromise; queueMicrotask(() => { firstStream.push({ type: "error", reason: "error", - error: createTestStreamErrorMessage(terminalThinkingSignatureError), + error: message("stream failed", terminalThinkingSignatureError), }); firstStream.end(); }); - for await (const event of response) { - void event; - } - - await expect(response.result()).resolves.toEqual(finalMessage); - expect(callCount).toBe(2); + await expectResult(response, result); + expect(inner).toHaveBeenCalledTimes(2); expect(recovered).toHaveBeenCalledTimes(1); }); }); +function summary(timestamp: number | string = 2000) { + return castAgentMessage({ role: "compactionSummary", summary: "s", tokensBefore: 0, timestamp }); +} + describe("stripStaleThinkingSignaturesForCompactionReplay", () => { - it("returns the original reference when no compaction summary is present", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ role: "user", content: "hello" }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "think", thinkingSignature: "sig" }], - timestamp: 1000, - }), - ]; + it.each([ + ["without a summary", () => [user("hello"), assistant([thinking("think", "sig")], 1000)]], + ["without an assistant timestamp", () => [summary(), assistant([thinking("think", "sig")])]], + [ + "at the summary timestamp", + () => [summary(), assistant([thinking("exact", "exact_sig")], 2000)], + ], + ] as const)("preserves signatures %s", (_name, makeMessages) => { + const messages = makeMessages(); expect(stripStaleThinkingSignaturesForCompactionReplay(messages)).toBe(messages); }); - - it("strips thinking signatures from assistant messages before the compaction timestamp", () => { - const compactionSummary = castAgentMessage({ - role: "compactionSummary", - summary: "summary", - tokensBefore: 100, - timestamp: 2000, - }); - const preCompaction = castAgentMessage({ - role: "assistant", - content: [ - { type: "thinking", thinking: "old think", thinkingSignature: "stale_sig" }, - { type: "text", text: "old answer" }, - ], - timestamp: 1000, - }); - const postCompaction = castAgentMessage({ - role: "assistant", - content: [ - { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, - { type: "text", text: "new answer" }, - ], - timestamp: 3000, - }); - const messages: AgentMessage[] = [ - compactionSummary, - preCompaction, - castAgentMessage({ role: "user", content: "q" }), - postCompaction, + it("strips stale signatures before the newest summary, retaining later signatures", () => { + const messages = [ + summary(), + assistant( + [ + thinking("old", "stale_sig"), + { type: "redacted_thinking", data: "opaque_sig" }, + text("old answer"), + ], + 1500, + ), + user("q"), + summary(1000), + assistant([thinking("new", "fresh_sig"), text("new answer")], 3000), ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); expect(result).not.toBe(messages); - - const pre = result[1] as AssistantMessage; - expect(pre.content).toEqual([ - { type: "thinking", thinking: "old think" }, - { type: "text", text: "old answer" }, - ]); - - const post = result[3] as AssistantMessage; - expect(post.content).toEqual([ - { type: "thinking", thinking: "new think", thinkingSignature: "fresh_sig" }, - { type: "text", text: "new answer" }, - ]); + expectContent(result, 1, [thinking("old"), { type: "redacted_thinking" }, text("old answer")]); + expectContent(result, 4, [thinking("new", "fresh_sig"), text("new answer")]); }); - - it("strips redacted_thinking data from pre-compaction messages", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [ - { type: "redacted_thinking", data: "opaque_sig" }, - { type: "text", text: "visible" }, - ], - timestamp: 1500, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - const assistant = result[1] as AssistantMessage; - expect(assistant.content).toEqual([ - { type: "redacted_thinking" }, - { type: "text", text: "visible" }, - ]); - }); - - it("skips assistant messages with no parseable timestamp", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage( - sparseAssistant([{ type: "thinking", thinking: "think", thinkingSignature: "sig" }]), - ), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - expect(result).toBe(messages); - }); - - it("uses the maximum compaction timestamp even when summaries are out of order", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "latest", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "mid", thinkingSignature: "sig_mid" }], - timestamp: 1500, - }), - castAgentMessage({ - role: "compactionSummary", - summary: "older", - tokensBefore: 0, - timestamp: 1000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "after", thinkingSignature: "sig_after" }], - timestamp: 3000, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - // mid (timestamp 1500 < 2000): signature stripped - const mid = result[1] as AssistantMessage; - expect(mid.content).toEqual([{ type: "thinking", thinking: "mid" }]); - // after (timestamp 3000 > 2000): signature kept - const after = result[3] as AssistantMessage; - expect((after.content[0] as unknown as Record).thinkingSignature).toBe( - "sig_after", - ); - }); - - it("preserves signatures on assistant messages at exactly the compaction timestamp", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: 2000, - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "exact", thinkingSignature: "exact_sig" }], - timestamp: 2000, - }), - ]; - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - // Same millisecond as compaction: treated as post-compaction; signature preserved - expect(result).toBe(messages); - }); - it("parses numeric-looking compaction strings as dates before numeric message timestamps", () => { - const messages: AgentMessage[] = [ - castAgentMessage({ - role: "compactionSummary", - summary: "s", - tokensBefore: 0, - timestamp: "2026", - }), - castAgentMessage({ - role: "assistant", - content: [{ type: "thinking", thinking: "old", thinkingSignature: "stale_sig" }], - timestamp: 1_000_000, - }), - ]; - - const result = stripStaleThinkingSignaturesForCompactionReplay(messages); - expect((result[1] as AssistantMessage).content).toEqual([ - { type: "thinking", thinking: "old" }, - ]); + const messages = [summary("2026"), assistant([thinking("old", "stale_sig")], 1_000_000)]; + expectContent(stripStaleThinkingSignaturesForCompactionReplay(messages), 1, [thinking("old")]); }); }); diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts index 40b172ccfd91..a5d486304ddb 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.test.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.test.ts @@ -1,5 +1,3 @@ -// Tool-result truncation tests cover live and persisted shrinking of oversized -// tool outputs while preserving transcript shape and update notifications. import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -21,7 +19,6 @@ import { onInternalSessionTranscriptUpdate } from "../../sessions/transcript-eve import { useSessionStoreTempDirs } from "../../test-utils/session-state-cleanup.js"; import { formatFullOutputFooter } from "../sessions/tools/tool-contracts.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; -import { resolveAutoLiveToolResultMaxChars } from "../tool-result-limits.js"; import { prepareEmbeddedAttemptPromptContext } from "./run/attempt-prompt-build.js"; import { buildRuntimeContextCustomMessage } from "./run/runtime-context-prompt.js"; import { @@ -64,8 +61,6 @@ afterEach(async () => { }); function toolResult(text: string, toolCallId = "call_1", details?: unknown): ToolResultMessage { - // Tool-result fixtures use increasing timestamps so persisted branch rewrites - // can preserve ordering while changing content. return { role: "toolResult", toolCallId, @@ -77,54 +72,75 @@ function toolResult(text: string, toolCallId = "call_1", details?: unknown): Too }; } -async function prepareProjection(params: { - sessionId: string; - messages: AgentMessage[]; - state: ToolResultPromptProjectionState; - raw?: boolean; -}) { - const prompt = params.raw ? "raw probe" : "continue"; +async function prepareProjection( + messages: AgentMessage[], + state: ToolResultPromptProjectionState, + raw = false, + sessionId = "projection", +) { + const prompt = raw ? "raw probe" : "continue"; await prepareEmbeddedAttemptPromptContext({ capabilityToolNames: new Set(), attempt: { config: {}, contextTokenBudget: 128_000, - sessionId: params.sessionId, - sessionKey: `agent:main:${params.sessionId}`, + sessionId, + sessionKey: `agent:main:${sessionId}`, suppressNextUserMessagePersistence: false, }, includeBoundaryTimestamp: false, - isRawModelRun: params.raw ?? false, - messages: params.messages, + isRawModelRun: raw, + messages, prompt: { effectivePrompt: prompt, effectiveTranscriptPrompt: prompt, }, replaceSessionMessages: () => {}, sessionAgentId: "main", - systemPromptText: params.raw ? "" : "system", - toolResultPromptProjectionState: params.state, + systemPromptText: raw ? "" : "system", + toolResultPromptProjectionState: state, }); } -describe("tool-result warning dedupe", () => { - const warningDedupeLimit = 1_024; +function toolResults(texts: string[], prefix = "call") { + return texts.map((text, index) => toolResult(text, `${prefix}_${index}`)); +} - it("bounds and evicts the oldest session recovery warning keys", () => { - const cache = toolResultWarningDedupe.sessionRecovery; +function totalText(messages: AgentMessage[]) { + return messages.reduce((sum, message) => sum + textLength(message), 0); +} - for (let index = 0; index <= warningDedupeLimit; index += 1) { - expect(cache.check(`session-${index}`)).toBe(false); - } +function findTool(messages: AgentMessage[], id: string) { + return messages.find((message) => message.role === "toolResult" && message.toolCallId === id); +} - expect(cache.size()).toBe(warningDedupeLimit); - expect(cache.peek("session-0")).toBe(false); - expect(cache.peek("session-1")).toBe(true); - expect(cache.peek(`session-${warningDedupeLimit}`)).toBe(true); - expect(cache.check("session-0")).toBe(false); - expect(cache.check(`session-${warningDedupeLimit}`)).toBe(true); - }); -}); +function projection(maxChars = 5_000, aggregate = 20_000, context = 128_000) { + const state = createToolResultPromptProjectionState(); + return { + state, + project: (messages: AgentMessage[], cap = maxChars, total = aggregate) => + truncate(messages, context, cap, total, state), + }; +} + +function expectProjectionSize( + state: ToolResultPromptProjectionState, + size: number, + replacements = size, +) { + expect([state.sourceHashByKey.size, state.frozen.size, state.replacements.size]).toEqual([ + size, + size, + replacements, + ]); +} + +function byToolPrefix(messages: AgentMessage[], prefix: string) { + return messages.filter( + (message): message is ToolResultMessage => + message.role === "toolResult" && message.toolCallId.startsWith(prefix), + ); +} function textWithFullOutputFooter(text: string, fullOutputPath: string): string { return `${text}\n\n[Showing truncated output. ${formatFullOutputFooter(fullOutputPath)}]`; @@ -205,18 +221,12 @@ describe("truncateToolResultText", () => { }); it.each([ - { text: "aaa😀z", maxChars: 5, suffix: () => "!", minKeepChars: 0, expected: "aaa!" }, - { text: "abcdef", maxChars: 1, suffix: () => "😀", minKeepChars: 0, expected: "" }, - { - text: "x".repeat(100), - maxChars: 4, - suffix: (truncatedChars: number) => `[${truncatedChars}]`, - minKeepChars: 1, - expected: "[100", - }, - ])( - "bounds suffix-only and code-point cuts at $maxChars chars", - ({ text, maxChars, suffix, minKeepChars, expected }) => { + ["aaa😀z", 5, () => "!", 0, "aaa!"], + ["abcdef", 1, () => "😀", 0, ""], + ["x".repeat(100), 4, (n: number) => `[${n}]`, 1, "[100"], + ] as const)( + "bounds suffix-only and code-point cuts for %s", + (text, maxChars, suffix, minKeepChars, expected) => { expect(truncateToolResultText(text, maxChars, { suffix, minKeepChars })).toBe(expected); }, ); @@ -239,17 +249,11 @@ describe("truncateToolResultText", () => { describe("truncateToolResultMessage", () => { it("truncates Codex protocol toolResult content blocks and mirrored content", () => { const oversized = "x".repeat(50_000); + const block = { type: "toolResult", toolUseId: "call_1", text: oversized, content: oversized }; const msg = { ...toolResult(""), toolName: "exec", - content: [ - { - type: "toolResult", - toolUseId: "call_1", - text: oversized, - content: oversized, - }, - ], + content: [block], } as unknown as ToolResultMessage; const result = truncateToolResultMessage(msg, 10_000, { @@ -264,42 +268,21 @@ describe("truncateToolResultMessage", () => { expect(firstBlock.content).toBe(firstBlock.text); }); - it("truncates dense CJK by weighted budget while leaving same-size ASCII unchanged", () => { - const maxChars = 16_000; - const asciiMessage = toolResult("a".repeat(maxChars)); - expect(truncateToolResultMessage(asciiMessage, maxChars)).toBe(asciiMessage); - - const cjk = "你".repeat(maxChars); - const cjkMessage = toolResult(cjk); - const result = truncateToolResultMessage(cjkMessage, maxChars); - const resultText = toolText(result); - - expect(result).not.toBe(cjkMessage); - expect(resultText.length).toBeLessThan(cjk.length); - expect(estimateStringChars(resultText)).toBeLessThanOrEqual(maxChars); - expect(resultText).toContain("truncated"); - }); - it.each([ - { - kind: "mixed CJK/ASCII", - texts: ["你".repeat(1_000), "a".repeat(4_000)], - maxChars: 4_000, - options: undefined, - }, - { - kind: "small blocks", - texts: ["a".repeat(50), "b".repeat(50), "c".repeat(500)], - maxChars: 100, - options: { suffix: () => "!", minKeepChars: 99 }, - }, - { - kind: "empty blocks", - texts: [...Array(150).fill(""), "x".repeat(500)], - maxChars: 100, - options: { suffix: () => "!", minKeepChars: 0 }, - }, - ])("reserves the weighted notice budget for $kind", ({ kind, texts, maxChars, options }) => { + ["mixed CJK/ASCII", ["你".repeat(1_000), "a".repeat(4_000)], 4_000, undefined], + [ + "small blocks", + ["a".repeat(50), "b".repeat(50), "c".repeat(500)], + 100, + { suffix: () => "!", minKeepChars: 99 }, + ], + [ + "empty blocks", + [...Array(150).fill(""), "x".repeat(500)], + 100, + { suffix: () => "!", minKeepChars: 0 }, + ], + ] as const)("reserves the weighted notice budget for %s", (kind, texts, maxChars, options) => { const msg = { ...toolResult("unused"), content: texts.map((text) => ({ type: "text" as const, text })), @@ -330,15 +313,10 @@ describe("perResultLimit", () => { [20_000, 16_000, 64_000], [128_000, 32_000, 256_000], [200_000, 64_000, 400_000], - [1_000_000, 64_000, 2_000_000], ])( "resolves aggregate live cap for %s token windows", (contextWindowTokens, perResultMaxChars, aggregateMaxChars) => { expect(perResultLimit(contextWindowTokens)).toBe(perResultMaxChars); - if (contextWindowTokens === 200_000) { - expect(resolveAutoLiveToolResultMaxChars(contextWindowTokens)).toBe(64_000); - expect(perResultMaxChars).toBeGreaterThan(perResultLimit(8_000)); - } expect( aggregateLimit({ contextWindowTokens, @@ -349,53 +327,24 @@ describe("perResultLimit", () => { ); }); -describe("sessionLikelyHasOversizedToolResults", () => { - it.each([ - { kind: "CJK", text: "你".repeat(5_000), count: 1, contextWindowTokens: 50_000 }, - { kind: "oversized", text: "x".repeat(500_000), count: 1, contextWindowTokens: 128_000 }, - { - kind: "aggregate", - text: "alpha beta gamma delta epsilon ".repeat(500), - count: 6, - contextWindowTokens: 20_000, - }, - ])("detects $kind pressure", ({ text, count, contextWindowTokens }) => { - const messages = Array.from({ length: count }, (_, index) => toolResult(text, `call_${index}`)); - expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens })).toBe(true); - }); -}); - describe("truncateOversizedToolResultsInMessages", () => { it("returns unchanged messages when nothing is oversized", () => { const messages = [user("hello"), assistant("using tool"), toolResult("small result")]; + expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens: 200_000 })).toBe( + false, + ); const { messages: result, truncatedCount } = truncate(messages, 200_000); expect(truncatedCount).toBe(0); expect(result).toEqual(messages); }); - it("truncates multiple oversized tool results", () => { - const messages: AgentMessage[] = [ - user("hello"), - assistant("reading files"), - toolResult("x".repeat(500_000), "call_1"), - toolResult("y".repeat(500_000), "call_2"), - ]; - const { messages: result, truncatedCount } = truncate(messages, 128_000); - expect(truncatedCount).toBe(2); - for (const msg of result.slice(2)) { - expect(msg.role).toBe("toolResult"); - const text = toolText(msg); - expect(text.length).toBeLessThan(500_000); - expect(text).toContain("truncated"); - } - }); - it("applies the aggregate cap to CJK-weighted prompt history", () => { - const messages = Array.from({ length: 6 }, (_, index) => - toolResult("你".repeat(3_000), `call_${index}`), - ); + const messages = toolResults(Array(6).fill("你".repeat(3_000))); const result = truncate(messages, 20_000); + expect(sessionLikelyHasOversizedToolResults({ messages, contextWindowTokens: 20_000 })).toBe( + true, + ); const estimated = result.messages.reduce( (sum, message) => sum + (message.role === "toolResult" ? estimateStringChars(toolText(message)) : 0), @@ -407,50 +356,27 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it("bounds aggregate tool-result text in prompt history without rewriting callers", () => { - // Live replay truncates cloned tool-result messages; the source array keeps - // full content for UI and transcript persistence. const medium = "alpha beta gamma delta epsilon ".repeat(800); const messages: AgentMessage[] = [ user("hello"), assistant("calling tools"), - toolResult(medium, "call_1"), - toolResult(medium, "call_2"), - toolResult(medium, "call_3"), + ...toolResults(Array(3).fill(medium)), ]; const { messages: result, truncatedCount } = truncate(messages, 128_000, 12_000, 12_000); + expect( + sessionLikelyHasOversizedToolResults({ + messages, + contextWindowTokens: 128_000, + maxCharsOverride: 12_000, + }), + ).toBe(true); - const totalChars = result.reduce( - (sum, message) => sum + (message.role === "toolResult" ? textLength(message) : 0), - 0, - ); expect(truncatedCount).toBeGreaterThan(0); - expect(totalChars).toBeLessThanOrEqual(12_000); + expect(totalText(result)).toBeLessThanOrEqual(12_000); expect(result[0]).toBe(messages[0]); expect(result[1]).toBe(messages[1]); - expect(messages.reduce((sum, message) => sum + textLength(message), 0)).toBe(medium.length * 3); - }); - - it("keeps #99495 historical bytes stable across attempts sharing session state", () => { - using firstLease = retainEmbeddedSessionPromptState("session-99495"); - const state = firstLease.state.toolResults; - const history = [ - toolResult("a".repeat(4_000), "history_1"), - toolResult("b".repeat(4_000), "history_2"), - ]; - const first = truncate(history, 128_000, 5_000, 20_000, state); - using secondLease = retainEmbeddedSessionPromptState("session-99495"); - const secondAttemptState = secondLease.state.toolResults; - const second = truncate( - [...history, toolResult("c".repeat(12_000), "current")], - 128_000, - 5_000, - 20_000, - secondAttemptState, - ); - - expect(secondAttemptState).toBe(state); - expect(second.messages.slice(0, history.length)).toEqual(first.messages); + expect(totalText(messages)).toBe(medium.length * 3); }); it("reclaims #99495 state from canonical compaction, not filtered projections", async () => { @@ -465,67 +391,46 @@ describe("truncateOversizedToolResultsInMessages", () => { truncate([retained], 128_000, 5_000, 20_000, state); expect(state.sourceHashByKey.size).toBe(2); - await prepareProjection({ sessionId, messages: [], state, raw: true }); + await prepareProjection([], state, true, sessionId); expect(state.sourceHashByKey.size).toBe(2); - await prepareProjection({ sessionId, messages: [retained], state }); + await prepareProjection([retained], state, false, sessionId); - expect(state).toMatchObject({ - sourceHashByKey: { size: 1 }, - frozen: { size: 1 }, - replacements: { size: 1 }, - }); + expectProjectionSize(state, 1); expect(truncate([retained], 128_000, 5_000, 20_000, state).messages).toEqual( projected.messages.slice(1), ); }); - it("keeps frozen aggregate projections byte-identical when a later turn exceeds the budget", () => { - const state = createToolResultPromptProjectionState(); + it("allows aggregate overflow without rewriting untruncated frozen history", () => { + const { project } = projection(8_000, 10_000, 1_000_000); const history = [ - toolResult("a".repeat(4_000), "history_1"), - toolResult("b".repeat(4_000), "history_2"), - user("continue"), + ...toolResults(["a".repeat(4_000), "b".repeat(4_000)], "history"), + user("establish a frozen projection baseline"), ]; - const first = truncate(history, 128_000, 8_000, 12_000, state); - const frozenBytes = first.messages.map((message) => JSON.stringify(message)); - const runtimeContextMessage = buildRuntimeContextCustomMessage("runtime context refresh"); - if (!runtimeContextMessage) { + const first = project(history); + expect(first.aggregatePressureEngaged).toBe(false); + const carrier = buildRuntimeContextCustomMessage("hard-cap runtime context"); + if (!carrier) { throw new Error("expected runtime context message"); } - - const second = truncate( - [ - ...history, - assistant("running exec"), - toolResult("c".repeat(6_000), "current"), - runtimeContextMessage, - ], - 128_000, - 8_000, - 12_000, - state, + const freshOutput = "OC99241_HARD_CAP_SENTINEL_".padEnd(4_000, "f"); + const messages = convertToLlm([...history, toolResult(freshOutput, "fresh"), carrier]); + expect(messages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); + const second = project(messages, 8_000, 100); + expect(byToolPrefix(second.messages, "history_")).toEqual( + byToolPrefix(first.messages, "history_"), ); - - expect(first.aggregateTruncatedCount).toBe(0); + expect(toolText(findTool(second.messages, "fresh")!)).toBe(freshOutput); expect(second.aggregateTruncatedCount).toBe(0); - expect( - second.messages.slice(0, history.length).map((message) => JSON.stringify(message)), - ).toEqual(frozenBytes); - const current = second.messages.find( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId === "current", - ); - expect(current && toolText(current)).toBe("c".repeat(6_000)); + expect(second.aggregatePressureEngaged).toBe(true); + expect(totalText(second.messages)).toBeGreaterThan(100); }); it("shrinks #99495 frozen bytes monotonically only under a tighter hard cap", () => { using promptStateLease = retainEmbeddedSessionPromptState("session-99495-shrink"); const state = promptStateLease.state.toolResults; - const history = [ - toolResult("a".repeat(8_000), "history_1"), - toolResult("b".repeat(8_000), "history_2"), - ]; + const history = toolResults(["a".repeat(8_000), "b".repeat(8_000)], "history"); const first = truncate(history, 128_000, 6_000, 20_000, state); const shrunk = truncate(history, 128_000, 3_000, 20_000, state); const relaxed = truncate(history, 128_000, 7_000, 20_000, state); @@ -538,31 +443,24 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it.each([ - { kind: "tiny", outputs: ["ABC"], totalCap: 32_100 }, - { - kind: "runtime carrier", - outputs: ["OC99756_EXEC_MARKER_".padEnd(4_000, "x")], - totalCap: 36_000, - }, - { - kind: "queued steering", - outputs: [ - "OC99241_SHORT_SENTINEL_".padEnd(234, "s"), - "OC99241_LONG_SENTINEL_".padEnd(4_000, "l"), - ], - totalCap: 36_234, - }, - { kind: "oversized", outputs: ["z".repeat(20_000)], totalCap: 40_000 }, - ])( - "preserves fresh $kind output without rewriting frozen history", - ({ kind, outputs, totalCap }) => { - const state = createToolResultPromptProjectionState(); + ["tiny", ["ABC"], 32_100], + ["runtime carrier", ["OC99756_EXEC_MARKER_".padEnd(4_000, "x")], 36_000], + [ + "queued steering", + ["OC99241_SHORT_SENTINEL_".padEnd(234, "s"), "OC99241_LONG_SENTINEL_".padEnd(4_000, "l")], + 36_234, + ], + ["oversized", ["z".repeat(20_000)], 40_000], + ] as const)( + "preserves fresh %s output without rewriting frozen history", + (kind, outputs, totalCap) => { + const { project } = projection(8_000, 32_000, 1_000_000); const history: AgentMessage[] = []; for (let index = 0; index < 50; index++) { history.push(assistant(`call ${index}`), toolResult("x".repeat(4_000), `history_${index}`)); } history.push(user("run commands")); - const first = truncate(history, 1_000_000, 8_000, 32_000, state); + const first = project(history); expect(first.truncatedCount).toBeGreaterThan(0); const carrier = kind === "queued steering" @@ -581,17 +479,13 @@ describe("truncateOversizedToolResultsInMessages", () => { if (kind === "runtime carrier") { expect(providerMessages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); } - const second = truncate(providerMessages, 1_000_000, 8_000, 32_000, state); - const freshResults = second.messages.filter( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId.startsWith("fresh_"), - ); + const budget = kind === "tiny" ? 100 : 32_000; + const second = project(providerMessages, 8_000, budget); + const freshResults = byToolPrefix(second.messages, "fresh_"); if (kind === "runtime carrier") { - const historicalResults = (entries: AgentMessage[]) => - entries.filter( - (message) => message.role === "toolResult" && message.toolCallId.startsWith("history_"), - ); - expect(historicalResults(second.messages)).toEqual(historicalResults(first.messages)); + expect(byToolPrefix(second.messages, "history_")).toEqual( + byToolPrefix(first.messages, "history_"), + ); } else { expect(second.messages.slice(0, history.length)).toEqual(first.messages); } @@ -610,55 +504,11 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(second.aggregateTruncatedCount).toBe(0); expect(second.aggregatePressureEngaged).toBe(true); } - expect( - second.messages.reduce((sum, message) => sum + textLength(message), 0), - ).toBeLessThanOrEqual(totalCap); + expect(totalText(second.messages)).toBeLessThanOrEqual(totalCap); + expect(totalText(second.messages)).toBeGreaterThan(budget); }, ); - it("allows aggregate overflow rather than rewriting frozen history", () => { - const state = createToolResultPromptProjectionState(); - const history: AgentMessage[] = [ - toolResult("a".repeat(4_000), "history_a"), - toolResult("b".repeat(4_000), "history_b"), - user("establish a frozen projection baseline"), - ]; - const first = truncate(history, 1_000_000, 8_000, 10_000, state); - expect(first.aggregatePressureEngaged).toBe(false); - - const freshOutput = "OC99241_HARD_CAP_SENTINEL_".padEnd(4_000, "f"); - const runtimeContextMessage = buildRuntimeContextCustomMessage("hard-cap runtime context"); - if (!runtimeContextMessage) { - throw new Error("expected runtime context message"); - } - const providerMessages = convertToLlm([ - ...history, - toolResult(freshOutput, "fresh_hard_cap"), - runtimeContextMessage, - ] as AgentMessage[]); - expect(providerMessages.at(-1)).toMatchObject({ role: "user", runtimeContext: {} }); - const second = truncate(providerMessages, 1_000_000, 8_000, 100, state); - const freshResult = second.messages.find( - (message): message is ToolResultMessage => - message.role === "toolResult" && message.toolCallId === "fresh_hard_cap", - ); - const freshText = freshResult ? toolText(freshResult) : ""; - const totalChars = second.messages.reduce( - (sum, message) => sum + (message.role === "toolResult" ? textLength(message) : 0), - 0, - ); - - expect( - second.messages.filter( - (message) => message.role === "toolResult" && message.toolCallId.startsWith("history_"), - ), - ).toEqual(first.messages.filter((message) => message.role === "toolResult")); - expect(freshText).toBe(freshOutput); - expect(second.aggregateTruncatedCount).toBe(0); - expect(second.aggregatePressureEngaged).toBe(true); - expect(totalChars).toBeGreaterThan(100); - }); - it("leaves fresh trailing batches intact when only they exceed the aggregate budget", () => { const state = createToolResultPromptProjectionState(); const messages: AgentMessage[] = [user("run several tools")]; @@ -667,13 +517,12 @@ describe("truncateOversizedToolResultsInMessages", () => { } const result = truncate(messages, 1_000_000, 8_000, 32_000, state); - const toolResults = result.messages.filter((message) => message.role === "toolResult"); - const totalChars = toolResults.reduce((sum, message) => sum + textLength(message), 0); + const results = result.messages.filter((message) => message.role === "toolResult"); expect(result.truncatedCount).toBe(0); expect(result.aggregatePressureEngaged).toBe(true); - expect(totalChars).toBeGreaterThan(32_000); - expect(toolResults.every((message) => toolText(message).length > 0)).toBe(true); + expect(totalText(results)).toBeGreaterThan(32_000); + expect(results.every((message) => toolText(message).length > 0)).toBe(true); }); it.each([ @@ -688,9 +537,7 @@ describe("truncateOversizedToolResultsInMessages", () => { const texts = result.messages.map(toolText); if (kind === "tiny") { expect(result.truncatedCount).toBeGreaterThan(0); - expect( - result.messages.reduce((sum, message) => sum + textLength(message), 0), - ).toBeLessThanOrEqual(8); + expect(totalText(result.messages)).toBeLessThanOrEqual(8); } else if (kind === "plain") { expect(texts[0]).toContain("[tool result elided"); expect(texts[0]).not.toContain("full output preserved at"); @@ -741,41 +588,31 @@ describe("truncateOversizedToolResultsInMessages", () => { ]; const result = truncate(messages, 128_000, perResult, budget); const text = toolText(result.messages[0] ?? toolResult("")); - switch (kind) { - case "live": - case "escaped": - expect(text).toContain("read"); - expect(text).toContain(spillPath); - break; - case "partial": - expect(text).toContain("partial"); - expect(text).toContain(spillPath); - expect(text).not.toContain("full output preserved"); - break; - case "deleted": - case "private": - expect(text).toContain("[tool result elided"); - expect(text).not.toContain(spillPath); - break; - case "compact": - expect(text).toBe(`[read ${spillPath}]`); - break; - case "suffix": - expect(text).toContain(`full output at ${spillPath}`); - expect(text).not.toContain("narrow args"); - break; + expect(text.includes(spillPath)).toBe(kind !== "deleted" && kind !== "private"); + if (kind === "live" || kind === "escaped") { + expect(text).toContain("read"); + } else if (kind === "partial") { + expect(text).toContain("partial"); + expect(text).not.toContain("full output preserved"); + } else if (kind === "deleted" || kind === "private") { + expect(text).toContain("[tool result elided"); + } else if (kind === "compact") { + expect(text).toBe(`[read ${spillPath}]`); + } else { + expect(text).toContain(`full output at ${spillPath}`); + expect(text).not.toContain("narrow args"); } }, ); it("does not restore filtered image blocks when reusing a projection", () => { - const state = createToolResultPromptProjectionState(); + const { project } = projection(12_000, 12_000); const source = toolResult("x".repeat(15_000), "image_call"); source.content = [ { type: "image", data: "filtered-after-conversion" }, { type: "text", text: "x".repeat(15_000) }, ] as never; - truncate([source], 128_000, 12_000, 12_000, state); + project([source]); const providerMessage: ToolResultMessage = { ...source, @@ -784,9 +621,7 @@ describe("truncateOversizedToolResultsInMessages", () => { { type: "text" as const, text: "x".repeat(15_000) }, ], }; - const result = truncate([providerMessage], 128_000, 12_000, 12_000, state).messages[0] as - | ToolResultMessage - | undefined; + const result = project([providerMessage]).messages[0] as ToolResultMessage | undefined; expect(result?.content?.[0]).toEqual({ type: "text", @@ -798,13 +633,13 @@ describe("truncateOversizedToolResultsInMessages", () => { }); it("retains bounded projection data while replaying current canonical metadata", () => { - const state = createToolResultPromptProjectionState(); + const { state, project } = projection(); const text = "x".repeat(100_000); const source = toolResult(text, "retained-read", { content: text }); source.content.push({ type: "image", data: "a".repeat(100_000), mimeType: "image/png" }); // SAFETY: exercise malformed plugin content that the projection owner preserves. source.content.push(null as never); - const first = truncate([source], 128_000, 5_000, 20_000, state); + const first = project([source]); const retained = JSON.stringify(state, (_key, value) => value instanceof Map || value instanceof Set ? [...value.values()] : value, ); @@ -812,8 +647,7 @@ describe("truncateOversizedToolResultsInMessages", () => { expect(first.messages[0]).toMatchObject({ details: source.details }); const current = { ...source, details: { revision: "current" } }; - const replay = truncate([current], 128_000, 5_000, 20_000, state) - .messages[0] as ToolResultMessage; + const replay = project([current]).messages[0] as ToolResultMessage; expect(replay.details).toBe(current.details); expect(replay.content).toEqual((first.messages[0] as ToolResultMessage).content); expect(source.content[0]).toEqual({ type: "text", text }); @@ -826,87 +660,53 @@ describe("truncateOversizedToolResultsInMessages", () => { ], [["\ud800"], ["\ud801"]], ])("invalidates rewritten canonical text with preserved framing: %j", async (before, after) => { - const state = createToolResultPromptProjectionState(); + const { state, project } = projection(); const source = toolResult("", "rewritten-source"); const blocks = (parts: string[]) => parts.map((text) => ({ type: "text" as const, text })); source.content = blocks(["x".repeat(15_000), ...before]); - truncate([source], 128_000, 5_000, 20_000, state); + project([source]); const rewritten = { ...source, content: blocks(["x".repeat(15_000), ...after]) }; - await prepareProjection({ - sessionId: "rewritten-source", - messages: [rewritten], - state, - }); + await prepareProjection([rewritten], state, false, "rewritten-source"); expect(state.replacements.size).toBe(0); expect(state.frozen.size).toBe(0); }); it("freezes #99495 ambiguous-key projections across filtered history", async () => { - const state = createToolResultPromptProjectionState(); - const duplicate = (text: string) => ({ - role: "toolResult" as const, - toolCallId: "duplicate-call", - toolName: "duplicate", - isError: false, - timestamp: 1, - content: [{ type: "text" as const, text }], - }); - const first = truncate( - [duplicate("a".repeat(100)), duplicate("b".repeat(100))], - 128_000, - 100, - 100, - state, - ); - const filtered = truncate([duplicate("b".repeat(100))], 128_000, 100, 100, state); + const { state, project } = projection(100, 100); + const duplicate = (text: string) => ({ ...toolResult(text, "duplicate-call"), timestamp: 1 }); + const first = project([duplicate("a".repeat(100)), duplicate("b".repeat(100))]); + const filtered = project([duplicate("b".repeat(100))]); expect(first.messages[0]).not.toEqual(first.messages[1]); expect(filtered.messages[0]).toEqual(first.messages[1]); - await prepareProjection({ - sessionId: "ambiguous-filtered-history", - messages: [duplicate("b".repeat(100))], + await prepareProjection( + [duplicate("b".repeat(100))], state, - }); - expect(state.sourceHashByKey.size).toBe(1); - expect(state.frozen.size).toBe(1); - expect(state.replacements.size).toBe(0); - expect(state.ambiguousBaseKeys.size).toBe(1); - expect(truncate([duplicate("b".repeat(100))], 128_000, 100, 100, state).messages[0]).toEqual( - first.messages[1], + false, + "ambiguous-filtered-history", ); - await prepareProjection({ - sessionId: "ambiguous-removed-history", - messages: [], - state, - }); + expectProjectionSize(state, 1, 0); + expect(state.ambiguousBaseKeys.size).toBe(1); + expect(project([duplicate("b".repeat(100))]).messages[0]).toEqual(first.messages[1]); + await prepareProjection([], state, false, "ambiguous-removed-history"); expect(state.ambiguousBaseKeys.size).toBe(0); }); it("drops an unselected identical-occurrence key without changing projected bytes", async () => { - const state = createToolResultPromptProjectionState(); - const duplicate = (): ToolResultMessage => ({ - role: "toolResult", - toolCallId: "identical-call", - toolName: "duplicate", - isError: false, - content: [{ type: "text", text: "x".repeat(100) }], + const { state, project } = projection(100, 100); + const duplicate = () => ({ + ...toolResult("x".repeat(100), "identical-call"), timestamp: 1_000, }); const history = [duplicate(), assistant("separator"), duplicate()]; - const first = truncate(history, 128_000, 100, 100, state); + const first = project(history); const stateWithStaleOccurrence = createToolResultPromptProjectionState(state); expect(stateWithStaleOccurrence.frozen.size).toBe(2); - await prepareProjection({ - sessionId: "identical-occurrence-compaction", - messages: [duplicate()], - state, - }); + await prepareProjection([duplicate()], state, false, "identical-occurrence-compaction"); const retainedWithStale = truncate([duplicate()], 128_000, 100, 100, stateWithStaleOccurrence); - const retainedAfterPrune = truncate([duplicate()], 128_000, 100, 100, state); - // After the first identical occurrence disappears, both states select :0; - // retaining the unreachable :1 entry cannot preserve or change provider bytes. + const retainedAfterPrune = project([duplicate()]); expect(retainedAfterPrune.messages).toEqual(retainedWithStale.messages); expect(retainedAfterPrune.messages[0]).toEqual(first.messages[0]); expect(state.frozen.size).toBe(1); @@ -914,7 +714,7 @@ describe("truncateOversizedToolResultsInMessages", () => { }); }); -async function createSession(sessionId: string, sessionKey: string) { +async function createSession(sessionId: string, sessionKey = `agent:main:${sessionId}`) { const storePath = path.join(sessionDirs.make(), "sessions.json"); const scope = { agentId: "main", sessionId, sessionKey, storePath }; const sessionFile = formatSqliteSessionFileMarker(scope); @@ -926,6 +726,24 @@ async function createSession(sessionId: string, sessionKey: string) { return scope; } +type SessionScope = Awaited>; + +function recover( + scope: SessionScope, + overrides: Partial[0]> = {}, +) { + return truncateOversizedToolResultsInSessionManager({ + sessionManager: SessionManager.open(scope), + ...scope, + contextWindowTokens: 100, + ...overrides, + }); +} + +function append(scope: SessionScope, message: AgentMessage) { + return appendTranscriptMessage(scope, { message }); +} + function activeMessages(scope: Awaited>) { return SessionManager.open(scope) .getBranch() @@ -948,9 +766,7 @@ async function storedMessages(scope: Awaited>) describe("truncateOversizedToolResultsInSession", () => { it("truncates SQLite runtime transcripts without treating the marker as a file", async () => { const scope = await createSession("runtime-sqlite-tool-truncation", "agent:main:test"); - await appendTranscriptMessage(scope, { - message: user("run tools"), - }); + await append(scope, user("run tools")); const staleCheckpointReplay = { v: 1, type: "openai-responses-compaction", @@ -960,22 +776,15 @@ describe("truncateOversizedToolResultsInSession", () => { model: "gpt-5.2", baseUrlHash: "ozhevd1smnk8s", } satisfies NonNullable; - const preBoundaryCheckpointOwner = assistant("pre-boundary checkpoint owner"); - preBoundaryCheckpointOwner.providerReplay = staleCheckpointReplay; - await appendTranscriptMessage(scope, { message: preBoundaryCheckpointOwner }); + const checkpoint = (text: string, providerReplay: AssistantMessage["providerReplay"]) => + append(scope, { ...assistant(text), providerReplay }); + await checkpoint("pre-boundary checkpoint owner", staleCheckpointReplay); const medium = "alpha beta gamma delta epsilon ".repeat(600); - const firstToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_1"), - }); - const secondToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_2"), - }); - const thirdToolResult = await appendTranscriptMessage(scope, { - message: toolResult(medium, "call_3"), - }); - const staleCheckpointOwner = assistant("stale checkpoint owner"); - staleCheckpointOwner.providerReplay = staleCheckpointReplay; - await appendTranscriptMessage(scope, { message: staleCheckpointOwner }); + const originalToolResultIds = new Set(); + for (const message of toolResults(Array(3).fill(medium))) { + originalToolResultIds.add((await append(scope, message)).messageId); + } + await checkpoint("stale checkpoint owner", staleCheckpointReplay); const staleAnthropicCheckpointReplay = { ...staleCheckpointReplay, type: "anthropic-compaction", @@ -983,25 +792,17 @@ describe("truncateOversizedToolResultsInSession", () => { api: "anthropic-messages", model: "claude-sonnet-4-6", } satisfies NonNullable; - const staleAnthropicCheckpointOwner = assistant("stale Anthropic checkpoint owner"); - staleAnthropicCheckpointOwner.providerReplay = staleAnthropicCheckpointReplay; - await appendTranscriptMessage(scope, { message: staleAnthropicCheckpointOwner }); + await checkpoint("stale Anthropic checkpoint owner", staleAnthropicCheckpointReplay); const suppressionReplay = { ...staleCheckpointReplay, type: "openai-responses-compaction-suppression", data: "rejected", } satisfies NonNullable; - const suppressionOwner = assistant("suppression owner"); - suppressionOwner.providerReplay = suppressionReplay; - await appendTranscriptMessage(scope, { message: suppressionOwner }); + await checkpoint("suppression owner", suppressionReplay); const listener = vi.fn(); const cleanup = onInternalSessionTranscriptUpdate(listener); - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 100, - }); + const result = await recover(scope); cleanup(); expect(result.truncated).toBe(true); @@ -1013,11 +814,6 @@ describe("truncateOversizedToolResultsInSession", () => { target: scope, }); - const originalToolResultIds = new Set([ - firstToolResult.messageId, - secondToolResult.messageId, - thirdToolResult.messageId, - ]); expect( (await storedMessages(scope)) .filter((entry) => originalToolResultIds.has(entry.id)) @@ -1027,9 +823,9 @@ describe("truncateOversizedToolResultsInSession", () => { ).toEqual([medium, medium, medium]); const branchMessages = activeMessages(scope); - const toolResultTexts = branchMessages.flatMap((message) => - message.role === "toolResult" ? [toolText(message as ToolResultMessage)] : [], - ); + const toolResultTexts = branchMessages + .filter((message) => message.role === "toolResult") + .map(toolText); const findAssistant = (text: string) => branchMessages.find( (message): message is AssistantMessage => @@ -1047,90 +843,74 @@ describe("truncateOversizedToolResultsInSession", () => { expect(findAssistant("suppression owner")?.providerReplay).toEqual(suppressionReplay); }); - it("reuses frozen provider projection bytes on the recovery branch", async () => { - const scope = await createSession( - "runtime-sqlite-frozen-projection", - "agent:main:frozen-projection", - ); - await appendTranscriptMessage(scope, { message: user("run tool") }); - const original = toolResult("frozen output ".repeat(2_000), "frozen_call"); - const persisted = await appendTranscriptMessage(scope, { message: original }); - const state = createToolResultPromptProjectionState(); - const projected = truncate([original], 128_000, 12_000, 48_000, state).messages[0]; - const staleProjectionState = createToolResultPromptProjectionState(state); - - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 128_000, - maxCharsOverride: 12_000, - aggregateMaxCharsOverride: 48_000, - projectionState: state, - }); - - expect(result.truncated).toBe(true); - expect(state).toMatchObject({ - sourceHashByKey: { size: 0 }, - replacements: { size: 0 }, - frozen: { size: 0 }, - }); - await prepareProjection({ - sessionId: scope.sessionId, - messages: SessionManager.open(scope).buildSessionContext().messages, - state: staleProjectionState, - }); - expect(staleProjectionState.sourceHashByKey.size).toBe(0); - expect(staleProjectionState.replacements.size).toBe(0); - expect(staleProjectionState.frozen.size).toBe(0); - expect(activeMessages(scope).find((message) => message.role === "toolResult")).toEqual( - projected, - ); - const originalEvent = (await storedMessages(scope)).find( - (entry) => entry.id === persisted.messageId, - ); - expect(originalEvent?.message).toEqual(original); - }); - - it("reduces aggregate-only frozen history on the recovery branch", async () => { - const scope = await createSession( - "runtime-sqlite-frozen-aggregate-recovery", - "agent:main:frozen-aggregate-recovery", - ); - await appendTranscriptMessage(scope, { message: user("run tools") }); - // Each result stays under the per-result cap; only the aggregate is over. - const frozenResults = [ - toolResult("a".repeat(6_000), "frozen_agg_1"), - toolResult("b".repeat(6_000), "frozen_agg_2"), - toolResult("c".repeat(6_000), "frozen_agg_3"), - ]; - for (const message of frozenResults) { - await appendTranscriptMessage(scope, { message }); - } - const state = createToolResultPromptProjectionState(); - // Dispatch under a loose budget freezes every result at full text. - truncate(frozenResults, 128_000, 8_000, 48_000, state); - expect(state.frozen.size).toBe(3); - - // A provider context failure then demands recovery under a tighter budget: - // frozen history is the only reducible mass and must still shrink. - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 128_000, - maxCharsOverride: 8_000, - aggregateMaxCharsOverride: 6_000, - projectionState: state, - }); - - expect(result.truncated).toBe(true); - const totalChars = activeMessages(scope).reduce((sum, message) => sum + textLength(message), 0); - expect(totalChars).toBeLessThan(18_000); - }); + it.each([ + ["per-result", 12_000, 48_000], + ["aggregate-only", 8_000, 6_000], + ] as const)( + "recovers frozen %s history without rewriting original events", + async (kind, cap, budget) => { + const scope = await createSession(`runtime-sqlite-frozen-${kind}`); + await append(scope, user("run tools")); + const originals = + kind === "per-result" + ? [toolResult("frozen output ".repeat(2_000), "frozen_call")] + : toolResults( + ["a", "b", "c"].map((char) => char.repeat(6_000)), + "frozen_agg", + ); + const persisted: Array>> = []; + for (const message of originals) { + persisted.push(await append(scope, message)); + } + const { state, project } = projection(cap, 48_000); + const projected = project(originals).messages; + const stale = createToolResultPromptProjectionState(state); + expect(state.frozen.size).toBe(originals.length); + const result = await recover(scope, { + contextWindowTokens: 128_000, + maxCharsOverride: cap, + aggregateMaxCharsOverride: budget, + projectionState: state, + }); + expect(result.truncated).toBe(true); + const events = await storedMessages(scope); + expect( + persisted.map(({ messageId }) => events.find(({ id }) => id === messageId)?.message), + ).toEqual(originals); + if (kind === "aggregate-only") { + expect(totalText(activeMessages(scope))).toBeLessThan(18_000); + } else { + expectProjectionSize(state, 0); + await prepareProjection( + SessionManager.open(scope).buildSessionContext().messages, + stale, + false, + scope.sessionId, + ); + expectProjectionSize(stale, 0); + expect(activeMessages(scope).find((message) => message.role === "toolResult")).toEqual( + projected[0], + ); + } + }, + ); it("honors SQLite leaf controls when truncating runtime transcripts", async () => { const scope = await createSession("runtime-sqlite-leaf-tool-truncation", "agent:main:test"); const activeLarge = "selected branch tool output ".repeat(700); const inactiveLarge = "inactive branch tool output ".repeat(700); + const messageEntry = ( + id: string, + parentId: string | null, + message: AgentMessage, + second: number, + ) => ({ + type: "message" as const, + id, + parentId, + message, + timestamp: `2026-01-01T00:00:0${second}.000Z`, + }); await replaceTranscriptEvents(scope, [ { type: "session", @@ -1139,27 +919,9 @@ describe("truncateOversizedToolResultsInSession", () => { timestamp: "2026-01-01T00:00:00.000Z", cwd: path.dirname(scope.storePath), }, - { - type: "message", - id: "root-user", - parentId: null, - timestamp: "2026-01-01T00:00:01.000Z", - message: user("run tools"), - }, - { - type: "message", - id: "selected-tool", - parentId: "root-user", - timestamp: "2026-01-01T00:00:02.000Z", - message: toolResult(activeLarge, "call_selected"), - }, - { - type: "message", - id: "inactive-tool", - parentId: "root-user", - timestamp: "2026-01-01T00:00:03.000Z", - message: toolResult(inactiveLarge, "call_inactive"), - }, + messageEntry("root-user", null, user("run tools"), 1), + messageEntry("selected-tool", "root-user", toolResult(activeLarge, "call_selected"), 2), + messageEntry("inactive-tool", "root-user", toolResult(inactiveLarge, "call_inactive"), 3), { type: "leaf", id: "selected-leaf", @@ -1169,16 +931,10 @@ describe("truncateOversizedToolResultsInSession", () => { }, ]); - const result = await truncateOversizedToolResultsInSessionManager({ - sessionManager: SessionManager.open(scope), - ...scope, - contextWindowTokens: 100, - }); + const result = await recover(scope); expect(result.truncated).toBe(true); const stored = (await storedMessages(scope)).map((entry) => entry.message); - const findTool = (messages: AgentMessage[], id: string) => - messages.find((message) => message.role === "toolResult" && message.toolCallId === id); const originalSelectedTool = findTool(stored, "call_selected"); const inactiveTool = findTool(stored, "call_inactive"); const selectedTool = findTool(activeMessages(scope), "call_selected"); @@ -1242,4 +998,3 @@ describe("truncateToolResultText head+tail strategy", () => { } }); }); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/agents/sessions/auth-storage.test.ts b/src/agents/sessions/auth-storage.test.ts index cd5184997a19..36a496619fd5 100644 --- a/src/agents/sessions/auth-storage.test.ts +++ b/src/agents/sessions/auth-storage.test.ts @@ -67,14 +67,7 @@ const { ensureAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore } = authProfileStoreRuntime; function providerBCredential(overrides: Partial = {}) { - return createCredential({ - provider: "provider-b", - access: "provider-b-access", - refresh: "provider-b-refresh", - expires: Date.now() + 600_000, - accountId: "acct-b", - ...overrides, - }); + return createCredential({ provider: "provider-b", accountId: "acct-b", ...overrides }); } function saveCredential(agentDir: string, profileId: string, credential: OAuthCredential) { @@ -83,6 +76,37 @@ function saveCredential(agentDir: string, profileId: string, credential: OAuthCr }); } +function registerRefresh(storage: AuthStorage, providerId: string, credential: OAuthCredential) { + const refresh = vi.fn(async () => credential); + getAuthStorageOAuthProviderRegistry(storage).register(oauthProvider(providerId, refresh)); + return refresh; +} + +function expectPublished( + storage: AuthStorage, + agentDir: string, + providerId: string, + credential: OAuthCredential | undefined, +) { + expect(storage.get(providerId)).toEqual(credential); + expect(readProfile(agentDir, `${providerId}:default`)).toEqual(credential); +} + +function changeCredential( + storage: AuthStorage, + providerId: string, + change: string, + replacement: OAuthCredential, +) { + if (change === "logout") { + storage.logout(providerId); + } else if (change === "replace") { + storage.set(providerId, replacement); + } else if (change === "unrelated") { + storage.set("other", { type: "api_key", key: "synthetic-other-new" }); + } +} + afterEach(() => { vi.restoreAllMocks(); clearAuthProfileMigrationDiagnostics(); @@ -94,7 +118,6 @@ describe("AuthStorage OAuth refresh ownership", () => { it("refreshes local OAuth credentials while the shared owner requires migration", () => withAgent(async (agentDir, state) => { const providerId = "test-oauth"; - const credential = createCredential({ provider: providerId, expires: 1 }); const sharedStore = { version: 1, profiles: {} }; await state.writeJson("agents/main/agent/auth-profiles.json", { version: 1, @@ -103,25 +126,18 @@ describe("AuthStorage OAuth refresh ownership", () => { }, }); writePersistedAuthProfileStoreRaw(sharedStore); - writePersistedAuthProfileStoreRaw(profileStore(credential), agentDir); - const storage = AuthStorage.forAgent(agentDir, {}); - const refreshed = createCredential({ - provider: providerId, - access: "synthetic-refreshed-access", - refresh: "synthetic-refreshed-refresh", - }); - const refreshToken = vi.fn(async () => refreshed); - getAuthStorageOAuthProviderRegistry(storage).register( - oauthProvider(providerId, refreshToken), + writePersistedAuthProfileStoreRaw( + profileStore(createCredential({ provider: providerId, expires: 1 })), + agentDir, ); + const storage = AuthStorage.forAgent(agentDir, {}); + const refreshed = createCredential({ provider: providerId }); + registerRefresh(storage, providerId, refreshed); const fallback = vi.fn(() => "synthetic-fallback-key"); storage.setFallbackResolver(fallback); await expect(storage.getApiKey(providerId)).resolves.toBe(refreshed.access); - expect(refreshToken).toHaveBeenCalledOnce(); - expect(loadPersistedAuthProfileStore(agentDir)?.profiles[`${providerId}:default`]).toEqual( - refreshed, - ); + expectPublished(storage, agentDir, providerId, refreshed); await expect(storage.getApiKey("anthropic")).rejects.toMatchObject({ code: "AUTH_PROFILE_MIGRATION_REQUIRED", affectedProviders: ["anthropic"], @@ -139,14 +155,15 @@ describe("AuthStorage OAuth refresh ownership", () => { "blocks credential writes and OAuth refresh when the %s destination requires migration", (agentId) => withAgent(async (agentDir, state) => { - const originalStore = profileStore( - createCredential({ provider: "test-oauth", expires: 1 }), + writePersistedAuthProfileStoreRaw( + profileStore(createCredential({ provider: "test-oauth", expires: 1 })), + agentDir, ); - writePersistedAuthProfileStoreRaw(originalStore, agentDir); const storage = AuthStorage.forAgent(agentDir, {}); - const refreshToken = vi.fn(async () => createCredential({ provider: "test-oauth" })); - getAuthStorageOAuthProviderRegistry(storage).register( - oauthProvider("test-oauth", refreshToken), + const refreshToken = registerRefresh( + storage, + "test-oauth", + createCredential({ provider: "test-oauth" }), ); // A populated SQLite owner treats legacy files as leftover bytes. Once // emptied, its matching legacy credential must block further credential use. @@ -159,10 +176,6 @@ describe("AuthStorage OAuth refresh ownership", () => { : profileStore(createCredential({ provider: "test-oauth" })), ); await refreshAuthProfileFileState(); - expect(() => assertAuthProfileMigrationReady(agentDir)).toThrow( - "requires legacy credential migration", - ); - const write = vi.spyOn(authProfileStoreRuntime, "saveAuthProfileStoreWithPreparedOwner"); expect(() => storage.set("litellm", { type: "api_key", key: "synthetic-local-key" }), ).toThrow("requires legacy credential migration"); @@ -170,196 +183,121 @@ describe("AuthStorage OAuth refresh ownership", () => { code: "AUTH_PROFILE_MIGRATION_REQUIRED", }); expect(refreshToken).not.toHaveBeenCalled(); - expect(write).not.toHaveBeenCalled(); expect(readPersistedAuthProfileStoreRaw(agentDir)).toEqual(migrationStore); }, agentId), ); - it.each(["pending", "compare-and-swap", "observer"])( - "rejects a provider change during OAuth manager %s", - async (phase) => { - await withAgent(async (agentDir) => { - const profileId = "openai:oauth"; + describe.each(["manager", "serialized"])("%s provider ownership", (backendKind) => { + it.each(["pending", "compare-and-swap", "observer"])("rejects changes at %s", (phase) => + withAgent(async (agentDir) => { + const profileId = "openai:default"; const attempted = createCredential({ expires: 1, accountId: "acct-a" }); - const providerB = providerBCredential(phase === "pending" ? { expires: 1 } : {}); - const foreignFence = createOAuthRefreshFence({ profileId, credential: providerB }); - saveCredential( - agentDir, - profileId, + const foreign = providerBCredential(phase === "pending" ? { expires: 1 } : {}); + const foreignFence = createOAuthRefreshFence({ profileId, credential: foreign }); + const initial = phase === "compare-and-swap" ? attempted : phase === "pending" ? foreignFence - : createOAuthRefreshFence({ profileId, credential: attempted }), - ); - const buildApiKey = vi.fn( - async (_provider, credential: OAuthCredential) => credential.access, - ); - const refreshCredential = vi.fn(async () => (phase === "observer" ? null : providerB)); - mockOAuthProviderOperations({ - buildApiKey, - refreshCredential, - canRefreshCredential: async () => { - if (phase === "compare-and-swap") { - saveCredential(agentDir, profileId, providerB); - } - return true; - }, - }); - const manager = createOAuthManager(); - - if (phase === "pending") { - const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfiles; - let reads = 0; - vi.spyOn( - authProfileStoreRuntime, - "loadAuthProfileStoreWithoutExternalProfiles", - ).mockImplementation((...args) => { - const store = load(...args); - const credential = store.profiles[profileId]; - if ( - credential?.type === "oauth" && - credential.provider === "provider-b" && - isPendingOAuthRefreshFence(credential) && - ++reads > 1 - ) { - throw new Error("provider-B fence must not be observed"); - } - return store; + : createOAuthRefreshFence({ profileId, credential: attempted }); + const refresh = vi.fn(async () => (phase === "observer" ? null : foreign)); + const resolve = vi.fn(async (credential: OAuthCredential) => credential.access); + let reads = 0; + const canRefresh = async (replace: (credential: OAuthCredential) => void) => { + if (phase === "compare-and-swap") { + replace(foreign); + } + return true; + }; + if (backendKind === "manager") { + const replace = (credential: OAuthCredential) => + saveCredential(agentDir, profileId, credential); + replace(initial); + mockOAuthProviderOperations({ + buildApiKey: async (_provider, credential) => resolve(credential), + refreshCredential: refresh, + canRefreshCredential: () => canRefresh(replace), }); - } else if (phase === "observer") { - const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfilesAsync; - let ownReads = 0; - let foreignReads = 0; - vi.spyOn( - authProfileStoreRuntime, - "loadAuthProfileStoreWithoutExternalProfilesAsync", - ).mockImplementation(async (...args) => { - const store = await load(...args); - const credential = store.profiles[profileId]; - if (credential?.type === "oauth" && credential.provider === "openai") { - if (++ownReads === 2) { - saveCredential(agentDir, profileId, foreignFence); + if (phase === "observer") { + const load = authProfileStoreRuntime.loadAuthProfileStoreWithoutExternalProfilesAsync; + vi.spyOn( + authProfileStoreRuntime, + "loadAuthProfileStoreWithoutExternalProfilesAsync", + ).mockImplementation(async (...args) => { + const store = await load(...args); + const credential = store.profiles[profileId]; + if ( + credential?.type === "oauth" && + credential.provider === "openai" && + ++reads === 2 + ) { + replace(foreignFence); } - } else if ( - credential?.type === "oauth" && - credential.provider === "provider-b" && - isPendingOAuthRefreshFence(credential) && - ++foreignReads > 1 - ) { - throw new Error("provider-B fence observation continued"); - } - return store; - }); + return store; + }); + } + await expect( + createOAuthManager().resolveOAuthAccess({ + agentDir, + credential: attempted, + profileId, + store: + phase === "compare-and-swap" + ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir) + : profileStore(attempted, profileId), + }), + ).resolves.toBeNull(); + if (phase === "compare-and-swap") { + expect(readProfile(agentDir, profileId)).toEqual(foreign); + } + } else { + const { backend, set } = serializedBackend( + initial, + { + after: () => { + if (++reads === 1 && phase === "observer") { + set(foreignFence); + } + }, + }, + profileId, + ); + await expect( + refreshSerializedOAuthCredential({ + backend, + provider: "openai", + profileId, + label: "test serialized provider ownership", + timeoutMs: 1_000, + parse: (current) => JSON.parse(current ?? "{}") as Record, + serialize: JSON.stringify, + readCredential: (data) => data[profileId], + writeCredential: (data, credential) => ({ ...data, [profileId]: credential }), + canRefresh: () => canRefresh(set), + refresh: async () => { + const credential = await refresh(); + return credential ? { apiKey: credential.access, credential } : null; + }, + resolve: async (credential) => ({ apiKey: await resolve(credential), credential }), + commit: () => {}, + }), + ).resolves.toBeNull(); } - - const store = - phase === "compare-and-swap" - ? ensureAuthProfileStoreWithoutExternalProfiles(agentDir) - : profileStore(attempted, profileId); - await expect( - manager.resolveOAuthAccess({ agentDir, credential: attempted, store, profileId }), - ).resolves.toBeNull(); - expect(buildApiKey).not.toHaveBeenCalled(); - if (phase !== "observer") { - expect(refreshCredential).not.toHaveBeenCalled(); + expect(resolve).not.toHaveBeenCalled(); + if (backendKind === "serialized" || phase !== "observer") { + expect(refresh).not.toHaveBeenCalled(); } - if (phase === "compare-and-swap") { - expect(readProfile(agentDir, profileId)).toEqual(providerB); - } - }); - }, - ); - it.each(["pending initial fence", "post-canRefresh replacement", "observer fence replacement"])( - "rejects a serialized provider change from a %s", - async (phase) => { - const profileId = "openai:default"; - const foreign = createCredential({ provider: "provider-b", expires: 1 }); - const foreignFence = createOAuthRefreshFence({ profileId, credential: foreign }); - const initial = - phase === "pending initial fence" - ? foreignFence - : phase === "observer fence replacement" - ? createOAuthRefreshFence({ profileId, credential: createCredential({ expires: 1 }) }) - : createCredential({ expires: 1 }); - let calls = 0; - const { backend, set } = serializedBackend( - initial, - { - before: () => { - calls += 1; - if ( - (phase === "pending initial fence" && calls > 1) || - (phase === "observer fence replacement" && calls > 2) - ) { - throw new Error("provider-B fence must not be observed"); - } - }, - after: () => { - if (phase === "observer fence replacement" && calls === 1) { - set(foreignFence); - } - }, - }, - profileId, - ); - const refreshed = providerBCredential(); - const refresh = vi.fn(async () => ({ apiKey: refreshed.access, credential: refreshed })); - const resolve = vi.fn(async (credential: OAuthCredential) => ({ - apiKey: credential.access, - credential, - })); - await expect( - refreshSerializedOAuthCredential({ - backend, - provider: "openai", - profileId, - label: "test serialized provider ownership", - timeoutMs: 1_000, - parse: (current) => JSON.parse(current ?? "{}") as Record, - serialize: JSON.stringify, - readCredential: (data) => data[profileId], - writeCredential: (data, credential) => ({ ...data, [profileId]: credential }), - canRefresh: async () => { - if (phase === "post-canRefresh replacement") { - set(createCredential({ provider: "provider-b", expires: Date.now() + 600_000 })); - } - return true; - }, - refresh, - resolve, - commit: () => {}, - }), - ).resolves.toBeNull(); - expect(refresh).not.toHaveBeenCalled(); - expect(resolve).not.toHaveBeenCalled(); - }, - ); - it("runs provider I/O outside custom backend locks and fences peer retries", async () => { - const providerId = "test-oauth"; - let lockDepth = 0; - const { backend, read } = serializedBackend( - createCredential({ - provider: providerId, - access: "claimed-access", - refresh: "claimed-refresh", - expires: 1, - accountId: "acct-123", }), - { - before: () => { - expect(lockDepth).toBe(0); - lockDepth += 1; - }, - after: () => { - lockDepth -= 1; - }, - }, + ); + }); + it("hides custom-backend refresh claims and shares their settled credential with peers", async () => { + const providerId = "test-oauth"; + const { backend, read } = serializedBackend( + createCredential({ provider: providerId, expires: 1, accountId: "acct-123" }), ); const started = createDeferredCore(); const refresh = createDeferredCore<{ access: string; refresh: string; expires: number }>(); const refreshToken = vi.fn(() => { - expect(lockDepth).toBe(0); started.resolve(); return refresh.promise; }); @@ -371,8 +309,8 @@ describe("AuthStorage OAuth refresh ownership", () => { await started.promise; expect(read()).toContain("openclaw-oauth-refresh-fence:v1:"); - expect(read()).not.toContain("claimed-access"); - expect(read()).not.toContain("claimed-refresh"); + expect(read()).not.toContain("access-token"); + expect(read()).not.toContain("refresh-token"); expect(storage.get(providerId)).toBeUndefined(); expect(storage.has(providerId)).toBe(false); expect(storage.list()).not.toContain(providerId); @@ -401,12 +339,7 @@ describe("AuthStorage OAuth refresh ownership", () => { it("preserves an expired credential when no refresh owner exists", async () => { const providerId = "unowned-oauth"; - const original = createCredential({ - provider: providerId, - access: "unowned-access", - refresh: "unowned-refresh", - expires: 1, - }); + const original = createCredential({ provider: providerId, expires: 1 }); const { backend, read } = serializedBackend(original); const storage = AuthStorage.fromStorage(backend); @@ -417,12 +350,7 @@ describe("AuthStorage OAuth refresh ownership", () => { it("does not replay a failed generation and allows environment fallback after restart", async () => { const providerId = "xai"; const { backend, read } = serializedBackend( - createCredential({ - provider: providerId, - access: "failed-access", - refresh: "failed-refresh", - expires: 1, - }), + createCredential({ provider: providerId, expires: 1 }), ); const refreshToken = vi.fn(async () => { throw new Error("simulated provider rejection"); @@ -439,8 +367,8 @@ describe("AuthStorage OAuth refresh ownership", () => { await expect(restarted.getApiKey(providerId)).resolves.toBe("environment-fallback"); expect(refreshToken).toHaveBeenCalledTimes(1); expect(read()).toContain("openclaw-oauth-refresh-fence:v1:"); - expect(read()).not.toContain("failed-access"); - expect(read()).not.toContain("failed-refresh"); + expect(read()).not.toContain("access-token"); + expect(read()).not.toContain("refresh-token"); } finally { vi.unstubAllEnvs(); } @@ -479,13 +407,7 @@ describe("AuthStorage OAuth publication", () => { ) { queued = true; queueMicrotask(() => { - if (change === "logout") { - mutate.logout(providerId); - } else if (change === "replace") { - mutate.set(providerId, replacement); - } else if (change === "unrelated") { - mutate.set("other", { type: "api_key", key: "synthetic-other-new" }); - } + changeCredential(mutate, providerId, change, replacement); }); } } @@ -497,8 +419,7 @@ describe("AuthStorage OAuth publication", () => { const expected = change === "logout" ? undefined : change === "replace" ? replacement : refreshed; expect(apiKey).toBe(expected?.access); - expect(storage.get(providerId)).toEqual(expected); - expect(durable?.[profileId]).toEqual(expected); + expectPublished(storage, agentDir, providerId, expected); expect(refreshToken).toHaveBeenCalledTimes( phase === "claim" && (change === "logout" || change === "replace") ? 0 : 1, ); @@ -545,11 +466,7 @@ describe("AuthStorage OAuth publication", () => { } else if (!changeQueued && durable.access === refreshed.access) { changeQueued = true; queueMicrotask(() => { - if (change === "logout") { - peer.logout(providerId); - } else if (change === "replace") { - peer.set(providerId, replacement); - } + changeCredential(peer, providerId, change, replacement); }); } } @@ -562,8 +479,7 @@ describe("AuthStorage OAuth publication", () => { const expected = change === "logout" ? undefined : change === "replace" ? replacement : refreshed; expect(apiKey).toBe(expected?.access); - expect(storage.get(providerId)).toEqual(expected); - expect(readProfile(agentDir, profileId)).toEqual(expected); + expectPublished(storage, agentDir, providerId, expected); }), ); @@ -606,8 +522,7 @@ describe("AuthStorage OAuth publication", () => { const durable = readProfile(agentDir, profileId); if (replace) { expect(apiKey).toBe(replacement.access); - expect(storage.get(providerId)).toEqual(replacement); - expect(durable).toEqual(replacement); + expectPublished(storage, agentDir, providerId, replacement); } else { expect(apiKey).toBeUndefined(); expect(storage.get(providerId)).toBeUndefined(); @@ -989,18 +904,12 @@ describe("SQLite auth storage", () => { const right = AuthStorage.forAgent(agentDir); const refreshEntered = createDeferredCore(); const releaseRefresh = createDeferredCore(); - let refreshCalls = 0; - let activeRefreshes = 0; - let maxActiveRefreshes = 0; - const provider = oauthProvider("test-oauth", async () => { - refreshCalls += 1; - activeRefreshes += 1; - maxActiveRefreshes = Math.max(maxActiveRefreshes, activeRefreshes); + const refresh = vi.fn(async () => { refreshEntered.resolve(); await releaseRefresh.promise; - activeRefreshes -= 1; return freshOAuth(); }); + const provider = oauthProvider("test-oauth", refresh); getAuthStorageOAuthProviderRegistry(left).register(provider); getAuthStorageOAuthProviderRegistry(right).register(provider); @@ -1036,7 +945,7 @@ describe("SQLite auth storage", () => { } finally { releaseRefresh.resolve(); } - expect([refreshCalls, maxActiveRefreshes]).toEqual([1, 1]); + expect(refresh).toHaveBeenCalledOnce(); await expect(right.getApiKey("test-oauth")).resolves.toBe("fake-fresh-access"); }); diff --git a/src/agents/sessions/model-registry.test.ts b/src/agents/sessions/model-registry.test.ts index c6cc7087f8a4..0e1a78eb1630 100644 --- a/src/agents/sessions/model-registry.test.ts +++ b/src/agents/sessions/model-registry.test.ts @@ -36,6 +36,10 @@ const providerConfig = (models: unknown[], config: Record = {}) function catalog(providers: Record) { return { generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY, providers }; } +const capturedCatalog = (pluginId: string, providers: Record) => ({ + pluginId, + contents: JSON.stringify(catalog(providers)), +}); const authResult = (apiKey?: string, headers?: Record) => ({ ok: true, @@ -58,14 +62,7 @@ describe("ModelRegistry", () => { const { writeModelsJson, writeModelsJsonWithPluginCatalog, writeModelsJsonWithPluginCatalogs } = installModelRegistryTestFixtures(); const writeRoot = (providers: Record = {}) => writeModelsJson({ providers }); - const nvidia = { baseUrl: "https://integrate.api.nvidia.com/v1", apiKey: "NVIDIA_API_KEY" }; - - const zai = { - baseUrl: "https://api.z.ai/api/paas/v4", - api: "openai-completions", - apiKey: "ZAI_API_KEY", - models: [{ id: "glm-5.1", name: "GLM 5.1" }], - }; + const zai = providerConfig([{ id: "glm-5.1", name: "GLM 5.1" }]); const custom = providerConfig([{ id: "authored-model", name: "Authored Model" }], { apiKey: "authored-provider-test-key", }); @@ -95,17 +92,6 @@ describe("ModelRegistry", () => { }); const ownerSnapshot = (...ids: string[]) => pluginOwnerSnapshotEntries(ids.map((id) => ({ providerId: id, pluginId: id }))); - async function generatedRegistry( - providers: Record, - auth = AuthStorage.inMemory(), - ) { - return createRegistry( - await writeCatalogs(providers), - { pluginMetadataSnapshot: ownerSnapshot(...Object.keys(providers)) }, - auth, - ); - } - function oauthProviderConfig(name: string, apiKeyPrefix: string): ProviderConfigInput { const credentials = () => ({ access: "test-token-placeholder", @@ -148,18 +134,16 @@ describe("ModelRegistry", () => { }); it("accepts Bedrock AWS SDK auth without apiKey", async () => { - const modelsPath = writeRoot({ - "amazon-bedrock": providerConfig([{ id: "bedrock-model" }], { - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", - api: "bedrock-converse-stream", - auth: "aws-sdk", + const registry = createRegistry( + writeRoot({ + "amazon-bedrock": providerConfig([{ id: "bedrock-model" }], { + api: "bedrock-converse-stream", + auth: "aws-sdk", + }), }), - }); - - const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath); + ); const model = registry.find("amazon-bedrock", "bedrock-model"); - expect(registry.getError()).toBeUndefined(); expect(registry.getAvailable()).toEqual([model]); await expect(registry.getApiKeyAndHeaders(model!)).resolves.toEqual(authResult()); expect(registry.getProviderAuthStatus("amazon-bedrock")).toEqual({ @@ -170,12 +154,16 @@ describe("ModelRegistry", () => { }); it("forks a catalog with request-isolated auth and provider mutations", async () => { - const template = createRegistry(exampleCatalog()); + const template = createRegistry(exampleCatalog("test-token-placeholder")); const firstAuth = AuthStorage.inMemory(); const secondAuth = AuthStorage.inMemory(); const first = template.fork(firstAuth); const second = template.fork(secondAuth); + await expect(first.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); + await expect( + first.getApiKeyAndHeaders(first.find("custom", "example-model")!), + ).resolves.toEqual(authResult("test-token-placeholder")); firstAuth.setRuntimeApiKey("custom", "first-runtime-key"); secondAuth.setRuntimeApiKey("custom", "second-runtime-key"); first.registerProvider("first-only", oauthProviderConfig("First only", "first")); @@ -197,17 +185,6 @@ describe("ModelRegistry", () => { expect(first.find("custom", "example-model")).toBeDefined(); }); - it("preserves models.json provider auth in a catalog fork", async () => { - const template = createRegistry(exampleCatalog("test-token-placeholder")); - const fork = template.fork(AuthStorage.inMemory()); - const model = fork.find("custom", "example-model"); - - await expect(fork.getApiKeyForProvider("custom")).resolves.toBe("test-token-placeholder"); - await expect(fork.getApiKeyAndHeaders(model!)).resolves.toEqual( - authResult("test-token-placeholder"), - ); - }); - it("skips native presence while resolving configured HTTP credentials", async () => { const auth = AuthStorage.inMemory({ custom: { @@ -219,8 +196,6 @@ describe("ModelRegistry", () => { const registry = createRegistry(exampleCatalog("synthetic-configured-api-key"), {}, auth); const model = registry.find("custom", "example-model")!; - expect(auth.hasAuth("custom")).toBe(true); - await expect(auth.getApiKey("custom", { includeFallback: false })).resolves.toBeUndefined(); await expect(registry.getApiKeyForProvider("custom")).resolves.toBe( "synthetic-configured-api-key", ); @@ -250,52 +225,36 @@ describe("ModelRegistry", () => { parameterCount: 4_000_000_000, family: "meta", maxTokens: 128_000, + contextWindow: 1_000_000, + contextTokens: 872_000, + }; + const config: ProviderConfigInput = { + api: "openai-responses", + baseUrl: "https://models.example/v1", + models: [{ ...model, input: [...model.input] }], }; - const windows = [ - ["fixture-primary", 1_000_000, 872_000], - ["fixture-secondary", 1_050_000, 922_000], - ] as const; - const providers: Record = Object.fromEntries( - windows.map(([provider, contextWindow, contextTokens]) => [ - provider, - { - api: "openai-responses", - baseUrl: "https://models.example/v1", - models: [{ ...model, input: [...model.input], contextWindow, contextTokens }], - }, - ]), - ); const registry = source === "persisted" - ? createRegistry(writeRoot(providers)) + ? createRegistry(writeRoot({ fixture: config })) : ModelRegistry.inMemory(AuthStorage.inMemory()); if (source === "registered") { - for (const [provider, config] of Object.entries(providers)) { - registry.registerProvider(provider, config); - } + registry.registerProvider("fixture", config); } expect(registry.getError()).toBeUndefined(); for (const candidate of [registry, registry.fork(AuthStorage.inMemory())]) { - for (const [provider, config] of Object.entries(providers)) { - expect(candidate.find(provider, "shared-model")).toMatchObject(config.models![0]!); - } + expect(candidate.find("fixture", model.id)).toMatchObject(model); } }); it("drops a malformed persisted family without rejecting the catalog", () => { const registry = createRegistry( writeRoot({ - "family-fixture": { - api: "openai-responses", - baseUrl: "https://models.example/v1", - models: [ - { id: "spaced", family: "Open AI" }, - { id: "cased", family: " Meta " }, - ], - }, + "family-fixture": providerConfig([ + { id: "spaced", family: "Open AI" }, + { id: "cased", family: " Meta " }, + ]), }), ); - expect(registry.getError()).toBeUndefined(); expect(registry.find("family-fixture", "spaced")).not.toHaveProperty("family"); expect(registry.find("family-fixture", "cased")?.family).toBe("meta"); }); @@ -314,7 +273,6 @@ describe("ModelRegistry", () => { ), }); const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(dirname(modelsPath)); - expect(pluginCatalogs).toHaveLength(1); const stored = JSON.parse(pluginCatalogs[0]!.contents).providers[provider].models; for (const id of ["unknown", "prompt-only"]) { expect(stored.find((row: { id: string }) => row.id === id)).not.toHaveProperty( @@ -327,15 +285,15 @@ describe("ModelRegistry", () => { pluginMetadataSnapshot: pluginOwnerSnapshot(provider, provider), }); for (const candidate of [registry, registry.fork(AuthStorage.inMemory())]) { - for (const [id, expected] of [ - ["unknown", { contextWindow: 128_000 }], - ["prompt-only", { contextWindow: 777_000, contextTokens: 777_000 }], - ] as const) { - expect(candidate.find(provider, id)).toMatchObject({ - ...expected, - contextWindowSource: "synthetic", - }); - } + expect(candidate.find(provider, "unknown")).toMatchObject({ + contextWindow: 128_000, + contextWindowSource: "synthetic", + }); + expect(candidate.find(provider, "prompt-only")).toMatchObject({ + contextWindow: 777_000, + contextTokens: 777_000, + contextWindowSource: "synthetic", + }); for (const [id, window] of [ ["reported", 128_000], ["authored", 64_000], @@ -350,21 +308,14 @@ describe("ModelRegistry", () => { it("loads only lifecycle-captured generated catalogs when the root catalog is absent", () => { const modelsPath = writeRoot(); rmSync(modelsPath); - const capturedCatalog = { - pluginId: "zai", - contents: JSON.stringify(catalog({ zai: { ...zai, apiKey: undefined } })), - }; - const pluginMetadataSnapshot = ownerSnapshot("zai", "other"); const registry = createRegistry(modelsPath, { - includePluginCatalogs: true, modelsJsonContents: null, - pluginCatalogs: [capturedCatalog], + pluginCatalogs: [capturedCatalog("zai", { zai })], pluginMetadataSnapshot, }); const fork = registry.fork(AuthStorage.inMemory()); - expect(registry.getError()).toBeUndefined(); expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1"); expect(registry.find("other", "unrelated-model")).toBeUndefined(); expect(registry.getProviderMetadataOwners()).toBe(pluginMetadataSnapshot.owners); @@ -382,73 +333,38 @@ describe("ModelRegistry", () => { }); it("loads richer generated catalog metadata without widening runtime inputs", async () => { - const registry = await generatedRegistry( - { - minimax: providerConfig([{ id: "video", input: ["text", "image", "video"] }], { - baseUrl: "https://api.minimaxi.com/v1", - apiKey: "MINIMAX_API_KEY", - }), - nvidia: providerConfig( - [ - { id: "audio", input: ["text", "image", "audio"] }, - { id: "audio-only", input: ["audio"] }, - { id: "explicit-empty", input: [] }, - ], - nvidia, - ), - }, - AuthStorage.inMemory({ - minimax: { type: "api_key", key: "sk-minimax" }, - nvidia: { type: "api_key", key: "sk-nvidia" }, - }), + const modelsPath = await writeCatalogs({ + fixture: providerConfig([ + { id: "mixed", input: ["text", "image", "video", "audio"] }, + { id: "unsupported", input: ["audio"] }, + { id: "empty", input: [] }, + ]), + }); + const registry = createRegistry( + modelsPath, + { pluginMetadataSnapshot: ownerSnapshot("fixture") }, + AuthStorage.inMemory({ fixture: { type: "api_key", key: "fixture-key" } }), ); - - expect(registry.getError()).toBeUndefined(); - expect(registry.find("minimax", "video")?.input).toEqual(["text", "image"]); - expect(registry.find("nvidia", "audio")?.input).toEqual(["text", "image"]); - expect(registry.find("nvidia", "audio-only")).toBeUndefined(); - expect(registry.find("nvidia", "explicit-empty")?.input).toEqual([]); - const availableRefs = registry.getAvailable().map((model) => `${model.provider}/${model.id}`); - expect(availableRefs).not.toContain("nvidia/audio-only"); - expect(availableRefs).toContain("nvidia/explicit-empty"); + expect(registry.find("fixture", "mixed")?.input).toEqual(["text", "image"]); + expect(registry.find("fixture", "unsupported")).toBeUndefined(); + expect(registry.find("fixture", "empty")?.input).toEqual([]); + const availableIds = registry.getAvailable().map((model) => model.id); + expect(availableIds).not.toContain("unsupported"); + expect(availableIds).toContain("empty"); }); it("isolates invalid persisted plugin catalogs from valid models", async () => { - const modelsPath = await writeModelsJsonWithPluginCatalogs({ - root: { - providers: { - custom: providerConfig([{ id: "root-model", name: "Root Model" }], { - api: "openai-responses", - apiKey: "CUSTOM_API_KEY", - }), - }, - }, - pluginCatalogs: [ - pluginCatalog("google", { - "google-vertex": { - baseUrl: "https://us-central1-aiplatform.googleapis.com/v1", - api: "google-vertex", - apiKey: "GOOGLE_API_KEY", - models: [{ id: "gemini-3.1-pro-preview", contextWindow: 0 }], - }, - }), - pluginCatalog("zai", { zai }), - ], - }); - - const registry = createRegistry(modelsPath, { - pluginMetadataSnapshot: pluginOwnerSnapshotEntries([ - { providerId: "google-vertex", pluginId: "google" }, - { providerId: "zai", pluginId: "zai" }, - ]), - }); - - expect(registry.getError()).toContain( - "Provider google-vertex, model gemini-3.1-pro-preview: invalid contextWindow", + const modelsPath = await writeCatalogs( + { invalid: providerConfig([{ id: "invalid", contextWindow: 0 }]), zai }, + { providers: { custom } }, ); - expect(registry.find("custom", "root-model")?.name).toBe("Root Model"); + const registry = createRegistry(modelsPath, { + pluginMetadataSnapshot: ownerSnapshot("invalid", "zai"), + }); + expect(registry.getError()).toContain("Provider invalid, model invalid: invalid contextWindow"); + expect(registry.find("custom", "authored-model")?.name).toBe("Authored Model"); expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1"); - expect(registry.find("google-vertex", "gemini-3.1-pro-preview")).toBeUndefined(); + expect(registry.find("invalid", "invalid")).toBeUndefined(); }); it.each([ @@ -610,16 +526,10 @@ describe("ModelRegistry", () => { if (process.getuid?.() === 0) { return; } - const modelsPath = await writeModelsJsonWithPluginCatalog({ - root: { providers: { custom } }, - ...pluginCatalog("anthropic", { - anthropic: providerConfig([{ id: "healthy-model", name: "Healthy Model" }], { - baseUrl: "https://anthropic.example/v1", - api: "anthropic-messages", - apiKey: "healthy-provider-test-key", - }), - }), - }); + const modelsPath = await writeCatalogs( + { anthropic: providerConfig([{ id: "healthy-model", name: "Healthy Model" }]) }, + { providers: { custom } }, + ); const sourcePath = join(dirname(modelsPath), "plugins", "zai", PLUGIN_MODEL_CATALOG_FILE); mkdirSync(dirname(sourcePath), { recursive: true }); writeFileSync( @@ -666,24 +576,19 @@ describe("ModelRegistry source composition", () => { vercelGatewayRouting: { order: ["provider-default"], only: ["shared-route"] }, }; const authoredHeaders = { "X-Authored-Provider": "root", "X-Authored-Model": "root" }; - const authored = { - api: "openai-completions", - baseUrl: rootUrl, - apiKey: "authored-fixture-key", - headers: { "X-Authored-Provider": "root" }, - models: [ + const authored = providerConfig( + [ { id: "shared", name: "Authored", maxTokens: 2048, headers: { "X-Authored-Model": "root" } }, { id: "Shared", name: "Case-distinct authored model" }, ], - }; - const generated = { - api: "openai-completions", - baseUrl: catalogUrl, - apiKey: "cached-fixture-key", - auth: "aws-sdk", - authHeader: true, - headers: { "X-Cached-Provider": "cache" }, - models: [ + { + baseUrl: rootUrl, + apiKey: "authored-fixture-key", + headers: { "X-Authored-Provider": "root" }, + }, + ); + const generated = providerConfig( + [ { id: "shared", name: "Generated", @@ -693,7 +598,14 @@ describe("ModelRegistry source composition", () => { }, { id: "generated-only", maxTokens: 4096 }, ], - }; + { + baseUrl: catalogUrl, + apiKey: "cached-fixture-key", + auth: "aws-sdk", + authHeader: true, + headers: { "X-Cached-Provider": "cache" }, + }, + ); const getAuth = (registry: ModelRegistry, id = "shared", owner = provider) => registry.getApiKeyAndHeaders(registry.find(owner, id)!); @@ -719,12 +631,7 @@ describe("ModelRegistry source composition", () => { root === null ? null : { [options.authoredProvider ?? provider]: root }, AuthStorage.inMemory(options.credentials ?? {}), { - pluginCatalogs: [ - { - pluginId, - contents: JSON.stringify(catalog({ [provider]: cached })), - }, - ], + pluginCatalogs: [capturedCatalog(pluginId, { [provider]: cached })], pluginMetadataSnapshot: pluginOwnerSnapshot(provider, pluginId), }, ); @@ -867,7 +774,7 @@ describe("ModelRegistry source composition", () => { ...authored, api, authHeader: true, - models: [{ ...authored.models[0], baseUrl: catalogUrl }], + models: [{ id: "shared", baseUrl: catalogUrl, headers: { "X-Authored-Model": "root" } }], }, generated: { api: "openai-completions", @@ -930,31 +837,27 @@ describe("ModelRegistry no-auth", () => { params.storedKey ? { custom: { type: "api_key", key: "stored-key" } } : {}, ); const getApiKey = vi.spyOn(authStorage, "getApiKey"); - const modelConfig = { - id: "model", - api: params.modelApi ?? "openai-completions", - headers: { authorization: params.authorization ?? "Bearer model", "X-Model": "model" }, - }; - const provider = { - baseUrl: "https://inference.example.invalid/v1", - api: params.providerApi ?? "openai-completions", - auth: params.auth, - ...(params.inlineKey === false ? {} : { apiKey: "inline-key" }), - authHeader: true, - headers: { - Authorization: params.authorization ?? "Bearer provider", - "X-Provider": "provider", + const provider = providerConfig( + [ + { + id: "model", + api: params.modelApi ?? "openai-completions", + headers: { authorization: params.authorization ?? "Bearer model", "X-Model": "model" }, + }, + ], + { + api: params.providerApi ?? "openai-completions", + auth: params.auth, + ...(params.inlineKey === false ? {} : { apiKey: "inline-key" }), + authHeader: true, + headers: { + Authorization: params.authorization ?? "Bearer provider", + "X-Provider": "provider", + }, }, - models: [modelConfig], - }; - const registry = capturedRegistry({ custom: provider }, authStorage, { - pluginCatalogs: [], - includePluginCatalogs: false, - }); - const model = registry.find("custom", "model"); - if (!model) { - throw new Error(registry.getError() ?? "Expected custom model"); - } + ); + const registry = capturedRegistry({ custom: provider }, authStorage); + const model = registry.find("custom", "model")!; return { registry, model, getApiKey }; } @@ -962,8 +865,8 @@ describe("ModelRegistry no-auth", () => { "keeps registry no-auth terminal with provider adapter %s and an effective Completions route", async (providerApi) => { const { registry, model, getApiKey } = noAuthRegistry({ - providerApi, auth: "none", + providerApi, storedKey: true, }); const requestModel = { diff --git a/src/agents/sessions/sdk.auth-migration.test.ts b/src/agents/sessions/sdk.auth-migration.test.ts index 96a44fc1ae2a..15e6e7a43c19 100644 --- a/src/agents/sessions/sdk.auth-migration.test.ts +++ b/src/agents/sessions/sdk.auth-migration.test.ts @@ -12,10 +12,7 @@ import { replaceSessionEntry, } from "../../config/sessions/session-accessor.js"; import * as contextWorker from "../../config/sessions/session-transcript-read-worker-runtime.js"; -import { - getOwnedSessionTranscriptWriterFence, - SessionTranscriptWriterClaimReboundError, -} from "../../config/sessions/transcript-write-context.js"; +import { SessionTranscriptWriterClaimReboundError } from "../../config/sessions/transcript-write-context.js"; import { snapshotFiles } from "../../infra/state-migrations.caller-mode.test-helpers.js"; import { autoMigrateLegacyState } from "../../infra/state-migrations.doctor.js"; import { inspectLegacyAgentDir } from "../../infra/state-migrations.legacy-sessions.js"; @@ -24,7 +21,6 @@ import { createDeferredCore } from "../../shared/deferred.js"; import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js"; import { closeOpenClawAgentDatabaseByPathAsync, - inspectOpenClawAgentDatabaseOwner, openOpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { runOpenClawAgentWorkerWrite } from "../../state/openclaw-agent-write-admission.js"; @@ -60,7 +56,6 @@ import { createAgentSession } from "./sdk.js"; import { sessionManagerReadInitialContext } from "./session-manager-current-turn.js"; import { SessionMetadataCommittedError } from "./session-manager-metadata-error.js"; import { sessionManagerAppendInitialMetadata } from "./session-manager-metadata.js"; -import type { ModelChangeEntry, ThinkingLevelChangeEntry } from "./session-manager-types.js"; import * as writeAdmission from "./session-manager-write-admission.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; @@ -101,10 +96,6 @@ describe("SDK credential migration", () => { expect(() => assertAuthProfileMigrationReady(ownerDir)).toThrow(migrationMessage); } - function expectKey(actual: string | undefined, expected: string) { - expect(actual === expected, "selected credential belongs to the expected owner").toBe(true); - } - async function stageLegacy( state: OpenClawTestState, legacy: AuthProfileStore, @@ -156,14 +147,8 @@ describe("SDK credential migration", () => { await writeFile(path.join(legacyDir, "bin", "fd"), "installed SDK tool"); openOpenClawAgentDatabase({ agentId: "main", env: state.env, path: databasePath }); await closeOpenClawAgentDatabaseByPathAsync(databasePath); - // The legacy standalone SDK fixture predates registry enrollment. unregisterOpenClawAgentDatabase({ agentId: "main", env: state.env, path: databasePath }); - expect(inspectOpenClawAgentDatabaseOwner(databasePath)).toEqual({ - status: "owned", - agentId: "main", - }); - // Payload inspection materializes native WAL sidecars before the snapshot. - expect(inspectLegacyAgentDir(legacyDir)).toEqual({ status: "payload" }); + inspectLegacyAgentDir(legacyDir); // Materialize WAL sidecars before taking the snapshot. const before = snapshotFiles(legacyDir); const cfg = { agents: { entries: { worker: {} } }, plugins: { enabled: false } }; await state.writeConfig(cfg); @@ -173,7 +158,6 @@ describe("SDK credential migration", () => { doctorOnlyStateMigrations: true, legacySessionSurfaces: EMPTY_LEGACY_SESSION_SURFACES, }); - expect(result.stepReceipts.find((entry) => entry.outcome === "refused")).toBeUndefined(); const receipt = result.stepReceipts.find((entry) => entry.id === "agent-dir"); expect(receipt).toMatchObject({ outcome: "deferred", @@ -185,9 +169,6 @@ describe("SDK credential migration", () => { path: legacyDir, }, ], - warnings: expect.arrayContaining([ - expect.stringContaining("Keep using the existing store"), - ]), }); expect(snapshotFiles(legacyDir)).toEqual(before); await expect( @@ -215,12 +196,9 @@ describe("SDK credential migration", () => { }); const model = makeProviderModelFixture({ id: "synthetic-model", - name: "Synthetic model", api: "openai-completions", provider: "arcee", baseUrl: modelUrl, - contextWindow: 1000, - maxTokens: 1000, }); const runtimeConfig = getRuntimeConfig(); const manager = SessionManager.inMemory(); @@ -247,16 +225,14 @@ describe("SDK credential migration", () => { const result = Promise.resolve(stream(model, { messages: [] }, {})); if (blocked) { await expect(result).rejects.toThrow(migrationMessage); + expect(providerIo).not.toHaveBeenCalled(); } else { await result; - } - expect(providerIo).toHaveBeenCalledTimes(blocked ? 0 : 1); - if (!blocked) { - const auth = await session.modelRegistry.getApiKeyAndHeaders(model); - expect( - auth.ok && auth.apiKey === credential, - "direct account credential selected", - ).toBe(true); + expect(providerIo).toHaveBeenCalledOnce(); + expect(await session.modelRegistry.getApiKeyAndHeaders(model)).toMatchObject({ + ok: true, + apiKey: credential, + }); } } finally { session.dispose(); @@ -317,7 +293,7 @@ describe("SDK credential migration", () => { "requires the active secrets runtime to materialize SecretRef credentials", ); } else { - expectKey(await resolve(), provider === "arcee" ? localKey : unaffectedKey); + expect(await resolve()).toBe(unaffectedKey); } expect(fallback).not.toHaveBeenCalled(); }, @@ -349,7 +325,7 @@ describe("SDK credential migration", () => { expect(fallback).not.toHaveBeenCalled(); clearAuthProfileMigrationDiagnostics(); storage.reload(); - expectKey(await storage.getApiKey("arcee", { baseUrl }), importedKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(importedKey); if (owner === "shared") { const localKey = "synthetic-new-local-key"; storage.set("arcee", { type: "api_key", key: localKey }); @@ -358,7 +334,7 @@ describe("SDK credential migration", () => { await state.writeJson(legacyPath, legacy); await refreshAuthProfileFileState(); expectMigrationRequired(); - expectKey(await storage.getApiKey("arcee", { baseUrl }), localKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(localKey); } }); }, @@ -372,7 +348,7 @@ describe("SDK credential migration", () => { await importLegacy(state, legacy); writeStore(legacy, agentDir); const { storage, fallback } = authWithFallback(agentDir); - expectKey(await storage.getApiKey("arcee", { baseUrl }), key); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(key); expect(fallback).not.toHaveBeenCalled(); }, { UNRESOLVED_IMPORTED_ARCEE: undefined }, @@ -422,7 +398,7 @@ describe("SDK credential migration", () => { }); await expect(storage.getApiKey("arcee", { baseUrl })).rejects.toMatchObject(migrationError); expect(reloadedSelectedCredential).toBe(true); - expectKey(await storage.getApiKey("arcee", { baseUrl }), localKey); + expect(await storage.getApiKey("arcee", { baseUrl })).toBe(localKey); }); }); @@ -443,7 +419,7 @@ describe("SDK credential migration", () => { if (blocked) { await expect(result).rejects.toMatchObject(migrationError); } else { - expect((await result) === key, "unrelated canonical credential remains usable").toBe(true); + expect(await result).toBe(key); } }); }); @@ -488,16 +464,9 @@ function registryFor(...models: (typeof testModel)[]) { registry.registerProvider(testModel.provider, { api: testModel.api, baseUrl: testModel.baseUrl, - models: models.map(({ id, name, api, reasoning, input, cost, contextWindow, maxTokens }) => ({ - id, - name, - api, - reasoning, - input, - cost, - contextWindow: contextWindow ?? 32_768, - maxTokens, - })), + models: models.map((model) => + Object.assign({}, model, { contextWindow: model.contextWindow ?? 32_768 }), + ), }); return registry; } @@ -573,23 +542,15 @@ it.each(["metadata", "context loading"] as const)( const replacementBefore = await loadTranscriptEvents(replacement); const originalBefore = await loadTranscriptEvents(original); const manager = SessionManager.open(original, state.workspaceDir); - const completed: { - entry?: ModelChangeEntry | ThinkingLevelChangeEntry; - records?: Awaited>; - watermark?: ReturnType; - } = {}; + let committedId: string | undefined; + let committedEntry: ReturnType; + let committedRecords: Awaited> | undefined; const retargetAfterAppend = async (append: () => Promise) => { - const id = await append(); - const entry = manager.getEntry(id); - if (!entry || (entry.type !== "model_change" && entry.type !== "thinking_level_change")) { - throw new Error("Expected the real append's committed metadata entry"); - } - completed.entry = structuredClone(entry); - // Retarget after the real append commits, before the factory exposes its session. - completed.records = loadTranscriptEventsSync(original); - completed.watermark = readSessionTranscriptWatermark(original); + committedId = await append(); + committedEntry = structuredClone(manager.getEntry(committedId)); + committedRecords = loadTranscriptEventsSync(original); manager.setSessionTarget(replacement); - return id; + return committedId; }; const appendInitial = manager[sessionManagerAppendInitialMetadata].bind(manager); const readInitialContext = manager[sessionManagerReadInitialContext].bind(manager); @@ -606,13 +567,7 @@ it.each(["metadata", "context loading"] as const)( return context; }); onTestFinished(() => intercepted.mockRestore()); - const model = { - ...testModel, - id: "sdk-metadata-fixture", - reasoning: true, - }; - expect(getOwnedSessionTranscriptWriterFence()).toBeUndefined(); - + const model = { ...testModel, reasoning: true }; const error = await rejectedSession( createTestSession(manager, state.workspaceDir, { model, @@ -624,24 +579,24 @@ it.each(["metadata", "context loading"] as const)( }), }), ); - expect(intercepted).toHaveBeenCalledOnce(); expect.soft(await loadTranscriptEvents(replacement)).toEqual(replacementBefore); if (after === "context loading") { expect(await loadTranscriptEvents(original)).toEqual(originalBefore); expect(error).toBeInstanceOf(SessionTranscriptWriterClaimReboundError); - expect(error).not.toBeInstanceOf(SessionMetadataCommittedError); expect(hasModelFallbackStop(error)).toBe(false); } else { - expect(completed.entry).toBeDefined(); - expect(completed.records).toHaveLength(3); - expect(await loadTranscriptEvents(original)).toEqual(completed.records); + const records = await loadTranscriptEvents(original); + const watermark = readSessionTranscriptWatermark(original); + expect(records).toHaveLength(3); + expect(records).toEqual(committedRecords); + expect(committedEntry).toMatchObject({ id: committedId, type: "thinking_level_change" }); expect(error).toBeInstanceOf(SessionMetadataCommittedError); expect(error).toMatchObject({ - committedEntry: completed.entry, + committedEntry, committedTarget: original, committedVersion: { - generation: completed.watermark?.generation, - rawSeq: completed.watermark?.maxSeq, + generation: watermark?.generation, + rawSeq: watermark?.maxSeq, }, }); expect(hasModelFallbackStop(error)).toBe(true); @@ -712,11 +667,7 @@ it.each([ const { session } = await pending; const expected = selection === "branch" ? [first] : []; expect(session.messages).toEqual(expected); - const next = { - role: "user" as const, - content: "Continue selected branch", - timestamp: 3, - }; + const next = { role: "user" as const, content: "Continue selected branch", timestamp: 3 }; manager.appendMessage(next); expect(manager.buildSessionContext().messages).toEqual([...expected, next]); expect((await loadTranscriptEvents(target)).slice(0, before.length)).toEqual(before); @@ -771,15 +722,15 @@ it("awaits extension entry, name, and label persistence before publishing their const id = await api.appendEntryAsync("extension-state", { count: 1 }); await api.setSessionNameAsync("Awaited name"); await api.setLabelAsync(id, "bookmark"); - expect(manager.getEntry(id)).toMatchObject({ + expect(changes).toEqual(["Awaited name"]); + const reopened = await SessionManager.openAsync(target, state.workspaceDir); + expect(reopened.getEntry(id)).toMatchObject({ type: "custom", customType: "extension-state", data: { count: 1 }, }); - expect(manager.getLabel(id)).toBe("bookmark"); - expect(changes).toEqual(["Awaited name"]); - const reopened = await SessionManager.openAsync(target, state.workspaceDir); expect(reopened.getEntry(id)).toEqual(manager.getEntry(id)); + expect(manager.getLabel(id)).toBe("bookmark"); expect(reopened.getSessionName()).toBe("Awaited name"); expect(reopened.getLabel(id)).toBe("bookmark"); } finally { @@ -882,7 +833,6 @@ it("does not publish a committed session name into a manager retargeted before c }), ]); await manager.setSessionTargetAsync(replacement); - expect(session.sessionManager).toBe(manager); expect(manager.getSessionName()).toBe("Replacement name"); release.resolve(); await expect @@ -936,12 +886,7 @@ it.each(["SDK initialization", "model transition"] as const)( try { process.chdir(firstDir); const manager = SessionManager.open(relativeTarget, firstDir); - const capturedTarget = manager.getSessionTarget(); - const reasoningModel = { - ...testModel, - id: "cwd-reasoning", - reasoning: true, - }; + const reasoningModel = { ...testModel, id: "cwd-reasoning", reasoning: true }; const plainModel = { ...reasoningModel, id: "cwd-plain", reasoning: false }; const modelRegistry = registryFor(reasoningModel, plainModel); const create = async () => @@ -985,13 +930,8 @@ it.each(["SDK initialization", "model transition"] as const)( await session.setModel(plainModel); } - expect(intercepted).toHaveBeenCalledOnce(); expect(completed.records).toBeDefined(); expect(process.cwd()).toBe(secondDir); - const expectedTarget = { ...first, env: { OPENCLAW_STATE_DIR: state.stateDir } }; - expect.soft(capturedTarget).toEqual(expectedTarget); - expect.soft(manager.getSessionTarget()).toEqual(expectedTarget); - expect(manager.getSessionId()).toBe(relativeTarget.sessionId); const firstAfter = await loadTranscriptEvents(first); expect(firstAfter.slice(0, completed.records?.length)).toEqual(completed.records); const level = operation === "SDK initialization" ? "high" : "off"; diff --git a/src/gateway/server.chat-abort-dispatch-rejection.test.ts b/src/gateway/server.chat-abort-dispatch-rejection.test.ts index 8ccb8a76ac9a..2a714a421e1a 100644 --- a/src/gateway/server.chat-abort-dispatch-rejection.test.ts +++ b/src/gateway/server.chat-abort-dispatch-rejection.test.ts @@ -146,6 +146,14 @@ function textAttachment(bytes: string) { }; } +async function history(socket: GatewaySocket, sessionKey = "main") { + const result = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { sessionKey }); + expect(result.ok).toBe(true); + return result.payload?.messages; +} + +const containingMessages = (...messages: Record[]) => + expect.arrayContaining(messages.map((message) => expect.objectContaining(message))); describe("gateway WebSocket chat abort ownership", () => { test("preserves committed native input across a profile-bound reconnect", async () => { const scope = await prepareChatSession("native-reconnect", "native-reconnect-bound"); @@ -435,11 +443,8 @@ describe("gateway WebSocket chat abort ownership", () => { const established = await rpcReq(socket, "chat.send", sendParameters); expectRun(established, runId, "error", false); expect(terminalStates).toEqual(yielded ? ["final", "error"] : ["error"]); - const history = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { - sessionKey: "main", - }); - expect(history.payload?.messages).toContainEqual( - expect.objectContaining({ + expect(await history(socket)).toEqual( + containingMessages({ role: "custom", customType: "run-failed-before-reply", content: expect.stringContaining("dispatch rejected before a late abort"), @@ -450,8 +455,10 @@ describe("gateway WebSocket chat abort ownership", () => { sessionKey: "main", runId, }); - expect(lateAbort.ok).toBe(true); - expect(lateAbort.payload).toMatchObject({ ok: true, aborted: false, runIds: [] }); + expect(lateAbort).toMatchObject({ + ok: true, + payload: { ok: true, aborted: false, runIds: [] }, + }); const replay = await rpcReq(socket, "chat.send", sendParameters); expect(replay.ok).toBe(false); @@ -564,19 +571,15 @@ describe("gateway WebSocket chat abort ownership", () => { }); if (!runtimeTerminal) { const settled = await rpcReq(socket, "agent.wait", { runId, timeoutMs: 1_000 }); - expect(settled.ok).toBe(true); - expect(settled.payload).toMatchObject({ - runId, - status: "error", - stopReason: "aborted", - endedAt: expect.any(Number), + expect(settled).toMatchObject({ + ok: true, + payload: { runId, status: "error", stopReason: "aborted", endedAt: expect.any(Number) }, }); } // The history RPC response follows every previously emitted chat // event on this socket, so it exposes any contradictory late terminal. - const barrier = await rpcReq(socket, "chat.history", { sessionKey: "main" }); - expect(barrier.ok).toBe(true); + await history(socket); expect(terminalStates).toEqual([terminalState]); } finally { dispatchRelease.resolve(); @@ -693,14 +696,8 @@ describe("gateway WebSocket chat abort ownership", () => { await withinTest(failureEntered.promise, signal); await lifecycle; // The same-socket response fences delivered events while the notice commit is held. - const heldHistory = await rpcReq<{ messages: unknown[] }>(socket, "chat.history", { - sessionKey: scope.sessionKey, - }); - expect(heldHistory.ok).toBe(true); - expect(heldHistory.payload?.messages).not.toEqual( - expect.arrayContaining([ - expect.objectContaining({ customType: "run-failed-before-reply" }), - ]), + expect(await history(socket, scope.sessionKey)).not.toEqual( + containingMessages({ customType: "run-failed-before-reply" }), ); expect.soft(terminalStates).toEqual([]); failureRelease.resolve(); @@ -708,24 +705,20 @@ describe("gateway WebSocket chat abort ownership", () => { expect.soft(terminalStates).toEqual(["error"]); expect.soft(terminalFrame.payload?.errorMessage).toBe(warning); expect(loadSessionEntry(scope)).toMatchObject({ status: "failed", lastRunId: runId }); - const history = await rpcReq<{ messages: unknown[] }>(reader, "chat.history", { - sessionKey: scope.sessionKey, - }); - expect(history.ok).toBe(true); - expect(history.payload?.messages).toEqual( - expect.arrayContaining([ - expect.objectContaining({ + expect(await history(reader, scope.sessionKey)).toEqual( + containingMessages( + { role: "assistant", content: expect.arrayContaining([ expect.objectContaining({ type: "text", text: "Running it now." }), ]), - }), - expect.objectContaining({ + }, + { role: "custom", customType: "run-failed-before-reply", content: warning, - }), - ]), + }, + ), ); expect(loadTranscriptEventsSync(scope)).toContainEqual( expect.objectContaining({ @@ -978,23 +971,18 @@ describe("gateway WebSocket chat abort ownership", () => { const response = await send; await sendWork; await discardWork; - expect(response.ok).toBe(true); - expect(response.payload).toMatchObject({ + const terminal = { runId, status: "timeout", summary: "aborted", stopReason: "rpc", - }); + }; + expect(response).toMatchObject({ ok: true, payload: terminal }); expect(responses).toEqual([ { filePresent: false, cleanupSettled: true, - payload: expect.objectContaining({ - runId, - status: "timeout", - summary: "aborted", - stopReason: "rpc", - }), + payload: expect.objectContaining(terminal), }, ]); expect(discardSpy).toHaveBeenCalledOnce(); diff --git a/src/gateway/server.sessions.reset-cleanup.test.ts b/src/gateway/server.sessions.reset-cleanup.test.ts index cd4d94c221ed..511bb208300f 100644 --- a/src/gateway/server.sessions.reset-cleanup.test.ts +++ b/src/gateway/server.sessions.reset-cleanup.test.ts @@ -6,18 +6,12 @@ import { createDeferred, withinTest, } from "../../test/helpers/promise.js"; -import { - isSessionEntryDataSql, - observeHostDataSql, -} from "../../test/helpers/sqlite-statement-execution-counter.js"; import { seedCanonicalAcpSessionMeta } from "../acp/runtime/session-meta-fixture.test-support.js"; -import { createAcpMeta } from "../acp/runtime/session-meta-lifecycle.test-support.js"; import { readAcpSessionEntry } from "../acp/runtime/session-meta.js"; import { listRegisteredAgentHarnesses, registerAgentHarness } from "../agents/harness/registry.js"; import { restoreRegisteredAgentHarnesses } from "../agents/harness/registry.test-support.js"; import * as preparedModelRuntime from "../agents/prepared-model-runtime.js"; import { loadSessionEntry, upsertSessionEntryCore } from "../config/sessions/session-accessor.js"; -import { memorySessionActorOwners } from "../config/sessions/session-actor-memory-owner.js"; import { RESET_PARENT_GRANT_FIXTURE } from "../config/sessions/session-lineage.test-support.js"; import type { InternalSessionEntry } from "../config/sessions/types.js"; import { peekSystemEvents } from "../infra/system-events.js"; @@ -36,7 +30,6 @@ import { } from "../sessions/session-controller.mailbox.js"; import { createReplyOperation } from "../sessions/session-controller.operation.js"; import { runExclusiveSessionLifecycle } from "../sessions/session-lifecycle-admission.test-support.js"; -import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createRequestGate } from "./server-methods.request-gate.test-support.js"; import { disposeSessionReadContexts } from "./server-methods/sessions-read-cache.test-support.js"; @@ -65,8 +58,7 @@ import { } from "./test/server-sessions.test-helpers.js"; const { createSessionStoreDir, seedActiveMainSession } = setupGatewaySessionsHandlerTestHarness(); -const { retireSessionMcpRuntime: retireMcp, disposeSessionMcpRuntime: disposeMcp } = - bundleMcpRuntimeMocks; +const { disposeSessionMcpRuntime: disposeMcp } = bundleMcpRuntimeMocks; afterEach(async () => { await disposeSessionReadContexts(); @@ -199,14 +191,6 @@ function installAcpRuntimeBackendWithFreshSession() { return prepareFreshSession; } -const retirement = (retainAcrossReuse: boolean) => ({ - sessionId: "sess-main", - reason: "gateway-session-cleanup", - preserveActiveLeases: true, - retainAcrossReuse, - onError: expect.any(Function), -}); - function seedAcp( sessionKey: string, overrides: Partial[0]> = {}, @@ -257,37 +241,6 @@ const closedAcpKeys = () => ([request]) => request?.sessionKey, ); -test("sessions.reset retires an unbound memory session without host session SQL", async () => { - await createSessionStoreDir(); - const location = { - agentId: "main", - path: resolveIncognitoOpenClawAgentSqlitePath({ agentId: "main" }), - }; - const sessionKey = "agent:main:dashboard:incognito-reset-source"; - const scope = { agentId: "main", storePath: location.path, sessionKey }; - try { - await upsertSessionEntryCore(scope, { - sessionId: "reset-source", - lifecycleRevision: "reset-source-lifecycle", - updatedAt: 1, - incognito: true, - }); - const sql = observeHostDataSql(); - try { - const result = await directSessionReq<{ deleted: boolean }>("sessions.reset", { - key: sessionKey, - }); - expect(result).toMatchObject({ ok: true, payload: { deleted: true } }); - expect(loadSessionEntry(scope)).toBeUndefined(); - expect(sql.queries.filter(isSessionEntryDataSql)).toEqual([]); - } finally { - sql.restore(); - } - } finally { - memorySessionActorOwners.closeDatabase(location); - } -}); - test("sessions.reset preempts the controller run and clears queues", async () => { const { activeRun, storePath } = await seedWaitingActiveMainSession(); const parentGrant = RESET_PARENT_GRANT_FIXTURE; @@ -335,15 +288,10 @@ test("sessions.reset preempts the controller run and clears queues", async () => expectActiveRunCleanup("agent:main:main", ["main", "agent:main:main", "sess-main"], "main"); expect(peekSystemEvents("agent:main:main")).toStrictEqual([]); expect(peekSystemEvents("agent:main:sess-main")).toStrictEqual([]); - expect(retireMcp).toHaveBeenCalledExactlyOnceWith(retirement(false)); expect(disposeMcp).toHaveBeenCalledWith("sess-main"); - const browserCalls: unknown[][] = - browserSessionTabMocks.closeTrackedBrowserTabsForSessions.mock.calls; - expect(browserCalls).toHaveLength(1); - expect(browserCalls[0]?.[0]).toMatchObject({ - sessionKeys: ["agent:main:main", "agent:main:sess-main"], - onWarn: expect.any(Function), - }); + expect(browserSessionTabMocks.closeTrackedBrowserTabsForSessions).toHaveBeenCalledWith( + expect.objectContaining({ sessionKeys: ["agent:main:main", "agent:main:sess-main"] }), + ); expect(subagentLifecycleHookMocks.runSubagentEnded).toHaveBeenCalledExactlyOnceWith( { targetSessionKey: "agent:main:main", @@ -475,29 +423,6 @@ test("sessions.reset clears retained native conversations from every execution o } }); -test.each([false, true])( - "sessions.reset interrupts only non-initiating work (initiator: %s)", - async (initiator) => { - const { storePath } = await seedMainSession(); - let interrupted = false; - let releaseAdmission = () => {}; - const admission = await admitMainWork(storePath, () => { - interrupted = true; - if (!initiator) { - releaseAdmission(); - } - }); - releaseAdmission = admission.release; - try { - const reset = await (initiator ? admission.run(resetMainSession) : resetMainSession()); - expect(reset.ok).toBe(true); - expect(interrupted).toBe(!initiator); - } finally { - admission.release(); - } - }, -); - test("sessions.reset rejects an active lifecycle mutation without interrupting admitted work", async () => { const { storePath } = await seedMainSession(); let interrupted = false; @@ -791,25 +716,11 @@ describe("acp-authority", () => { const { closeAcpRuntimeForSession } = await import("./session-reset-acp.js"); const { storePath } = await seedActiveMainSession(); const sessionKey = "agent:main:main"; - seedCanonicalAcpSessionMeta({ - sessionKey, - meta: createAcpMeta({ - backend: "acpx", - agent: "codex", - identity: { - state: "resolved", - acpxRecordId: sessionKey, - acpxSessionId: "original-backend", - source: "status", - lastUpdatedAt: Date.now(), - }, - cwd: "/tmp/acp-session", - }), - }); - acpRuntimeMocks.getAcpRuntimeBackend.mockReturnValue({ - id: "acpx", - runtime: { prepareFreshSession: vi.fn(async () => {}) }, + seedAcp(sessionKey, { + backendSessionId: "original-backend", + runtimeSessionName: "original-runtime", }); + installAcpRuntimeBackendWithFreshSession(); const scope = { agentId: "main", sessionKey, storePath }; const readState = () => ({ entry: loadSessionEntry(scope), diff --git a/src/gateway/session-row-selection.test.ts b/src/gateway/session-row-selection.test.ts index c97e0179df12..15eadf34668b 100644 --- a/src/gateway/session-row-selection.test.ts +++ b/src/gateway/session-row-selection.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { subagentRuns } from "../agents/subagents/registry/subagent-registry-memory.js"; -import { buildSubagentRunReadIndexFromRuns } from "../agents/subagents/registry/subagent-registry-queries.js"; import { persistRegistryFixture, saveSubagentRegistryToSqlite, @@ -13,44 +12,42 @@ import { patchSessionEntryCore, replaceSessionEntrySync, } from "../config/sessions/session-accessor.js"; -import { writeSessionEntry } from "../config/sessions/session-accessor.sqlite-entry-store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js"; -import * as sessionKeys from "../sessions/session-key-utils.js"; import { sessionChanges } from "../sessions/session-row-changes.js"; -import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { registerOpenClawAgentDatabase } from "../state/openclaw-agent-db-registry.js"; -import { - resolveOpenClawAgentSqlitePath, - runOpenClawAgentWriteTransaction, -} from "../state/openclaw-agent-db.js"; +import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { createSessionConversationTestRegistry } from "../test-utils/session-conversation-registry.js"; import { registerRpcSourceForTest } from "./server-methods/rpc-source-fixtures.test-support.js"; import { requestContext } from "./server-methods/sessions-read-cache.test-support.js"; -import { listSessionFixture } from "./session-list.test-support.js"; import { retainSessionListForegroundWork } from "./session-projection-work.js"; -import { indexSessionRow } from "./session-row-projection-identities.js"; -import { - create as createSessionRow, - identity, - type Query, - type Row, -} from "./session-row-projection-record.js"; +import { create as createSessionRow } from "./session-row-projection-record.js"; import { createSessionRowProjection } from "./session-row-projection.js"; import { holdSubagentExecution } from "./session-row-projection.read-fixture.test-support.js"; import { createSessionRowProjectionFixture } from "./session-row-projection.test-support.js"; -import { createSessionRowEntrySelector, selectMatchingSessionRows } from "./session-row-scope.js"; -import * as childOwners from "./session-utils-core.js"; import { filterAndSortSessionEntries, listProjectedSessions, prepareProjectedSessionList, - prepareSessionRowSelection, } from "./session-utils-list.js"; import { claimRpcSourceForTest } from "./test-helpers.rpc-source.js"; +type ListOptions = Parameters[0]; +const listRows = (projection: ListOptions["projection"], opts: ListOptions["opts"], key?: string) => + listProjectedSessions({ projection, opts, key }); + +function writeMain(sessionKey: string, entry: Partial) { + replaceSessionEntrySync( + { agentId: "main", sessionKey }, + { sessionId: sessionKey, updatedAt: 1, ...entry }, + ); +} + +const rowKeys = (result: Awaited>) => + result.sessions.map((row) => row.key); + beforeEach(() => { setActivePluginRegistry(createSessionConversationTestRegistry()); }); @@ -59,109 +56,6 @@ afterEach(() => { resetPluginRuntimeStateForTest(); }); -it("preserves scoped key and ID selection against a scan over randomized federated rows", () => { - let seed = 1467; - const random = (bound: number) => { - seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; - return seed % bound; - }; - const cfg = { agents: { entries: { main: {}, work: {}, other: {} } } }; - const agents = Object.keys(cfg.agents.entries); - const paths = ["/synthetic/a", "/synthetic/b", "/synthetic/c"]; - const scope = { - physicalPaths: (locator: string, agentId?: string) => - locator === "/synthetic/shared" - ? agentId - ? [paths[agents.indexOf(agentId)]!] - : paths - : [locator], - }; - for (let fixture = 0; fixture < 20; fixture++) { - const rows = new Map(); - for (let index = 0; index < 80; index++) { - const agentId = agents[random(agents.length)]!; - const key = ["global", "unknown", `agent:${agentId}:key-${random(12)}`][random(3)]!; - const entry = { sessionId: `id-${random(12)}`, updatedAt: random(10) }; - const row = { - ...createSessionRow({ - key, - agentId, - storeTarget: { - agentId: agents[random(agents.length)]!, - storePath: paths[random(paths.length)]!, - }, - }), - entry, - }; - rows.set(identity(row), row); - } - const indexes = { - byKey: new Map>(), - byAgent: new Map>(), - byStore: new Map>(), - byParent: new Map>(), - }; - for (const row of rows.values()) { - indexSessionRow(row, indexes); - } - const matching = (query: Query, kind?: string) => - selectMatchingSessionRows({ rows, indexes, scope }, query, kind); - const select = createSessionRowEntrySelector({ - isActive: () => true, - runAsOwner: (read) => read(), - prepare: () => true, - state: () => ({ - cfg, - scope, - ...indexes, - rows, - dirty: new Set(), - hasUnresolvedCategories: false, - matching, - acquire: (row) => row, - referenced: () => undefined, - }), - }); - // The reference scans rows, independent of the production candidate indexes. - const scan = (query: Query, kind = "key") => { - const selectedPaths = query.storePath - ? scope.physicalPaths(query.storePath, query.agentId) - : undefined; - const candidates = - !query.key && selectedPaths - ? selectedPaths.flatMap((pathname) => - [...rows.values()].filter((row) => row.storeTarget.storePath === pathname), - ) - : [...rows.values()]; - return candidates.filter( - (row) => - (!query.key || (kind === "id" ? row.entry?.sessionId : row.key) === query.key) && - (!query.agentId || - row.agentId === query.agentId || - ((query.key || selectedPaths) && row.storeTarget.agentId === query.agentId)) && - (!selectedPaths || selectedPaths.includes(row.storeTarget.storePath)), - ); - }; - for (let sample = 0; sample < 40; sample++) { - const query: Query = { - agentId: [...agents, undefined][random(4)], - storePath: [...paths, "/synthetic/shared", undefined][random(5)], - sortBy: null, - ...(random(2) - ? { sessionIdOrKey: random(2) ? `id-${random(12)}` : "global" } - : { key: random(2) ? `agent:main:key-${random(12)}` : undefined }), - }; - expect(matching(query).map(identity)).toEqual(scan(query).map(identity)); - const indexed = { ...query, key: query.sessionIdOrKey }; - const keys = new Set([...scan(indexed, "id"), ...scan(indexed)].map((row) => row.key)); - const expected = ( - query.sessionIdOrKey ? [...keys].flatMap((key) => scan({ ...query, key })) : scan(query) - ).filter((row) => !query.agentId || row.agentId === query.agentId); - expect(select(query).map(identity)).toEqual(expected.map(identity)); - } - } -}); - it("maintains list order across metadata changes and archived-row rematerialization", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { const cfg = { agents: { entries: { main: {} } } }; @@ -171,147 +65,86 @@ it("maintains list order across metadata changes and archived-row rematerializat [first, 2, 1], [second, 1, 2], ] as const) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt, lastInteractionAt }, - ); + writeMain(sessionKey, { updatedAt, lastInteractionAt }); } const release = retainSessionListForegroundWork(); const projection = await createSessionRowProjection({ cfg, modelCatalog: [] }); - const scan = vi.spyOn(projection, "selectEntries"); try { const list = async (sortBy?: "lastInteractionAt" | "activity") => - (await listProjectedSessions({ projection, opts: { limit: 1, sortBy } })).sessions; + (await listRows(projection, { limit: 1, sortBy })).sessions; expect((await list())[0]?.key).toBe(first); - scan.mockClear(); - emitSessionTranscriptUpdate({ - target: { agentId: "main", sessionId: first, sessionKey: first }, - }); - expect((await list())[0]?.key).toBe(first); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); expect((await list("lastInteractionAt"))[0]?.key).toBe(second); expect((await list("activity"))[0]?.key).toBe(second); expect((await list())[0]?.key).toBe(first); - replaceSessionEntrySync( - { agentId: "main", sessionKey: second }, - { sessionId: second, updatedAt: 3, label: "Changed metadata" }, - ); + writeMain(second, { updatedAt: 3, label: "Changed metadata" }); expect((await list())[0]).toMatchObject({ key: second, label: "Changed metadata" }); const third = "agent:main:third"; - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3 }, - ); + writeMain(third, { updatedAt: 4, lastInteractionAt: 3 }); expect((await list())[0]?.key).toBe(third); expect((await list("lastInteractionAt"))[0]?.key).toBe(third); - expect( - await listProjectedSessions({ projection, opts: { limit: 1, offset: 1 } }), - ).toMatchObject({ + expect(await listRows(projection, { limit: 1, offset: 1 })).toMatchObject({ totalCount: 3, nextOffset: 2, hasMore: true, sessions: [{ key: second }], }); - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3, archivedAt: 5 }, - ); - expect((await list())[0]?.key).toBe(second); - const archived = await listProjectedSessions({ projection, opts: { archived: true } }); - expect(archived.sessions.map((row) => row.key)).toEqual([third]); - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3 }, - ); - expect((await list())[0]?.key).toBe(third); - expect(await listProjectedSessions({ projection, opts: { archived: true } })).toMatchObject({ - sessions: [], - totalCount: 0, - }); // Archive membership is presence, including a zero timestamp. - replaceSessionEntrySync( - { agentId: "main", sessionKey: third }, - { sessionId: third, updatedAt: 4, lastInteractionAt: 3, archivedAt: 0 }, - ); + writeMain(third, { updatedAt: 4, lastInteractionAt: 3, archivedAt: 0 }); expect((await list())[0]?.key).toBe(second); - expect( - await listProjectedSessions({ projection, opts: { archived: true, limit: 1 } }), - ).toMatchObject({ sessions: [{ key: third }], totalCount: 1, hasMore: false }); + expect(await listRows(projection, { archived: true, limit: 1 })).toMatchObject({ + sessions: [{ key: third }], + totalCount: 1, + hasMore: false, + }); const archivedQuery = { agentId: "main", key: third }; sessionChanges.emit({ all: true, scope: "catalog" }); await projection.ensureMaterialized(); expect(projection.capture(archivedQuery)?.materialized).toBeUndefined(); - const rematerialized = await listProjectedSessions({ - projection, - opts: { archived: true }, - }); - expect(rematerialized.sessions.map((row) => row.key)).toEqual([third]); + const rematerialized = await listRows(projection, { archived: true }); + expect(rowKeys(rematerialized)).toEqual([third]); expect(rematerialized.totalCount).toBe(1); expect(projection.capture(archivedQuery)?.materialized).toBeDefined(); expect((await list())[0]?.key).toBe(second); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); await deleteSessionEntryLifecycle({ agentId: "main", storePath: projection.capture({ agentId: "main", key: third })!.storeTarget.storePath, archiveTranscript: false, target: { canonicalKey: third, storeKeys: [third] }, }); - const remaining = await listProjectedSessions({ - projection, - opts: { archived: "all", sortBy: "activity" }, - }); - expect(remaining.sessions.map((row) => row.key)).toEqual([second, first]); + const remaining = await listRows(projection, { archived: "all", sortBy: "activity" }); + expect(rowKeys(remaining)).toEqual([second, first]); expect(remaining).toMatchObject({ totalCount: 2, nextOffset: null, hasMore: false }); - // Deletion publishes topology; keyed updates must reuse the rebuilt scope. - scan.mockClear(); - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 2, lastInteractionAt: 1, pinnedAt: 10 }, - ); + writeMain(first, { updatedAt: 2, lastInteractionAt: 1, pinnedAt: 10 }); expect((await list())[0]?.key).toBe(first); expect((await list("activity"))[0]?.key).toBe(second); expect((await list("lastInteractionAt"))[0]?.key).toBe(first); // A retained pin becomes effective only when the child is a sidebar root. for (const sidebarRoot of [false, true, false]) { - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { - sessionId: first, - updatedAt: 2, - lastInteractionAt: 1, - pinnedAt: 10, - parentSessionKey: second, - sidebarRoot, - }, - ); + writeMain(first, { + updatedAt: 2, + lastInteractionAt: 1, + pinnedAt: 10, + parentSessionKey: second, + sidebarRoot, + }); expect((await list())[0]?.key).toBe(sidebarRoot ? first : second); expect( - (await listProjectedSessions({ projection, opts: { pinned: true } })).sessions.map( - (row) => row.key, - ), + (await listRows(projection, { pinned: true })).sessions.map((row) => row.key), ).toEqual(sidebarRoot ? [first] : []); } - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 2, lastInteractionAt: 1, label: "Unpinned" }, - ); + writeMain(first, { updatedAt: 2, lastInteractionAt: 1, label: "Unpinned" }); expect((await list())[0]?.key).toBe(second); - expect(scan.mock.calls.filter(([query]) => !query?.key)).toHaveLength(0); projection.onSelectionChange(() => { throw new Error("Synthetic selection observer failure"); }); - replaceSessionEntrySync( - { agentId: "main", sessionKey: first }, - { sessionId: first, updatedAt: 5, label: "Changed despite observer failure" }, - ); + writeMain(first, { updatedAt: 5, label: "Changed despite observer failure" }); expect((await list())[0]).toMatchObject({ key: first, label: "Changed despite observer failure", }); - expect(() => projection.dispose()).not.toThrow(); + projection.dispose(); expect(projection.selectEntries()).toEqual([]); } finally { - scan.mockRestore(); projection.dispose(); release(); } @@ -336,10 +169,7 @@ it("refreshes agent owner facets when the sidebar roster changes", async () => { { owner: { type: "agent", id: "owner" }, assignedBy: { type: "system", id: "test" } }, ); const acpKey = "agent:main:acp-owner"; - replaceSessionEntrySync( - { agentId: "main", sessionKey: acpKey }, - { sessionId: "acp-owner", updatedAt: 2, archivedAt: 3 }, - ); + writeMain(acpKey, { sessionId: "acp-owner", updatedAt: 2, archivedAt: 3 }); assignSessionOwner( { agentId: "main", sessionKey: acpKey }, { owner: { type: "agent", id: "transport" }, assignedBy: { type: "system", id: "test" } }, @@ -351,17 +181,14 @@ it("refreshes agent owner facets when the sidebar roster changes", async () => { }); try { const list = (scoped = true) => - listProjectedSessions({ - projection, - opts: { - agentId: scoped ? "main" : undefined, - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - rowMode: "sidebar", - limit: 200, - archived: "all", - }, + listRows(projection, { + agentId: scoped ? "main" : undefined, + configuredAgentsOnly: true, + includeGlobal: true, + includeUnknown: true, + rowMode: "sidebar", + limit: 200, + archived: "all", }); // Read the cold ACP row before a scoped read can populate its owner projection. expect((await list(false)).owners?.map(({ id }) => id)).toEqual(["owner"]); @@ -390,33 +217,16 @@ it("selects an exact row before pagination while retaining discovery filters", a const key = "agent:main:target"; for (let index = 0; index < 7; index++) { const sessionKey = index === 0 ? key : `${key}-${index}`; - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: `transcript-${index}`, updatedAt: index + 1 }, - ); + writeMain(sessionKey, { sessionId: `transcript-${index}`, updatedAt: index + 1 }); } const projection = await createSessionRowProjection({ cfg }); try { - const page = await listProjectedSessions({ - projection, - opts: { agentId: "main", search: key, limit: 5 }, - }); - expect(page.sessions.map((row) => row.key)).not.toContain(key); - const exact = await listProjectedSessions({ - projection, - key, - opts: { agentId: "main", limit: 1 }, - }); + const page = await listRows(projection, { agentId: "main", search: key, limit: 5 }); + expect(rowKeys(page)).not.toContain(key); + const exact = await listRows(projection, { agentId: "main", limit: 1 }, key); expect(exact.sessions).toMatchObject([{ key, sessionId: "transcript-0" }]); - replaceSessionEntrySync( - { agentId: "main", sessionKey: key }, - { sessionId: "transcript-0", updatedAt: 8, archivedAt: 8 }, - ); - const hidden = await listProjectedSessions({ - projection, - key, - opts: { agentId: "main", limit: 1 }, - }); + writeMain(key, { sessionId: "transcript-0", updatedAt: 8, archivedAt: 8 }); + const hidden = await listRows(projection, { agentId: "main", limit: 1 }, key); expect(hidden.sessions).toEqual([]); } finally { projection.dispose(); @@ -424,259 +234,94 @@ it("selects an exact row before pagination while retaining discovery filters", a }); }); -it("reuses resident key predicates across list requests and refreshes legacy spawned classification", async () => { +it("filters active sentinels only after selecting their physical store winner", async () => { await withOpenClawTestState({ scenario: "minimal" }, async () => { - const cfg = { agents: { entries: { main: {} } } }; - const keys = [ - "agent:main:legacy-visible", - "agent:main:cron:job:run:one", - "agent:main:subagent:child", - "agent:main:matrix:channel:!Room:example.org:thread:$Event", - "agent:main:signal:group:OpaqueGroup", - "agent:main:sessions", + const cfg = { agents: { entries: { main: {}, ops: {} } } }; + const projection = createSessionRowProjectionFixture({ cfg, store: {} }); + const samples = [ + ["shadow-global", "global", "main", "fallback"], + ["main-global", "global", "main", "primary"], + ["ops-global", "global", "ops", "primary"], + ["ops-shadow", "global", "ops", "fallback"], + ["unknown-winner", "unknown", "ops", "primary"], + ["unknown-shadow", "unknown", "main", "fallback"], ] as const; - for (const sessionKey of keys) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt: 1 }, + const rows = samples.map(([sessionId, key, agentId, storePath]) => { + const entry = { sessionId, updatedAt: 1 }; + return { + ...createSessionRow({ key, agentId, storeTarget: { agentId, storePath } }, entry), + entry, + }; + }); + projection.selectEntries = (query) => { + if (!query?.sessionIdOrKey) { + return rows; + } + const keys = new Set( + rows + .filter( + (row) => + row.key === query.sessionIdOrKey || row.entry.sessionId === query.sessionIdOrKey, + ) + .map((row) => row.key), ); - } - const projection = await createSessionRowProjection({ cfg }); + return rows.filter((row) => keys.has(row.key)); + }; + projection.state.scope = () => ({ + paths: new Map([ + ["primary", 0], + ["fallback", 1], + ]), + path: "(multiple)", + agentId: undefined, + configuredAgentIds: new Set(["main", "ops"]), + ownerAgentIds: new Set(["main", "ops"]), + }); + const registrations = await Promise.all( + [ + ["main", "shadow-global"], + ["ops", "ops-global"], + ["main", "unknown-shadow"], + ].map(async ([agentId, sessionId]) => { + const registration = registerRpcSourceForTest({ + storeScope: samples.find(([id]) => id === sessionId)![3], + runId: sessionId!, + agentId, + sessionId: sessionId!, + sessionKey: `agent:${agentId}:run-alias:${sessionId}`, + }); + const release = await claimRpcSourceForTest(registration.entry!); + return Object.assign({}, registration, { release }); + }), + ); try { - await projection.ensureMaterialized(); - const opts = { agentId: "main", excludeSubagents: true, archived: "all" as const }; - const expected = [keys[0], keys[3], keys[4], keys[5]].toSorted(); - for (let iteration = 0; iteration < 2; iteration++) { - const prepared = prepareSessionRowSelection(projection, opts); - const cron = vi.spyOn(sessionKeys, "isCronRunSessionKey"); - const subagent = vi.spyOn(sessionKeys, "isSubagentSessionKey"); - try { - expect( - filterAndSortSessionEntries(prepared) - .map(([key]) => key) - .toSorted(), - ).toEqual(expected); - expect(cron).not.toHaveBeenCalled(); - expect(subagent).not.toHaveBeenCalled(); - } finally { - cron.mockRestore(); - subagent.mockRestore(); - } - } - const sessionKey = keys[0]; - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { - sessionId: sessionKey, - updatedAt: 2, - spawnedBy: "agent:main:parent", - archivedAt: 2, + const { filters } = prepareProjectedSessionList({ + projection, + context: requestContext(cfg), + opts: { + activeOnly: true, + configuredAgentsOnly: true, + includeGlobal: true, + includeUnknown: true, }, - ); - const hidden = await listProjectedSessions({ projection, opts }); - expect(hidden.sessions.map((row) => row.key).toSorted()).toEqual( - expected.filter((key) => key !== sessionKey), - ); - for (const category of ["Research", " "]) { - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { - sessionId: sessionKey, - updatedAt: 2, - spawnedBy: "agent:main:parent", - archivedAt: 2, - category, - }, - ); - const grouped = await listProjectedSessions({ projection, opts }); - expect(grouped.sessions.map((row) => row.key).toSorted()).toEqual( - category.trim() ? expected : expected.filter((key) => key !== sessionKey), - ); - expect( - ( - await listProjectedSessions({ projection, opts: { ...opts, archived: false } }) - ).sessions.some((row) => row.key === sessionKey), - ).toBe(false); - } - replaceSessionEntrySync( - { agentId: "main", sessionKey }, - { sessionId: sessionKey, updatedAt: 3, archivedAt: 2 }, - ); - const restored = await listProjectedSessions({ projection, opts }); - expect(restored.sessions.map((row) => row.key).toSorted()).toEqual(expected); + now: Date.now(), + metadataPrepared: true, + }); + // An active fallback cannot displace its inactive winner; other agents remain visible. + expect(filterAndSortSessionEntries(filters).map(([, entry]) => entry.sessionId)).toEqual([ + "ops-global", + "unknown-shadow", + ]); } finally { + for (const registration of registrations) { + registration.release(); + registration.cleanup(); + } projection.dispose(); } }); }); -it.each([false, true])( - "preserves sentinel precedence, ties, and resident order before filtering (activeOnly: %s)", - async (activeOnly) => { - await withOpenClawTestState({ scenario: "minimal" }, async () => { - const cfg = { agents: { entries: { main: {}, ops: {} } } }; - const projection = createSessionRowProjectionFixture({ cfg, store: {} }); - const parent = "agent:main:parent"; - const now = Date.now(); - projection.state.rowContext.subagentRuns = buildSubagentRunReadIndexFromRuns({ - now, - runs: new Map([ - [ - "ordinary-run", - { - runId: "ordinary-run", - childSessionKey: "agent:main:ordinary", - requesterSessionKey: parent, - requesterDisplayKey: parent, - task: "Synthetic child selection", - cleanup: "keep", - createdAt: now, - execution: { status: "running", startedAt: now }, - }, - ], - ]), - }); - const samples = [ - ["shadow-global", "global", "main", "fallback"], - ["ordinary", "agent:main:ordinary", "main", "primary"], - ["main-global", "global", "main", "primary"], - ["ops-global", "global", "ops", "primary"], - ["ops-shadow", "global", "ops", "fallback"], - ["unknown-winner", "unknown", "ops", "primary"], - ["unknown-shadow", "unknown", "main", "fallback"], - ["excluded", "agent:main:ordinary", "main", "excluded"], - ["retired", "agent:retired:ordinary", "retired", "primary"], - ] as const; - const rows = samples.map(([sessionId, key, agentId, storePath]) => { - const entry = { - sessionId, - updatedAt: 1, - ...(["ordinary", "shadow-global", "unknown-shadow"].includes(sessionId) - ? { spawnedBy: parent } - : {}), - }; - return { - ...createSessionRow( - { - key, - agentId, - storeTarget: { agentId, storePath }, - }, - entry, - ), - entry, - }; - }); - projection.selectEntries = (query) => { - if (query?.sessionIdOrKey) { - const keys = new Set( - rows - .filter( - (row) => - row.key === query.sessionIdOrKey || row.entry.sessionId === query.sessionIdOrKey, - ) - .map((row) => row.key), - ); - return rows.filter((row) => keys.has(row.key)); - } - return query?.parentSessionKey - ? rows.filter((row) => row.entry.spawnedBy === query.parentSessionKey) - : rows; - }; - projection.state.scope = () => ({ - paths: new Map([ - ["primary", 0], - ["fallback", 1], - ]), - path: "(multiple)", - agentId: undefined, - configuredAgentIds: new Set(["main", "ops"]), - ownerAgentIds: new Set(["main", "ops"]), - }); - try { - const prepared = prepareSessionRowSelection(projection, { - activeOnly, - configuredAgentsOnly: true, - includeGlobal: true, - includeUnknown: true, - }); - expect(prepared.entries("all").map(([, entry]) => entry.sessionId)).toEqual( - activeOnly - ? ["ordinary", "main-global", "ops-global", "unknown-winner", "unknown-shadow"] - : ["ordinary", "main-global", "unknown-winner"], - ); - for (const [key, entry] of prepared.entries("all")) { - const target = prepared.getTarget(key)!; - const original = rows.find((row) => row.entry === entry)!; - expect(target.entry).toBe(entry); - expect(target.storeTarget).toBe(original.storeTarget); - expect(target.storeKey ?? key).toBe(original.key); - } - const visible = filterAndSortSessionEntries({ - ...prepared, - entryFilter: (_key, entry) => entry.sessionId !== "main-global", - }); - expect(visible.map(([, entry]) => entry.sessionId)).toEqual( - expect.not.arrayContaining(["main-global", "shadow-global", "ops-shadow"]), - ); - expect(rows.map((row) => row.entry.sessionId)).toEqual(samples.map(([id]) => id)); - const scoped = filterAndSortSessionEntries({ - ...prepared, - opts: { ...prepared.opts, agentId: "ops" }, - }); - expect(scoped.map(([, entry]) => entry.sessionId)).toEqual( - activeOnly ? ["main-global", "ops-global", "unknown-winner"] : ["main-global"], - ); - const children = filterAndSortSessionEntries( - prepareSessionRowSelection(projection, { ...prepared.opts, spawnedBy: parent }), - ); - expect(children.map(([, entry]) => entry.sessionId)).toEqual(["ordinary"]); - if (activeOnly) { - const context = requestContext(cfg); - const registrations = await Promise.all( - [ - ["main", "shadow-global"], - ["ops", "ops-global"], - ["main", "unknown-shadow"], - ].map(async ([agentId, sessionId]) => { - const registration = registerRpcSourceForTest({ - storeScope: samples.find(([sampleId]) => sampleId === sessionId)![3], - runId: sessionId!, - agentId, - sessionId: sessionId!, - sessionKey: `agent:${agentId}:run-alias:${sessionId}`, - }); - const release = await claimRpcSourceForTest(registration.entry!); - return Object.assign({}, registration, { release }); - }), - ); - try { - const { filters } = prepareProjectedSessionList({ - projection, - context, - opts: prepared.opts, - now, - metadataPrepared: true, - }); - // The live fallback ID cannot displace its inactive physical winner. - // Other agents' sentinel rows and same-ID aliases remain independently visible. - expect( - filterAndSortSessionEntries(filters).map(([, entry]) => entry.sessionId), - ).toEqual(["ops-global", "unknown-shadow"]); - } finally { - for (const registration of registrations) { - registration.release(); - registration.cleanup(); - } - } - } - } finally { - projection.dispose(); - } - }); - }, -); - it("rejects duplicate ordinary keys introduced after store admission before filtering or pagination", async () => { await withOpenClawTestState({ scenario: "minimal" }, async (state) => { const cfg = { agents: { entries: { main: {} } } }; @@ -701,53 +346,30 @@ it("rejects duplicate ordinary keys introduced after store admission before filt const projection = await createSessionRowProjection({ cfg }); try { const opts = { configuredAgentsOnly: true }; - expect((await listProjectedSessions({ projection, opts })).sessions).toHaveLength(2); + expect((await listRows(projection, opts)).sessions).toHaveLength(2); const duplicate = { agentId: "main", storePath: secondary, sessionKey: key }; replaceSessionEntrySync(duplicate, { sessionId: "duplicate", updatedAt: Date.now() + 1 }); - await expect( - listProjectedSessions({ projection, opts: { ...opts, limit: 1, offset: 1 } }), - ).rejects.toThrow("duplicate rows resolve to canonical session key"); - await expect( - listProjectedSessions({ projection, opts: { ...opts, spawnedBy: parent } }), - ).rejects.toThrow("duplicate rows resolve to canonical session key"); + await expect(listRows(projection, { ...opts, limit: 1, offset: 1 })).rejects.toThrow( + "duplicate rows resolve to canonical session key", + ); + await expect(listRows(projection, { ...opts, spawnedBy: parent })).rejects.toThrow( + "duplicate rows resolve to canonical session key", + ); await deleteSessionEntryLifecycle({ agentId: "main", storePath: secondary, archiveTranscript: false, target: { canonicalKey: key, storeKeys: [key] }, }); - const result = await listProjectedSessions({ projection, opts }); - expect(result.sessions.map((row) => row.key).toSorted()).toEqual([key, "agent:main:other"]); + const result = await listRows(projection, opts); + expect(rowKeys(result).toSorted()).toEqual([key, "agent:main:other"]); } finally { projection.dispose(); } }); }); -it("keeps ungrouped visible spawned work discoverable", async () => { - const result = await listSessionFixture({ - cfg: { agents: { entries: { main: {} } } }, - storePath: "/tmp/openclaw-visible-session-activity", - store: { - "agent:main:subagent:hidden": { - sessionId: "hidden", - updatedAt: 3, - spawnedBy: "agent:main:discussion", - }, - "agent:main:dashboard:visible": { - sessionId: "visible", - updatedAt: 2, - spawnedBy: "agent:main:discussion", - }, - "agent:main:discussion": { sessionId: "parent", updatedAt: 1 }, - }, - opts: { excludeSubagents: true, limit: 1 }, - }); - expect(result.sessions.map((row) => row.key)).toEqual(["agent:main:dashboard:visible"]); - expect(result).toMatchObject({ totalCount: 2, nextOffset: 1, hasMore: true }); -}); - -it("lists indexed children without inspecting unrelated resident ownership", async () => { +it("pages children by their current controller and retains persistent children", async () => { await withOpenClawTestState( { scenario: "minimal", env: { OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_SQLITE: "1" } }, async () => { @@ -758,18 +380,6 @@ it("lists indexed children without inspecting unrelated resident ownership", asy const parent = "agent:main:parent"; const other = "agent:main:other"; const key = (name: string) => `agent:main:${name}`; - const unrelated = 96; - const entries = new Map( - Array.from({ length: unrelated }, (_, index) => [ - key(`unrelated-${index}`), - { - sessionId: `unrelated-${index}`, - updatedAt: now, - archivedAt: 1, - parentSessionKey: key(`unrelated-parent-${index}`), - }, - ]), - ); for (const [name, fields] of [ ["spawned", { spawnedBy: parent }], ["dashboard:persistent", { parentSessionKey: parent, status: "done", endedAt: 1 }], @@ -777,25 +387,13 @@ it("lists indexed children without inspecting unrelated resident ownership", asy ["moved", { spawnedBy: parent }], ["runtime", {}], ] as const) { - entries.set(key(name), { sessionId: name, updatedAt: now, ...fields }); + writeMain(key(name), { sessionId: name, updatedAt: now, ...fields }); } - const seed = (agentId: string, rows: Iterable<[string, SessionEntry]>) => - runOpenClawAgentWriteTransaction( - (database) => { - for (const [sessionKey, entry] of rows) { - writeSessionEntry(database, sessionKey, entry, { - canonicalPreviousEntry: null, - previousEntry: null, - }); - } - }, - { agentId }, - ); - seed("main", entries); const crossAgent = "agent:other:child"; - seed("other", [ - [crossAgent, { sessionId: "cross-agent", updatedAt: now, spawnedBy: parent }], - ]); + replaceSessionEntrySync( + { agentId: "other", sessionKey: crossAgent }, + { sessionId: "cross-agent", updatedAt: now, spawnedBy: parent }, + ); const run = (name: string, controller: string): SubagentRunRecord => ({ runId: name, childSessionKey: key(name), @@ -819,39 +417,22 @@ it("lists indexed children without inspecting unrelated resident ownership", asy saveSubagentRegistryToSqlite(runs); const release = retainSessionListForegroundWork(); const projection = await createSessionRowProjection({ cfg, modelCatalog: [] }); - const ownership = vi.spyOn(childOwners, "resolveSessionChildOwners"); try { const opts = { spawnedBy: parent, archived: "all" as const, limit: 2 }; - const list = () => listProjectedSessions({ projection, opts }); + const list = () => listRows(projection, opts); const first = await list(); - expect(first.sessions.map((row) => row.key)).toEqual([ - key("dashboard:persistent"), - key("runtime"), - ]); + expect(rowKeys(first)).toEqual([key("dashboard:persistent"), key("runtime")]); expect(first).toMatchObject({ totalCount: 4, nextOffset: 2, hasMore: true }); - const second = await listProjectedSessions({ projection, opts: { ...opts, offset: 2 } }); - expect(second.sessions.map((row) => row.key)).toEqual([key("spawned"), crossAgent]); + const second = await listRows(projection, { ...opts, offset: 2 }); + expect(rowKeys(second)).toEqual([key("spawned"), crossAgent]); expect(second).toMatchObject({ totalCount: 4, nextOffset: null, hasMore: false }); - ownership.mockClear(); - expect(await list()).toEqual(first); - const visited = new Set(ownership.mock.calls.map(([params]) => params.key)); - ownership.mockRestore(); - const sentinel = await listProjectedSessions({ - projection, - opts: { ...opts, spawnedBy: "global" }, - }); + const sentinel = await listRows(projection, { ...opts, spawnedBy: "global" }); expect(sentinel.sessions).toEqual([]); - expect([...visited].filter((sessionKey) => sessionKey.includes("unrelated-")).length).toBe( - 0, - ); runs.set(runtime.runId, { ...runtime, controllerSessionKey: other }); subagentRuns.set(runtime.runId, runs.get(runtime.runId)!); persistRegistryFixture(runs, [runtime.runId]); const moved = await list(); - expect(moved.sessions.map((row) => row.key)).toEqual([ - key("dashboard:persistent"), - key("spawned"), - ]); + expect(rowKeys(moved)).toEqual([key("dashboard:persistent"), key("spawned")]); expect(moved.totalCount).toBe(3); runs.set(runtime.runId, { ...runtime, @@ -862,7 +443,6 @@ it("lists indexed children without inspecting unrelated resident ownership", asy persistRegistryFixture(runs, [runtime.runId]); expect((await list()).totalCount).toBe(4); } finally { - ownership.mockRestore(); projection.dispose(); runtimeExecution[Symbol.dispose](); movedExecution[Symbol.dispose](); diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index b7af4a41dc2a..5deac3f39d74 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -3,9 +3,6 @@ import os from "node:os"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core/expect"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; -import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; -import * as sessionEntryRows from "../config/sessions/session-accessor.sqlite-status.js"; import { replaceTranscriptEvents } from "../config/sessions/session-accessor.sqlite-transcript-write.test-support.js"; import { memorySessionActorOwners } from "../config/sessions/session-actor-memory-owner.js"; import { commitMemory } from "../config/sessions/session-actor-memory-publication.test-support.js"; @@ -14,15 +11,10 @@ import type { SessionActorStorageWrites } from "../config/sessions/session-actor import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; -import { persistUserTurnTranscript } from "../sessions/user-turn-transcript.test-support.js"; import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js"; import { runOpenClawAgentWriteTransaction } from "../state/openclaw-agent-db.js"; import { resolveIncognitoOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js"; -import { setAvatar, setDisplayName } from "../state/user-profile-writes.worker.js"; -import { ensureProfileForEmail } from "../state/user-profiles.js"; -import { resolveCurrentUserProfileDisplay } from "./current-user-profile-display.js"; import * as sessionHistoryState from "./session-history-state.js"; -import { SessionHistorySseState } from "./session-history-state.js"; import { appendHistoryMessage, expectErrorResponse, @@ -32,7 +24,6 @@ import { openHistoryStream, readSessionHistoryBody, type SessionHistoryBody, - type SessionHistoryMessage, type SessionHistorySseStream, } from "./sessions-history-http-read.test-support.js"; import { @@ -97,20 +88,6 @@ async function appendMessage( return { ...appended, message }; } -function persistHistoryTurn( - storePath: string, - input: Parameters[0]["input"], -) { - return persistUserTurnTranscript({ - agentId: AGENT_ID, - sessionEntry: { sessionId: "sess-main", updatedAt: 1 }, - sessionId: "sess-main", - sessionKey: SESSION_KEY, - storePath, - input, - }); -} - function replaceHistory( storePath: string, events: Parameters[1], @@ -122,23 +99,6 @@ function replaceHistory( ); } -const sessionHistoryRowIdentity = (message: SessionHistoryMessage) => - `${message["__openclaw"]?.seq}:${message.role}:${message.content?.[0]?.text}`; - -function withMockedDateNow(now: number, run: () => T): T { - using _ = vi.spyOn(Date, "now").mockReturnValue(now); - return run(); -} - -function currentProfileAvatarUrl(profileId: string): string { - const display = resolveCurrentUserProfileDisplay(profileId); - expect(display.kind).toBe("resolved"); - if (display.kind !== "resolved") { - throw new Error("expected a resolved current profile display"); - } - return display.avatarUrl; -} - function withGatewayHistoryStream( run: (stream: SessionHistorySseStream, port: number) => Promise, query?: string, @@ -243,30 +203,19 @@ describe("session history HTTP endpoints", () => { await withGatewayHarness(async (harness) => { const cases = { "text/event-stream": [ - "text/event-stream", - "TEXT/EVENT-STREAM", - " text/event-stream ", - "text/event-stream;", - "text/event-stream; charset=utf-8", - "text/event-stream;q=0.001", - "text/event-stream;Q=1.000", + " TEXT/EVENT-STREAM;Q=1.000;charset=UTF-8; ", "text/event-stream;q=0, text/event-stream;q=0.5", "text/event-stream;q=0, text/event-stream;charset=utf-8;q=0.5", - "text/event-stream;q=0.5;charset=utf-8", ], "application/json": [ 'text/event-stream; note="quoted,comma;semicolon\\\"quote"; q=0.5', "text/event-stream;q=1, text/event-stream;charset=utf-8;q=0", - "text/event-stream;q=1;charset=utf-16", "text/event-stream;charset=utf-16", "text/event-streaming", - "text/event-streamx", 'application/json; note="text/event-stream"', "text/*", "*/*", - "text/event-stream;q=0", "text/event-stream;q=0, */*;q=1", - "text/event-stream;q=0.1234", "text/event-stream;q =0.5", "text/event-stream;q= 0.5", "text/event-stream;\u00a0q=0.5", @@ -293,167 +242,14 @@ describe("session history HTTP endpoints", () => { }); }); - test("reads only the selected history entry for default and blank cursor queries", async () => { - const { storePath } = await seedSession({ text: "hello from history" }); - const unrelatedPrompt = "Unrelated saved session prompt"; - await replaceSessionEntry( - { agentId: AGENT_ID, sessionKey: "agent:main:unrelated", storePath }, - { - sessionId: "sess-unrelated", - updatedAt: 1, - skillsSnapshot: { prompt: unrelatedPrompt, skills: [] }, - }, - ); + test("treats blank cursor queries as default history", async () => { + await seedSession({ text: "hello from history" }); await withGatewayHarness(async (harness) => { - using decode = vi.spyOn(sessionEntryRows, "parseSessionEntryJson"); for (const query of ["", "?cursor=", "?cursor=%20"]) { - decode.mockClear(); - const context = `query ${JSON.stringify(query)}`; - const res = await fetchSessionHistory(harness.port, SESSION_KEY, { query }); - expect(res.status, context).toBe(200); - const body = (await res.json()) as SessionHistoryBody; - expect(body, context).toMatchObject({ + expect(await readSessionHistoryBody(harness.port, SESSION_KEY, { query })).toMatchObject({ sessionKey: SESSION_KEY, messages: [{ content: [{ text: "hello from history" }], __openclaw: { seq: 1 } }], }); - expect( - decode.mock.calls.some(([row]) => row.entry_json.includes(unrelatedPrompt)), - context, - ).toBe(false); - } - }); - }); - - test("shares revisioned current-profile projection across REST and initial and inline SSE", async () => { - const { storePath } = await seedSession(); - - const profile = withMockedDateNow(1_800_000_000_000, () => { - const created = ensureProfileForEmail("session-history-profile@example.com"); - setDisplayName(created.id, "Old Display Name"); - expect(setAvatar(created.id, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true); - return created; - }); - const oldAvatarUrl = currentProfileAvatarUrl(profile.id); - const persistAttributedTurn = async (id: string, senderName: string, text: string) => { - const turn = await persistHistoryTurn(storePath, { - idempotencyKey: `session-history-profile:${id}`, - sender: { - id: profile.id, - identity: { type: "profile", id: profile.id }, - name: senderName, - username: "ada", - }, - text, - }); - expect(turn).toBeDefined(); - return turn!; - }; - const first = await persistAttributedTurn("first", "Historical Ada", "first turn"); - const turn = (id: string, seq: number, name: string, text: string, avatarUrl: string) => ({ - role: "user", - content: text, - __openclaw: { - id, - seq, - senderId: profile.id, - senderName: name, - senderUsername: "ada", - senderProfileAvatarUrl: avatarUrl, - }, - }); - - await withGatewayHarness(async (harness) => { - const initialRest = await readSessionHistoryBody(harness.port, SESSION_KEY); - await using stream = await openHistoryStream(harness.port, SESSION_KEY); - const initialSse = await stream.next(); - const oldExpected = turn(first.messageId, 1, "Historical Ada", "first turn", oldAvatarUrl); - expect(initialRest.messages?.[0]).toMatchObject(oldExpected); - expect(initialSse.messages?.[0]).toMatchObject(oldExpected); - - withMockedDateNow(1_900_000_000_000, () => { - setDisplayName(profile.id, "Current Ada"); - expect(setAvatar(profile.id, new Uint8Array([4, 5, 6]), "image/png").ok).toBe(true); - }); - const newAvatarUrl = currentProfileAvatarUrl(profile.id); - expect(newAvatarUrl).not.toBe(oldAvatarUrl); - - const inlineEventPromise = stream.next(); - const second = await persistAttributedTurn("second", "Current Ada", "second turn"); - const refreshEvent = await inlineEventPromise; - const expected = [ - turn(first.messageId, 1, "Historical Ada", "first turn", newAvatarUrl), - turn(second.messageId, 2, "Current Ada", "second turn", newAvatarUrl), - ]; - for (const body of [refreshEvent, await readSessionHistoryBody(harness.port, SESSION_KEY)]) { - expect(body.messages).toMatchObject(expected); - } - }); - }); - - test("attributes forwarded history only from structured provenance across transports and pages", async () => { - const { storePath } = await seedSession(); - const sessionKey = SESSION_KEY; - const cases = [ - { - body: "Verified sender body\n indented line", - sourceSessionKey: "agent:helper:ops", - senderLabel: "Forwarded from helper", - senderSession: { sessionKey: "agent:helper:ops", agentId: "helper" }, - }, - { - body: "Unverified sender body\n indented line", - sourceSessionKey: undefined, - senderLabel: "Forwarded agent message", - senderSession: undefined, - }, - ]; - for (const entry of cases) { - const persisted = await persistHistoryTurn(storePath, { - text: `[Inter-session message] sourceSession=agent:grimwald:asserted sourceTool=sessions_send isUser=false\n${entry.body}`, - provenance: { - kind: "inter_session", - sourceTool: "sessions_send", - ...(entry.sourceSessionKey ? { sourceSessionKey: entry.sourceSessionKey } : {}), - }, - }); - expect(persisted).toBeDefined(); - } - - await withGatewayHarness(async (harness) => { - const ws = await harness.openWs(); - try { - expect((await connectReq(ws, { scopes: ["operator.read"] })).ok).toBe(true); - let cursor: string | undefined; - for (const [offset, entry] of cases.toReversed().entries()) { - const http = await readSessionHistoryBody(harness.port, sessionKey, { - query: `?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, - }); - const websocket = await rpcReq<{ messages: unknown[]; hasMore: boolean }>( - ws, - "chat.history", - { sessionKey, limit: 1, offset }, - ); - expect(websocket.ok).toBe(true); - for (const page of [http, websocket.payload]) { - expect(page?.messages).toHaveLength(1); - expect(page?.messages?.[0]).toMatchObject({ - role: "assistant", - content: entry.body, - senderLabel: entry.senderLabel, - ...(entry.senderSession ? { senderSession: entry.senderSession } : {}), - }); - if (!entry.senderSession) { - expect(page?.messages?.[0]).not.toHaveProperty("senderSession"); - } - expect(page?.hasMore).toBe(offset < cases.length - 1); - } - if (offset < cases.length - 1) { - expect(http.nextCursor).toEqual(expect.any(String)); - } - cursor = http.nextCursor; - } - } finally { - ws.close(); } }); }); @@ -595,53 +391,6 @@ describe("session history HTTP endpoints", () => { ); }); - test("keeps repeated assistant replies from separate hidden user turns in REST and SSE history", async () => { - const sessionId = "sess-hidden-turn-replies"; - const sessionKey = SESSION_KEY; - const { storePath } = await seedSession({ sessionId }); - const timestamp = Date.UTC(2026, 7, 15, 9, 30, 0); - const assistantMessage = (text: string, model: string) => ({ - ...makeTranscriptAssistantMessage({ text, provider: "openclaw", model }), - timestamp, - }); - const replies = ["First reply.", "Second reply.", "Third reply."]; - await replaceHistory( - storePath, - replies.flatMap((text, index) => [ - { message: assistantMessage(text, "acp-runtime") }, - { message: { role: "user", content: index ? HEARTBEAT_PROMPT : "" } }, - ...(index === 2 ? [{ message: { role: "assistant", content: "HEARTBEAT_OK" } }] : []), - { message: assistantMessage(text, "gateway-injected") }, - ]), - sessionId, - ); - - const expectedRows = [ - [1, 3], - [4, 6], - [7, 10], - ].flatMap((sequences, index) => sequences.map((seq) => `${seq}:assistant:${replies[index]}`)); - await withGatewayHarness(async (harness) => { - const history = await readSessionHistoryBody(harness.port, sessionKey); - expect(history.messages?.map(sessionHistoryRowIdentity)).toEqual(expectedRows); - expect(history.messages?.map((message) => message.timestamp)).toEqual( - Array.from({ length: 6 }, () => timestamp), - ); - expect(history.messages?.map((message) => message["__openclaw"]?.turnBoundary)).toEqual([ - undefined, - undefined, - undefined, - true, - undefined, - true, - ]); - - await using stream = await openHistoryStream(harness.port, SESSION_KEY); - const streamedHistory = await stream.next(); - expect(streamedHistory.messages?.map(sessionHistoryRowIdentity)).toEqual(expectedRows); - }); - }); - test("caps all-digit direct REST history limits that exceed safe integer range", async () => { const { storePath } = await seedSession({ text: "first message" }); await appendText(storePath, "second message"); @@ -689,48 +438,6 @@ describe("session history HTTP endpoints", () => { }); }); - test("sanitizes phased output_text assistant history before returning it", async () => { - const { storePath } = await seedSession(); - - await withGatewayHarness(async (harness) => { - const visibleMessageId = "visible-phased-assistant"; - await replaceHistory(storePath, [ - { id: "hidden-control", message: makeTranscriptAssistantMessage({ text: "NO_REPLY" }) }, - { - id: visibleMessageId, - message: { - ...makeTranscriptAssistantMessage({ text: "Done." }), - content: [ - { id: "item_commentary", phase: "commentary", text: "internal reasoning" }, - { id: "item_final", phase: "final_answer", text: "Done." }, - ].map(({ text, ...signature }) => ({ - type: "output_text", - text, - textSignature: JSON.stringify({ v: 1, ...signature }), - })), - }, - }, - ]); - - const body = await readSessionHistoryBody(harness.port, SESSION_KEY); - expect(body.sessionKey).toBe(SESSION_KEY); - expect(body.messages).toHaveLength(2); - expect(body.messages?.[0]).toMatchObject({ - content: [{ type: "text", text: "internal reasoning" }], - openclawStreamFallback: { - itemId: "item_commentary", - replacementText: "internal reasoning", - source: "segment", - }, - }); - expect(body.messages?.[1]?.content?.map((block) => block.text)).toEqual(["Done."]); - expect(body.messages?.[1]?.["__openclaw"]).toMatchObject({ - id: visibleMessageId, - seq: 2, - }); - }); - }); - test("includes updates committed while opening bounded SSE history", async () => { const { storePath } = await seedSession({ text: "first message" }); @@ -750,9 +457,8 @@ describe("session history HTTP endpoints", () => { }); }); - test("bounds retained SSE history without truncating full history or live updates", async () => { - const retainedLimit = 1_000; - const initialCount = retainedLimit + 2; + test("serves complete history and live updates beyond the SSE retention window", async () => { + const initialCount = 1_002; const { storePath } = await seedSession(); await replaceHistory( storePath, @@ -763,7 +469,6 @@ describe("session history HTTP endpoints", () => { })), ); - using snapshotSpy = vi.spyOn(SessionHistorySseState.prototype, "snapshot"); const expectWindow = ( body: SessionHistoryBody | undefined, length: number, @@ -778,31 +483,22 @@ describe("session history HTTP endpoints", () => { expect(body?.messages?.at(-1)?.content?.[0]?.text).toBe(last); } }; - const retainedSnapshot = () => snapshotSpy.mock.results.at(-1)?.value; await withGatewayHistoryStream(async (stream, port) => { const initial = await stream.next(); expectWindow(initial, initialCount, "history message 1"); - expectWindow(retainedSnapshot(), retainedLimit, "history message 3"); await using cursorStream = await openHistoryStream( port, SESSION_KEY, `?cursor=${initialCount + 1}`, ); expectWindow(await cursorStream.next(), initialCount); - expectWindow(retainedSnapshot(), retainedLimit, "history message 3"); const messageId = await appendText(storePath, "live history message"); const lastSequence = initial.messages?.at(-1)?.["__openclaw"]?.seq; expect(lastSequence).toEqual(expect.any(Number)); await stream.expectMessage("live history message", (lastSequence ?? 0) + 1, messageId); - const liveRetained = snapshotSpy.mock.results.findLast((result) => { - const snapshot = result.value; - return snapshot?.messages?.at(-1)?.content?.[0]?.text === "live history message"; - })?.value; - expectWindow(liveRetained, retainedLimit, undefined, "live history message"); expectWindow(await cursorStream.next(), initialCount, "history message 1"); - expectWindow(retainedSnapshot(), retainedLimit); const completeHistory = await vi.waitFor(() => readSessionHistoryBody(port, SESSION_KEY), { interval: 25, diff --git a/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts b/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts index 833c4fa19de8..d85c6139763a 100644 --- a/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts +++ b/src/gateway/worker-environments/worker-turn-launcher-terminal-results.test.ts @@ -143,19 +143,13 @@ describe("worker turn launcher terminal results", () => { providerFailure?: true; }>([ { stopReason: "stop", reconciliationFails: false }, - { stopReason: "error", reconciliationFails: false }, - { stopReason: "stop", reconciliationFails: true }, { stopReason: "error", reconciliationFails: true }, { stopReason: "error", reconciliationFails: false, providerFailure: true }, - ...[ - "Browser cleanup timed out", - "Browser cleanup failed: rate limit exceeded", - "Browser disposal failed", - ].map((cleanupFailure) => ({ - stopReason: "stop" as const, + { + stopReason: "stop", reconciliationFails: false, - cleanupFailure, - })), + cleanupFailure: "Browser cleanup failed: rate limit exceeded", + }, ])( "retains the ACKed finishing outcome after assistant $stopReason (reconciliation fails: $reconciliationFails; cleanup: $cleanupFailure; provider fallback: $providerFailure)", async ({ stopReason, reconciliationFails, cleanupFailure, providerFailure }) => { @@ -290,7 +284,6 @@ describe("worker turn launcher terminal results", () => { }), ); }), - syncWorkspace: vi.fn(), reconcileWorkspace: vi.fn(async (request) => { if (reconciliationFails) { throw reconciliationError; @@ -322,7 +315,7 @@ describe("worker turn launcher terminal results", () => { const uninstall = installSessionPlacementAdmissionProvider(provider); try { const runLocal = vi.fn(localResult); - const execute = vi.fn(() => provider.executeTurn(runIdentity(runId), workerTurn, runLocal)); + const execute = () => provider.executeTurn(runIdentity(runId), workerTurn, runLocal); const config = { ...workerTurn.config, agents: { @@ -336,7 +329,7 @@ describe("worker turn launcher terminal results", () => { }; let mediaTasks = getGeneratedMediaTaskIdsForSessionKey(fx.SESSION_KEY); const candidateFailures: unknown[] = []; - const runCandidate = vi.fn((candidateProvider: string, model: string) => { + const runCandidate = (candidateProvider: string, model: string) => { mediaTasks = getGeneratedMediaTaskIdsForSessionKey(fx.SESSION_KEY); return runEmbeddedAgent({ ...workerTurn, @@ -348,7 +341,7 @@ describe("worker turn launcher terminal results", () => { candidateFailures.push(error); throw error; }); - }); + }; const runOuterEntry = () => runEmbeddedAgentEntry({ selection: { @@ -411,7 +404,6 @@ describe("worker turn launcher terminal results", () => { await expectSettled(); if (providerFailure) { expect(launchedModels).toEqual(["gpt-5.6-luna", "gpt-5.6-sol"]); - expect(runCandidate).toHaveBeenCalledTimes(2); expect(observed).toMatchObject({ message: "Unexpected second worker execution" }); await expect(fs.stat(effectFile)).rejects.toMatchObject({ code: "ENOENT" }); return; @@ -420,14 +412,13 @@ describe("worker turn launcher terminal results", () => { .soft(launchedModels, "cleanup must not replay the committed worker effect") .toEqual(["gpt-5.6-luna"]); expect.soft(await fs.readFile(effectFile, "utf8")).toBe("effect\n"); - expect.soft(runCandidate).toHaveBeenCalledOnce(); expect.soft(observed).toBe(candidateFailures[0]); return; } expect(observed).toMatchObject({ message: expect.stringContaining(failure) }); if (reconciliationFails) { - expect(observed).toMatchObject({ cause: expect.any(WorkerWorkspaceReconciliationError) }); expect(observed).toMatchObject({ cause: { cause: reconciliationError } }); + expect(observed).toMatchObject({ cause: expect.any(WorkerWorkspaceReconciliationError) }); expect(resolveModelFallbackError(observed)).toEqual({ kind: "coordination", error: observed, @@ -437,8 +428,7 @@ describe("worker turn launcher terminal results", () => { expect(observed).toMatchObject({ message: failure }); expect(reconcileActivePlacement).not.toHaveBeenCalled(); } - expect(execute).toHaveBeenCalledOnce(); - expect(tunnel.launchTurn).toHaveBeenCalledOnce(); + expect(launchedModels).toHaveLength(1); expect(runLocal).not.toHaveBeenCalled(); await expectSettled(); await expect( @@ -764,7 +754,6 @@ describe("worker turn launcher terminal results", () => { await completed.appendMessageAsync( makeAgentAssistantMessage({ content: [{ type: "toolCall", id: "call-usage", name: "read", arguments: {} }], - provider: "openai", model: "gpt-first-call", stopReason: "toolUse", timestamp: 21, diff --git a/src/infra/agent-events.test.ts b/src/infra/agent-events.test.ts index 26c94551bb79..0b85c0bf6ebd 100644 --- a/src/infra/agent-events.test.ts +++ b/src/infra/agent-events.test.ts @@ -213,32 +213,27 @@ test("defers clearing an existing context until all overlapping owners exit", () }); test("reserves exclusive run ids for owner-only delivery and cleanup", () => { - const claimId = claimRun("run", { sessionKey: "worker" }, { exclusive: true, trackOwner: true })!; - expect(claimRun("run", { sessionKey: "local" }, { trackOwner: true })).toBeUndefined(); - const seen: unknown[] = []; - onAgentEventForRun("run", ({ data }) => seen.push(data.text)); - const event = (text: string) => ({ runId: "run", stream: "assistant", data: { text } }); - emitAgentEventIfCurrent(event("local")); - emitAgentEventForOwner(event("worker"), claimId); - clearRun("run", readGeneration()); - expect(getRun("run")?.sessionKey).toBe("worker"); - releaseRun("run", claimId); - expect(getRun("run")).toBeUndefined(); - emitAgentEventForOwner(event("late"), claimId); - expect(seen).toEqual(["worker"]); -}); - -test("drops stale explicit-generation events before shared listeners", () => { - const lifecycleGeneration = readGeneration(); - registerRun("run", { sessionKey: "main", lifecycleGeneration }); - const seen = captureEvents(); - expect( - sendEvent("run", "lifecycle", { phase: "end" }, { lifecycleGeneration: "pre-restart" }), - ).toBe(false); - expect( - sendEvent("run", "lifecycle", { phase: "start", startedAt: 1_000 }, { lifecycleGeneration }), - ).toBe(true); - expect(seen).toMatchObject([{ seq: 1, data: { phase: "start" } }]); + withGeneration(readGeneration(), () => { + const claimId = claimRun( + "run", + { sessionKey: "worker" }, + { exclusive: true, trackOwner: true }, + )!; + expect(claimRun("run", { sessionKey: "local" }, { trackOwner: true })).toBeUndefined(); + const seen: unknown[] = []; + onAgentEventForRun("run", ({ data }) => seen.push(data.text)); + const event = (text: string) => ({ runId: "run", stream: "assistant", data: { text } }); + emitAgentEventIfCurrent(event("local")); + emitAgentEventForOwner(event("worker"), claimId); + clearRun("run", readGeneration()); + expect(getRun("run")?.sessionKey).toBe("worker"); + releaseRun("run", claimId); + expect(getRun("run")).toBeUndefined(); + emitAgentEventForOwner(event("late"), claimId); + expect(seen).toEqual(["worker"]); + rotateGeneration(); + expect(emitAgentEventIfCurrent(event("rotated"))).toBe(false); + }); }); test("rejects inherited stale ownership and cannot reclaim the admitted replacement", () => { @@ -847,28 +842,6 @@ describe("agent event routing after cancellation", () => { expect(events[2]?.sessionKey).toBeUndefined(); }); - it("never lets routing provenance revive a released claim or rotated execution", () => { - const generation = readGeneration(); - const events = captureEvents(onAgentRuntimeEvent); - withGeneration(generation, () => { - const claim = claimRun( - "owned", - { agentId: "delivery", sessionKey: "agent:delivery:owned" }, - { exclusive: true, trackOwner: true, ownsContext: true }, - ); - expect(claim).toBeDefined(); - const event = { runId: "owned", stream: "tool", data: { phase: "result" } }; - emitAgentEventForOwner(event, claim!); - clearRun("owned", generation, claim); - releaseRun("owned", claim); - expect(getRun("owned")).toBeUndefined(); - emitAgentEventForOwner(event, claim!); - rotateGeneration(); - expect(emitAgentEventIfCurrent(event)).toBe(false); - }); - expect(events).toHaveLength(1); - }); - it("extends a retained v2026.9.4 scope without replacing its execution owner", async () => { const lifecycleGeneration = readGeneration(); // Released updaters can retain this exact shape while loading current runtime aliases. @@ -1001,12 +974,14 @@ describe("live agent model projection", () => { rotateGeneration(); clearRun(runId, generation); registerRun(runId, { sessionKey: "agent:main:replacement" }); - emitAgentEvent({ - runId, - lifecycleGeneration: generation, - stream: "lifecycle", - data: { phase: "model", provider: "stale", model: "stale" }, - }); + expect( + emitAgentEventIfCurrent({ + runId, + lifecycleGeneration: generation, + stream: "lifecycle", + data: { phase: "model", provider: "stale", model: "stale" }, + }), + ).toBe(false); expect(getRun(runId)).not.toHaveProperty("activeModel"); }); diff --git a/src/infra/git-worker.lifecycle.test.ts b/src/infra/git-worker.lifecycle.test.ts index bcf07bc900a1..7ee65fdd2a6d 100644 --- a/src/infra/git-worker.lifecycle.test.ts +++ b/src/infra/git-worker.lifecycle.test.ts @@ -13,16 +13,10 @@ import * as worktreeGit from "../agents/worktrees/git.js"; import { runPublicationCommand } from "../gateway/github-publication-git-transport.js"; import { runWorkspaceInventoryCommandToFile } from "../gateway/worker-environments/workspace-sync-inventory.js"; import { ensureStagedInputDirectory, stagedInputDirectory } from "../media/staged-inputs.js"; -import { emitChildProcessSpawnSample } from "../process/spawn-diagnostics.js"; import { createDeferredCore } from "../shared/deferred.js"; import { drainGlobalSingletonLifecycleState } from "../shared/global-singleton.js"; import { isPidAlive } from "../shared/pid-alive.js"; import { killPidIfAlive } from "../test-utils/process-tree.js"; -import { - onDiagnosticEvent, - setDiagnosticsEnabledForProcess, - type DiagnosticEventPayload, -} from "./diagnostic-events.js"; import * as gitExec from "./git-exec.js"; import { installUnknownDirentFixture } from "./git-worker-dir.test-support.js"; import { runGitWorkerOperation, type GitWorkerOperationOptions } from "./git-worker.js"; @@ -431,18 +425,6 @@ describe("Git operation host lifecycle", () => { const peerRoot = path.join(root, "peer"); await fs.mkdir(peerRoot); const peer = await repository(peerRoot); - let now = 0; - vi.spyOn(performance, "now").mockImplementation(() => now); - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); - setDiagnosticsEnabledForProcess(true); - const spawns: Extract[] = - []; - const stop = onDiagnosticEvent((event) => { - if (event.type === "diagnostic.child_process.spawn") { - spawns.push(event); - } - }); const entered = createDeferredCore(); const release = createDeferredCore(); const realRun = worktreeGit.runGitBytes; @@ -489,18 +471,7 @@ describe("Git operation host lifecycle", () => { } finally { release.resolve(); await Promise.all(pending); - now = 60_000; - emitChildProcessSpawnSample(); - stop(); - setDiagnosticsEnabledForProcess(false); - emitChildProcessSpawnSample(); } - expect( - spawns - .map(({ operation }) => operation ?? "unknown") - .toSorted((left, right) => left.localeCompare(right)), - ).toEqual(["checkout.diff", "repository.branches"]); - expect(spawns.every(({ family, count }) => family === "git" && count > 0)).toBe(true); }); it.each(["cleanup-inspection", "snapshot"] as const)( diff --git a/src/infra/state-migrations.shared-auth-store.test.ts b/src/infra/state-migrations.shared-auth-store.test.ts index e7410c88b67f..2e1bec880302 100644 --- a/src/infra/state-migrations.shared-auth-store.test.ts +++ b/src/infra/state-migrations.shared-auth-store.test.ts @@ -8,7 +8,6 @@ import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveAgentDir } from "../agents/agent-scope-config.js"; import * as ownership from "../agents/auth-profiles/path-resolve.js"; import { prepareSharedAuthStoreOwnershipForNative } from "../agents/auth-profiles/path-resolve.native.js"; -import * as persisted from "../agents/auth-profiles/persisted.js"; import { setAuthProfileOrder } from "../agents/auth-profiles/profiles.js"; import * as paths from "../agents/auth-profiles/shared-main-dir.js"; import * as bootstrap from "../agents/auth-profiles/shared-store-bootstrap.js"; @@ -18,7 +17,6 @@ import { readSharedAuthProfileRows, } from "../agents/auth-profiles/sqlite-read.js"; import * as sqlite from "../agents/auth-profiles/sqlite.js"; -import * as authState from "../agents/auth-profiles/state.js"; import { loadAuthProfileStoreWithoutExternalProfiles, saveAuthProfileStore, @@ -101,9 +99,6 @@ function receiptRow(db: EngineConnection) { "SELECT source_sha256, source_record_count, status, removed_source FROM migration_sources WHERE target_table = 'auth_profile_stores'", ); } -function count(db: EngineConnection, table: string, key: string, value: string) { - return row(db, `SELECT COUNT(*) AS count FROM ${table} WHERE ${key} = ?`, value)?.count; -} function completedReceipt(source: unknown) { return { source_sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"), @@ -321,43 +316,13 @@ describe("shared auth relocation lifecycle", () => { `); } - const sourceChecks = new Map(); - // oxlint-disable-next-line typescript/unbound-method -- Invoked with the intercepted native database receiver. - const prepare = DatabaseSync.prototype.prepare; - vi.spyOn(DatabaseSync.prototype, "prepare").mockImplementation(function ( - this: DatabaseSync, - sql, - ) { - const statement = prepare.call(this, sql); - if (this.location() !== sourcePath || !/^PRAGMA (?:quick|integrity)_check\b/u.test(sql)) { - return statement; - } - const all = statement.all.bind(statement); - statement.all = () => { - const checks = sourceChecks.get(this) ?? []; - checks.push(sql); - sourceChecks.set(this, checks); - return all(); - }; - return statement; - }); - const result = await migrate(sourcePath, integrityCheck); if (integrityCheck === "full") { - expect([...sourceChecks.values()]).toEqual([["PRAGMA integrity_check;"]]); expect(result).toMatchObject({ changes: [], warnings: [expect.stringMatching(/integrity_check.*idx_agent_cache_updated/su)], }); } else { - expect([...sourceChecks.values()]).toEqual([ - ["PRAGMA quick_check;"], - [ - "PRAGMA quick_check;", - "PRAGMA integrity_check('auth_profile_store');", - "PRAGMA integrity_check('auth_profile_state');", - ], - ]); expect(result.warnings).toEqual([]); expect( await readSharedAuthProfileRows(captureOpenClawStateWorkerContext({ env: state.env })), @@ -481,10 +446,8 @@ it.runIf(process.platform === "linux")( }, ); -it.each([false, true])("records ownership only (source=%s)", async (hasSource) => { - const fixture = await createEmptyFixture(hasSource); - expect(fs.existsSync(fixture.sourcePath)).toBe(hasSource); - +it("records ownership for an empty source database without reporting relocation", async () => { + const fixture = await createEmptyFixture(true); const detected = fixture.detect(); expect(detected.hasLegacy).toBe(true); expect(await fixture.migrate(detected)).toEqual(NO_CHANGES); @@ -493,65 +456,26 @@ it.each([false, true])("records ownership only (source=%s)", async (hasSource) = }); }); -it("moves exact rows, preserves every effective agent store, and records receipts", async () => { +it("preserves effective main and private-agent auth across relocation", async () => { const fixture = await createFixture(); - const effectiveBytes = (agentDir: string) => { - const effective = loadAuthProfileStoreWithoutExternalProfiles(agentDir); - return JSON.stringify({ - credentials: persisted.buildPersistedAuthProfileSecretsStore(effective), - state: authState.buildPersistedAuthProfileState(effective), + const effective = () => + [fixture.mainAgentDir, fixture.opsAgentDir].map((agentDir) => { + const { profiles, order, lastGood } = loadAuthProfileStoreWithoutExternalProfiles(agentDir); + return { profiles, order, lastGood }; }); - }; - const before = { - main: effectiveBytes(fixture.mainAgentDir), - ops: effectiveBytes(fixture.opsAgentDir), - }; - const detected = fixture.detect(); - - expect(await fixture.migrate(detected)).toMatchObject({ + const before = effective(); + expect(await fixture.migrate(fixture.detect())).toMatchObject({ warnings: [], changes: [expect.stringContaining("Relocated shared auth")], }); - - expect({ - store: sqlite.readPersistedAuthProfileStoreRaw(), - state: sqlite.readPersistedAuthProfileStateRaw(), - local: localAuth(fixture.mainAgentDir), - }).toEqual({ - store: fixture.sharedStore, - state: fixture.sharedState, - local: { store: null, state: null }, - }); - expect({ - main: effectiveBytes(fixture.mainAgentDir), - ops: effectiveBytes(fixture.opsAgentDir), - }).toEqual(before); - - const database = stateDb.openOpenClawStateDatabase({ env: fixture.env }).db; - for (const [key, value] of [ - ["authProfiles.store", fixture.sharedStore], - ["authProfiles.state", fixture.sharedState], - ["auth.sharedStore", { location: "state-db" }], - ] as const) { - expect(machineRow(database, key)).toEqual({ value_json: JSON.stringify(value) }); - } - expect( - row( - database, - "SELECT COUNT(*) AS count FROM migration_sources WHERE migration_kind = ?", - "shared-auth-store-state-db", - ), - ).toEqual({ count: 2 }); + expect(effective()).toEqual(before); }); -it.each(["standard", "custom", "main override"] as const)( +it.each(["custom", "main override"] as const)( "relocates the configured inheritance owner through Doctor (%s)", async (layout) => { const fixture = await createEmptyFixture(false); - const ownerDir = path.join( - fixture.stateDir, - layout === "custom" ? "custom-owner" : "agents/ops/agent", - ); + const ownerDir = path.join(fixture.stateDir, "custom-owner"); const peerDir = path.join(fixture.stateDir, "agents/dev/agent"); const config: OpenClawConfig = { agents: { @@ -755,11 +679,6 @@ it.each([ expect(result.warnings).toEqual(converges ? [] : [expect.stringMatching(/conflict.*Back up/)]); if (!converges) { expect(result.warnings[0]).toContain(conflict); - expect(result.warnings[0]).toContain("openclaw doctor --fix"); - expect(result.warnings[0]).toContain(JSON.stringify(sourcePath)); - expect(result.warnings[0]).toContain( - JSON.stringify(path.join(fixture.stateDir, "state", "openclaw.sqlite")), - ); expect(result.warnings[0]).not.toMatch(/shared-key|extra-key|different-key|legacyMetadata/); } expect(machineRow(migratedTarget, "authProfiles.store", true)).toEqual(targetRow); @@ -769,6 +688,13 @@ it.each([ expect(fixture.detect().hasLegacy).toBe(!converges); if (scenario === "changed credential") { const warning = result.warnings[0]!; + for (const guidance of [ + "openclaw doctor --fix", + sourcePath, + path.join(fixture.stateDir, "state", "openclaw.sqlite"), + ]) { + expect(warning).toContain(guidance); + } expect(warning).toContain(`${JSON.stringify(escapedProfileId)}: missing from target`); expect(warning.indexOf(JSON.stringify(escapedProfileId))).toBeLessThan( warning.indexOf('"openai:shared"'), @@ -834,14 +760,10 @@ it.each(["absolute", "tilde"] as const)( }, { env: source.env }, ); - expect(localAuth(source.agentDir)).toEqual({ store: source.store, state: source.state }); } const detected = await detectLegacy(selected.env, () => selected.owner.home); - expect(detected).toMatchObject({ - stateDir: selected.owner.stateDir, - stateSchema: { hasLegacy: false }, - }); + expect(detected.stateDir).toBe(selected.owner.stateDir); const selectedSourcePath = sqlite.resolveAuthProfileDatabasePath(selected.agentDir); expect .soft(detected.sharedAuthStore) @@ -981,14 +903,15 @@ for (const crashState of [ value_json: targetStoreJson, }); } - expect( - ["authProfiles.store", "authProfiles.state"].map((key) => - count(migratedTarget, "config_machine_state", "state_key", key), - ), - ).toEqual([1, 1]); + expect(sharedAuth(fixture.env)).toEqual({ + store: JSON.parse(targetStoreJson), + state: fixture.sharedState, + }); using cleanedSource = new DatabaseSync(sourcePath, { readOnly: true }); - expect(count(cleanedSource, "auth_profile_store", "store_key", "primary")).toBe(0); - expect(count(cleanedSource, "auth_profile_state", "state_key", "primary")).toBe(0); + expect([readSource(cleanedSource, "store"), readSource(cleanedSource, "state")]).toEqual([ + undefined, + undefined, + ]); }); } diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 52c1be935a0d..b16c4396768b 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -1,4 +1,5 @@ import "../test-utils/prepare-compiled-subprocesses.js"; +import assert from "node:assert/strict"; import { constants } from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; @@ -7,8 +8,6 @@ import { configureFsSafeNative, getFsSafeNativeConfig } from "@openclaw/fs-safe/ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as jsonFiles from "../infra/json-files.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; -import { withEnvAsync } from "../test-utils/env.js"; -import { withTempDir } from "../test-utils/temp-dir.js"; import { createMarketplaceInstallInput, expectMarketplaceInstallSuccess, @@ -76,8 +75,6 @@ function mockSuccessfulMarketplaceInstall() { ok: true, pluginId: "frontend-design", targetDir: "/tmp/frontend-design", - version: "0.1.0", - extensions: ["index.ts"], }); } @@ -107,25 +104,6 @@ async function writeLocalMarketplaceFixture(rootDir: string) { }; } -async function withKnownMarketplaceRegistry( - marketplaces: Record | ((homeDir: string) => Record), - run: (homeDir: string) => Promise, -): Promise { - return await withTempDir("openclaw-marketplace-known-", async (homeDir) => { - const openClawHome = path.join(homeDir, "openclaw-home"); - const registryPath = path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"); - await fs.mkdir(path.dirname(registryPath), { recursive: true }); - await fs.mkdir(openClawHome, { recursive: true }); - await fs.writeFile( - registryPath, - JSON.stringify(typeof marketplaces === "function" ? marketplaces(homeDir) : marketplaces), - ); - return await withEnvAsync({ HOME: homeDir, OPENCLAW_HOME: openClawHome }, async () => - run(homeDir), - ); - }); -} - function mockRemoteMarketplaceClone(params: { manifest: unknown; pluginDir?: string; @@ -133,10 +111,9 @@ function mockRemoteMarketplaceClone(params: { symlinkPath?: string; }) { runCommandWithTimeoutMock.mockImplementationOnce(async (argv: string[]) => { - const repoDir = argv.at(-1); - expect(typeof repoDir).toBe("string"); + const repoDir = argv.at(-1)!; await writeRemoteMarketplaceFixture({ - repoDir: repoDir as string, + repoDir, ...params, }); if (params.symlinkPath) { @@ -144,7 +121,7 @@ function mockRemoteMarketplaceClone(params: { "openclaw-marketplace-outside", tempOutsideDirs, ); - const link = path.join(repoDir as string, params.symlinkPath); + const link = path.join(repoDir, params.symlinkPath); await fs.mkdir(path.dirname(link), { recursive: true }); await fs.symlink(outsideDir, link); } @@ -152,21 +129,6 @@ function mockRemoteMarketplaceClone(params: { }); } -function expectRemoteCloneCommand() { - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1); - const [argv, options] = runCommandWithTimeoutMock.mock.calls[0] ?? []; - expect(Array.isArray(argv)).toBe(true); - expect((argv as unknown[]).slice(0, 5)).toEqual([ - "git", - "clone", - "--depth", - "1", - "https://github.com/owner/repo.git", - ]); - expect(typeof (argv as unknown[])[5]).toBe("string"); - expect(options).toEqual({ timeoutMs: 120_000 }); -} - function expectLocalMarketplaceInstallResult( result: unknown, pluginDir: string, @@ -177,14 +139,31 @@ function expectLocalMarketplaceInstallResult( } describe("marketplace plugins", () => { + let rootDir: string; + beforeEach(async () => { + rootDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-marketplace-test-")); + }); + afterEach(async () => { fetchWithSsrFGuardMock.mockClear(); installPluginFromPathMock.mockReset(); runCommandWithTimeoutMock.mockReset(); vi.unstubAllGlobals(); + vi.unstubAllEnvs(); await cleanupTrackedTempDirsAsync(tempOutsideDirs); + await fs.rm(rootDir, { recursive: true, force: true }); }); + async function known(marketplaces: Record) { + vi.stubEnv("HOME", rootDir); + vi.stubEnv("OPENCLAW_HOME", path.join(rootDir, "openclaw-home")); + await fs.mkdir(path.join(rootDir, "openclaw-home"), { recursive: true }); + const registryPath = path.join(rootDir, ".claude", "plugins", "known_marketplaces.json"); + await fs.mkdir(path.dirname(registryPath), { recursive: true }); + await fs.writeFile(registryPath, JSON.stringify(marketplaces)); + return rootDir; + } + it.each([ [ "GitHub primary identifier and ref", @@ -223,54 +202,31 @@ describe("marketplace plugins", () => { { type: " ", source: "git", url: 42, repo: " https://example.com/alias.git " }, gitSource("https://example.com/alias.git"), ], - [ - "GitHub missing optional fields", - { type: "github", repo: "owner/repo" }, - githubSource("owner/repo"), - ], - [ - "Git explicit ref before branch and tag", - { - type: "git", - url: "https://example.com/repo.git", - ref: " pinned ", - branch: "main", - tag: "v1", - }, - gitSource("https://example.com/repo.git", "pinned"), - ], ] as const)("normalizes %s in local marketplace listings", async (_label, source, expected) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - await writeMarketplaceManifest(rootDir, { plugins: [{ name: "example", source }] }); + await writeMarketplaceManifest(rootDir, { plugins: [{ name: "example", source }] }); - const result = await listMarketplacePlugins({ marketplace: rootDir }); + const result = await listMarketplacePlugins({ marketplace: rootDir }); - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error(result.error); - } - expect(result.manifest.plugins[0]?.source).toStrictEqual(expected); - expectNoNetwork(); - }); + assert(result.ok); + expect(result.manifest.plugins[0]?.source).toStrictEqual(expected); + expectNoNetwork(); }); it.each([ { source: { type: "github", repo: " ", url: false }, error: 'github source missing "repo"' }, { source: { type: "git", url: null, repo: "\t" }, error: 'git source missing "url"' }, ])("preserves $error in local marketplace listings", async ({ source, error }) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const manifestPath = await writeMarketplaceManifest(rootDir, { - plugins: [{ name: "example", source }], - }); - - const result = await listMarketplacePlugins({ marketplace: rootDir }); - - expect(result).toEqual({ - ok: false, - error: `invalid marketplace entry "example" in ${manifestPath}: ${error}`, - }); - expectNoNetwork(); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [{ name: "example", source }], }); + + const result = await listMarketplacePlugins({ marketplace: rootDir }); + + expect(result).toEqual({ + ok: false, + error: `invalid marketplace entry "example" in ${manifestPath}: ${error}`, + }); + expectNoNetwork(); }); it.each([false, true])("loads a symlinked manifest with oversized=%s", async (oversized) => { @@ -278,57 +234,41 @@ describe("marketplace plugins", () => { // Windows CI does not guarantee symlink support. return; } - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); - await fs.mkdir(path.dirname(manifestPath), { recursive: true }); - const targetPath = path.join(rootDir, "real-manifest.json"); - await fs.writeFile( - targetPath, - oversized - ? Buffer.alloc(16 * 1024 * 1024 + 1, "x") - : JSON.stringify(manifestFor(".", "symlinked-plugin")), - ); - await fs.symlink(targetPath, manifestPath); + const manifestPath = path.join(rootDir, ".claude-plugin", "marketplace.json"); + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + const targetPath = path.join(rootDir, "real-manifest.json"); + await fs.writeFile( + targetPath, + oversized + ? Buffer.alloc(16 * 1024 * 1024 + 1, "x") + : JSON.stringify(manifestFor(".", "symlinked-plugin")), + ); + await fs.symlink(targetPath, manifestPath); - const result = await listMarketplacePlugins({ marketplace: rootDir }); + const result = await listMarketplacePlugins({ marketplace: rootDir }); - if (oversized) { - expect(result).toEqual({ ok: false, error: "Marketplace manifest too large" }); - } else { - expect(result.ok).toBe(true); - if (!result.ok) { - throw new Error(result.error); - } - expect(result.manifest.plugins).toHaveLength(1); - } - }); + if (oversized) { + expect(result).toEqual({ ok: false, error: "Marketplace manifest too large" }); + } else { + assert(result.ok); + expect(result.manifest.plugins).toHaveLength(1); + } }); it("preserves the logical local install path instead of canonicalizing it", async () => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const canonicalRootDir = await fs.realpath(rootDir); - const canonicalPluginDir = path.join(canonicalRootDir, "plugins", "frontend-design"); - const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); - mockSuccessfulMarketplaceInstall(); + const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); + mockSuccessfulMarketplaceInstall(); - const result = await installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "frontend-design", - }); + const result = await installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "frontend-design", + }); - expectLocalMarketplaceInstallResult(result, pluginDir, manifestPath); - expect(installPluginInput().installPolicyRequest).toMatchObject({ - kind: "plugin-dir", - requestedSpecifier: `frontend-design@${manifestPath}`, - source: { kind: "local-path", authority: "user", mutable: true, network: false }, - }); - if (canonicalPluginDir !== pluginDir) { - expect( - installPluginFromPathMock.mock.calls.some( - ([input]) => (input as { path?: unknown } | undefined)?.path === canonicalPluginDir, - ), - ).toBe(false); - } + expectLocalMarketplaceInstallResult(result, pluginDir, manifestPath); + expect(installPluginInput().installPolicyRequest).toMatchObject({ + kind: "plugin-dir", + requestedSpecifier: `frontend-design@${manifestPath}`, + source: { kind: "local-path", authority: "user", mutable: true, network: false }, }); }); @@ -338,187 +278,150 @@ describe("marketplace plugins", () => { ] as const)( "rejects only ambiguous marketplace selections during %s", async (mode, duplicateName) => { - await withTempDir("openclaw-marketplace-test-", async (rootDir) => { - const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); - await writeMarketplaceManifest(rootDir, { - plugins: [ - { name: pluginName, source: relativePlugin }, - { name: duplicateName, source: "./plugins/other" }, - { name: "Frontend-design", source: relativePlugin }, - ], - }); - mockSuccessfulMarketplaceInstall(); - - await expect(listMarketplacePlugins({ marketplace: manifestPath })).resolves.toMatchObject({ - ok: true, - manifest: { - plugins: [{ name: pluginName }, { name: pluginName }, { name: "Frontend-design" }], - }, - }); - await expect( - installPluginFromMarketplace({ marketplace: manifestPath, plugin: pluginName, mode }), - ).resolves.toEqual({ - ok: false, - error: `plugin "${pluginName}" is ambiguous in marketplace ${manifestPath}: 2 entries share this name; remove or rename the duplicate entries`, - }); - expect(installPluginFromPathMock).not.toHaveBeenCalled(); - - await expect( - installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "Frontend-design", - mode, - }), - ).resolves.toMatchObject({ ok: true, marketplacePlugin: "Frontend-design" }); - expect(installPluginInput()).toMatchObject({ path: pluginDir, mode }); - expectNoNetwork(); + const { pluginDir, manifestPath } = await writeLocalMarketplaceFixture(rootDir); + await writeMarketplaceManifest(rootDir, { + plugins: [ + { name: pluginName, source: relativePlugin }, + { name: duplicateName, source: "./plugins/other" }, + { name: "Frontend-design", source: relativePlugin }, + ], }); + mockSuccessfulMarketplaceInstall(); + + await expect(listMarketplacePlugins({ marketplace: manifestPath })).resolves.toMatchObject({ + ok: true, + manifest: { + plugins: [{ name: pluginName }, { name: pluginName }, { name: "Frontend-design" }], + }, + }); + await expect( + installPluginFromMarketplace({ marketplace: manifestPath, plugin: pluginName, mode }), + ).resolves.toEqual({ + ok: false, + error: `plugin "${pluginName}" is ambiguous in marketplace ${manifestPath}: 2 entries share this name; remove or rename the duplicate entries`, + }); + expect(installPluginFromPathMock).not.toHaveBeenCalled(); + + await expect( + installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "Frontend-design", + mode, + }), + ).resolves.toMatchObject({ ok: true, marketplacePlugin: "Frontend-design" }); + expect(installPluginInput()).toMatchObject({ path: pluginDir, mode }); + expectNoNetwork(); }, ); it("does not publish a marketplace plugin after authority closes during artifact review", async () => { - await withTempDir("openclaw-marketplace-guard-", async (rootDir) => { - const { pluginDir, extensionsDir } = createBundleInstallFixtureFactory(() => rootDir)({ - bundleFormat: "claude", - name: "Guarded Bundle", - }); - const manifestPath = await writeMarketplaceManifest(rootDir, { - plugins: [{ name: "guarded-bundle", source: "./plugin-src" }], - }); - const { installPluginFromPath } = await import("./install-package.js"); - installPluginFromPathMock.mockImplementationOnce(installPluginFromPath); - let authorityActive = true; - const result = await installPluginFromMarketplace({ - marketplace: manifestPath, - plugin: "guarded-bundle", - extensionsDir, - onBeforePluginArtifactCommit: async () => { - authorityActive = false; - }, - beforePersistentApply: () => { - if (!authorityActive) { - throw new Error("plugin installation authority closed"); - } - }, - }); - - expect(authorityActive).toBe(false); - expect(result).toMatchObject({ - ok: false, - error: expect.stringContaining("plugin installation authority closed"), - }); - await expect(fs.stat(path.join(extensionsDir, "guarded-bundle"))).rejects.toMatchObject({ - code: "ENOENT", - }); - await expect(fs.stat(pluginDir)).resolves.toBeDefined(); + const { pluginDir, extensionsDir } = createBundleInstallFixtureFactory(() => rootDir)({ + bundleFormat: "claude", + name: "Guarded Bundle", }); + const manifestPath = await writeMarketplaceManifest(rootDir, { + plugins: [{ name: "guarded-bundle", source: "./plugin-src" }], + }); + const { installPluginFromPath } = await import("./install-package.js"); + installPluginFromPathMock.mockImplementationOnce(installPluginFromPath); + let authorityActive = true; + const result = await installPluginFromMarketplace({ + marketplace: manifestPath, + plugin: "guarded-bundle", + extensionsDir, + onBeforePluginArtifactCommit: async () => { + authorityActive = false; + }, + beforePersistentApply: () => { + if (!authorityActive) { + throw new Error("plugin installation authority closed"); + } + }, + }); + + expect(authorityActive).toBe(false); + expect(result).toMatchObject({ + ok: false, + error: expect.stringContaining("plugin installation authority closed"), + }); + await expect(fs.stat(path.join(extensionsDir, "guarded-bundle"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect(fs.stat(pluginDir)).resolves.toBeDefined(); }); it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { - await withKnownMarketplaceRegistry( - (homeDir) => ({ - "claude-plugins-official": { - source: { source: "github", repo: "anthropics/claude-plugins-official" }, - installLocation: path.join(homeDir, ".claude", "plugins", "marketplaces", "official"), - }, - }), - async () => { - expect( - await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"), - ).toEqual({ - ok: true, - plugin: "superpowers", - marketplaceSource: "claude-plugins-official", - }); - }, - ); + await known({ fixture: { installLocation: rootDir } }); + expect(await resolveMarketplaceInstallShortcut("tool@fixture")).toEqual({ + ok: true, + plugin: "tool", + marketplaceSource: "fixture", + }); }); - const cyclicKnownMarketplaces = [ - { - label: "self-referential", - marketplace: "loop", - marketplaces: { - loop: pathAlias("loop"), - }, - cycle: "loop -> loop", - }, - { - label: "two-marketplace", - marketplace: "alpha", - marketplaces: { - alpha: pathAlias("beta"), - beta: pathAlias("alpha"), - }, - cycle: "alpha -> beta -> alpha", - }, - ] as const; - - it.each(cyclicKnownMarketplaces)( - "rejects a $label known marketplace alias cycle while listing", - async ({ marketplace, marketplaces, cycle }) => { - await withKnownMarketplaceRegistry(marketplaces, async () => { - const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); - for (let read = 0; read <= Object.keys(marketplaces).length; read += 1) { - registryRead.mockResolvedValueOnce(marketplaces); - } - // Bound the unfixed recursive loader so the regression never leaves a runaway task. - registryRead.mockResolvedValueOnce({}); - - try { - const result = await listMarketplacePlugins({ marketplace }); - - expect(result).toEqual({ - ok: false, - error: `known marketplace source cycle: ${cycle}`, - }); - expect(registryRead).toHaveBeenCalledTimes(1); - expectNoNetwork(); - } finally { - registryRead.mockRestore(); - } - }); - }, - ); - - it("resolves independent known marketplace alias calls from one registry snapshot each", async () => { - await withKnownMarketplaceRegistry({}, async (homeDir) => { - const marketplaceRoot = path.join(homeDir, "known-marketplace"); - const { pluginDir } = await writeLocalMarketplaceFixture(marketplaceRoot); - await fs.writeFile( - path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), - JSON.stringify({ - alpha: pathAlias("beta"), - beta: { installLocation: marketplaceRoot, ...pathAlias("alpha") }, - }), - ); - mockSuccessfulMarketplaceInstall(); + it.each([ + ["loop", { loop: pathAlias("loop") }, "loop -> loop"], + ["alpha", { alpha: pathAlias("beta"), beta: pathAlias("alpha") }, "alpha -> beta -> alpha"], + ] as const)( + "rejects known marketplace alias cycles from %s", + async (marketplace, marketplaces, cycle) => { + await known(marketplaces); const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); - + for (let read = 0; read <= Object.keys(marketplaces).length; read += 1) { + registryRead.mockResolvedValueOnce(marketplaces); + } + // Bound the unfixed recursive loader so the regression never leaves a runaway task. + registryRead.mockResolvedValueOnce({}); try { - const listed = await listMarketplacePlugins({ marketplace: "alpha" }); - const installed = await installPluginFromMarketplace({ - marketplace: "alpha", - plugin: "frontend-design", + expect(await listMarketplacePlugins({ marketplace })).toEqual({ + ok: false, + error: `known marketplace source cycle: ${cycle}`, }); - - expect(listed).toMatchObject({ ok: true, sourceLabel: "beta" }); - expectLocalMarketplaceInstallResult(installed, pluginDir, "alpha"); - expect(registryRead).toHaveBeenCalledTimes(2); + expect(registryRead).toHaveBeenCalledTimes(1); expectNoNetwork(); } finally { registryRead.mockRestore(); } - }); + }, + ); + + it("resolves independent known marketplace alias calls from one registry snapshot each", async () => { + const homeDir = await known({}); + const marketplaceRoot = path.join(homeDir, "known-marketplace"); + const { pluginDir } = await writeLocalMarketplaceFixture(marketplaceRoot); + await fs.writeFile( + path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), + JSON.stringify({ + alpha: pathAlias("beta"), + beta: { installLocation: marketplaceRoot, ...pathAlias("alpha") }, + }), + ); + mockSuccessfulMarketplaceInstall(); + const registryRead = vi.spyOn(jsonFiles, "tryReadJson"); + + try { + const listed = await listMarketplacePlugins({ marketplace: "alpha" }); + const installed = await installPluginFromMarketplace({ + marketplace: "alpha", + plugin: "frontend-design", + }); + + expect(listed).toMatchObject({ ok: true, sourceLabel: "beta" }); + expectLocalMarketplaceInstallResult(installed, pluginDir, "alpha"); + expect(registryRead).toHaveBeenCalledTimes(2); + expectNoNetwork(); + } finally { + registryRead.mockRestore(); + } }); it("preserves ordinary source fallback for an invalid known marketplace alias", async () => { - await withKnownMarketplaceRegistry({ broken: { source: { source: "path" } } }, async () => { - const result = await listMarketplacePlugins({ marketplace: "broken" }); - - expect(result).toEqual({ ok: false, error: "unsupported marketplace source: broken" }); - expectNoNetwork(); + await known({ broken: { source: { source: "path" } } }); + expect(await listMarketplacePlugins({ marketplace: "broken" })).toEqual({ + ok: false, + error: "unsupported marketplace source: broken", }); + expectNoNetwork(); }); it.each([ @@ -545,15 +448,6 @@ describe("marketplace plugins", () => { ? /[\\/]repo[\\/]plugins[\\/]frontend-design\.tgz$/ : /[\\/]repo[\\/]plugins[\\/]frontend-design$/, ); - if (commit) { - expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(2); - expect(runCommandWithTimeoutMock.mock.calls.map(([argv]) => argv)).toEqual([ - ["git", "clone", "https://github.com/owner/repo.git", expect.any(String)], - ["git", "switch", "--detach", "--", commit], - ]); - } else { - expectRemoteCloneCommand(); - } expect(installPluginInput().installPolicyRequest).toMatchObject({ kind: extension ? "plugin-archive" : "plugin-git", requestedSpecifier: `${pluginName}@${marketplace}`, @@ -624,14 +518,6 @@ describe("marketplace archive downloads", () => { .toSorted(); } - function expectFetchDownloadCall(url = "https://example.com/frontend-design.tgz") { - expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).toMatchObject({ - url, - timeoutMs: 120_000, - auditContext: "marketplace-plugin-download", - }); - } - const archiveUrl = "https://example.com/frontend-design.tgz"; let rootDir: string; let manifestPath: string; @@ -655,24 +541,6 @@ describe("marketplace archive downloads", () => { ); } - function fakeResponse(kind = "stream") { - const cancel = vi.fn(async () => undefined); - const arrayBuffer = vi.fn(async () => new Uint8Array([1, 2, 3]).buffer); - const reader = { read: vi.fn(), cancel: vi.fn(async () => undefined), releaseLock: vi.fn() }; - const response = { - ok: true, - status: 200, - arrayBuffer, - headers: new Headers( - kind.endsWith("length") - ? { "content-length": kind === "malformed length" ? "1e9" : "268435457" } - : {}, - ), - body: { getReader: () => reader, cancel }, - } as unknown as Response; - return { response, reader, cancel, arrayBuffer }; - } - function expectDownloadFailure(result: unknown, error: string) { expect(result).toEqual({ ok: false, error: `failed to download ${archiveUrl}: ${error}` }); expect(installPluginFromPathMock).not.toHaveBeenCalled(); @@ -708,28 +576,26 @@ describe("marketplace archive downloads", () => { ["oversized length", "download too large: 268435457 bytes (limit: 268435456 bytes)"], ["drive-relative filename", "invalid download filename"], ] as const)("rejects %s responses before installing an archive", async (kind, error) => { - const fixture = fakeResponse(kind); + const cancel = vi.fn(); const response = - kind === "HTTP" - ? streamingResponse(fixture.cancel, 503) - : kind === "empty" - ? new Response(null) - : kind === "drive-relative filename" - ? new Response("tgz-bytes") - : fixture.response; + kind === "empty" + ? new Response(null) + : streamingResponse(cancel, kind === "HTTP" ? 503 : 200); + if (kind.endsWith("length")) { + response.headers.set("content-length", kind === "malformed length" ? "1e9" : "268435457"); + } + const getReader = response.body ? vi.spyOn(response.body, "getReader") : undefined; const release = respond( response, kind === "drive-relative filename" ? "https://cdn.example.com/C:plugin.tgz" : archiveUrl, ); expectDownloadFailure(await install(), error); - expectFetchDownloadCall(); expect(release).toHaveBeenCalledTimes(1); if (kind === "HTTP" || kind.endsWith("length")) { - expect(fixture.cancel).toHaveBeenCalledTimes(1); + expect(cancel).toHaveBeenCalledTimes(1); } if (kind.endsWith("length")) { - expect(fixture.reader.read).not.toHaveBeenCalled(); - expect(fixture.reader.cancel).not.toHaveBeenCalled(); + expect(getReader).not.toHaveBeenCalled(); } }); @@ -751,7 +617,6 @@ describe("marketplace archive downloads", () => { marketplacePlugin: "frontend-design", marketplaceSource: manifestPath, }); - expectFetchDownloadCall(); expect(String(installPluginInput().path)).toMatch(/[\\/]frontend-design\.tgz$/); expect(installPluginInput().installPolicyRequest).toMatchObject({ kind: "plugin-archive", @@ -859,10 +724,7 @@ describe("marketplace archive downloads", () => { expect(result).toEqual({ ok: false, error: "failed to download ***: Invalid URL" }); expect(fetchWithSsrFGuardMock).not.toHaveBeenCalled(); } else { - expect(result.ok).toBe(false); - if (result.ok) { - return; - } + assert(!result.ok); expect(result.error).toContain( "failed to download https://***:***@example.com/frontend-design.tgz:", ); diff --git a/src/plugins/openai-compatible-embedding-provider.test.ts b/src/plugins/openai-compatible-embedding-provider.test.ts index af17b6bddf0a..ebdac588b06f 100644 --- a/src/plugins/openai-compatible-embedding-provider.test.ts +++ b/src/plugins/openai-compatible-embedding-provider.test.ts @@ -1,33 +1,35 @@ // Covers OpenAI-compatible embedding provider plugin behavior. import "../test-utils/prepare-compiled-subprocesses.js"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; -import type { AddressInfo } from "node:net"; import { Agent, type Dispatcher } from "undici"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createMemorySearchDeadlineControl, MEMORY_SEARCH_DEADLINE_CONTROL, } from "../../packages/memory-host-sdk/src/host/search-deadline-control.js"; +import { + closeHttpServer, + listenLoopback, + readRequestText, +} from "../../test/helpers/gateway-integration-fixtures.js"; import { withTestTimeout } from "../../test/helpers/promise.js"; -import type { ConfiguredProviderLocalServiceTarget } from "../agents/provider-local-service-target.js"; +import type { + AcquireConfiguredProviderLocalService, + ConfiguredProviderLocalServiceTarget, +} from "../agents/provider-local-service-target.js"; import type { EmbeddingProviderCreateOptions } from "./embedding-providers.js"; import { openAICompatibleEmbeddingProviderAdapter } from "./openai-compatible-embedding-provider.js"; -async function createProvider(options: EmbeddingProviderCreateOptions) { +async function createProvider( + options: EmbeddingProviderCreateOptions & { + acquireLocalService?: AcquireConfiguredProviderLocalService; + }, +) { const result = await openAICompatibleEmbeddingProviderAdapter.create(options); if (!result.provider) { throw new Error("expected OpenAI-compatible embedding provider"); } - const cacheKeyData = result.runtime?.cacheKeyData as - | { baseUrl?: string; headers?: Record } - | undefined; - return { - provider: result.provider, - client: { - baseUrl: cacheKeyData?.baseUrl, - headers: cacheKeyData?.headers ?? {}, - }, - }; + return { ...result, provider: result.provider }; } type CapturedRequest = { @@ -37,28 +39,7 @@ type CapturedRequest = { body: Record; }; -type FixtureResponse = { - object: "list"; - data: Array<{ - object?: "embedding"; - embedding: number[]; - index: number; - }>; - model?: string; - usage?: { - prompt_tokens?: number; - total_tokens?: number; - }; -}; - -type OversizedStreamServer = { - baseUrl: string; - closed: Promise; - getBodyBytesSent: () => number; - getPlannedBodyBytes: () => number; -}; - -const servers: Array<{ close: () => Promise }> = []; +const servers: Server[] = []; function createOptions( overrides: Partial = {}, @@ -71,45 +52,41 @@ function createOptions( }; } +function configuredOptions( + provider: string, + config: NonNullable< + NonNullable["providers"] + >[string], + overrides: Partial = {}, +) { + return createOptions({ + provider, + config: { models: { providers: { [provider]: config } } }, + ...overrides, + }); +} + +function vectors(...embeddings: number[][]) { + return { data: embeddings.map((embedding, index) => ({ embedding, index })) }; +} + async function readJsonBody(req: IncomingMessage): Promise> { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const text = Buffer.concat(chunks).toString("utf8"); - return JSON.parse(text) as Record; + return JSON.parse(await readRequestText(req)) as Record; } -async function listen(server: Server, destroyConnections = false) { - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(0, "127.0.0.1", () => { - server.off("error", reject); - resolve(); - }); - }); - servers.push({ - close: () => - new Promise((resolve, reject) => { - if (destroyConnections) { - server.closeAllConnections(); - } - server.close((error) => (error ? reject(error) : resolve())); - }), - }); - return `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1`; +async function listen(server: Server) { + const baseUrl = await listenLoopback(server); + servers.push(server); + return `${baseUrl}/v1`; } -function textEmbeddings({ body }: CapturedRequest, usage = false): FixtureResponse { +function textEmbeddings({ body }: CapturedRequest, usage = false) { const texts = Array.isArray(body.input) ? body.input : [body.input]; return { - object: "list", data: texts.map((text, index) => ({ - object: "embedding", embedding: [String(text).length, index + 0.25, 1], index, })), - model: String(body.model), ...(usage ? { usage: { prompt_tokens: texts.length, total_tokens: texts.length } } : {}), }; } @@ -129,13 +106,16 @@ function localProvider(baseUrl: string): EmbeddingProviderCreateOptions["config" }; } -async function providerAt(baseUrl: string) { - return createProvider(createOptions({ remote: { baseUrl } })); +async function providerAt( + baseUrl: string, + overrides: Partial = {}, +) { + return createProvider(createOptions({ remote: { baseUrl }, ...overrides })); } async function startEmbeddingServer(params?: { token?: string; - respond?: (request: CapturedRequest) => FixtureResponse | Record | null; + respond?: (request: CapturedRequest) => Record | null; status?: number; redirectUrl?: string; }): Promise<{ baseUrl: string; requests: CapturedRequest[] }> { @@ -163,15 +143,7 @@ async function startEmbeddingServer(params?: { ...(params?.redirectUrl ? { location: params.redirectUrl } : {}), }); res.end( - JSON.stringify( - params?.respond - ? params.respond(captured) - : { - object: "list", - data: [{ object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }], - model: body.model, - }, - ), + JSON.stringify(params?.respond ? params.respond(captured) : vectors([0.1, 0.2, 0.3])), ); } catch (error) { res.writeHead(500, { "content-type": "application/json" }); @@ -188,10 +160,11 @@ const EMBEDDING_ERROR_BOUNDARY_BODY = `${EMBEDDING_ERROR_BOUNDARY_PREFIX}😀${" 8 * 1024 - EMBEDDING_ERROR_BOUNDARY_PREFIX.length - 4, )}`; -async function startHangingErrorEmbeddingServer(): Promise<{ - baseUrl: string; - closed: Promise; -}> { +async function startStreamServer( + status: number, + write: (res: ServerResponse) => void, + contentType = "application/json", +) { let resolveClosed: () => void = () => undefined; const closed = new Promise((resolve) => { resolveClosed = resolve; @@ -200,86 +173,20 @@ async function startHangingErrorEmbeddingServer(): Promise<{ void (async () => { await readJsonBody(req); res.on("close", resolveClosed); - res.writeHead(502, { "content-type": "text/plain" }); - res.write(EMBEDDING_ERROR_BOUNDARY_BODY); + res.writeHead(status, { "content-type": contentType }); + write(res); })(); }); return { - baseUrl: await listen(server, true), + baseUrl: await listen(server), closed, }; } -async function startOversizedSuccessEmbeddingServer(): Promise { - const chunk = Buffer.alloc(64 * 1024, 0x20); - const prefix = Buffer.from('{"data":['); - const plannedBodyBytes = 64 * 1024 * 1024; - let bodyBytesSent = 0; - let resolveClosed: () => void = () => undefined; - const closed = new Promise((resolve) => { - resolveClosed = resolve; - }); - const server = createServer((req: IncomingMessage, res: ServerResponse) => { - void (async () => { - await readJsonBody(req); - let closedAlready = false; - res.on("close", () => { - closedAlready = true; - resolveClosed(); - }); - res.writeHead(200, { "content-type": "application/json" }); - const writeChunk = async (buffer: Buffer): Promise => { - if (closedAlready) { - return false; - } - const accepted = res.write(buffer); - bodyBytesSent += buffer.byteLength; - if (accepted) { - return true; - } - return await new Promise((resolve) => { - const cleanup = () => { - res.off("drain", onDrain); - res.off("close", onClose); - }; - const onDrain = () => { - cleanup(); - resolve(!closedAlready); - }; - const onClose = () => { - cleanup(); - resolve(false); - }; - res.once("drain", onDrain); - res.once("close", onClose); - }); - }; - - if (!(await writeChunk(prefix))) { - return; - } - const chunksToSend = Math.ceil((plannedBodyBytes - bodyBytesSent) / chunk.byteLength); - for (let i = 0; i < chunksToSend; i++) { - if (!(await writeChunk(chunk))) { - return; - } - } - res.end("]}"); - })(); - }); - - return { - baseUrl: await listen(server, true), - closed, - getBodyBytesSent: () => bodyBytesSent, - getPlannedBodyBytes: () => plannedBodyBytes, - }; -} - afterEach(async () => { const pending = servers.splice(0); - await Promise.all(pending.map((server) => server.close())); + await Promise.all(pending.map(closeHttpServer)); }); describe("openai-compatible generic embedding provider", () => { @@ -287,22 +194,16 @@ describe("openai-compatible generic embedding provider", () => { const server = await startEmbeddingServer(); for (const remoteOverride of [false, true]) { const { provider } = await createProvider( - createOptions({ - provider: "private-fixture", - config: { - models: { - providers: { - "private-fixture": { - api: "openai-completions", - baseUrl: remoteOverride ? "https://provider.example.test/v1" : server.baseUrl, - request: { allowPrivateNetwork: false }, - models: [], - }, - }, - }, + configuredOptions( + "private-fixture", + { + api: "openai-completions", + baseUrl: remoteOverride ? "https://provider.example.test/v1" : server.baseUrl, + request: { allowPrivateNetwork: false }, + models: [], }, - ...(remoteOverride ? { remote: { baseUrl: server.baseUrl } } : {}), - }), + remoteOverride ? { remote: { baseUrl: server.baseUrl } } : {}, + ), ); await expect(provider.embed("private input")).rejects.toThrow( /private\/internal\/special-use/, @@ -317,9 +218,7 @@ describe("openai-compatible generic embedding provider", () => { status: 307, redirectUrl: `${target.baseUrl}/embeddings`, }); - const { provider } = await createProvider( - createOptions({ remote: { baseUrl: source.baseUrl } }), - ); + const { provider } = await providerAt(source.baseUrl); await expect(provider.embed("private input")).rejects.toThrow(/private\/internal\/special-use/); expect(source.requests).toHaveLength(1); expect(target.requests).toEqual([]); @@ -327,9 +226,7 @@ describe("openai-compatible generic embedding provider", () => { it.each([true, false])("preserves caller-owned HTTP deadlines: signal=%s", async (withSignal) => { const server = await startEmbeddingServer(); - const { provider } = await createProvider( - createOptions({ remote: { baseUrl: server.baseUrl } }), - ); + const { provider } = await providerAt(server.baseUrl); const requests: Dispatcher.DispatchOptions[] = []; // oxlint-disable-next-line typescript/unbound-method -- The observer calls the original with the same Agent receiver. const dispatch = Agent.prototype.dispatch; @@ -349,8 +246,9 @@ describe("openai-compatible generic embedding provider", () => { }), ).resolves.toEqual([[0.1, 0.2, 0.3]]); expect(requests).toHaveLength(1); - expect(requests[0]?.headersTimeout).toBe(withSignal ? 0 : undefined); - expect(requests[0]?.bodyTimeout).toBe(withSignal ? 0 : undefined); + expect([requests[0]?.headersTimeout, requests[0]?.bodyTimeout]).toEqual( + withSignal ? [0, 0] : [undefined, undefined], + ); } finally { spy.mockRestore(); } @@ -367,16 +265,14 @@ describe("openai-compatible generic embedding provider", () => { events.push("reconcile"); return { release }; }); - const options = { + const { provider } = await createProvider({ ...createOptions({ config: localProvider(server.baseUrl), provider: "gpu-spark", model: "gpu-spark/nomic-embed-text", }), acquireLocalService, - }; - - const { provider } = await createProvider(options); + }); const control = createMemorySearchDeadlineControl(); control.subscribe((action) => events.push(action)); const caller = new AbortController(); @@ -395,17 +291,15 @@ describe("openai-compatible generic embedding provider", () => { it("does not lease a configured local service for a remote endpoint override", async () => { const server = await startEmbeddingServer(); const acquireLocalService = vi.fn(async () => ({ release: vi.fn() })); - const options = createOptions({ - config: localProvider("http://spark.local:11434/v1"), - provider: "gpu-spark", - model: "gpu-spark/nomic-embed-text", - remote: { baseUrl: server.baseUrl }, - }) as EmbeddingProviderCreateOptions & { - acquireLocalService: typeof acquireLocalService; - }; - options.acquireLocalService = acquireLocalService; - - const { provider } = await createProvider(options); + const { provider } = await createProvider({ + ...createOptions({ + config: localProvider("http://spark.local:11434/v1"), + provider: "gpu-spark", + model: "gpu-spark/nomic-embed-text", + remote: { baseUrl: server.baseUrl }, + }), + acquireLocalService, + }); await expect(provider.embed("hello")).resolves.toEqual([0.1, 0.2, 0.3]); expect(acquireLocalService).not.toHaveBeenCalled(); }); @@ -426,13 +320,7 @@ describe("openai-compatible generic embedding provider", () => { }), ); - expect(result.runtime?.cacheKeyData).toMatchObject({ - headers: { - accept: "application/json", - "content-type": "application/json", - "x-deployment": "tenant-a", - }, - }); + expect(result.runtime?.cacheKeyData).toMatchObject({ headers: { "x-deployment": "tenant-a" } }); expect(result.runtime?.cacheKeyData).not.toHaveProperty("authorization"); expect( (result.runtime!.cacheKeyData as { headers?: Record }).headers, @@ -456,26 +344,22 @@ describe("openai-compatible generic embedding provider", () => { }; for (const key of ["fixture-before", "fixture-after"]) { const result = await openAICompatibleEmbeddingProviderAdapter.create( - createOptions({ - provider: "custom", - model: "fixture-embedding", - dimensions: 3, - inputType: "default", - queryInputType: "query", - documentInputType: "document", - config: { - models: { - providers: { - custom: { - baseUrl: canonical.baseUrl, - headers: { "X-Access": key, "X-Deployment": "tenant-a" }, - request: { auth: { mode: "header", headerName: " X-Access ", value: key } }, - models: [], - }, - }, - }, + configuredOptions( + "custom", + { + baseUrl: canonical.baseUrl, + headers: { "X-Access": key, "X-Deployment": "tenant-a" }, + request: { auth: { mode: "header", headerName: " X-Access ", value: key } }, + models: [], }, - }), + { + model: "fixture-embedding", + dimensions: 3, + inputType: "default", + queryInputType: "query", + documentInputType: "document", + }, + ), ); const priorIdentity = { ...canonical, @@ -487,12 +371,8 @@ describe("openai-compatible generic embedding provider", () => { }, }; expect(result.runtime?.cacheKeyData).toEqual(canonical); - expect(result.runtime?.indexIdentityAliases).toEqual([ - { - model: "fixture-embedding", - cacheKeyData: priorIdentity, - }, - ]); + expect(result.runtime?.indexIdentityAliases).toHaveLength(1); + expect(result.runtime?.indexIdentityAliases?.[0]?.model).toBe("fixture-embedding"); expect(JSON.stringify(result.runtime?.indexIdentityAliases?.[0]?.cacheKeyData)).toBe( JSON.stringify(priorIdentity), ); @@ -518,22 +398,16 @@ describe("openai-compatible generic embedding provider", () => { "does not create an index identity alias for $name", async ({ authHeaderName, headers, remote }) => { const result = await openAICompatibleEmbeddingProviderAdapter.create( - createOptions({ - provider: "custom", - config: { - models: { - providers: { - custom: { - baseUrl: "http://127.0.0.1:18001/v1", - headers, - request: { auth: { mode: "header", headerName: authHeaderName, value: "key" } }, - models: [], - }, - }, - }, + configuredOptions( + "custom", + { + baseUrl: "http://127.0.0.1:18001/v1", + headers, + request: { auth: { mode: "header", headerName: authHeaderName, value: "key" } }, + models: [], }, - remote, - }), + { remote }, + ), ); expect(result.runtime?.indexIdentityAliases).toBeUndefined(); }, @@ -546,7 +420,7 @@ describe("openai-compatible generic embedding provider", () => { respond: (request) => textEmbeddings(request, true), }); - const { provider, client } = await createProvider( + const { provider, runtime } = await createProvider( createOptions({ model: "text-embedding-bge-m3", dimensions: 3, @@ -565,7 +439,7 @@ describe("openai-compatible generic embedding provider", () => { model: "text-embedding-bge-m3", dimensions: 3, }); - expect(client.baseUrl).toBe(server.baseUrl); + expect(runtime?.cacheKeyData).toMatchObject({ baseUrl: server.baseUrl }); expect(server.requests).toHaveLength(0); const onUsage = vi.fn(); @@ -583,35 +457,21 @@ describe("openai-compatible generic embedding provider", () => { expect(server.requests[0]).toMatchObject({ method: "POST", url: "/v1/embeddings", - body: { - model: "text-embedding-bge-m3", - input: ["hello"], - dimensions: 3, + headers: { + "content-type": expect.stringContaining("application/json"), + accept: "application/json", + "x-local-runtime": "ollama", }, }); - expect(server.requests[0]?.body).not.toHaveProperty("encoding_format"); - expect(server.requests[0]?.body).not.toHaveProperty("input_type"); - expect(server.requests[0]?.headers).toMatchObject({ - "content-type": expect.stringContaining("application/json"), - accept: "application/json", - "x-local-runtime": "ollama", - }); - expect(server.requests[1]?.body).toEqual({ - model: "text-embedding-bge-m3", - input: ["a", "abcd"], - dimensions: 3, - }); + expect(server.requests.map(({ body }) => body)).toEqual([ + { model: "text-embedding-bge-m3", input: ["hello"], dimensions: 3 }, + { model: "text-embedding-bge-m3", input: ["a", "abcd"], dimensions: 3 }, + ]); }); it("returns endpoint vectors unchanged when dimensions are not configured", async () => { const server = await startEmbeddingServer({ - respond: () => ({ - object: "list", - data: [ - { object: "embedding", embedding: [0.1, 0.2, 0.3], index: 0 }, - { object: "embedding", embedding: [0.4, 0.5], index: 1 }, - ], - }), + respond: () => vectors([0.1, 0.2, 0.3], [0.4, 0.5]), }); const result = await openAICompatibleEmbeddingProviderAdapter.create( createOptions({ remote: { baseUrl: server.baseUrl } }), @@ -626,40 +486,42 @@ describe("openai-compatible generic embedding provider", () => { expect(server.requests[0]?.body).not.toHaveProperty("dimensions"); }); - it("rejects vectors that do not match the requested dimensions", async () => { - // Simulate a server that ignores `dimensions` and returns the model's native size. - const native = await startEmbeddingServer({ respond: (request) => textEmbeddings(request) }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: native.baseUrl } }), - ); - - await expect(provider.embed("hello", { inputType: "query" })).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - expectedDimensions: 2, - actualDimensions: 3, - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): expected 2 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); - await expect(provider.embedBatch(["a", "b"], { inputType: "document" })).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 2): expected 2 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); - - const { provider: wider } = await createProvider( - createOptions({ dimensions: 4, remote: { baseUrl: native.baseUrl } }), - ); - await expect(wider.embed("hello")).rejects.toThrow( - "expected 4 dimensions, got 3 at index 0; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - ); - expect(native.requests.map((request) => request.body.dimensions)).toEqual([2, 2, 4]); - }); + it.each([ + { dimensions: 2, inputs: ["hello"], batch: false, index: 0 }, + { dimensions: 2, inputs: ["a", "b", "c"], batch: true, index: 2 }, + ])( + "rejects mismatched dimensions ($dimensions, batch=$batch, index=$index)", + async ({ dimensions, inputs, batch, index }) => { + const server = await startEmbeddingServer({ + respond: + index === 2 + ? () => ({ + data: [ + { embedding: [0, 0.6, 0.8], index: 2 }, + { embedding: [0.6, 0.8], index: 1 }, + { embedding: [1, 0], index: 0 }, + ], + }) + : textEmbeddings, + }); + const { provider } = await providerAt(server.baseUrl, { dimensions }); + await expect( + batch + ? provider.embedBatch(inputs, { inputType: "document" }) + : provider.embed(inputs[0]!, { inputType: "query" }), + ).rejects.toMatchObject({ + code: "INVALID_EMBEDDING_RESPONSE", + expectedDimensions: dimensions, + actualDimensions: 3, + message: `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: ${inputs.length}): expected ${dimensions} dimensions, got 3 at index ${index}; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.`, + }); + expect(server.requests[0]?.body.dimensions).toBe(dimensions); + }, + ); it("keeps malformed response errors distinct from dimension mismatches", async () => { const server = await startEmbeddingServer({ respond: () => ({ data: [{}] }) }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: server.baseUrl } }), - ); + const { provider } = await providerAt(server.baseUrl, { dimensions: 2 }); const error = await provider.embed("query").catch((cause: unknown) => cause); expect(error).toMatchObject({ code: "INVALID_EMBEDDING_RESPONSE", @@ -667,75 +529,34 @@ describe("openai-compatible generic embedding provider", () => { }); expect(error).not.toHaveProperty("expectedDimensions"); expect(error).not.toHaveProperty("actualDimensions"); - expect(server.requests).toHaveLength(1); - }); - - it("rejects a batch when any vector differs from the requested dimensions", async () => { - const server = await startEmbeddingServer({ - respond: () => ({ - object: "list", - data: [ - { object: "embedding", embedding: [0, 0.6, 0.8], index: 2 }, - { object: "embedding", embedding: [0.6, 0.8], index: 1 }, - { object: "embedding", embedding: [1, 0], index: 0 }, - ], - }), - }); - const { provider } = await createProvider( - createOptions({ dimensions: 2, remote: { baseUrl: server.baseUrl } }), - ); - - await expect(provider.embedBatch(["a", "b", "c"])).rejects.toMatchObject({ - code: "INVALID_EMBEDDING_RESPONSE", - message: - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 3): expected 2 dimensions, got 3 at index 2; the endpoint did not honor the requested dimensions. Remove memory.search.outputDimensionality or request dimensions, or use a server that supports them.", - }); }); it("bounds exact-limit embedding errors without splitting UTF-16 and cancels", async () => { - const server = await startHangingErrorEmbeddingServer(); + const server = await startStreamServer( + 502, + (res) => { + res.write(EMBEDDING_ERROR_BOUNDARY_BODY); + }, + "text/plain", + ); const { provider } = await providerAt(server.baseUrl); - const outcome = await withTestTimeout( - provider.embed("hello").then( - () => ({ type: "resolved" as const }), - (error: unknown) => ({ type: "rejected" as const, error }), - ), + const request = withTestTimeout( + provider.embed("hello"), 1_000, "timed out waiting for bounded embedding error", ); - - if (outcome.type !== "rejected") { - throw new Error(`expected embedding request to reject, got ${outcome.type}`); - } - expect(outcome.error).toBeInstanceOf(Error); - expect((outcome.error as Error).message).toBe( - `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): HTTP 502: ${EMBEDDING_ERROR_BOUNDARY_PREFIX}... [truncated]`, - ); + await expect(request).rejects.toBeInstanceOf(Error); + await expect(request).rejects.toMatchObject({ + message: `openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): HTTP 502: ${EMBEDDING_ERROR_BOUNDARY_PREFIX}... [truncated]`, + }); await expect( withTestTimeout( - server.closed.then(() => "closed" as const), + server.closed, 1_000, "timed out waiting for embedding error server to close", ), - ).resolves.toBe("closed"); - }); - - it("bounds and cancels oversized successful embedding JSON bodies", async () => { - const server = await startOversizedSuccessEmbeddingServer(); - const { provider } = await providerAt(server.baseUrl); - - await expect(provider.embed("hello")).rejects.toThrow( - "openai-compatible embeddings failed (model: text-embedding-bge-m3, batch size: 1): JSON response exceeds 16777216 bytes", - ); - await expect( - withTestTimeout( - server.closed.then(() => "closed" as const), - 1_000, - "timed out waiting for oversized response server to close", - ), - ).resolves.toBe("closed"); - expect(server.getBodyBytesSent()).toBeLessThan(server.getPlannedBodyBytes() / 2); + ).resolves.toBeUndefined(); }); it("maps configured memory input_type labels onto query and document requests", async () => { @@ -743,7 +564,6 @@ describe("openai-compatible generic embedding provider", () => { const result = await openAICompatibleEmbeddingProviderAdapter.create( createOptions({ - model: "text-embedding-bge-m3", inputType: " default ", queryInputType: " query ", documentInputType: " document ", @@ -780,10 +600,7 @@ describe("openai-compatible generic embedding provider", () => { const server = await startEmbeddingServer({ respond: ({ body }) => { expect(body.input).toEqual(["ab"]); - return { - object: "list", - data: [{ object: "embedding", embedding: [2, 1], index: 0 }], - }; + return vectors([2, 1]); }, }); const { provider } = await providerAt(server.baseUrl); diff --git a/src/plugins/plugin-lifecycle-lease.test.ts b/src/plugins/plugin-lifecycle-lease.test.ts index 743e53b08909..bbf9214ed588 100644 --- a/src/plugins/plugin-lifecycle-lease.test.ts +++ b/src/plugins/plugin-lifecycle-lease.test.ts @@ -1,13 +1,14 @@ import { AsyncResource } from "node:async_hooks"; -import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; -import type { Readable, Writable } from "node:stream"; import { setImmediate as nextTurn } from "node:timers/promises"; import { pathToFileURL } from "node:url"; import { queryObjects } from "node:v8"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createDeferred } from "../../test/helpers/promise.js"; +import { stopChildProcess } from "../../test/helpers/stop-child-process.js"; +import { runCliProcessChild } from "../cli/cli-process-child.test-helpers.js"; import { createPluginLifecycleLeaseTestClock } from "../gateway/config-reload.test-support.js"; import { sqliteWorkerPreloadEnv } from "../infra/sqlite-worker-preload.test-support.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; @@ -35,7 +36,7 @@ import { } from "./plugin-lifecycle-lease.js"; import { seedInstalledPluginIndex } from "./test-helpers/installed-plugin-index.js"; -type LeaseChild = ChildProcessByStdio; +type LeaseChild = ChildProcessWithoutNullStreams; type LeaseChildRun = { child: LeaseChild; ready: Promise; @@ -68,19 +69,6 @@ afterEach(() => { closeOpenClawStateDatabaseForTest(); }); -async function terminateLeaseChild(child: LeaseChild): Promise { - await new Promise((resolve) => { - const onClose = () => resolve(); - child.once("close", onClose); - if (child.exitCode !== null || child.signalCode !== null) { - child.off("close", onClose); - resolve(); - return; - } - child.kill("SIGKILL"); - }); -} - async function withLeaseChildren(fn: (children: Set) => Promise): Promise { const children = new Set(); try { @@ -89,7 +77,7 @@ async function withLeaseChildren(fn: (children: Set) => Promis } finally { await Promise.all( Array.from(children, async ({ child, completed }) => { - await terminateLeaseChild(child); + await stopChildProcess(child, 5_000, { force: true }); await completed.catch(() => {}); }), ); @@ -102,59 +90,47 @@ async function withLeaseChildren(fn: (children: Set) => Promis } } -function runLeaseChild( +async function runLeaseChild( children: Set, scriptPath: string, args: string[], env?: NodeJS.ProcessEnv, -): LeaseChildRun { - const child = spawn(process.execPath, ["--import", "tsx", scriptPath, ...args], { - stdio: ["pipe", "pipe", "pipe"], - env, - }); - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - +): Promise { let stdout = ""; let stderr = ""; - let pendingLine = ""; const phases = new Set(); const phaseWaiters = new Map>>(); - - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - pendingLine += chunk; - const lines = pendingLine.split("\n"); - pendingLine = lines.pop() ?? ""; - for (const line of lines) { - phases.add(line); - phaseWaiters.get(line)?.resolve(); + const started = createDeferred(); + const completed = runCliProcessChild({ + nodeArgs: ["--import", "tsx", scriptPath, ...args], + nodeArgsPolicy: "caller", + env: env ?? process.env, + onStdout(output) { + stdout = output; + for (const line of output.split("\n").slice(0, -1)) { + phases.add(line); + phaseWaiters.get(line)?.resolve(); + } + }, + interact(child) { + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.stdin.end(); + started.resolve(child); + }, + }).then(({ code, signal }) => { + if (code !== 0) { + throw new Error(`lease child ${args[0]} exited ${code ?? signal}`); } }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - - const completed = new Promise((resolve, reject) => { - const fail = (phase: string, error: Error) => - reject( - new Error(`failed to ${phase} lease child ${args[0]}: ${error.message}`, { cause: error }), - ); - child.stdin.once("error", (error) => { - fail("signal", error); - }); - child.once("error", (error) => { - fail("start", error); - }); - child.once("close", (code, signal) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`lease child ${args[0]} exited ${code ?? signal}`)); - } - }); - }); void completed.catch(() => {}); + const child = await Promise.race([ + started.promise, + completed.then(() => { + throw new Error("Lease child exited before starting"); + }), + ]); const waitForPhase = (phase: string): Promise => { if (phases.has(phase)) { return Promise.resolve(); @@ -241,8 +217,12 @@ describe("plugin lifecycle lease", () => { const operations: Promise[] = []; try { expect(hasPluginLifecycleLeaseDemand()).toBe(false); - const holder = withPluginLifecycleLease({ env: state.env, signal }, async () => { - await withPluginLifecycleLease({}, async () => { + const holder = withPluginLifecycleLease({ env: state.env, signal }, async (outer) => { + await withPluginLifecycleLease({}, async (nested) => { + expect(nested).toBe(outer); + expect(nested.databasePath).toBe( + path.resolve(state.stateDir, "state", "openclaw.sqlite"), + ); expect(hasPluginLifecycleLeaseDemand()).toBe(false); }); entered.resolve(); @@ -286,51 +266,28 @@ describe("plugin lifecycle lease", () => { }, ); - it.each([ - [false, false], - [true, false], - [false, true], - [true, true], - ])( - "preserves operation and cleanup outcomes (run failure: %s, cleanup failure: %s)", - async (failRun, failCleanup) => { - await withOpenClawTestState({ label: "plugin-lifecycle-outcome" }, async (state) => { - const runError = new Error("operation failed"); - const cleanupError = new Error("cleanup failed"); - const value = {}; - let cache: ReturnType | undefined; - const operation = withPluginLifecycleLease({ env: state.env }, async () => { + it("returns the operation value while recording cleanup failure", async () => { + await withOpenClawTestState({ label: "plugin-lifecycle-outcome" }, async (state) => { + const cleanupError = new Error("cleanup failed"); + const value = {}; + let cache: ReturnType | undefined; + await expect( + withPluginLifecycleLease({ env: state.env }, async () => { cache = getPluginCache(); cacheCleanup(cache, "lease-cleanup", () => { - if (failCleanup) { - throw cleanupError; - } + throw cleanupError; }); - if (failRun) { - throw runError; - } return value; - }); - const [outcome] = await Promise.allSettled([operation]); - expect(cache).toBeDefined(); - const cleanup = await retirePluginCache(cache!); - expect(cleanup.failures.map((failure) => failure.error)).toEqual( - failCleanup ? [cleanupError] : [], - ); - if (failRun) { - expectRejected(outcome, runError); - } else { - expect(outcome.status).toBe("fulfilled"); - if (outcome.status === "fulfilled") { - expect(outcome.value).toBe(value); - } - } - await expect( - withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => value), - ).resolves.toBe(value); - }); - }, - ); + }), + ).resolves.toBe(value); + expect(cache).toBeDefined(); + const cleanup = await retirePluginCache(cache!); + expect(cleanup.failures.map((failure) => failure.error)).toEqual([cleanupError]); + await expect( + withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => value), + ).resolves.toBe(value); + }); + }); it("joins process cleanup outcomes while preserving the original operation failure", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-cleanup-owners" }, async (state) => { @@ -422,17 +379,14 @@ describe("plugin lifecycle lease", () => { }); }); - it.each([ - ["one state directory", false], - ["an explicit database path across different state directories", true], - ])("serializes lifecycle work sharing %s", async (_label, explicitPath) => { + it("serializes lifecycle work sharing an explicit database across state directories", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-lease" }, async (state) => { const firstEntered = createDeferred(); const releaseFirst = createDeferred(); const events: string[] = []; const leaseOptions = (caller: string) => ({ - env: explicitPath ? { ...state.env, OPENCLAW_STATE_DIR: state.path(caller) } : state.env, - ...(explicitPath ? { path: state.path("shared-plugin-lifecycle.sqlite") } : {}), + env: { ...state.env, OPENCLAW_STATE_DIR: state.path(caller) }, + path: state.path("shared-plugin-lifecycle.sqlite"), leaseMs: 1_000, waitMs: 3_000, }); @@ -463,11 +417,10 @@ describe("plugin lifecycle lease", () => { it("reclaims process-bound lifecycle work after its process is killed", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-killed-owner" }, async (state) => { await withLeaseChildren(async (children) => { - const leaseModuleUrl = pluginModuleUrl("plugin-lifecycle-lease.ts"); const script = await state.writeText( "killed-lease.mts", ` - import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)}; + import { withPluginLifecycleLease } from ${JSON.stringify(pluginModuleUrl("plugin-lifecycle-lease.ts"))}; const env = { ...process.env, OPENCLAW_STATE_DIR: process.argv[2] }; await withPluginLifecycleLease({ env, processBound: true }, async () => { process.stdout.write("ready\\n"); @@ -475,10 +428,10 @@ describe("plugin lifecycle lease", () => { }); `, ); - const holder = runLeaseChild(children, script, [state.stateDir]); + const holder = await runLeaseChild(children, script, [state.stateDir]); await holder.ready; - await terminateLeaseChild(holder.child); - // SIGKILL is the expected result, already joined above. + await stopChildProcess(holder.child, 5_000, { force: true }); + await holder.completed.catch(() => {}); children.delete(holder); await expect( withPluginLifecycleLease({ env: state.env, waitMs: 0 }, async () => "recovered"), @@ -487,85 +440,6 @@ describe("plugin lifecycle lease", () => { }); }); - it("serializes lifecycle work across processes", async () => { - await withOpenClawTestState({ label: "plugin-lifecycle-processes" }, async (state) => { - await withLeaseChildren(async (children) => { - const secondMarker = state.path("second-entered"); - const secondResult = state.path("second-result"); - const leaseModuleUrl = pluginModuleUrl("plugin-lifecycle-lease.ts"); - const childScript = await state.writeText( - "lease-child.mts", - ` - import fs from "node:fs/promises"; - import { createInterface } from "node:readline"; - import { withPluginLifecycleLease } from ${JSON.stringify(leaseModuleUrl)}; - const [role, stateDir, secondMarker, secondResult] = process.argv.slice(2); - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; - const input = createInterface({ input: process.stdin }); - const commands = input[Symbol.asyncIterator](); - try { - // Complete cold database/worker admission before either contested lease starts. - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => {}); - process.stdout.write("ready\\n"); - if ((await commands.next()).value !== "go") { - throw new Error("expected go command"); - } - if (role === "second") { - try { - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => { - await fs.writeFile(secondMarker, "entered"); - }); - await fs.writeFile(secondResult, "acquired"); - } catch (error) { - await fs.writeFile(secondResult, error?.code ?? String(error)); - } - } else { - await withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 5_000 }, async () => { - process.stdout.write("acquired\\n"); - if ((await commands.next()).value !== "release") { - throw new Error("expected release command"); - } - }); - } - } finally { - input.close(); - } - `, - ); - - const childArgs = [state.stateDir, secondMarker, secondResult]; - const first = runLeaseChild(children, childScript, ["first", ...childArgs]); - await first.ready; - const second = runLeaseChild(children, childScript, ["second", ...childArgs]); - await second.ready; - first.child.stdin.write("go\n"); - await first.waitForPhase("acquired"); - second.child.stdin.end("go\n"); - // Wait for the child to close so its result write is fully flushed before - // reading; file existence alone can race with the write after open(). - await second.completed; - - let assertionError: unknown; - try { - await expect(fs.readFile(secondResult, "utf8")).resolves.toBe( - "OPENCLAW_STATE_LEASE_HELD", - ); - await expect(fs.access(secondMarker)).rejects.toMatchObject({ code: "ENOENT" }); - } catch (error) { - assertionError = error; - } finally { - first.child.stdin.end("release\n"); - } - await Promise.all([first.completed, second.completed]); - if (assertionError) { - throw assertionError instanceof Error - ? assertionError - : new Error("cross-process lease assertion failed", { cause: assertionError }); - } - }); - }); - }); - it("reloads install records after waiting for another process", async () => { await withOpenClawTestState({ label: "plugin-lifecycle-record-cache" }, async (state) => { await withLeaseChildren(async (children) => { @@ -635,7 +509,9 @@ if (process.versions.bun) { await waitForMarker(goMarker); if (pluginId === "beta") { await assert.rejects( - withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => {}), + withPluginLifecycleLease({ env, leaseMs: 1_000, waitMs: 0 }, async () => { + assert.fail("blocked lifecycle callback must not run"); + }), { code: "OPENCLAW_STATE_LEASE_HELD" }, ); process.stdout.write("held\\n"); @@ -668,13 +544,13 @@ if (process.versions.bun) { `, ); - const alpha = runLeaseChild( + const alpha = await runLeaseChild( children, childScript, ["alpha", state.stateDir, alphaGoMarker, releaseAlphaMarker], childEnv, ); - const beta = runLeaseChild( + const beta = await runLeaseChild( children, childScript, ["beta", state.stateDir, betaGoMarker, releaseAlphaMarker], @@ -729,26 +605,6 @@ if (process.versions.bun) { }); }); - it("reuses the active lease for nested lifecycle work", async () => { - await withOpenClawTestState({ label: "plugin-lifecycle-reentrant" }, async (state) => { - const events: string[] = []; - await withPluginLifecycleLease( - { env: state.env, leaseMs: 1_000, waitMs: 0 }, - async (outerLease) => { - events.push("outer"); - await withPluginLifecycleLease({}, async (innerLease) => { - events.push("inner"); - expect(innerLease).toBe(outerLease); - expect(innerLease.databasePath).toBe( - path.resolve(state.stateDir, "state", "openclaw.sqlite"), - ); - }); - }, - ); - expect(events).toEqual(["outer", "inner"]); - }); - }); - it.each([ { authority: "outer", explicitNestedEnv: false }, { authority: "nested", explicitNestedEnv: false }, diff --git a/src/state/github-publication.worker.test.ts b/src/state/github-publication.worker.test.ts index a3ad556298a0..5e96604ca65a 100644 --- a/src/state/github-publication.worker.test.ts +++ b/src/state/github-publication.worker.test.ts @@ -33,15 +33,11 @@ import { prepareGitHubPublicationSource, } from "../gateway/github-publication-source.js"; import { - claimRepositoryGitHubPublicationAsync, readGitHubPublicationRequestAsync, runGitHubPublicationMaintenanceAsync, } from "../gateway/github-publication-store-async.js"; import { listRepositoryGitHubPublicationsInDatabase } from "../gateway/github-repository-publication-read.worker.js"; -import { - claimRepositoryGitHubPublicationInDatabase, - insertRepositoryGitHubPublicationInDatabase, -} from "../gateway/github-repository-publication-store.worker.js"; +import { insertRepositoryGitHubPublicationInDatabase } from "../gateway/github-repository-publication-store.worker.js"; import { repositoryGitHubPublicationDigest } from "../gateway/github-repository-publication.kernel.js"; import * as workerAdmission from "../infra/sqlite-worker-operation-admission.js"; import { sqliteWorkerOwnerProbe } from "../infra/sqlite-worker-owner-probe.test-support.js"; @@ -54,10 +50,7 @@ import { waitForPublicationGate, } from "./github-publication-fixtures.test-support.js"; import type { RepositoryGitHubPublicationRow as RepositoryPublicationRow } from "./github-publication-read.types.js"; -import { - captureGitHubPublicationLifecycle, - githubPublicationReceipts, -} from "./github-publication-receipts.js"; +import { githubPublicationReceipts } from "./github-publication-receipts.js"; import { prepareGitHubPublicationSessionLifecycle } from "./github-publication-session-lifecycles.js"; import { insertGitHubPublicationSessionLifecycle, @@ -85,10 +78,7 @@ import { openOpenClawStateDatabase, runOpenClawStateWriteTransaction, } from "./openclaw-state-db.js"; -import { - captureOpenClawStateReadContext, - captureOpenClawStateWorkerContext, -} from "./openclaw-state-worker-context.js"; +import { captureOpenClawStateWorkerContext } from "./openclaw-state-worker-context.js"; import type { OpenClawStateWorkerContext } from "./openclaw-state-worker-context.types.js"; import { createSessionRepositoryWorkspaceInDatabase } from "./session-repository-workspaces.kernel.js"; import { @@ -231,9 +221,7 @@ async function sourceFixture( return { row, source }; } -it("creates the lazy shared schema and publishes lifecycle authority before its first insert reply", async ({ - signal, -}) => { +it("creates the lazy shared schema and replaces a missing lifecycle binding on insert", async () => { const request = { sessionKey: "agent:main:first-shared-publication", agentId: "main", @@ -284,30 +272,7 @@ it("creates the lazy shared schema and publishes lifecycle authority before its requestId: "first-shared-publication-request", }); expect(lifecycle.readCurrent()).toBeUndefined(); - const committed = createDeferredCore(); - let atSettlement: ReturnType; - onTestFinished( - githubPublicationReceipts.subscribeFacts((change) => { - if ( - change.kind === "committed" && - change.receipt.source.identity === context.admission.identity.key - ) { - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - committed.resolve(); - } - if (change.kind === "settled" && change.identity === context.admission.identity.key) { - atSettlement = captureGitHubPublicationLifecycle(captureOpenClawStateReadContext(), { - publicationKind: "shared", - requestId: "first-shared-publication-request", - }).readCurrent(); - } - }), - ); - const reply = holdNextReply("githubPublications.insert", () => { - // An uncommitted insert cannot authorize publication from a missing binding. - expect(lifecycle.readCurrent()).toBeUndefined(); - }); - const inserting = insertGitHubPublicationRequestAsync( + const inserted = await insertGitHubPublicationRequestAsync( { request, requestId: "first-shared-publication-request", @@ -325,13 +290,6 @@ it("creates the lazy shared schema and publishes lifecycle authority before its }, source, ); - try { - await waitForPublicationGate(signal, inserting, reply.ready, committed.promise); - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - } finally { - reply.release(); - } - const inserted = await inserting; expect(inserted).toMatchObject({ request_id: "first-shared-publication-request", status: "requested", @@ -344,7 +302,6 @@ it("creates the lazy shared schema and publishes lifecycle authority before its requester_authority_json: '{"version":1,"actor":{"kind":"system"},"scopes":["operator.admin"],"grant":null}', }; - expect(atSettlement).toEqual(expected); expect( ( await prepareGitHubPublicationSessionLifecycle({ @@ -542,30 +499,21 @@ it("publishes a committed receipt before a delayed ordinary worker reply", async const row = seed("delayed-reply"); const reply = holdNextReply(); const { facts } = observeAuthority(); - const authorityAtNotification: unknown[] = []; const key = JSON.stringify(["repository", row.request_id]); const committed = createDeferredCore(); - const published = vi.fn((_receipt: PublicationMutationReceipt) => { - authorityAtNotification.push(facts.get(key)); + const published = () => { + expect(facts.get(key)).toMatchObject({ + kind: "postimage", + value: { request_id: row.request_id, status: "publishing", execution_id: "execution" }, + }); committed.resolve(); - }); + }; const claim = mutatePublication( scope, { operation: "claim", row, instanceId: "gateway", executionId: "execution" }, published, ); await waitForPublicationGate(signal, claim, reply.ready, committed.promise); - expect(published).toHaveBeenCalledTimes(1); - expect(authorityAtNotification).toMatchObject([ - { - kind: "postimage", - value: { request_id: row.request_id, status: "publishing", execution_id: "execution" }, - }, - ]); - expect(published.mock.calls[0]?.[0]).toMatchObject({ - kind: "repository", - rows: [{ request_id: row.request_id, status: "publishing", execution_id: "execution" }], - }); expectRows(row, [{ request_id: row.request_id, status: "publishing" }]); reply.release(); await expect(claim).resolves.toMatchObject({ rows: [{ status: "publishing" }] }); @@ -588,90 +536,53 @@ it("publishes a committed receipt before a delayed ordinary worker reply", async expectRows(row, [{ reported_at_ms: expect.any(Number) }]); }); -it.for(["superseded", "closed"] as const)( - "fences a late authority receipt after its owner is %s", - async (outcome, { signal }) => { - const lifecycleInput = { - publicationKind: "shared" as const, - requestId: `late-${outcome}-lifecycle`, - }; - runOpenClawStateWriteTransaction(({ db }) => - insertGitHubPublicationSessionLifecycle(db, { - ...lifecycleInput, - lifecycleRevision: "original-revision", - }), - ); - const lifecycle = await prepareGitHubPublicationSessionLifecycle(lifecycleInput); - let closed = false; - const scope = createScope({ - ...context, - assertPublicationCurrent: () => { - if (closed) { - throw new Error("Synthetic publication owner closed"); - } - (context.assertPublicationCurrent ?? context.admission.assertCurrent)(); - }, - }); - const row = seed(`late-${outcome}`); - const { facts, unknown } = observeAuthority(); - const key = JSON.stringify(["repository", row.request_id]); - const receipt = holdNextReceipt(); - const reply = holdNextReply(); - const published = vi.fn(); - const claim = mutatePublication( - scope, - { operation: "claim", row, instanceId: "gateway", executionId: "original" }, - published, - ); - await waitForPublicationGate(signal, claim, receipt.ready, reply.ready); - if (outcome === "superseded") { - runOpenClawStateWriteTransaction((database) => - claimRepositoryGitHubPublicationInDatabase( - database, - { - ...row, - status: "publishing", - execution_id: "original", - gateway_instance_id: "gateway", - }, - "gateway", - "replacement", - { - assertCurrent: context.admission.assertCurrent, - assertCustody: context.admission.assertCurrent, - }, - ), - ); - expect(facts.get(key)).toMatchObject({ - kind: "postimage", - value: { execution_id: "replacement" }, - }); - receipt.release(); - expect(facts.get(key)).toEqual({ kind: "unknown" }); - expect(published).toHaveBeenCalledTimes(1); - } else { - closed = true; - expect(receipt.release).toThrow("publication owner closed"); - expect(facts.has(key)).toBe(false); - expect(unknown).toContain(context.admission.identity.key); - expect(published).not.toHaveBeenCalled(); - } - reply.release(); - await expect(claim).resolves.toMatchObject({ rows: [{ execution_id: "original" }] }); - expectRows(row, [{ execution_id: outcome === "superseded" ? "replacement" : "original" }]); - if (outcome === "closed") { - expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); - await expect(prepareGitHubPublicationSessionLifecycle(lifecycleInput)).rejects.toThrow( - "lifecycle authority is unavailable", - ); - } else { - expect(lifecycle.readCurrent()).toEqual({ - lifecycle_revision: "original-revision", - requester_authority_json: null, - }); - } - }, -); +it("fences a late authority receipt after its owner closes", async ({ signal }) => { + const lifecycleInput = { + publicationKind: "shared" as const, + requestId: "late-closed-lifecycle", + }; + runOpenClawStateWriteTransaction(({ db }) => + insertGitHubPublicationSessionLifecycle(db, { + ...lifecycleInput, + lifecycleRevision: "original-revision", + }), + ); + const lifecycle = await prepareGitHubPublicationSessionLifecycle(lifecycleInput); + let closed = false; + const scope = createScope({ + ...context, + assertPublicationCurrent: () => { + if (closed) { + throw new Error("Synthetic publication owner closed"); + } + (context.assertPublicationCurrent ?? context.admission.assertCurrent)(); + }, + }); + const row = seed("late-closed"); + const { facts, unknown } = observeAuthority(); + const key = JSON.stringify(["repository", row.request_id]); + const receipt = holdNextReceipt(); + const reply = holdNextReply(); + const published = vi.fn(); + const claim = mutatePublication( + scope, + { operation: "claim", row, instanceId: "gateway", executionId: "original" }, + published, + ); + await waitForPublicationGate(signal, claim, receipt.ready, reply.ready); + closed = true; + expect(receipt.release).toThrow("publication owner closed"); + expect(facts.has(key)).toBe(false); + expect(unknown).toContain(context.admission.identity.key); + expect(published).not.toHaveBeenCalled(); + reply.release(); + await expect(claim).resolves.toMatchObject({ rows: [{ execution_id: "original" }] }); + expectRows(row, [{ execution_id: "original" }]); + expect(lifecycle.readCurrent).toThrow("lifecycle authority is unavailable"); + await expect(prepareGitHubPublicationSessionLifecycle(lifecycleInput)).rejects.toThrow( + "lifecycle authority is unavailable", + ); +}); it("revokes prepared sources when canonical deletion commits before its ordinary reply", async ({ signal, @@ -724,32 +635,6 @@ it("revokes prepared sources when canonical deletion commits before its ordinary expect(() => bindGitHubPublicationSource(source)).toThrow("source authority changed"); }); -it("settles execution bookkeeping without source authority while distinguishing observed effects", async () => { - const row = seed("observed-effect"); - const execution = await claimRepositoryGitHubPublicationAsync(row, "gateway", { - assertCustody: context.admission.assertCurrent, - assertAction() { - throw new Error("No action authority"); - }, - async prepareSource() { - throw new Error("No source authority"); - }, - }); - await execution.updateHead("d".repeat(40)); - await execution.recordEffect("push"); - expectRows(row, [{ effect_state: "dispatched", pushed_head_commit: null }]); - await execution.recordEffect("push", { headCommit: "d".repeat(40) }); - await execution.interrupt(); - expectRows(row, [ - { - status: "requested", - last_effect: "push", - effect_state: "observed", - pushed_head_commit: "d".repeat(40), - }, - ]); -}); - it("refuses delayed observation from an execution replaced by a later claim", async () => { const scope = createScope(); const row = seed("replaced-execution"); diff --git a/src/state/openclaw-agent-db.postgres.test.ts b/src/state/openclaw-agent-db.postgres.test.ts index 0a8ba5da70eb..faf47067b631 100644 --- a/src/state/openclaw-agent-db.postgres.test.ts +++ b/src/state/openclaw-agent-db.postgres.test.ts @@ -8,8 +8,6 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; import pg from "pg"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; -import * as doctorEntries from "../commands/doctor/shared/session-entry-rewrite.js"; -import { migrateLegacySessionEntryState } from "../commands/doctor/shared/session-entry-shape.js"; import { reconcileSessionTranscriptIndexInTransaction } from "../config/sessions/session-transcript-index.js"; import type { TranscriptTurnBoundary } from "../config/sessions/transcript-entry-anchor.js"; import { createTranscriptEventInserter } from "../config/sessions/transcript-payload.js"; @@ -26,11 +24,9 @@ import { iterateSqliteQuerySync } from "../infra/kysely-sync.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; import { isPostgresSqlConnection, type EngineConnection } from "../infra/sql-connection.js"; import { retireSqliteDatabaseAdmissionForPath as retireAdmission } from "../infra/sqlite-database-admission.js"; -import { readDatabasePathIdentitySync } from "../infra/sqlite-worker-identity.js"; import { migrateHistoricalTranscriptDirectives } from "../infra/state-migrations.transcript-directives.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION as schemaVersion } from "./openclaw-agent-db-contract.js"; import { agentEngineSchema } from "./openclaw-agent-db-engine.js"; -import { readOpenClawAgentDatabaseIdentity as readIdentity } from "./openclaw-agent-db-identity.js"; import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js"; import { createDatabaseOpener, @@ -86,21 +82,6 @@ function exportFts(database: { db: EngineConnection }, sessionId: string) { ); } -async function inspectSchema(pathname: string, agentId?: string) { - await using inspector = owners.createAgentSchemaInspectionWorker(); - const result = await inspector.inspect({ - pathname, - ...(agentId ? { agentId } : {}), - env: process.env, - supportedVersion: schemaVersion, - verifyCurrentSchemaShape: true, - inspectOwnership: true, - requireStartupMigrationReadiness: true, - }); - expect(inspector).toMatchObject({ snapshotCount: 0, processCount: 1 }); - return result; -} - async function queryRows( sql: string, values?: unknown[], @@ -165,27 +146,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }); beforeEach(() => vi.stubEnv("OPENCLAW_STATE_DIR", root)); - it("reads persisted model catalogs through the worker without local application tables", async () => { - const { loadPersistedPluginModelCatalogs } = - await import("../agents/plugin-model-catalog-execution.js"); - const agentId = "model-catalog"; - const contents = JSON.stringify({ providers: { fixture: { models: [{ id: "synthetic" }] } } }); - const database = openDatabase({ agentId }); - database.write( - "INSERT INTO cache_entries (scope, key, value_json, updated_at) VALUES (?, ?, ?, ?)", - "plugin-model-catalog-v1", - "fixture", - contents, - 1, - ); - const databasePath = database.path; - closeDatabase(databasePath); - expect(await loadPersistedPluginModelCatalogs(path.dirname(databasePath), ["fixture"])).toEqual( - [{ pluginId: "fixture", contents }], - ); - expectAnchorOnly(databasePath); - }); - it("inspects clean PostgreSQL transcripts and preserves history requiring a file repair backup", async () => { const target = await createSession("doctor-transcript", "doctor-transcripts", "main"); await owners.appendTranscriptMessage(target, { @@ -228,69 +188,7 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { expectAnchorOnly(database.path); }); - it("scans and repairs legacy Doctor state while retaining opaque PostgreSQL JSON numbers", () => { - admit(() => { - using database = openDatabase({ agentId: "doctor" }); - const sessionKey = "agent:doctor:main"; - const raw = - '{"sessionId":"doctor-session","updatedAt":42,"status":"queued","pendingFinalDelivery":true,"pendingFinalDeliveryText":"saved reply","opaqueCount":9007199254740993,"opaque":{"value":123456789012345678901234567890,"label":"retained"}}'; - database.run( - "INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, ?, ?)", - sessionKey, - "doctor-session", - raw, - 42, - ); - database.run( - "INSERT INTO session_windows (session_id, session_key, session_scope, created_at, updated_at) VALUES (?, ?, 'conversation', ?, ?)", - "doctor-session", - sessionKey, - 42, - 42, - ); - const scope = { agentId: "doctor", storePath: database.path }; - const expectedIdentity = readDatabasePathIdentitySync(database.path); - const scanned: Array<{ sessionKey: string; entry: Record }> = []; - doctorEntries.scanDoctorSessionEntryRecords( - scope, - (record) => scanned.push(record), - expectedIdentity, - ); - expect(scanned).toEqual([ - { - sessionKey, - entry: expect.objectContaining({ status: "queued", pendingFinalDelivery: true }), - }, - ]); - expect( - doctorEntries.rewriteDoctorSessionEntries({ - scope, - sessionKeys: [sessionKey], - expectedIdentity, - rawTransform: (entry, key, updatedAt) => - migrateLegacySessionEntryState(entry, updatedAt, key), - }), - ).toBe(1); - const repairedRaw = database.get( - "SELECT entry_json FROM session_nodes WHERE session_key = ?", - sessionKey, - )?.entry_json; - assert(typeof repairedRaw === "string"); - expect(JSON.parse(repairedRaw)).toMatchObject({ - sessionId: "doctor-session", - updatedAt: 42, - status: "interrupted", - pendingFinalDelivery: { kind: "replayable", text: "saved reply", createdAt: 42 }, - opaque: { label: "retained" }, - }); - expect(repairedRaw).not.toContain("pendingFinalDeliveryText"); - expect(repairedRaw).toContain("9007199254740993"); - expect(repairedRaw).toContain("123456789012345678901234567890"); - expectAnchorOnly(database.path); - }); - }); - - it("opens default and explicit stores, retains identity, and closes both handles", () => { + it("closes default and explicit stores with paired handles and a PostgreSQL receipt", () => { return admit(() => { for (const options of [ { agentId: "main" }, @@ -298,19 +196,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { ]) { const database = openDatabase(options); assert(isPostgresSqlConnection(database.db)); - const facts = postgresFacts(database.db); - expect(facts).toMatchObject({ store: "agent", version: schemaVersion }); - expect( - database.get( - "SELECT agent_id, role, schema_version FROM schema_meta WHERE meta_key = 'primary'", - ), - ).toEqual({ agent_id: options.agentId, role: "agent", schema_version: schemaVersion }); - const identity = readIdentity(database); - const retained = owners.retainOpenClawAgentDatabaseReadOnly(options); - assert(retained.found); - expect(retained.claim.identity).toBe(identity.identity); - expect(retained.claim.isCurrent()).toBe(true); - retained.claim.release(); const anchor = database.db.anchor; closeReceipts.mockClear(); expect(owners.closeOpenClawAgentDatabaseByPath(database.path)).toBe(true); @@ -319,12 +204,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { `agent database clean-close receipt: postgres-closed; agent=${options.agentId}`, expect.objectContaining({ agentId: options.agentId, receipt: "postgres-closed" }), ); - const reopened = owners.openOpenClawAgentDatabase(options); - assert(isPostgresSqlConnection(reopened.db)); - expect(postgresFacts(reopened.db)?.namespace).toBe(facts?.namespace); - expect(readIdentity(reopened).identity).toBe(identity.identity); - expect(readIdentity(reopened).incarnation).not.toBe(identity.incarnation); - closeDatabase(reopened.path); } }); }); @@ -341,10 +220,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }, }; const assertMemorySearch = (db: EngineConnection) => { - const extensions = db.prepare("SELECT search_extensions FROM openclaw_schema_meta").get(); - expect(JSON.parse(String(extensions?.search_extensions))).toEqual({ - "memory.text": memorySearch, - }); expect( db .prepare(`SELECT id, text FROM memory_index_chunks @@ -485,27 +360,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { server_major: facts.capabilities.serverMajor, }); }); - const indexes = await queryRows( - `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = $1 - AND indexname IN ('idx_agent_session_windows_conversation', 'idx_agent_transcript_event_parent', - 'idx_agent_session_nodes_last_interaction_at', 'idx_agent_session_reactions_message')`, - [facts.namespace], - ); - expect(indexes).toEqual([ - { - indexname: "idx_agent_session_windows_conversation", - indexdef: `CREATE INDEX idx_agent_session_windows_conversation ON ${facts.namespace}.session_windows USING btree (primary_conversation_id NULLS FIRST) WHERE (primary_conversation_id IS NOT NULL)`, - }, - ]); - expect( - await queryRows( - `SELECT p.proname FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - WHERE n.nspname = $1 AND p.proname IN - ('openclaw_memory_index_sources_trigger', 'openclaw_memory_index_chunks_trigger')`, - [facts.namespace], - ), - ).toEqual([]); expect( await queryRows(`SELECT revision FROM "${facts.namespace}".memory_index_state`), ).toEqual([{ revision: "1" }]); @@ -543,29 +397,6 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { } }); - it("discovers a cold explicit store owner through the selected reader worker", async () => { - const pathname = path.join(root, "explicit", "openclaw-agent.sqlite"); - admit(() => { - owners.openOpenClawAgentDatabase({ agentId: "alternate", path: pathname }); - owners.closeOpenClawAgentDatabaseByPath(pathname); - }); - await owners.closeOpenClawAgentDatabaseByPathAsync(pathname); - const { readActiveOpenClawAgentDatabaseLeasesReadOnly } = - await import("./openclaw-agent-db-lease.js"); - const activeLeases = () => - admit(() => - readActiveOpenClawAgentDatabaseLeasesReadOnly({ env: process.env }).filter( - (lease) => lease.path === pathname, - ), - ); - expect(activeLeases()).toEqual([]); - retireAdmission(pathname); - expect(await inspectSchema(pathname)).toMatchObject({ - agentSchemaMeta: { agentId: "alternate", role: "agent", schemaVersion }, - }); - expect(activeLeases()).toEqual([]); - }); - it("appends and hydrates plain transcript JSON and drains interleaved turns through the agent worker in FIFO order", async () => { const { appendTranscriptMessage, @@ -601,29 +432,11 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { }); parentId = reply.messageId; } - const hydrated = await prepareSessionTranscriptHydration(target).read(); - expect( - hydrated.snapshot.events.filter((event) => isRecord(event) && event.type === "message"), - ).toHaveLength(7); - expect(await owners.searchSessionTranscripts({ ...target, query: "progress" })).toMatchObject({ - hits: [ - { - sessionId: target.sessionId, - sessionKey: target.sessionKey, - role: "assistant", - snippet: expect.stringContaining("progress"), - }, - ], - indexing: false, - }); const database = openDatabase({ agentId: "actor" }); assert(isPostgresSqlConnection(database.db)); const facts = postgresFacts(database.db); assert(facts); - expect(await inspectSchema(database.path, "actor")).toMatchObject({ - version: schemaVersion, - agentSchemaMeta: { agentId: "actor", role: "agent", schemaVersion }, - }); + const hydrated = await prepareSessionTranscriptHydration(target).read(); const payloads = await queryRows<{ event_json: string; event_zstd: Buffer | null }>( `SELECT event_json, event_zstd FROM "${facts.namespace}".transcript_events ORDER BY seq`, ); @@ -685,223 +498,92 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { ).toEqual([{ count: "0" }]); }); - it("searches active branches through the worker with session and role filters and lifecycle deletion", async () => { - const target = targetFor("search-branch"); - const sibling = { - ...target, - sessionId: "search-sibling", - sessionKey: "agent:actor:search-sibling", + it("preserves FTS row identity for nullable and repeated IDs across bounded inserts", async () => { + const count = 515; + const sessionId = `fts-identities-${count}`; + const options = { agentId: "actor" }; + const database = openDatabase(options); + const postgres = database.db; + assert(isPostgresSqlConnection(postgres)); + const facts = postgresFacts(postgres); + assert(facts); + // Cold restoration preserves these nullable/repeated stored IDs; normal + // transcript append filters absent IDs and deduplicates event identities. + const entries = Array.from({ length: count }, (_, index) => ({ + messageId: index % 4 < 2 ? null : "repeated", + text: `ftskeytoken${index}`, + role: index % 2 ? "assistant" : "user", + timestamp: index, + })); + let statements = 0; + const observation = channel("openclaw.database.statement"); + const observe = (value: unknown) => { + if (isRecord(value) && value.engine === "postgres") { + statements += 1; + } }; - for (const scope of [target, sibling]) { - await owners.upsertSessionEntryCore(scope, { sessionId: scope.sessionId, updatedAt: 1 }); - } - await owners.appendTranscriptMessage(target, { - eventId: "search-user", - parentId: null, - message: { role: "user", content: "searchable alpha origin" }, - }); - await owners.appendTranscriptMessage(target, { - eventId: "search-assistant", - parentId: "search-user", - message: { role: "assistant", content: "searchable beta abandoned" }, - }); - await owners.appendTranscriptMessage(sibling, { - eventId: "search-sibling-user", - message: { role: "user", content: "searchable survivor" }, - }); - const search = { - agentId: target.agentId, - storePath: target.storePath, - env: process.env, - query: "searchable", - sessionKeys: [target.sessionKey, sibling.sessionKey], - }; - const find = (overrides: Partial[0]> = {}) => - owners.searchSessionTranscripts({ ...search, ...overrides }); - expect((await find({ role: "assistant" })).hits).toEqual([ - expect.objectContaining({ messageId: "search-assistant" }), - ]); - expect((await find({ sessionId: sibling.sessionId })).hits).toEqual([ - expect.objectContaining({ messageId: "search-sibling-user" }), - ]); - expect( - (await find({ sessionKeys: [target.sessionKey] })).hits - .map((hit) => hit.messageId) - .toSorted(), - ).toEqual(["search-assistant", "search-user"]); - await owners.appendTranscriptEvent(target, { - type: "leaf", - id: "search-leaf", - parentId: "search-assistant", - targetId: "search-user", - }); - expect((await find({ query: "beta" })).hits).toEqual([]); - const options = { agentId: target.agentId, env: process.env }; - await owners.waitForSessionTranscriptIndexReconcile(options); - if ((await find()).indexing) { - await owners.reconcileSessionTranscriptIndexes(options); - } - expect(await find({ query: "alpha" })).toMatchObject({ - hits: [expect.objectContaining({ messageId: "search-user" })], - indexing: false, - }); - expect((await find({ query: "beta" })).hits).toEqual([]); - const removal = { - ...target, - target: { canonicalKey: target.sessionKey, storeKeys: [target.sessionKey] }, - archiveTranscript: false, - deleteTranscriptWithoutArchive: true, - }; - await owners.upsertSessionEntryCore(sibling, { previousSessionId: target.sessionId }); - expect(await owners.deleteSessionEntryLifecycle(removal)).toMatchObject({ deleted: true }); - expect((await find({ query: "alpha" })).hits).toEqual([ - expect.objectContaining({ sessionId: target.sessionId, messageId: "search-user" }), - ]); - await owners.upsertSessionEntryCore(target, { sessionId: target.sessionId, updatedAt: 2 }); - await owners.upsertSessionEntryCore(sibling, { previousSessionId: undefined }); - expect( - await owners.deleteSessionEntryLifecycle({ ...removal, archiveTranscript: true }), - ).toMatchObject({ deleted: true, archivedTranscripts: [expect.anything()] }); - expect((await find()).hits).toEqual([ - expect.objectContaining({ sessionId: sibling.sessionId, messageId: "search-sibling-user" }), - ]); - }); - - it("indexes only accepted searchable messages from a transcript append batch", async () => { - const target = await createSession("fts-append-batch"); - function event(id: string, parentId: string | null, role: string, content: string) { - return { type: "message", id, parentId, timestamp: 1, message: { role, content } }; - } admit(() => - owners.runOpenClawAgentWriteTransaction( - (database) => { - expect( - owners.appendTranscriptEventsInTransaction(database, target, [ - event("batch-user", null, "user", "batchneedle accepted origin"), - event("batch-user", null, "user", "batchneedle rejected duplicate"), - event("batch-tool", "batch-user", "toolResult", "batchneedle hidden tool"), - event("batch-reply", "batch-tool", "assistant", "batchneedle accepted reply"), - ]), - ).toBe(3); - }, - { agentId: target.agentId }, - ), - ); - const search = (query: string) => owners.searchSessionTranscripts({ ...target, query }); - expect((await search("batchneedle")).hits.map((hit) => hit.messageId).toSorted()).toEqual([ - "batch-reply", - "batch-user", - ]); - expect((await search("rejected")).hits).toEqual([]); - expect((await search("hidden")).hits).toEqual([]); - }); - - it.each([4, 515])( - "preserves FTS row identity for %i nullable and repeated message IDs in bounded statements", - async (count) => { - const sessionId = `fts-identities-${count}`; - const options = { agentId: "actor" }; - const database = openDatabase(options); - const postgres = database.db; - assert(isPostgresSqlConnection(postgres)); - const facts = postgresFacts(postgres); - assert(facts); - // Cold restoration preserves these nullable/repeated stored IDs; normal - // transcript append filters absent IDs and deduplicates event identities. - const entries = Array.from({ length: count }, (_, index) => ({ - messageId: index % 4 < 2 ? null : "repeated", - text: `ftskeytoken${index}`, - role: index % 2 ? "assistant" : "user", - timestamp: index, - })); - let statements = 0; - const observation = channel("openclaw.database.statement"); - const observe = (value: unknown) => { - if (isRecord(value) && value.engine === "postgres") { - statements += 1; + owners.runOpenClawAgentWriteTransaction(({ db }) => { + const insert = owners.createSessionTranscriptFtsInserter(db, sessionId); + observation.subscribe(observe); + try { + insert(entries); + } finally { + observation.unsubscribe(observe); } - }; - admit(() => - owners.runOpenClawAgentWriteTransaction(({ db }) => { - const insert = owners.createSessionTranscriptFtsInserter(db, sessionId); - observation.subscribe(observe); - try { - insert(entries); - } finally { - observation.unsubscribe(observe); - } - }, options), - ); - const persisted = await queryRows( - `SELECT r.id, r.id = f.rowid AS same_id, r.message_id AS owner_message_id, + }, options), + ); + const persisted = await queryRows( + `SELECT r.id, r.id = f.rowid AS same_id, r.message_id AS owner_message_id, f.message_id, f.text, f.role, f.timestamp FROM "${facts.namespace}".session_transcript_fts_rows r LEFT JOIN "${facts.namespace}".session_transcript_fts f ON f.rowid = r.id WHERE r.session_id = $1 ORDER BY f.timestamp`, - [sessionId], + [sessionId], + ); + const expected = entries.map(({ messageId, ...entry }) => ({ + message_id: messageId, + ...entry, + })); + expect(persisted.map(({ id: _id, ...row }) => row)).toEqual( + expected.map((entry) => ({ same_id: true, owner_message_id: entry.message_id, ...entry })), + ); + const ids = persisted.map((row) => BigInt(row.id)); + for (let index = 1; index < ids.length; index++) { + expect(ids[index]).toBeGreaterThan(ids[index - 1]!); + } + expect(admit(() => exportFts(database, sessionId))).toEqual(expected); + // Each observed PostgreSQL statement makes one bridge/server request. + expect(statements).toBe(2); + admit(() => { + // Keep the first bridge open so the pooled writer uses another backend. + const next = owners.openAgentEngineStore( + postgres.anchor, + options.agentId, + database.path, + true, ); - const expected = entries.map(({ messageId, ...entry }) => ({ - message_id: messageId, - ...entry, - })); - expect(persisted.map(({ id: _id, ...row }) => row)).toEqual( - expected.map((entry) => ({ same_id: true, owner_message_id: entry.message_id, ...entry })), - ); - const ids = persisted.map((row) => BigInt(row.id)); - for (let index = 1; index < ids.length; index++) { - expect(ids[index]).toBeGreaterThan(ids[index - 1]!); - } - expect(admit(() => exportFts(database, sessionId))).toEqual(expected); - for (const index of [0, 1, 2, 3]) { - const searched = await queryRows( - `SELECT message_id, text FROM "${facts.namespace}".session_transcript_fts - WHERE session_id = $1 AND openclaw_search @@ - plainto_tsquery('"${facts.namespace}".openclaw_simple_unaccent', $2)`, - [sessionId, `ftskeytoken${index}`], + assert(isPostgresSqlConnection(next)); + try { + expect(next.prepare("SELECT pg_backend_pid() AS pid").get()?.pid).not.toBe( + postgres.prepare("SELECT pg_backend_pid() AS pid").get()?.pid, ); - expect(searched).toEqual([ - { message_id: index < 2 ? null : "repeated", text: `ftskeytoken${index}` }, - ]); + next.runTransaction(() => + owners.createSessionTranscriptFtsInserter(next, `${sessionId}-next`)(entries.slice(0, 2)), + ); + const later = next + .prepare("SELECT id FROM session_transcript_fts_rows WHERE session_id = ? ORDER BY id") + .all(`${sessionId}-next`); + expect(later).toHaveLength(2); + expect(BigInt(String(later[0]!.id))).toBeGreaterThan(ids.at(-1)!); + expect(BigInt(String(later[1]!.id))).toBeGreaterThan(BigInt(String(later[0]!.id))); + } finally { + next.close(); } - // Each observed PostgreSQL statement makes one bridge/server request. - expect(statements).toBe(count === 4 ? 1 : 2); - if (count > 512) { - admit(() => { - // Keep the first bridge open so the pooled writer uses another backend. - const next = owners.openAgentEngineStore( - postgres.anchor, - options.agentId, - database.path, - true, - ); - assert(isPostgresSqlConnection(next)); - try { - expect(next.prepare("SELECT pg_backend_pid() AS pid").get()?.pid).not.toBe( - postgres.prepare("SELECT pg_backend_pid() AS pid").get()?.pid, - ); - next.runTransaction(() => - owners.createSessionTranscriptFtsInserter( - next, - `${sessionId}-next`, - )(entries.slice(0, 2)), - ); - const later = next - .prepare( - "SELECT id FROM session_transcript_fts_rows WHERE session_id = ? ORDER BY id", - ) - .all(`${sessionId}-next`); - expect(later).toHaveLength(2); - expect(BigInt(String(later[0]!.id))).toBeGreaterThan(ids.at(-1)!); - expect(BigInt(String(later[1]!.id))).toBeGreaterThan(BigInt(String(later[0]!.id))); - } finally { - next.close(); - } - }); - } - }, - ); + }); + }); - it("matches SQLite recent-search cutoffs and cold-export order for equal-timestamp batches", () => { + it("preserves recent-search cutoffs and cold-export order for equal-timestamp batches", () => { const events = Array.from({ length: 130 }, (_, index) => ({ type: "message", id: `ordered-${index}`, @@ -909,99 +591,36 @@ describe.skipIf(!connection)("PostgreSQL agent database owner", () => { timestamp: 1, message: { role: "user", content: `chronologyneedle entry ${index}` }, })); - const results = (["sqlite", "postgres"] as const).map((engine) => - engineSelection.withDatabaseEngineSelection( - { - effectiveEngines: resolveDatabaseFamilyEngines({ database: { engine } }), - postgres: { connection: connection!, schemaPrefix: prefix }, - }, - () => - admit(() => { - const options = { - agentId: "actor", - ...(engine === "sqlite" - ? { path: path.join(root, "sqlite-fts-order", "agent.sqlite") } - : {}), - }; - const target = { - ...options, - sessionId: "fts-chronology", - sessionKey: "agent:actor:fts-chronology", - }; - using database = openDatabase(options); - owners.runOpenClawAgentWriteTransaction((writer) => { - expect( - owners.appendTranscriptEventsInTransaction(writer, target, events, { - scheduleProjectionReconcile: false, - }), - ).toBe(events.length); - }, options); - const exported = exportFts(database, target.sessionId); - expect(exported.map((row) => row.message_id)).toEqual(events.map((event) => event.id)); - return [1, 3, 25].map((limit) => { - const result = owners.searchSessionTranscriptsReadOnlySync( - { ...target, query: "chronologyneedle", order: "recent", limit }, - { ...options, path: database.path }, - ); - const ids = result.hits.map((hit) => hit.messageId); - expect(ids).toEqual( - events - .slice(-limit) - .toReversed() - .map((event) => event.id), - ); - return ids; - }); + admit(() => { + const options = { agentId: "actor" }; + const target = { + ...options, + sessionId: "fts-chronology", + sessionKey: "agent:actor:fts-chronology", + }; + using database = openDatabase(options); + owners.runOpenClawAgentWriteTransaction((writer) => { + expect( + owners.appendTranscriptEventsInTransaction(writer, target, events, { + scheduleProjectionReconcile: false, }), - ), - ); - expect(results[1]).toEqual(results[0]); - }); - - it("reads persisted auth through the selected owner and agent worker", async () => { - const store = { - version: 1, - profiles: { fixture: { type: "api_key", provider: "fixture", key: "synthetic" } }, - }; - for (const options of [ - { agentId: "main" }, - { agentId: "alternate", path: path.join(root, "explicit", "openclaw-agent.sqlite") }, - ]) { - const database = openDatabase(options); - admit(() => - owners.runOpenClawAgentWriteTransaction( - ({ db }) => owners.writeAuthProfileJsonCell(db, "store", "agent", store), - options, - ), + ).toBe(events.length); + }, options); + expect(exportFts(database, target.sessionId).map((row) => row.message_id)).toEqual( + events.map((event) => event.id), ); - expect( - admit(() => owners.inspectPersistedAuthProfileStoreRaw(path.dirname(database.path))), - ).toEqual({ status: "readable", raw: store }); - closeDatabase(database.path); - await owners.closeOpenClawAgentDatabaseByPathAsync(database.path); - const reader = owners.prepareAgentAuthProfileRowsRead({ - databasePath: database.path, - agentId: options.agentId, - env: process.env, - }); - try { - expect((await reader.read()).store).toEqual({ status: "readable", raw: store }); - } finally { - await reader.dispose(); + for (const limit of [1, 3, 25]) { + const result = owners.searchSessionTranscriptsReadOnlySync( + { ...target, query: "chronologyneedle", order: "recent", limit }, + { ...options, path: database.path }, + ); + expect(result.hits.map((hit) => hit.messageId)).toEqual( + events + .slice(-limit) + .toReversed() + .map((event) => event.id), + ); } - } - const sharedMainPath = path.join(root, "agents", "main", "agent", "openclaw-agent.sqlite"); - await owners.closeOpenClawAgentDatabaseByPathAsync(sharedMainPath); - retireAdmission(sharedMainPath); - expect( - await owners.prepareFreshSharedAuthStoreWriteAsync({ - agentDir: undefined, - allowExplicitMain: false, - env: process.env, - }), - ).toBe(true); - expect(owners.resolveSharedAuthStoreOwnership(process.env)).toEqual({ - location: "legacy-main", }); }); diff --git a/src/state/openclaw-agent-worker-store.test.ts b/src/state/openclaw-agent-worker-store.test.ts index a8d5a1fd1284..ae04cec7c1fb 100644 --- a/src/state/openclaw-agent-worker-store.test.ts +++ b/src/state/openclaw-agent-worker-store.test.ts @@ -1,7 +1,6 @@ import { channel } from "node:diagnostics_channel"; import fs from "node:fs"; import path from "node:path"; -import { DatabaseSync } from "node:sqlite"; import { setImmediate as nextTurn } from "node:timers/promises"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; @@ -10,15 +9,12 @@ import { openFixtureReceiptChannel, type FixtureReceiptChannel, } from "../../test/helpers/fixture-receipts.js"; -import { awaitGateBeforeSettlement, withinTest } from "../../test/helpers/promise.js"; +import { withinTest } from "../../test/helpers/promise.js"; import { useSqliteWorkerFault } from "../../test/helpers/sqlite-worker-fault.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; import { resolveRuntimeWorkerUrl } from "../infra/runtime-worker-url.js"; import { readSqliteDatabaseCleanClose } from "../infra/sqlite-database-admission.js"; -import { SQLITE_IDLE_HANDLE_TTL_MS } from "../infra/sqlite-handle-lifecycle.js"; import { throwSqliteLifecycleErrors } from "../infra/sqlite-lifecycle-errors.js"; -import { observeSqliteWalPeriodicWork } from "../infra/sqlite-wal-scheduler.test-support.js"; -import * as sqliteWal from "../infra/sqlite-wal.js"; import { readDatabasePathIdentitySync } from "../infra/sqlite-worker-identity.js"; import * as admission from "../infra/sqlite-worker-operation-admission.js"; import { sqliteWorkerOwnerProbe as probe } from "../infra/sqlite-worker-owner-probe.test-support.js"; @@ -31,21 +27,12 @@ import { } from "../process/gateway-work-admission.js"; import { createDeferredCore } from "../shared/deferred.js"; import { requireNativeSqliteDatabase } from "../test-utils/native-sqlite.js"; -import { - closeOpenClawAgentDatabasesAsync, - retainAgentDatabase, -} from "./openclaw-agent-db-lifecycle.js"; -import { - isAgentDatabaseClosing, - registerOpenClawAgentDatabaseAsyncResource, - revokeAgentDatabaseResources, -} from "./openclaw-agent-db-resources.js"; +import { closeOpenClawAgentDatabasesAsync } from "./openclaw-agent-db-lifecycle.js"; +import { revokeAgentDatabaseResources } from "./openclaw-agent-db-resources.js"; import { withOpenClawAgentDatabaseWrite } from "./openclaw-agent-db-write.js"; import { openOpenClawAgentDatabase as openDatabase, closeOpenClawAgentDatabasesForTest, - withOpenClawAgentDatabaseAdmission, - withOpenClawAgentDatabaseAsync, } from "./openclaw-agent-db.js"; import type { OpenClawAgentDatabaseExecution } from "./openclaw-agent-execution-contract.js"; import { captureOpenClawAgentDatabaseExecution as captureExecution } from "./openclaw-agent-execution.js"; @@ -159,81 +146,8 @@ it("leaves agent worker checkpoints to maintenance instead of foreground commits expect(await worker.execute({ type: "inspect", input: "wal_autocheckpoint" }, allow)).toBe(0); }); -it("renews idle retention on reborrow", async () => { - const { db, worker } = await setup(); - const shared = openOpenClawStateDatabase(); - const releaseState = retainOpenClawStateDatabaseForIdle(shared); - const readLeases = leaseReader(shared); - const hostLeases = readLeases(); - const publish = (value: string) => worker.run((scope) => scope.execute(append(value)), allow); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - await publish("first"); - const retainedLeases = readLeases(); - expect(retainedLeases).toHaveLength(hostLeases.length + 1); - - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS - 1); - const borrowed = captureExecution(options); - executions.add(borrowed); - await publish("reborrowed"); - expect(readLeases()).toEqual(retainedLeases); - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS); - expect(readLeases()).toEqual(retainedLeases); - await borrowed.release(); - executions.delete(borrowed); - - await vi.advanceTimersByTimeAsync(SQLITE_IDLE_HANDLE_TTL_MS - 1); - expect(readLeases()).toEqual(retainedLeases); - await vi.advanceTimersByTimeAsync(1); - // A new request joins the expired generation's cleanup before reopening it. - await publish("after idle"); - expect(readLeases()).toHaveLength(retainedLeases.length); - expect(readLeases()).not.toEqual(retainedLeases); - expectValues(db, ["first", "reborrowed", "after idle"]); - } finally { - vi.useRealTimers(); - releaseState(); - } -}); - -it("retains warm execution across fallback preparation", async () => { - const { db, worker } = await setup(); - const firstThread = await worker.execute(append("before preparation"), allow); - const lifetime = captureExecution(options); - executions.add(lifetime); - await worker.close(); - const entered = createDeferredCore(); - const resume = createDeferredCore(); - let next: OpenClawAgentSqliteWorkerStore | undefined; - const publication = (async () => { - entered.resolve(); - await resume.promise; - next = await openWorker(db); - return next.run((scope) => scope.execute(append("after preparation")), allow); - })(); - try { - await entered.promise; - markGatewayRestartDraining(); - resume.resolve(); - expect(await publication).toBe(firstThread); - expectValues(db, ["before preparation", "after preparation"]); - await next?.close(); - expect(db.isOpen).toBe(true); - beginGatewayShutdownCleanup(); - await lifetime.release(); - expect(db.isOpen).toBe(false); - expect(readSqliteDatabaseCleanClose(options.path)).toBe(true); - } finally { - resume.resolve(); - await Promise.allSettled([publication]); - await Promise.allSettled([next?.close(), lifetime.release(), worker.close()]); - executions.delete(lifetime); - resetGatewayWorkAdmission(); - } -}); - it.each([false, true])( - "releases shutdown leases unless retained by an accepted sequence (%s)", + "reuses retained publications across reopen and releases shutdown leases (%s)", async (retainGeneration) => { const { db, worker } = await setup(); const execution = retainGeneration ? captureExecution(options) : undefined; @@ -244,6 +158,7 @@ it.each([false, true])( const releaseState = retainOpenClawStateDatabaseForIdle(shared); const readLeases = leaseReader(shared); const hostLeases = readLeases(); + let following = worker; try { const firstThread = await worker.run(async (scope) => { const thread = await scope.execute(append("first")); @@ -255,10 +170,16 @@ it.each([false, true])( expect(secondThread).toBe(firstThread); expect(retainedLeases).toHaveLength(hostLeases.length + 1); expect(readLeases()).toEqual(retainedLeases); - await worker.run((scope) => scope.execute(append("third")), allow); + if (retainGeneration) { + await worker.close(); + } + following = retainGeneration ? await openWorker(db) : worker; + expect(await following.run((scope) => scope.execute(append("third")), allow)).toBe( + firstThread, + ); expect(readLeases()).toEqual(retainedLeases); beginGatewayShutdownCleanup(); - const cleanupThread = await worker.execute(append("cleanup"), allow); + const cleanupThread = await following.execute(append("cleanup"), allow); if (retainGeneration) { expect(cleanupThread).toBe(firstThread); expect(readLeases()).toEqual(retainedLeases); @@ -266,12 +187,13 @@ it.each([false, true])( expect(readLeases()).toEqual(hostLeases); } expectValues(db, ["first", "second", "third", "cleanup"]); - await worker.close(); + await following.close(); await execution?.release(); expect(db.isOpen).toBe(false); expect(readLeases()).toEqual([]); expect(readSqliteDatabaseCleanClose(options.path)).toBe(true); } finally { + await following.close(); await worker.close(); await execution?.release(); if (execution) { @@ -283,136 +205,6 @@ it.each([false, true])( }, ); -it.each([false, true])( - "certifies native close only after its WAL is empty (pinned: %s)", - async (pinned) => { - const { db, worker } = await setup(); - const releaseHost = retainAgentDatabase(db); - let reader: DatabaseSync | undefined; - try { - await worker.execute(append("worker commit"), () => {}); - beginGatewayShutdownCleanup(); - await worker.close(); - if (pinned) { - reader = new DatabaseSync(options.path, { readOnly: true }); - reader.exec("BEGIN"); - reader.prepare("SELECT value FROM worker_proof").all(); - } - db.exec("INSERT INTO worker_proof VALUES ('after worker checkpoint')"); - const prepare = vi.spyOn(db, "prepare"); - const exec = vi.spyOn(db, "exec"); - await closeOpenClawAgentDatabasesAsync(root); - expect(prepare).not.toHaveBeenCalled(); - // Retirement may change lock policy, but must not run host-thread data SQL. - expect(exec.mock.calls).toEqual([["PRAGMA busy_timeout = 0"]]); - expect(readSqliteDatabaseCleanClose(options.path)).toBe(!pinned); - resetGatewayWorkAdmission(); - const reopened = openDatabase(options); - expect(reopened.db.prepare("SELECT value FROM worker_proof ORDER BY rowid").all()).toEqual([ - { value: "worker commit" }, - { value: "after worker checkpoint" }, - ]); - } finally { - reader?.close(); - releaseHost(); - resetGatewayWorkAdmission(); - } - }, -); - -it("closes siblings while a publication retains its database", ({ signal }) => - fixture.run(async () => { - const { db, worker } = await setup(); - const heldPath = options.path; - const healthy = openDatabase({ - agentId: "healthy", - path: path.join(root, "healthy.sqlite"), - }); - const shared = openOpenClawStateDatabase(); - const readLeases = leaseReader(shared); - const entered = createDeferredCore(); - const release = createDeferredCore(); - const healthyClosed = createDeferredCore(); - const nativeClose = healthy.db.close.bind(healthy.db); - vi.spyOn(healthy.db, "close").mockImplementation(() => { - nativeClose(); - healthyClosed.resolve(); - }); - const publication = worker.run(async (scope) => { - const result = await scope.execute(append("committed")); - entered.resolve(); - await release.promise; - return result; - }, allow); - let closing: Promise | undefined; - let closed = false; - try { - await withinTest( - awaitGateBeforeSettlement( - entered.promise, - publication, - "Worker publication was not retained", - ), - signal, - ); - expect(readLeases(heldPath)).toHaveLength(2); - expect(readLeases(healthy.path)).toHaveLength(1); - closing = closeOpenClawAgentDatabasesAsync(root).then(() => { - closed = true; - }); - await withinTest( - awaitGateBeforeSettlement( - healthyClosed.promise, - closing, - "Root close settled before the healthy database closed", - ), - signal, - ); - expect(healthy.db.isOpen).toBe(false); - expect(readLeases(healthy.path)).toEqual([]); - expect(readSqliteDatabaseCleanClose(healthy.path)).toBe(true); - expect(db.isOpen).toBe(true); - expect(readLeases(heldPath)).toHaveLength(2); - expect(readSqliteDatabaseCleanClose(heldPath)).toBe(false); - expect(closed).toBe(false); - expect(isAgentDatabaseClosing(healthy)).toBe(true); - const later = { agentId: "late", path: path.join(root, "late.sqlite") }; - expect(() => openDatabase(later)).toThrow("resources are closing"); - await expect(withOpenClawAgentDatabaseAsync(later, (database) => database)).rejects.toThrow( - "resources are closing", - ); - await expect( - withOpenClawAgentDatabaseAdmission( - later, - async (run) => run(() => {}), - (database) => database, - ), - ).rejects.toThrow("resources are closing"); - expect(fs.existsSync(later.path)).toBe(false); - expect(readLeases(later.path)).toEqual([]); - expect(openDatabase(options).db).toBe(db); - expect(() => - registerOpenClawAgentDatabaseAsyncResource({ - agentId: "new", - path: path.join(root, "new.sqlite"), - revoke() {}, - close: async () => {}, - }), - ).toThrow("resources are closing"); - release.resolve(); - await withinTest(publication, signal); - await withinTest(closing, signal); - expect(db.isOpen).toBe(false); - expect(readLeases(heldPath)).toEqual([]); - expect(readSqliteDatabaseCleanClose(heldPath)).toBe(true); - expect(isAgentDatabaseClosing(healthy)).toBe(false); - expect(openDatabase(later).db.isOpen).toBe(true); - } finally { - release.resolve(); - await Promise.allSettled([publication, closing]); - } - })); - describe("pooled agent publication owner (captured source)", () => { beforeEach(() => { sourceMode = "captured"; @@ -579,110 +371,36 @@ describe("pooled agent publication owner (captured source)", () => { expectValues(db, ["first", "following"]); }); - it("preserves command success despite binding cleanup failure", async () => { - const { db, worker } = await setup({ closeFailure: "fixture binding cleanup failed" }); - expect(await worker.execute(append("committed"), allow)).toBeGreaterThan(0); - expectValues(db, ["committed"]); + it("preserves command admission errors when cleanup admission fails", async () => { + const { db, worker } = await setup({ cleanupAdmission: true }); + const commandRefusal = new Error("controlled command admission refusal"); + let refusals = 0; + const interception = probe.admission(admission, (request, grant, admit) => { + if (request.stage === "transaction") { + throw commandRefusal; + } + if (isRecord(request.facts) && request.facts.kind === "fixture-cleanup") { + refusals++; + throw new Error("controlled publication cleanup admission refusal"); + } + admit(request, grant); + }); + await expect(worker.execute(append("failed"), allow)).rejects.toBe(commandRefusal); + expect(refusals).toBe(1); + expectValues(db, []); + interception.mockRestore(); + const following = await openWorker(db); expect(await following.execute(append("follower"), allow)).toBeGreaterThan(0); - expectValues(db, ["committed", "follower"]); + expectValues(db, ["follower"]); }); - it.each(["ordinary", "admission"] as const)( - "preserves %s errors when cleanup admission fails", - async (failure) => { - const { db, worker } = await setup({ cleanupAdmission: true }); - db.exec(` - CREATE TRIGGER fail_command BEFORE INSERT ON worker_proof - WHEN NEW.value = 'failed' - BEGIN - SELECT RAISE(ABORT, 'controlled command failure'); - END - `); - const commandRefusal = new Error("controlled command admission refusal"); - let commandRefusals = 0; - let refusals = 0; - const interception = probe.admission(admission, (request, grant, admit) => { - if (failure === "admission" && request.stage === "transaction") { - commandRefusals++; - throw commandRefusal; - } - if (isRecord(request.facts) && request.facts.kind === "fixture-cleanup") { - refusals++; - throw new Error("controlled publication cleanup admission refusal"); - } - admit(request, grant); - }); - const result = worker.execute(append("failed"), allow); - if (failure === "admission") { - await expect(result).rejects.toBe(commandRefusal); - } else { - await expect(result).rejects.toThrow("controlled command failure"); - } - expect(commandRefusals).toBe(failure === "admission" ? 1 : 0); - expect(refusals).toBe(1); - expectValues(db, []); - interception.mockRestore(); - - const following = await openWorker(db); - expect(await following.execute(append("follower"), allow)).toBeGreaterThan(0); - expectValues(db, ["follower"]); - }, - ); - it("does not grant a cleanup transaction after a single read", async () => { const { db, worker } = await setup({ closeWriteValue: "must not commit" }); await expect(worker.execute({ type: "inspect", input: undefined }, allow)).resolves.toBe(0); expectValues(db, []); }); - it("refuses factory entry without terminating siblings", async () => { - const siblings = []; - const resume = createDeferredCore(); - const held: Promise[] = []; - try { - for (let index = 0; index < 4; index++) { - options = { ...options, path: path.join(root, "agent-" + index + ".sqlite") }; - const sibling = await setup(); - siblings.push(sibling); - const entered = createDeferredCore(); - const work = sibling.worker.run(async (scope) => { - entered.resolve(); - await resume.promise; - await scope.execute(append("survived")); - }, allow); - held.push(work); - void work.catch(allow); - await Promise.race([entered.promise, work]); - } - let refused = 0; - const interception = probe.admission(admission, (request, grant, admit) => { - if (request.stage === "open") { - refused++; - throw new Error("controlled opening revocation"); - } - admit(request, grant); - }); - options = { ...options, path: path.join(root, "refused.sqlite") }; - const openMarker = path.join(root, "factory-entered"); - const { worker: refusedWorker } = await setup({ openMarker }); - await expect( - refusedWorker.run((scope) => scope.execute(append("refused")), allow), - ).rejects.toThrow("controlled opening revocation"); - interception.mockRestore(); - expect(refused).toBe(1); - expect(fs.existsSync(openMarker)).toBe(false); - resume.resolve(); - await Promise.all(held); - for (const { db } of siblings) { - expectValues(db, ["survived"]); - } - } finally { - resume.resolve(); - await Promise.allSettled(held); - } - }); - it("preserves commits and queued followers after cleanup refusal", async () => { const { db, worker } = await setup(); const committed = createDeferredCore(); @@ -780,49 +498,6 @@ describe("pooled agent publication owner (captured source)", () => { } throwSqliteLifecycleErrors(failures, "Publication fixture and cleanup failed"); }); - it("queues a real periodic maintenance tick behind publication", ({ signal }) => - fixture.run(async () => { - signal.throwIfAborted(); - let capturing = false; - const scheduled = observeSqliteWalPeriodicWork(() => capturing); - const configure = sqliteWal.configureSqliteConnectionPragmas; - vi.spyOn(sqliteWal, "configureSqliteConnectionPragmas").mockImplementation((db, policy) => { - capturing = policy?.databasePath === options.path; - try { - return configure(db, policy); - } finally { - capturing = false; - } - }); - const { db, worker } = await setup().finally(scheduled.restore); - const tick = scheduled.periodic; - db.exec(`INSERT INTO cache_entries(scope, key, blob, updated_at) - VALUES ('maintenance-proof', 'pages', zeroblob(4194304), 1); - DELETE FROM cache_entries WHERE scope = 'maintenance-proof';`); - const freePages = () => - Number(db.prepare("PRAGMA freelist_count").get()?.freelist_count ?? 0); - const before = freePages(); - expect(before).toBeGreaterThan(512); - const exec = vi.spyOn(db, "exec"); - const transactionMarker = path.join(root, "maintenance-transaction"); - const work = worker.run( - (scope) => scope.execute(append("published", { transactionMarker, delayMs: 250 })), - allow, - ); - await waitForFixtureEntry(receipts, transactionMarker, work, signal); - const started = performance.now(); - const maintenance = Promise.resolve(tick()); - expect(performance.now() - started).toBeLessThan(100); - expect(exec.mock.calls.some(([sql]) => sql.includes("incremental_vacuum"))).toBe(false); - expect(freePages()).toBe(before); - await work; - await maintenance; - await withOpenClawAgentDatabaseWrite(options, allow, db); - expect(exec.mock.calls.some(([sql]) => sql.includes("incremental_vacuum"))).toBe(false); - expect(before - freePages()).toBeGreaterThan(0); - expect(before - freePages()).toBeLessThanOrEqual(512); - expectValues(db, ["published"]); - })); }); type V2Worker = OpenClawAgentSqliteWorkerStoreV2; @@ -855,17 +530,9 @@ async function expectReadmission( expectValues(db, [...prior, "readmitted"]); } function observeTerminal(worker: V2Worker) { - const notify = vi.fn(); const terminal = createDeferredCore(); - worker.terminalSignal.addEventListener( - "abort", - () => { - notify(worker.terminalSignal.reason); - terminal.resolve(); - }, - { once: true }, - ); - return { notify, terminal: terminal.promise }; + worker.terminalSignal.addEventListener("abort", () => terminal.resolve(), { once: true }); + return terminal.promise; } it.each([false, true])( @@ -925,7 +592,7 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( }, ) : await openV2Worker(); - const observed = observeTerminal(worker); + const terminal = observeTerminal(worker); if (failure === "owner revocation") { await expect(worker.execute(append("refused"), allow)).rejects.toThrow("V2 owner revoked"); } else if (failure === "resource revocation") { @@ -944,7 +611,7 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( return result; }, allow); expect(await withinTest(accepted, signal)).toBeGreaterThan(0); - await withinTest(observed.terminal, signal); + await withinTest(terminal, signal); await revoked; expect(terminalBeforePublication).toBe(false); expect(committedAtNotification).toEqual([[{ value: "accepted" }]]); @@ -955,14 +622,14 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( { status: "rejected", reason: expect.any(Error) }, { status: "rejected", reason: expect.any(Error) }, ]); - await withinTest(observed.terminal, signal); + await withinTest(terminal, signal); expect(worker.terminalSignal.aborted).toBe(true); expect(worker.terminalSignal.reason).toMatchObject({ message: expect.stringMatching(/worker|exit|crash/i), }); } - await withinTest(observed.terminal, signal); - expect(observed.notify).toHaveBeenCalledExactlyOnceWith(expect.any(Error)); + await withinTest(terminal, signal); + expect(worker.terminalSignal.reason).toBeInstanceOf(Error); const reason: unknown = worker.terminalSignal.reason; if (failure === "owner revocation") { expect(reason).toMatchObject({ message: "V2 owner revoked" }); @@ -974,7 +641,6 @@ it.for(["owner revocation", "crash", "exit", "resource revocation"] as const)( if (failure === "crash" || failure === "exit") { await worker.close(); expect(worker.terminalSignal.reason).toBe(reason); - expect(observed.notify).toHaveBeenCalledTimes(1); } }, ); diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 187ffde3a499..27ae42356e74 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -38,8 +38,6 @@ import { import type { ToolStreamHost } from "./tool-stream-contract.ts"; import { buildToolStreamIdentity } from "./tool-stream-identity.ts"; -const historyBudget = { limit: 80, maxBytes: 256 * 1024, toolResultMaxChars: 2_000 }; - beforeEach(() => vi.useFakeTimers()); afterEach(() => { vi.clearAllTimers(); @@ -85,6 +83,22 @@ function expectSettled(state: ChatState) { expect(state).toMatchObject({ chatRunId: null, chatStream: null, chatStreamStartedAt: null }); } +function expectTranscript(state: ChatState, ...rows: Array<[string, string]>) { + expect(state.chatMessages.map(messageShape)).toEqual( + rows.map(([role, text]) => ({ role, content: [{ type: "text", text }] })), + ); +} + +function messageShape(message: unknown) { + const { role, content } = requireRecord(message); + return { role, content }; +} + +const expectAssistant = (state: ChatState, ...texts: string[]) => + expectTranscript(state, ...texts.map((text): [string, string] => ["assistant", text])); + +const requireRecord = createRequireRecord("record", "expected-non-array-record"); + function createState(overrides: Partial = {}): ChatState { return { chatAttachments: [], @@ -116,12 +130,12 @@ function createState(overrides: Partial = {}): ChatState { it.each([true, false])( "keeps identical injected notes separate without adopting a run (persisted first=%s)", (persistedFirst) => { - const user = textMessage("user", "Previous question", { id: "user", seq: 1 }); - const reply = textMessage("assistant", "Previous reply", { id: "reply", seq: 2 }); + const user = userText("Previous question", { id: "user", seq: 1 }); + const reply = replyText("Previous reply", { id: "reply", seq: 2 }); const state = createState({ chatMessages: [user, reply] }); const ids = ["note-one", "note-two"]; const notes = ids.map((id, index) => - textMessage("assistant", "Synthetic weekly report", { id, seq: index + 3 }), + replyText("Synthetic weekly report", { id, seq: index + 3 }), ); for (const [index, saved] of notes.entries()) { if (persistedFirst) { @@ -130,7 +144,7 @@ it.each([true, false])( const event = { runId: `inject-${ids[index]}`, seq: 0, - message: textMessage("assistant", "Synthetic weekly report"), + message: replyText("Synthetic weekly report"), }; receive(state, "final", event); receive(state, "final", event); @@ -153,7 +167,7 @@ it.each([true, false])( it("settles a saved transformed interrupted partial once with both error emitters", () => { const runId = "interrupted-run"; const text = "The saved partial reply should appear once."; - const user = textMessage("user", "Ask", { id: "user", seq: 1, runId }); + const user = userText("Ask", { id: "user", seq: 1, runId }); const saved = { role: "assistant", content: [ @@ -170,7 +184,7 @@ it("settles a saved transformed interrupted partial once with both error emitter }, }; const state = createState({ chatMessages: [user], chatRunId: runId }); - receive(state, "delta", { runId, seq: 12, message: textMessage("assistant", text) }); + receive(state, "delta", { runId, seq: 12, message: replyText(text) }); const persist = () => applySessionMessagePayload(state, { runId, message: saved }, true, { kind: "history-delta" }); persist(); @@ -178,7 +192,7 @@ it("settles a saved transformed interrupted partial once with both error emitter runId, seq: 13, errorMessage: "codex app-server client closed before turn completed", - message: textMessage("assistant", text, { runId, idempotencyKey: "saved-key" }), + message: replyText(text, { runId, idempotencyKey: "saved-key" }), }); receive(state, "error", { runId, seq: 1, errorMessage: "Outer returned failure" }); expect(state.chatMessages).toEqual([user, saved]); @@ -195,9 +209,8 @@ it("keeps a later final distinct after an unpositioned steer", () => { const state = createState({ chatRunId: runId, chatMessages: [ - textMessage("user", "Ask", { id: "prompt", seq: 1, idempotencyKey: "run-1:user" }, 1), - textMessage( - "user", + userText("Ask", { id: "prompt", seq: 1, idempotencyKey: "run-1:user" }, 1), + userText( "Earlier steer", { id: "earlier-steer", @@ -207,7 +220,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { }, 2, ), - textMessage("assistant", "Earlier answer", { id: "earlier-answer", seq: 3, runId }, 3), + replyText("Earlier answer", { id: "earlier-answer", seq: 3, runId }, 3), ], }); applySessionMessagePayload( @@ -215,8 +228,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { { clientRunId: runId, messageId: "latest-steer", - message: textMessage( - "user", + message: userText( "Latest steer", { idempotencyKey: "latest:user", @@ -229,7 +241,7 @@ it("keeps a later final distinct after an unpositioned steer", () => { { kind: "live", activeRunId: runId }, ); receive(state, "final", { - message: textMessage("assistant", "New final", undefined, 5), + message: replyText("New final", undefined, 5), }); expect(state.chatMessages.map(extractText)).toEqual([ "Ask", @@ -291,7 +303,7 @@ function createHistoryStateForClient( function createAssistantHistory(text: string, overrides: Omit = {}) { return { - messages: [textMessage("assistant", text)], + messages: [replyText(text)], ...overrides, }; } @@ -387,14 +399,6 @@ function createLiveToolHistoryState( return liveState; } -const requireRecord = createRequireRecord("record", "expected-non-array-record"); - -function expectTextMessage(message: unknown, role: string, text: string): void { - const record = requireRecord(message); - expect(record.role).toBe(role); - expect(record.content).toEqual([{ type: "text", text }]); -} - function textMessage( role: "assistant" | "user", text: string, @@ -424,6 +428,43 @@ function projectChatMessageEvent( state.chatMessages = [...projection.messages]; } +function seedTerminal( + state: ChatState, + status: "completed" | "error", + extra: { message?: unknown; errorMessage?: string }, +) { + const scope = { sessionKey: state.sessionKey }; + publishChatSessionProjection( + state, + reduceSessionProjection(getChatSessionProjection(state, scope), { + type: "runTerminal", + runId: "run-1", + status, + scope, + ...extra, + }), + ); + return scope; +} + +const replyText = textMessage.bind(null, "assistant"); +const userText = textMessage.bind(null, "user"); +const toolCall = (toolCallId: string) => ({ + role: "assistant", + toolCallId, + runId: "run-1", + content: [{ type: "toolcall", name: "shell", arguments: {} }], +}); +const pendingSend = (sendRunId: string) => ({ + id: "pending", + text: "Ask", + createdAt: 1, + sendRunId, + sendState: "sending" as const, +}); +const steer = (text: string, key: string, timestamp: number) => + userText(text, { idempotencyKey: `${key}:user`, steerTargetRunId: "run-1" }, timestamp); + function createActiveStreamingState() { return createState({ chatRunId: "run-user", chatStream: "Working...", chatStreamStartedAt: 123 }); } @@ -436,7 +477,7 @@ describe("handleChatGatewayEvent", () => { const before = state.chatMessages.length; handleChatGatewayEvent(state, { state: "final", - message: textMessage("assistant", "leaked companion answer"), + message: replyText("leaked companion answer"), } as ChatEventPayload); expect(state.chatMessages.length).toBe(before); expect(JSON.stringify(state.chatMessages)).not.toContain("leaked companion answer"); @@ -444,15 +485,7 @@ describe("handleChatGatewayEvent", () => { it("adopts startup status only for the queued local run before its ACK", () => { const state = createState({ - chatQueue: [ - { - id: "queued-1", - text: "hello", - createdAt: 1, - sendRunId: "run-1", - sendState: "sending", - }, - ], + chatQueue: [pendingSend("run-1")], }); receive(state, "status", { runId: "run-other", phase: "preparing_workspace" }); expect(state.chatRunId).toBeNull(); @@ -507,7 +540,7 @@ describe("handleChatGatewayEvent", () => { const payload: ChatEventPayload = chatEvent("final", { sessionKey: "background", message: { - ...textMessage("assistant", "complete delivery result"), + ...replyText("complete delivery result"), openclawDisplayContent: [{ type: "text", text: "background final" }], }, }); @@ -517,39 +550,12 @@ describe("handleChatGatewayEvent", () => { handleChatGatewayEvent(state, payload); } expect(readChatMessagesFromCache(cache, states[0]!, { sessionKey: "background" })).toEqual([ - textMessage("assistant", "background final"), + replyText("background final"), ]); }); - it("appends configured default-session finals under runtime aliases", () => { - const activeSessionKey = "agent:ops:other"; - const payloadSessionKey = "agent:ops:home"; - const state = createState({ sessionKey: activeSessionKey, chatMessagesBySession: new Map() }); - const payload: ChatEventPayload = chatEvent("final", { - sessionKey: payloadSessionKey, - message: textMessage("assistant", "cached final"), - }); - - (state as Record).hello = { - snapshot: { - sessionDefaults: { - defaultAgentId: "ops", - mainKey: "home", - }, - }, - }; - seedChatSnapshot(state, { sessionKey: payloadSessionKey }); - handleChatGatewayEvent(state, payload); - expect( - readChatMessagesFromCache(state.chatMessagesBySession ?? new Map(), state, { - sessionKey: payloadSessionKey, - }), - ).toEqual([payload.message]); - expect(state.chatMessagesBySession?.size).toBe(1); - }); - it("appends inactive global finals under the payload agent only", () => { - const visibleMessage = textMessage("assistant", "work visible"); + const visibleMessage = replyText("work visible"); const state = createState({ sessionKey: "global", assistantAgentId: "work", @@ -561,7 +567,7 @@ describe("handleChatGatewayEvent", () => { runId: "run-main-global", sessionKey: "global", agentId: "main", - message: textMessage("assistant", "main final"), + message: replyText("main final"), }); seedChatSnapshot(state, { sessionKey: "global", agentId: "main" }); handleChatGatewayEvent(state, payload); @@ -582,7 +588,7 @@ describe("handleChatGatewayEvent", () => { const state = createState({ chatRunId: "run-1", chatStream: previous }); receive(state, "delta", { deltaText: "", - message: textMessage("assistant", snapshot), + message: replyText(snapshot), replace: true, }); expect(state.chatStream).toBe(snapshot); @@ -594,7 +600,7 @@ describe("handleChatGatewayEvent", () => { timestamp: 42, __openclaw: { id: "legacy-final", seq: 2 }, }; - const user = textMessage("user", "Ask", { id: "persisted-user", seq: 1 }); + const user = userText("Ask", { id: "persisted-user", seq: 1 }); const request = vi.fn().mockResolvedValue({ messages: [user] }); const state = createHistoryState(request, { chatMessages: [user], @@ -617,7 +623,7 @@ describe("handleChatGatewayEvent", () => { it("settles an active run when its terminal reply was already accepted", () => { const runId = "run-1"; - const final = textMessage("assistant", "Delivered answer"); + const final = replyText("Delivered answer"); const state = runningSession("Delivered answer", { sessionKey: "main", chatRunId: runId, @@ -626,15 +632,7 @@ describe("handleChatGatewayEvent", () => { }); state.chatStreamSegments = [{ text: "Retained commentary", ts: 122, toolCallId: "call-1" }]; state.knownAgentRunIds = new Set([runId]); - const scope = { sessionKey: state.sessionKey }; - const projection = reduceSessionProjection(getChatSessionProjection(state, scope), { - type: "runTerminal", - runId, - status: "completed", - message: final, - scope, - }); - publishChatSessionProjection(state, projection); + const scope = seedTerminal(state, "completed", { message: final }); publishChatSessionProjectionMessages(state, [final], { scope }); receive(state, "final", { runId, message: final }); expect(state.chatMessages).toEqual([final]); @@ -672,12 +670,7 @@ describe("handleChatGatewayEvent", () => { source: "segment", }, }, - textMessage( - "user", - "Focus on deployment", - { idempotencyKey: "steer-send-1:user", steerTargetRunId: "run-1" }, - 3, - ), + steer("Focus on deployment", "steer-send-1", 3), ], }); state.chatStreamSegments = [ @@ -691,11 +684,13 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { message: textMessage("assistant", "Final answer.", undefined, 5), }); - expect(state.chatMessages).toHaveLength(4); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "assistant", "Looking into it."); - expectTextMessage(state.chatMessages[2], "user", "Focus on deployment"); - expectTextMessage(state.chatMessages[3], "assistant", "Final answer."); + expectTranscript( + state, + ["user", "Ask"], + ["assistant", "Looking into it."], + ["user", "Focus on deployment"], + ["assistant", "Final answer."], + ); }); it("keeps the complete terminal reply after an accepted steer when no later delta arrived", () => { @@ -704,25 +699,19 @@ describe("handleChatGatewayEvent", () => { chatStream: "Before steer.", chatStreamStartedAt: 2, chatMessages: [ - textMessage("user", "Ask", { idempotencyKey: "run-1:user" }, 1), - textMessage( - "user", - "Steer", - { - idempotencyKey: "steer-1:user", - steerTargetRunId: "run-1", - }, - 3, - ), + userText("Ask", { idempotencyKey: "run-1:user" }, 1), + steer("Steer", "steer-1", 3), ], }); receive(state, "final", { - message: textMessage("assistant", "Before steer. Final unseen suffix.", undefined, 4), + message: replyText("Before steer. Final unseen suffix.", undefined, 4), }); - expect(state.chatMessages).toHaveLength(3); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "user", "Steer"); - expectTextMessage(state.chatMessages[2], "assistant", "Before steer. Final unseen suffix."); + expectTranscript( + state, + ["user", "Ask"], + ["user", "Steer"], + ["assistant", "Before steer. Final unseen suffix."], + ); expect(renderedMessageTexts(state, "terminal-after-steer")).toEqual([ "Ask", "Steer", @@ -731,20 +720,15 @@ describe("handleChatGatewayEvent", () => { }); it("clears keyed commentary when chatPersistCommentary is false", () => { - const user = textMessage("user", "Ask", undefined, 1); + const user = userText("Ask", undefined, 1); const state = createState({ chatRunId: "run-1", chatMessages: [user], settings: { chatPersistCommentary: false }, }); state.chatStreamSegments = [{ text: "Looking into it.", ts: 2, itemId: "preamble-1" }]; - const payload: ChatEventPayload = chatEvent("final", { - message: textMessage("assistant", "Final answer.", undefined, 5), - }); - handleChatGatewayEvent(state, payload); - expect(state.chatMessages).toHaveLength(2); - expectTextMessage(state.chatMessages[0], "user", "Ask"); - expectTextMessage(state.chatMessages[1], "assistant", "Final answer."); + receive(state, "final", { message: replyText("Final answer.", undefined, 5) }); + expectTranscript(state, ["user", "Ask"], ["assistant", "Final answer."]); expect(state.chatStreamSegments).toEqual([]); }); @@ -820,12 +804,11 @@ describe("handleChatGatewayEvent", () => { occurredAt: 100, }; state.fallbackClearTimer = setTimeout(() => undefined, 1_000); - const payload: ChatEventPayload = chatEvent("final", { + receive(state, "final", { stopReason: "completed", yielded: true, - message: textMessage("assistant", "Live reply"), + message: replyText("Live reply"), }); - handleChatGatewayEvent(state, payload); expectSettled(state); expect(state.compactionStatus).toBeNull(); @@ -848,10 +831,7 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { stopReason: "end_turn", yielded: true, - message: textMessage( - "assistant", - "The gateway will restart; I will resume verification afterward.", - ), + message: replyText("The gateway will restart; I will resume verification afterward."), }); expect(state.chatRunId).toBeNull(); expect(state.chatStream).toBeNull(); @@ -863,37 +843,22 @@ describe("handleChatGatewayEvent", () => { }); }); - it("appends final payload from another run without clearing active stream", () => { + it.each([ + { name: "another run", runId: "run-announce", message: replyText("Sub-agent findings") }, + { name: "unowned", runId: undefined, message: undefined }, + ])("keeps the active stream for $name finals", ({ runId, message }) => { const state = createActiveStreamingState(); - const payload: ChatEventPayload = chatEvent("final", { - runId: "run-announce", - message: textMessage("assistant", "Sub-agent findings"), + receive(state, "final", { runId, message }); + expect(state).toMatchObject({ + chatRunId: "run-user", + chatStream: "Working...", + chatStreamStartedAt: 123, }); - handleChatGatewayEvent(state, payload); - expect(state.chatRunId).toBe("run-user"); - expect(state.chatStream).toBe("Working..."); - expect(state.chatStreamStartedAt).toBe(123); - expect(state.chatMessages).toHaveLength(1); - expect(state.chatMessages[0]).toEqual(payload.message); - }); - - it("keeps active stream for unowned final payloads", () => { - const state = createActiveStreamingState(); - const payload: ChatEventPayload = chatEvent("final", { runId: undefined }); - handleChatGatewayEvent(state, payload); - expect(state.chatRunId).toBe("run-user"); - expect(state.chatStream).toBe("Working..."); - expect(state.chatStreamStartedAt).toBe(123); - expect(state.chatMessages).toStrictEqual([]); + expect(state.chatMessages).toEqual(message ? [message] : []); }); it("preserves a split stream when the terminal message only overlaps its prefix", () => { - const terminal = textMessage( - "assistant", - "First thought. Configure provider auth.", - undefined, - 101, - ); + const terminal = replyText("First thought. Configure provider auth.", undefined, 101); const state = createState({ chatRunId: "run-1", chatStream: "After tool.", @@ -903,12 +868,10 @@ describe("handleChatGatewayEvent", () => { receive(state, "error", { errorMessage: "gateway disconnected", message: terminal }); expect(state.chatRunId).toBeNull(); expect(state.chatStream).toBeNull(); - expect(state.chatMessages).toHaveLength(3); - expectTextMessage(state.chatMessages[0], "assistant", "First thought."); - expectTextMessage(state.chatMessages[1], "assistant", "After tool."); - expectTextMessage( - state.chatMessages[2], - "assistant", + expectAssistant( + state, + "First thought.", + "After tool.", "First thought. Configure provider auth.", ); expect(state.chatMessages[2]).toEqual(terminal); @@ -925,9 +888,7 @@ describe("handleChatGatewayEvent", () => { ["openai", 429, "subscription_sharing_usage_limit_exceeded", "chatgpt_consumer_usage_limit"], ["openai", 429, "rate_limit_exceeded", undefined], ["openai", 403, "subscription_sharing_workplace_individual_usage_limit_exceeded", undefined], - ["openai", 403, "subscription_sharing_usage_limit_exceeded", undefined], ["other", 429, "subscription_sharing_workplace_individual_usage_limit_exceeded", undefined], - ["other", 429, "subscription_sharing_usage_limit_exceeded", undefined], ])( "classifies ChatGPT sharing limits only from provider facts (%s, %s, %s)", (provider, httpStatus, providerErrorCode, kind) => { @@ -955,17 +916,7 @@ describe("handleChatGatewayEvent", () => { chatRunId: "run-1", chatRunError: { runId: "run-1", summary }, }); - const scope = { sessionKey: state.sessionKey }; - publishChatSessionProjection( - state, - reduceSessionProjection(getChatSessionProjection(state, scope), { - type: "runTerminal", - runId: "run-1", - status: "error", - errorMessage: summary, - scope, - }), - ); + seedTerminal(state, "error", { errorMessage: summary }); receive(state, "error", { errorMessage: summary, errorDetail: { @@ -1004,17 +955,16 @@ describe("handleChatGatewayEvent", () => { const state = createState({ chatRunId: "run-completed" }); receive(state, "final", { runId: "run-completed", - message: textMessage("assistant", "Delivered once."), + message: replyText("Delivered once."), }); receive(state, "delta", { runId: "run-newer", - message: textMessage("assistant", "Newer response"), + message: replyText("Newer response"), }); receive(state, "error", { runId: "run-completed", errorMessage: "late provider failure" }); expect(state.chatRunId).toBe("run-newer"); expect(state.chatStream).toBe("Newer response"); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "Delivered once."); + expectAssistant(state, "Delivered once."); expect(state.chatRunError).toBeNull(); }); @@ -1023,12 +973,11 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { runId: "run-empty-final" }); const deliveredFinal = chatEvent("final", { runId: "run-empty-final", - message: textMessage("assistant", "Delayed authoritative reply."), + message: replyText("Delayed authoritative reply."), }); handleChatGatewayEvent(state, deliveredFinal); handleChatGatewayEvent(state, deliveredFinal); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "Delayed authoritative reply."); + expectAssistant(state, "Delayed authoritative reply."); expect(state.chatRunId).toBeNull(); }); @@ -1081,7 +1030,7 @@ describe("handleChatGatewayEvent", () => { receive(state, "delta", { ...envelope, seq: seq++, - message: textMessage("assistant", text), + message: replyText(text), }); expect(state.chatStream).toBe(text); expect(state.chatRunId).toBe(envelope.runId); @@ -1091,10 +1040,9 @@ describe("handleChatGatewayEvent", () => { receive(state, "final", { ...envelope, seq, - message: textMessage("assistant", "I agree with that product direction."), + message: replyText("I agree with that product direction."), }); - expect(state.chatMessages).toHaveLength(1); - expectTextMessage(state.chatMessages[0], "assistant", "I agree with that product direction."); + expectAssistant(state, "I agree with that product direction."); expect(state.chatStreamSegments ?? []).toEqual([]); expect(state.chatRunId).toBeNull(); }); @@ -1106,7 +1054,7 @@ describe("authoritative terminal history identity", () => { async (historyFirst) => { const text = "Scan this QR code with the OpenClaw iOS app:\n\n- Gateway: wss://pair.example.invalid\n- Auth: token\n- Security: single-use bootstrap token"; - const saved = textMessage("assistant", text, { id: "pair-reply", seq: 2, runId: "run-1" }); + const saved = replyText(text, { id: "pair-reply", seq: 2, runId: "run-1" }); const qr = { type: "openclaw_pairing_qr", image_url: "data:image/png;base64,c3ludGhldGljLXFy", @@ -1149,8 +1097,8 @@ describe("authoritative terminal history identity", () => { it("does not retire a native terminal for user or imported identities with a colliding id", () => { const collisions = [ - textMessage("user", "Different native user", { id: "native-terminal" }), - textMessage("assistant", "Different imported assistant", { + userText("Different native user", { id: "native-terminal" }), + replyText("Different imported assistant", { id: "native-terminal", importedFrom: "claude-cli", cliSessionId: "external-session", @@ -1158,13 +1106,10 @@ describe("authoritative terminal history identity", () => { }), ]; const host = {}; - const nativeTerminal = textMessage("assistant", "Native terminal", { + const nativeTerminal = replyText("Native terminal", { id: "native-terminal", }); - const liveTerminal = rememberLiveTerminalRun( - textMessage("assistant", "Native terminal"), - "run-1", - ); + const liveTerminal = rememberLiveTerminalRun(replyText("Native terminal"), "run-1"); rememberAuthoritativeTerminal({ event: { key: "main", runId: "run-1", hasActiveRun: false }, host, @@ -1200,17 +1145,11 @@ describe("loadChatHistory filtering", () => { const repair = "[openclaw] missing tool result in session history; inserted synthetic error result for transcript repair."; const visible = [ - textMessage("user", "NO_REPLY"), - textMessage("user", repair), - textMessage("assistant", "no_reply"), - textMessage("assistant", "NO_REPLY"), + userText("NO_REPLY"), + userText(repair), + replyText("NO_REPLY"), { role: "assistant", text: " NO_REPLY " }, - textMessage("assistant", "NO_REPLY."), - textMessage("assistant", '{"action":"NO_REPLY"}'), - textMessage("assistant", "I will not reply: NO_REPLY"), - textMessage("assistant", "ANNOUNCE_SKIP"), - textMessage("assistant", "REPLY_SKIP"), - textMessage("assistant", "Real answer"), + replyText("Real answer"), { role: "assistant", text: "real reply", content: "NO_REPLY" }, { role: "user", content: "", __openclaw: { media: [{ path: "/tmp/user.png" }] } }, { @@ -1221,9 +1160,8 @@ describe("loadChatHistory filtering", () => { }, ]; const hidden = [ - textMessage("assistant", "HEARTBEAT_OK"), - textMessage( - "user", + replyText("HEARTBEAT_OK"), + userText( "<<>>\nsubagent completion payload\n<<>>", ), { role: "user", content: "" }, @@ -1289,7 +1227,7 @@ describe("loadChatHistory filtering", () => { }); it("caches global history under the selected agent only", async () => { - const messages = [textMessage("assistant", "work history")]; + const messages = [replyText("work history")]; const { state } = createResolvedHistoryState( { messages }, { @@ -1346,11 +1284,11 @@ describe("loadChatHistory retry handling", () => { expect(request.mock.calls[1]?.[2]?.signal.aborted).toBe(true); await loadChatHistory(secondState); expect(request).toHaveBeenCalledTimes(3); - expect(secondState.chatMessages).toEqual([textMessage("assistant", "awake")]); + expect(secondState.chatMessages).toEqual([replyText("awake")]); expect(firstState.chatMessages).toEqual([]); secondAttempt.resolve(createAssistantHistory("expired")); await vi.advanceTimersByTimeAsync(0); - expect(secondState.chatMessages).toEqual([textMessage("assistant", "awake")]); + expect(secondState.chatMessages).toEqual([replyText("awake")]); }); const historyTool = (id: string, text: string, timestamp: number, seq: number) => ({ @@ -1369,11 +1307,9 @@ describe("loadChatHistory retry handling", () => { historyTool("call_1", "first output", 2, 2), partial ? { - role: "assistant", - toolCallId: "call_2", - runId: "run-1", + ...toolCall("call_2"), content: [ - { type: "toolcall", name: "shell", arguments: {} }, + ...toolCall("call_2").content, { type: "toolresult", name: "shell", text: "second output" }, ], timestamp: 4, @@ -1401,7 +1337,7 @@ describe("loadChatHistory retry handling", () => { const remainingSegments = partial ? [{ text: "before second tool", ts: 3, toolCallId: "call_2" }] : []; - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); + const persistedUser = userText("latest ask", { seq: 1 }); const state = createLiveToolHistoryState( [persistedUser, ...tools.slice(0, persistedCount)], stream, @@ -1411,21 +1347,26 @@ describe("loadChatHistory retry handling", () => { await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(expectedRows.length + 1); - expect(state.chatMessages[0]).toEqual(persistedUser); - for (const [index, row] of expectedRows.entries()) { - if (typeof row === "number") { - expect(state.chatMessages[index + 1]).toEqual(tools[row]); - } else { - expectTextMessage(state.chatMessages[index + 1], "assistant", row.text); - expect(requireRecord(state.chatMessages[index + 1]).timestamp).toBe(row.timestamp); - } - } - expect(state.chatRunId).toBe("run-1"); - expect(state.chatStream).toBe(stream); - expect(visibleCurrentAssistantStreamTail(state, () => false)).toBe("Still answering."); - expect(state.chatStreamStartedAt).toBe(100); + expect(state.chatMessages).toEqual([ + persistedUser, + ...expectedRows.map((row) => + typeof row === "number" + ? tools[row] + : expect.objectContaining(replyText(row.text, undefined, row.timestamp)), + ), + ]); + expect(state).toMatchObject({ + chatRunId: "run-1", + chatStream: stream, + chatStreamStartedAt: 100, + }); expect(state.chatToolMessages).toEqual(remainingTools.map((index) => tools[index])); + expect(state.toolStreamOrder).toEqual( + remainingTools.map((index) => + buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), + ), + ); + expect(visibleCurrentAssistantStreamTail(state, () => false)).toBe("Still answering."); expect( visibleParts(state).map(({ text, timestamp, toolCallId }) => ({ text, @@ -1433,63 +1374,39 @@ describe("loadChatHistory retry handling", () => { toolCallId, })), ).toEqual(remainingSegments); - expect(state.toolStreamById.size).toBe(remainingTools.length); - expect(state.toolStreamOrder).toEqual( - remainingTools.map((index) => - buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), - ), - ); - for (const index of remainingTools) { - expect( - state.toolStreamById.has( - buildToolStreamIdentity("run-1", String(tools[index]?.toolCallId)), - ), - ).toBe(true); - } + expect(new Set(state.toolStreamById.keys())).toEqual(new Set(state.toolStreamOrder)); }, ); it("clears live tool cards when history catches up with content-block tool ids", async () => { - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); + const persistedUser = userText("latest ask", { seq: 1 }); const persistedToolCall = { role: "assistant", - content: [ - { - type: "toolCall", - id: "call_1", - name: "shell", - arguments: {}, - }, - ], + content: [{ type: "toolCall", id: "call_1", name: "shell", arguments: {} }], timestamp: 2, __openclaw: { seq: 2 }, }; const state = createLiveToolHistoryState( [persistedUser, persistedToolCall], "Still answering.", - [ - { - role: "assistant", - toolCallId: "call_1", - runId: "run-1", - content: [{ type: "toolcall", name: "shell", arguments: {} }], - }, - ], + [toolCall("call_1")], [{ text: "before tool", ts: 1 }], ); await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(3); - expect(state.chatMessages[0]).toEqual(persistedUser); - expectTextMessage(state.chatMessages[1], "assistant", "before tool"); - expect(requireRecord(state.chatMessages[1]).timestamp).toBe(1); - expect(state.chatMessages[2]).toEqual(persistedToolCall); - expect(state.chatRunId).toBe("run-1"); - expect(state.chatStream).toBe("Still answering."); - expect(state.chatStreamStartedAt).toBe(100); - expect(state.chatToolMessages).toEqual([]); + expect(state.chatMessages).toEqual([ + persistedUser, + expect.objectContaining({ ...replyText("before tool"), timestamp: 1 }), + persistedToolCall, + ]); + expect(state).toMatchObject({ + chatRunId: "run-1", + chatStream: "Still answering.", + chatStreamStartedAt: 100, + chatToolMessages: [], + toolStreamOrder: [], + }); expect(visibleParts(state)).toEqual([]); expect(state.toolStreamById.size).toBe(0); - expect(state.toolStreamOrder).toEqual([]); }); it("places materialized streamed text after a persisted user prompt with a later clock", async () => { @@ -1497,7 +1414,7 @@ describe("loadChatHistory retry handling", () => { // The user turn owns placement; retiming the saved stream would reorder live tools. const userTimestamp = 200; const streamTimestamp = 100; - const persistedUser = textMessage("user", "first", { seq: 1 }, userTimestamp); + const persistedUser = userText("first", { seq: 1 }, userTimestamp); const { state } = createHistorySnapshot([persistedUser], { chatMessages: [persistedUser], chatRunId: null, @@ -1505,13 +1422,12 @@ describe("loadChatHistory retry handling", () => { chatStreamStartedAt: streamTimestamp, }); await loadChatHistory(state); - expect(state.chatMessages).toHaveLength(2); - expect(state.chatMessages[0]).toEqual(persistedUser); - expectTextMessage( - state.chatMessages[1], - "assistant", - "Partial answer before history catch-up.", + expectTranscript( + state, + ["user", "first"], + ["assistant", "Partial answer before history catch-up."], ); + expect(state.chatMessages[0]).toEqual(persistedUser); expect(renderedMessageTexts(state, "materialized-stream-order")).toEqual([ "first", "Partial answer before history catch-up.", @@ -1522,18 +1438,9 @@ describe("loadChatHistory retry handling", () => { }); it("keeps live tool cards when history only replaces streamed text", async () => { - const persistedUser = textMessage("user", "latest ask", { seq: 1 }); - const historyAssistant = textMessage( - "assistant", - "First visible stream text. More final text.", - { seq: 2 }, - ); - const liveToolMessage = { - role: "assistant", - toolCallId: "call_current", - runId: "run-1", - content: [{ type: "toolcall", name: "shell", arguments: {} }], - }; + const persistedUser = userText("latest ask", { seq: 1 }); + const historyAssistant = replyText("First visible stream text. More final text.", { seq: 2 }); + const liveToolMessage = toolCall("call_current"); const state = createLiveToolHistoryState( [persistedUser, historyAssistant], "First visible stream text.", @@ -1557,7 +1464,7 @@ describe("loadChatHistory retry handling", () => { }); const request = vi.fn().mockRejectedValue(new GatewayRequestError(scopeError)); const state = createHistoryState(request, { - chatMessages: [textMessage("assistant", "old")], + chatMessages: [replyText("old")], chatVerboseLevel: "full", }); await loadChatHistory(state); @@ -1587,7 +1494,7 @@ describe("loadChatHistory retry handling", () => { }); const state = createHistoryState(request); const firstLoad = loadChatHistory(state); - const pending = textMessage("user", "new local ask", { + const pending = userText("new local ask", { idempotencyKey: "same-session-pending-run:user", }); projectChatMessageEvent(state, { @@ -1597,28 +1504,19 @@ describe("loadChatHistory retry handling", () => { }); const secondLoad = loadChatHistory(state); const thirdLoad = loadChatHistory(state); - expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ - ["chat.history", { sessionKey: "main", ...historyBudget, knownState: {} }], - ]); + expect(request).toHaveBeenCalledTimes(1); + expect(request.mock.calls[0]?.[1]).not.toHaveProperty("inputRunIds"); expect(state.chatMessages).toEqual([pending]); staleHistory.resolve(createAssistantHistory("stale history")); await currentHistoryStarted.promise; - expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([ - ["chat.history", { sessionKey: "main", ...historyBudget, knownState: {} }], - [ - "chat.history", - { - sessionKey: "main", - ...historyBudget, - knownState: {}, - inputRunIds: ["same-session-pending-run"], - }, - ], - ]); + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]?.[1]).toMatchObject({ + inputRunIds: ["same-session-pending-run"], + }); expect(state.chatMessages).toEqual([pending]); expect(state.chatLoading).toBe(true); - const persisted = textMessage("assistant", "persisted history", { + const persisted = replyText("persisted history", { id: "same-session-history-assistant", seq: 1, }); @@ -1629,57 +1527,49 @@ describe("loadChatHistory retry handling", () => { expect(state.chatLoading).toBe(false); }); - it("rejects stale success and cleanup after a same-client reconnect", async () => { - const staleRequest = createDeferred(); - const freshRequest = createDeferred(); - const request = vi - .fn() - .mockImplementationOnce(() => staleRequest.promise) - .mockImplementationOnce(() => freshRequest.promise); - const client = createTestClient(request); - const visibleMessage = textMessage("assistant", "visible before reconnect"); - const state = createHistoryStateForClient(client, { - chatMessages: [visibleMessage], - connectionEpoch: 1, - }); - const staleLoad = loadChatHistory(state); - state.connected = false; - state.connectionEpoch = 2; - state.connected = true; - state.connectionEpoch = 3; - const freshLoad = loadChatHistory(state); - expect(request).toHaveBeenCalledTimes(2); - staleRequest.resolve(createAssistantHistory("stale history", { thinkingLevel: "high" })); - await staleLoad; - expect(state.chatMessages).toEqual([visibleMessage]); - expect(state.chatLoading).toBe(true); - - freshRequest.resolve(createAssistantHistory("fresh history", { thinkingLevel: "low" })); - await freshLoad; - expect(state.chatMessages).toEqual([textMessage("assistant", "fresh history")]); - expect(state.chatLoading).toBe(false); - }); - - it("rejects stale errors and cleanup after a same-client reconnect", async () => { - const staleRequest = createDeferred(); - const request = vi.fn(() => staleRequest.promise); - const client = createTestClient(request); - const state = createHistoryStateForClient(client, { - connectionEpoch: 1, - }); - const staleLoad = loadChatHistory(state); - state.connected = false; - state.connectionEpoch = 2; - state.connected = true; - state.connectionEpoch = 3; - state.chatLoading = true; - staleRequest.reject(new Error("stale history failure")); - await staleLoad; - expect(state.lastError).toBeNull(); - expect(state.chatError).toBeNull(); - expect(getChatHistoryLoadState(state)).toMatchObject({ phase: "pending-connection" }); - expect(state.chatLoading).toBe(true); - }); + it.each(["success", "error"] as const)( + "rejects stale %s and cleanup after reconnect", + async (outcome) => { + const staleRequest = createDeferred(); + const freshRequest = createDeferred(); + const request = vi + .fn() + .mockImplementationOnce(() => staleRequest.promise) + .mockImplementationOnce(() => freshRequest.promise); + const client = createTestClient(request); + const visibleMessage = replyText("visible before reconnect"); + const state = createHistoryStateForClient(client, { + chatMessages: [visibleMessage], + connectionEpoch: 1, + }); + const staleLoad = loadChatHistory(state); + state.connected = false; + state.connectionEpoch = 2; + state.connected = true; + state.connectionEpoch = 3; + const freshLoad = outcome === "success" ? loadChatHistory(state) : undefined; + if (outcome === "success") { + expect(request).toHaveBeenCalledTimes(2); + staleRequest.resolve(createAssistantHistory("stale history", { thinkingLevel: "high" })); + } else { + state.chatLoading = true; + staleRequest.reject(new Error("stale history failure")); + } + await staleLoad; + expect(state.chatMessages).toEqual([visibleMessage]); + expect(state.chatLoading).toBe(true); + if (outcome === "success") { + freshRequest.resolve(createAssistantHistory("fresh history", { thinkingLevel: "low" })); + await freshLoad; + expect(state.chatMessages).toEqual([replyText("fresh history")]); + expect(state.chatLoading).toBe(false); + } else { + expect(state.lastError).toBeNull(); + expect(state.chatError).toBeNull(); + expect(getChatHistoryLoadState(state)).toMatchObject({ phase: "pending-connection" }); + } + }, + ); it("ignores stale global history responses after switching selected agents", async () => { const workRequest = createDeferred(); @@ -1693,14 +1583,14 @@ describe("loadChatHistory retry handling", () => { sessionKey: "global", assistantAgentId: "work", agentsList: { defaultId: "main" }, - chatMessages: [textMessage("assistant", "visible old")], + chatMessages: [replyText("visible old")], }); const load = loadChatHistory(state); state.assistantAgentId = "main"; workRequest.resolve(createAssistantHistory("work history", { thinkingLevel: "high" })); await load; expect(state.chatLoading).toBe(false); - expect(state.chatMessages).toEqual([textMessage("assistant", "visible old")]); + expect(state.chatMessages).toEqual([replyText("visible old")]); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ @@ -1715,11 +1605,10 @@ describe("aborted chat diagnostics", () => { const expectedError = "Error: This reply stopped because the provider was signed out. Sign in again or choose another model."; const state = createAbortDiagnosticState(); - const payload: ChatEventPayload = chatEvent("aborted", { + receive(state, "aborted", { runId: "run-validation-abort", stopReason, }); - handleChatGatewayEvent(state, payload); expect(state.chatRunError?.summary ?? null).toBe(expectedError); expect(state.chatRunStatus).toMatchObject({ phase: "interrupted", @@ -1735,43 +1624,33 @@ describe("aborted chat diagnostics", () => { expect(state.chatRunId).toBeNull(); }); - it("does not restore an old diagnostic while a newer send awaits its ACK", () => { - const state = createAbortDiagnosticState("run-old"); - receive(state, "aborted", { runId: "run-old" }); - state.chatQueue = [ - { - id: "pending-new-send", - text: "New request", - createdAt: 200, - sendRunId: "run-new", - sendState: "sending", - }, - ]; - receive(state, "aborted", { - runId: "run-old", - errorMessage: "edit tool validation failed: invalid arguments", - }); - expect(state.chatRunError).toBeNull(); - expect(state.chatRunId).toBeNull(); - expect(state.lastLocalTerminalReconcile?.runId).toBe("run-old"); - }); - - it("does not publish an old aborted diagnostic after a newer run completes", () => { - const state = createAbortDiagnosticState("run-old"); - receive(state, "aborted", { runId: "run-old" }); - state.chatRunId = "run-new"; - state.chatStream = "New final answer"; - receive(state, "final", { - runId: "run-new", - message: textMessage("assistant", "New final answer"), - }); - receive(state, "aborted", { - runId: "run-old", - errorMessage: "edit tool validation failed: invalid arguments", - }); - expect(state.chatRunError).toBeNull(); - expect(state.lastLocalTerminalReconcile?.runId).toBe("run-new"); - expect(state.chatRunId).toBeNull(); - expect(state.chatMessages.at(-1)).toMatchObject(textMessage("assistant", "New final answer")); - }); + it.each(["pending", "completed"] as const)( + "does not restore an old diagnostic after a newer %s send", + (phase) => { + const state = createAbortDiagnosticState("run-old"); + receive(state, "aborted", { runId: "run-old" }); + if (phase === "pending") { + state.chatQueue = [pendingSend("run-new")]; + } else { + state.chatRunId = "run-new"; + state.chatStream = "New final answer"; + receive(state, "final", { + runId: "run-new", + message: replyText("New final answer"), + }); + } + receive(state, "aborted", { + runId: "run-old", + errorMessage: "edit tool validation failed: invalid arguments", + }); + expect(state.chatRunError).toBeNull(); + expect(state.lastLocalTerminalReconcile?.runId).toBe( + phase === "pending" ? "run-old" : "run-new", + ); + expect(state.chatRunId).toBeNull(); + if (phase === "completed") { + expect(state.chatMessages.at(-1)).toMatchObject(replyText("New final answer")); + } + }, + ); }); diff --git a/ui/src/pages/chat/components/chat-audio-player.test.ts b/ui/src/pages/chat/components/chat-audio-player.test.ts index f6458adaa540..b2c2e73eecb5 100644 --- a/ui/src/pages/chat/components/chat-audio-player.test.ts +++ b/ui/src/pages/chat/components/chat-audio-player.test.ts @@ -9,6 +9,49 @@ import { CHAT_AUDIO_WAVEFORM_MAX_BYTES } from "./chat-audio-waveform.ts"; type ChatAudioPlayer = HTMLElementTagNameMap["openclaw-chat-audio-player"]; const audio = (player: ChatAudioPlayer) => player.querySelector("audio")!; +const downloadLink = (player: ChatAudioPlayer) => + player.querySelector("a[download]")!; +const headerDownload = (player: ChatAudioPlayer) => + player.querySelector(".chat-assistant-attachment-card__download"); +const waveformElement = (player: ChatAudioPlayer) => + player.querySelector(".chat-audio-player__waveform"); +const waveformRects = (player: ChatAudioPlayer) => + player.querySelectorAll(".chat-audio-player__waveform rect"); +const seekInput = (player: ChatAudioPlayer) => + player.querySelector(".chat-audio-player__seek")!; +const source = (player: ChatAudioPlayer) => player.querySelector("audio")?.getAttribute("src"); +function pendingFetch() { + let resolve!: (response: Response) => void; + const fetchMock = vi.fn( + async () => + await new Promise((respond) => { + resolve = respond; + }), + ); + vi.stubGlobal("fetch", fetchMock); + return { fetchMock, resolve: (response: Response) => resolve(response) }; +} +async function update(player: ChatAudioPlayer, properties: Partial) { + Object.assign(player, properties); + await player.updateComplete; +} +async function fail(player: ChatAudioPlayer) { + audio(player).dispatchEvent(new Event("error")); + await player.updateComplete; +} +function transcodePlayer() { + return createPlayer("voice", { + src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", + sourceIdentity: "/tmp/voice.caf", + label: "voice.caf", + playback: "transcode", + }); +} +function expectUnavailable(player: ChatAudioPlayer) { + expect(card(player, "--compact")).not.toBeNull(); + expect(card(player, "__reason")).toBeNull(); + expect(player.querySelector(".chat-audio-player")).toBeNull(); +} const control = (player: ChatAudioPlayer, suffix: string) => player.querySelector(`.chat-audio-player__${suffix}`)!; const card = (player: ChatAudioPlayer, suffix = "") => @@ -22,21 +65,26 @@ const audioResponse = () => status: 200, headers: { "Content-Type": "audio/mpeg", "Content-Length": "4" }, }); -function mockPlayback(media: HTMLMediaElement) { - let paused = true; +function mockPlayback(media: HTMLMediaElement, active = false, events = true, pauseEvent = events) { + let paused = !active; Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); return { play: vi.spyOn(media, "play").mockImplementation(async () => { - paused = false; - media.dispatchEvent(new Event("play")); + if (!active) { + paused = false; + } + if (events) { + media.dispatchEvent(new Event("play")); + } }), pause: vi.spyOn(media, "pause").mockImplementation(() => { paused = true; - media.dispatchEvent(new Event("pause")); + if (pauseEvent) { + media.dispatchEvent(new Event("pause")); + } }), }; } - function setMediaNumber( media: HTMLMediaElement, property: "currentTime" | "duration", @@ -44,20 +92,15 @@ function setMediaNumber( ) { Object.defineProperty(media, property, { configurable: true, writable: true, value }); } - function mockActivePlayback(media: HTMLMediaElement) { - let paused = false; - Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); setMediaNumber(media, "currentTime", 20); setMediaNumber(media, "duration", 80); - return { - play: vi.spyOn(media, "play").mockResolvedValue(undefined), - pause: vi.spyOn(media, "pause").mockImplementation(() => { - paused = true; - }), - }; + return mockPlayback(media, true, false); +} +function mockPlay(media: HTMLMediaElement) { + Object.defineProperty(media, "paused", { configurable: true, value: true }); + return vi.spyOn(media, "play").mockResolvedValue(undefined); } - async function createPlayer( label: string, overrides: Partial = {}, @@ -124,39 +167,20 @@ afterEach(() => { }); describe("ChatAudioPlayer", () => { - it("keeps the download action for normalized base64 audio", async () => { - const player = await createPlayer("inline", { - src: "data:audio/wav;base64,UklGRg==", - sourceIdentity: "inline-audio", - label: "inline.wav", - }); - - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("href"), - ).toBe("data:audio/wav;base64,UklGRg=="); - }); - it("keeps voice notes player-only and never opens the attachment sidebar", async () => { const player = await createPlayer("voice-note"); - player.voiceNote = true; - player.onExpand = vi.fn(); - player.sizeBytes = 2048; - await player.updateComplete; + await update(player, { voiceNote: true, onExpand: vi.fn(), sizeBytes: 2048 }); expect(player.querySelector(".chat-assistant-attachment-card__header")).toBeNull(); expect(player.querySelector(".chat-attachment-file-icon, [data-openable]")).toBeNull(); expect(player.textContent).not.toContain("voice-note.mp3"); expect(player.querySelector('[role="group"]')?.getAttribute("aria-label")).toBe("Voice note"); player.querySelector(".chat-assistant-attachment-card")!.click(); expect(player.onExpand).not.toHaveBeenCalled(); - - audio(player).dispatchEvent(new Event("error")); - await player.updateComplete; + await fail(player); expect(player.querySelector("audio, .chat-audio-player__toggle")).toBeNull(); expect(player.querySelectorAll("[role=status]")).toHaveLength(1); expect(player.querySelector("[role=status]")?.textContent).toContain("Preview unavailable"); - expect(player.querySelector("a[download]")).toMatchObject({ + expect(downloadLink(player)).toMatchObject({ href: "https://example.com/voice-note.mp3", target: "_blank", rel: "noreferrer", @@ -164,55 +188,38 @@ describe("ChatAudioPlayer", () => { expect(card(player, "__expand")).toBeNull(); }); - it("formats elapsed, total, and buffered media time from metadata", async () => { - const player = await createPlayer("timing"); - expect(times(player)).toEqual(["0:00 / 0:00"]); - + it("drives play, pause, seek, and keyboard state through the hidden audio element", async () => { + const inline = "data:audio/wav;base64,UklGRg=="; + const player = await createPlayer("briefing", { src: inline }); + expect(headerDownload(player)?.getAttribute("href")).toBe(inline); const media = audio(player); - setMediaNumber(media, "currentTime", 65.9); - setMediaNumber(media, "duration", 3_665); + expect(times(player)).toEqual(["0:00 / 0:00"]); Object.defineProperty(media, "buffered", { configurable: true, value: { length: 1, end: () => 1_832.5 }, }); + setMediaNumber(media, "duration", 3_665); + setMediaNumber(media, "currentTime", 65.9); media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - expect(times(player)).toEqual(["1:05 / 61:05"]); - expect( - player - .querySelector(".chat-audio-player__seek")! - .style.getPropertyValue("--chat-audio-buffered"), - ).toBe("50%"); - }); - - it("drives play, pause, seek, and keyboard state through the hidden audio element", async () => { - const player = await createPlayer("briefing"); - const media = audio(player); + expect(seekInput(player).style.getPropertyValue("--chat-audio-buffered")).toBe("50%"); setMediaNumber(media, "duration", 125); setMediaNumber(media, "currentTime", 0); const { play, pause } = mockPlayback(media); - media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - expect(player.querySelector(".chat-audio-player__time")?.textContent).toContain("2:05"); - + expect(times(player)).toEqual(["0:00 / 2:05"]); control(player, "toggle").click(); await waitForSolid(() => expect(play).toHaveBeenCalledOnce()); await waitForSolid(() => expect(control(player, "toggle")?.getAttribute("aria-label")).toBe("Pause"), ); - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("download"), - ).toBe("briefing.mp3"); - - const seek = player.querySelector(".chat-audio-player__seek")!; + expect(headerDownload(player)?.getAttribute("download")).toBe("briefing.mp3"); + const seek = seekInput(player); seek.value = "35"; seek.dispatchEvent(new Event("input", { bubbles: true })); expect(media.currentTime).toBe(35); - for (const [key, time] of [ ["ArrowRight", 40], ["ArrowUp", 45], @@ -224,12 +231,13 @@ describe("ChatAudioPlayer", () => { expect(event.defaultPrevented).toBe(true); expect(media.currentTime).toBe(time); } - const controls = player.querySelector(".chat-audio-player")!; controls.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); expect(media.currentTime).toBe(40); controls.dispatchEvent(new KeyboardEvent("keydown", { key: " ", bubbles: true })); expect(pause).toHaveBeenCalledOnce(); + await player.updateComplete; + expect(times(player)).toEqual(["0:40 / 2:05"]); }); it("does not expand the attachment when muting through the changing SVG icon", async () => { @@ -256,58 +264,37 @@ describe("ChatAudioPlayer", () => { const status = first.querySelector('[role="status"]'); expect(status?.textContent).toBe(""); const firstMedia = audio(first); - let paused = true; - Object.defineProperty(firstMedia, "paused", { configurable: true, get: () => paused }); - const play = vi.spyOn(firstMedia, "play").mockImplementation(async () => { - paused = false; - firstMedia.dispatchEvent(new Event("play")); - }); - const pauseFirst = vi.spyOn(firstMedia, "pause").mockImplementation(() => { - paused = true; - }); - + const { play, pause: pauseFirst } = mockPlayback(firstMedia, false, true, false); control(first, "toggle").click(); await first.updateComplete; expect(play).toHaveBeenCalledOnce(); expect(control(first, "toggle")?.getAttribute("aria-label")).toBe("Pause"); - firstMedia.dispatchEvent(new Event("error")); await first.updateComplete; expect(first.querySelector(".chat-audio-player__toggle")).toBeNull(); - expect(card(first, "--compact")).not.toBeNull(); - expect(card(first, "__reason")).toBeNull(); - expect(first.querySelector(".chat-audio-player")).toBeNull(); + expectUnavailable(first); expect(first.querySelector('[role="status"]')).toBe(status); expect(status?.textContent).toBe("broken.mp3: Preview unavailable"); expect(status?.getAttribute("aria-live")).toBe("polite"); expect(status?.getAttribute("aria-atomic")).toBe("true"); - expect( - first - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("download"), - ).toBe("broken.mp3"); + expect(headerDownload(first)?.getAttribute("download")).toBe("broken.mp3"); expect(pauseFirst).toHaveBeenCalledOnce(); pauseFirst.mockClear(); - const second = await createPlayer("working"); audio(second).dispatchEvent(new Event("play")); expect(pauseFirst).not.toHaveBeenCalled(); - first.src = "https://example.com/broken.mp3?renewed"; await waitForSolid(() => expect(control(first, "toggle")).not.toBeNull()); expect(first.querySelector('[role="status"]')).toBe(status); expect(status?.textContent).toBe(""); - audio(first).dispatchEvent(new Event("error")); - await first.updateComplete; + await fail(first); expect(status?.textContent).toBe("broken.mp3: Preview unavailable"); - first.src = "https://example.com/replacement.mp3"; first.sourceIdentity = "media://replacement"; first.label = "replacement.mp3"; await waitForSolid(() => expect(control(first, "toggle")).not.toBeNull()); expect(status?.textContent).toBe(""); - audio(first).dispatchEvent(new Event("error")); - await first.updateComplete; + await fail(first); expect(status?.textContent).toBe("replacement.mp3: Preview unavailable"); }); @@ -332,7 +319,7 @@ describe("ChatAudioPlayer", () => { outside.focus(); } await player.updateComplete; - const download = player.querySelector("a[download]")!; + const download = downloadLink(player); expect(download.getAttribute("download")).toBe("focus-" + target + ".mp3"); expect(document.activeElement).toBe( target === "outside" || target === "moved" ? outside : download, @@ -341,54 +328,13 @@ describe("ChatAudioPlayer", () => { }, ); - it("shows preparing, retries a 202 rendition, and then plays", async () => { - vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 202 })) - .mockResolvedValueOnce(new Response(null, { status: 200 })); - vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("voice", { - src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", - sourceIdentity: "/tmp/voice.caf", - label: "voice.caf", - playback: "transcode", - }); - await Promise.resolve(); - await vi.advanceTimersByTimeAsync(0); - await player.updateComplete; - - expect(player.textContent).toContain("Preparing playback…"); - expect(player.querySelector("[role=status]")?.textContent).toBe(""); - await vi.advanceTimersByTimeAsync(2_000); - await player.updateComplete; - - const media = audio(player); - let paused = true; - Object.defineProperty(media, "paused", { configurable: true, get: () => paused }); - const play = vi.spyOn(media, "play").mockImplementation(async () => { - paused = false; - media.dispatchEvent(new Event("play")); - }); - expect(media.getAttribute("src")).toContain("mediaTicket=ticket&playback=1"); - control(player, "toggle").click(); - await vi.advanceTimersByTimeAsync(0); - await player.updateComplete; - expect(play).toHaveBeenCalledOnce(); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - it("keeps header Download focused when the parent Expand callback changes", async () => { const player = await createPlayer("callback-focus", { onExpand: vi.fn() }); - const download = player.querySelector( - ".chat-assistant-attachment-card__download", - )!; + const download = headerDownload(player)!; download.focus(); expect(document.activeElement).toBe(download); - player.onExpand = vi.fn(); await player.updateComplete; - expect(document.activeElement).toBe(download); }); @@ -419,12 +365,9 @@ describe("ChatAudioPlayer", () => { : player.querySelector(`.chat-assistant-attachment-card__${target}`)!; focused.focus(); expect(document.activeElement).toBe(focused); - respond(new Response(null, { status: 500 })); await waitForSolid(() => expect(card(player, "--compact")).not.toBeNull()); - expect(document.activeElement).toBe( - target === "outside" ? outside : player.querySelector("a[download]"), - ); + expect(document.activeElement).toBe(target === "outside" ? outside : downloadLink(player)); }, ); @@ -432,62 +375,47 @@ describe("ChatAudioPlayer", () => { vi.useFakeTimers(); const fetchMock = vi.fn(async () => new Response(null, { status: 202 })); vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("voice", { - src: "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=ticket", - sourceIdentity: "/tmp/voice.caf", - label: "voice.caf", - playback: "transcode", - }); + const player = await transcodePlayer(); await vi.runAllTimersAsync(); await player.updateComplete; - - expect(card(player, "--compact")).not.toBeNull(); - expect(card(player, "__reason")).toBeNull(); - expect(player.querySelector(".chat-audio-player")).toBeNull(); - expect( - player - .querySelector(".chat-assistant-attachment-card__download") - ?.getAttribute("href"), - ).not.toContain("playback=1"); - + expectUnavailable(player); + expect(headerDownload(player)?.getAttribute("href")).not.toContain("playback=1"); const failedAttempts = fetchMock.mock.calls.length; - player.label = "renamed-voice.caf"; - player.onMediaLoaded = vi.fn(); - player.sizeBytes = 2048; - player.serverDurationMs = 4000; - await player.updateComplete; + await update(player, { + label: "renamed-voice.caf", + onMediaLoaded: vi.fn(), + sizeBytes: 2048, + serverDurationMs: 4000, + }); await vi.runAllTimersAsync(); expect(fetchMock).toHaveBeenCalledTimes(failedAttempts); expect(player.querySelector("audio")).toBeNull(); expect(card(player, "--compact")).not.toBeNull(); - fetchMock.mockResolvedValue(new Response(null, { status: 200 })); player.src = "/__openclaw__/assistant-media?source=voice.caf&mediaTicket=recovered"; await player.updateComplete; await vi.runAllTimersAsync(); await player.updateComplete; - expect(card(player, "--compact")).toBeNull(); - expect(player.querySelector("audio")?.getAttribute("src")).toContain( - "mediaTicket=recovered&playback=1", - ); + expect(source(player)).toContain("mediaTicket=recovered&playback=1"); }); it("renders decoded waveform peaks and scopes waveform cache reuse to auth context", async () => { const intersect = observeIntersections(); const decodeAudioData = mockAudioContext(); - let resolveFetch: ((response: Response) => void) | undefined; - const fetchMock = vi.fn( - async () => - await new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - vi.stubGlobal("fetch", fetchMock); + const { fetchMock, resolve: resolveFetch } = pendingFetch(); vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:waveform-audio"); vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); const player = await createPlayer("waveform-reuse"); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + const initialPlay = mockPlay(audio(player)); + control(player, "toggle").click(); + await initialPlay.mock.results[0]!.value; + await Promise.resolve(); + flush(); + expect(waveformElement(player)).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(decodeAudioData).not.toHaveBeenCalled(); + initialPlay.mockRestore(); player.serverDurationMs = 4_000; await player.updateComplete; expect(fetchMock).not.toHaveBeenCalled(); @@ -495,24 +423,16 @@ describe("ChatAudioPlayer", () => { await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); const media = audio(player); const { play } = mockPlayback(media); - resolveFetch?.(audioResponse()); - await waitForSolid(() => - expect(player.querySelectorAll(".chat-audio-player__waveform rect")).toHaveLength(96), - ); + await waitForSolid(() => expect(waveformRects(player)).toHaveLength(96)); await player.updateComplete; expect( - new Set( - Array.from(player.querySelectorAll(".chat-audio-player__waveform rect"), (rect) => - rect.getAttribute("height"), - ), - ).size, + new Set(Array.from(waveformRects(player), (rect) => rect.getAttribute("height"))).size, ).toBeGreaterThan(1); player.voiceNote = true; await player.updateComplete; - const voiceHeights = Array.from( - player.querySelectorAll(".chat-audio-player__waveform rect"), - (rect) => Number(rect.getAttribute("height")), + const voiceHeights = Array.from(waveformRects(player), (rect) => + Number(rect.getAttribute("height")), ); expect(Math.min(...voiceHeights)).toBeGreaterThanOrEqual(6); expect(Math.max(...voiceHeights)).toBeLessThanOrEqual(14); @@ -520,29 +440,22 @@ describe("ChatAudioPlayer", () => { expect(fetchMock).toHaveBeenCalledOnce(); expect(decodeAudioData).toHaveBeenCalledOnce(); expect(media.getAttribute("src")).toBe("blob:waveform-audio"); - control(player, "toggle").click(); await waitForSolid(() => expect(play).toHaveBeenCalledOnce()); expect(media.getAttribute("src")).toBe("blob:waveform-audio"); expect(fetchMock).toHaveBeenCalledOnce(); - player.authToken = "new-principal"; await player.updateComplete; expect(media.getAttribute("src")).toBe("https://example.com/waveform-reuse.mp3"); - player.remove(); - const refreshed = document.createElement("openclaw-chat-audio-player"); - refreshed.src = "https://example.com/waveform-reuse.mp3?mediaTicket=fresh"; - refreshed.sourceIdentity = "media://waveform-reuse"; - refreshed.authToken = "different-principal"; - refreshed.label = "waveform-reuse.mp3"; - refreshed.serverDurationMs = 4_000; - mountSolid(() => refreshed); - await refreshed.updateComplete; + const refreshed = await createPlayer("waveform-reuse", { + src: "https://example.com/waveform-reuse.mp3?mediaTicket=fresh", + authToken: "different-principal", + serverDurationMs: 4_000, + }); intersect(); const refreshedMedia = refreshed.querySelector("audio")!; - Object.defineProperty(refreshedMedia, "paused", { configurable: true, value: true }); - vi.spyOn(refreshedMedia, "play").mockResolvedValue(undefined); + mockPlay(refreshedMedia); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledTimes(3)); resolveFetch?.(new Response(null, { status: 500 })); }); @@ -576,16 +489,13 @@ describe("ChatAudioPlayer", () => { const label = change === "unmount" ? "waveform-unmount" : "waveform-replacement"; const player = await createPlayer(label, { serverDurationMs: 4_000 }); intersect(); - await waitForSolid(() => - expect(player.querySelector(".chat-audio-player__waveform")).not.toBeNull(), - ); - const waveform = player.querySelector(".chat-audio-player__waveform")!; + await waitForSolid(() => expect(waveformElement(player)).not.toBeNull()); + const waveform = waveformElement(player)!; const media = player.querySelector("audio")!; expect(observe).toHaveBeenCalledExactlyOnceWith(waveform); expect(disconnect).not.toHaveBeenCalled(); expect(media.getAttribute("src")).toBe("blob:waveform-disposal"); const { pause } = mockActivePlayback(media); - if (change === "unmount") { player.remove(); await Promise.resolve(); @@ -627,21 +537,17 @@ describe("ChatAudioPlayer", () => { intersect(); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); resolveFirstFetch?.(audioResponse()); - await waitForSolid(() => - expect(player.querySelector("audio")?.getAttribute("src")).toBe("blob:waveform-audio"), - ); - + await waitForSolid(() => expect(source(player)).toBe("blob:waveform-audio")); const media = audio(player); Object.defineProperty(media, "error", { configurable: true, value: {} as MediaError }); media.dispatchEvent(new Event("error")); await player.updateComplete; expect(card(player, "--compact")).not.toBeNull(); expect(media.isConnected).toBe(false); - player.src = "https://example.com/waveform-recovery.mp3?mediaTicket=recovered"; await player.updateComplete; await waitForSolid(() => - expect(player.querySelector("audio")?.getAttribute("src")).toBe( + expect(source(player)).toBe( "https://example.com/waveform-recovery.mp3?mediaTicket=recovered", ), ); @@ -650,25 +556,6 @@ describe("ChatAudioPlayer", () => { expect(card(player, "--compact")).toBeNull(); }); - it("skips waveform work without a server-probed duration", async () => { - const decodeAudioData = stubAudioContext(); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - const player = await createPlayer("unknown-duration"); - const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); - const play = vi.spyOn(media, "play").mockResolvedValue(undefined); - - control(player, "toggle").click(); - await play.mock.results[0]!.value; - await Promise.resolve(); - flush(); - - expect(fetchMock).not.toHaveBeenCalled(); - expect(decodeAudioData).not.toHaveBeenCalled(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); - }); - it("discards waveform peaks when decoded duration exceeds the server gate by 20 percent", async () => { const decodeAudioData = vi.fn(async () => decodedAudio(121)); const AudioContextMock = vi.fn(function (options?: AudioContextOptions) { @@ -684,17 +571,14 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("duration-mismatch"); player.serverDurationMs = 100_000; const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); setMediaNumber(media, "duration", 100); media.dispatchEvent(new Event("loadedmetadata")); await player.updateComplete; - vi.spyOn(media, "play").mockResolvedValue(undefined); - + mockPlay(media); control(player, "toggle").click(); await waitForSolid(() => expect(media.getAttribute("src")).toBe("blob:duration-mismatch")); - expect(decodeAudioData).toHaveBeenCalledOnce(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + expect(waveformElement(player)).toBeNull(); expect(times(player)).toEqual(["0:00 / 1:40"]); }); @@ -715,17 +599,14 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("oversized-waveform"); player.serverDurationMs = 4_000; const media = audio(player); - Object.defineProperty(media, "paused", { configurable: true, value: true }); - const play = vi.spyOn(media, "play").mockResolvedValue(undefined); - + const play = mockPlay(media); control(player, "toggle").click(); expect(play).toHaveBeenCalledOnce(); await waitForSolid(() => expect(fetchMock).toHaveBeenCalledOnce()); await waitForSolid(() => expect(response.body?.locked).toBe(false)); - expect(createObjectURL).not.toHaveBeenCalled(); expect(decodeAudioData).not.toHaveBeenCalled(); - expect(player.querySelector(".chat-audio-player__waveform")).toBeNull(); + expect(waveformElement(player)).toBeNull(); expect(media.getAttribute("src")).toBe("https://example.com/oversized-waveform.mp3"); }); @@ -743,31 +624,21 @@ describe("ChatAudioPlayer", () => { player.serverDurationMs = 4_000; const media = audio(player); const { play } = mockPlayback(media); - control(player, "toggle").click(); await waitForSolid(() => expect(FailingAudioContext).toHaveBeenCalledOnce()); await Promise.resolve(); flush(); media.pause(); control(player, "toggle").click(); - expect(play).toHaveBeenCalledTimes(2); expect(media.getAttribute("src")).toBe("blob:context-fallback"); }); it("cancels the old source when identity changes during rendition preparation", async () => { - let resolveFetch: ((response: Response) => void) | undefined; - const fetchMock = vi.fn( - async () => - await new Promise((resolve) => { - resolveFetch = resolve; - }), - ); - vi.stubGlobal("fetch", fetchMock); + const { fetchMock, resolve: resolveFetch } = pendingFetch(); const player = await createPlayer("identity-before"); const media = audio(player); const { play, pause } = mockActivePlayback(media); - player.src = "/__openclaw__/assistant-media?source=after.caf&mediaTicket=ticket"; player.sourceIdentity = "media://identity-after"; player.label = "after.caf"; @@ -776,7 +647,6 @@ describe("ChatAudioPlayer", () => { expect(pause).toHaveBeenCalledOnce(); expect(fetchMock).not.toHaveBeenCalled(); await waitForSolid(() => expect(player.textContent).toContain("Preparing playback…")); - expect(pause).toHaveBeenCalledOnce(); expect(fetchMock).toHaveBeenCalledOnce(); expect(fetchMock.mock.calls[0]?.[0]).toBe( @@ -796,7 +666,6 @@ describe("ChatAudioPlayer", () => { const player = await createPlayer("disconnect-resume"); const media = audio(player); const { play, pause } = mockActivePlayback(media); - player.src = "https://example.com/disconnect-resume-fresh.mp3"; await player.updateComplete; media.dispatchEvent(new Event("error")); @@ -807,7 +676,6 @@ describe("ChatAudioPlayer", () => { mountSolid(() => player); media.currentTime = 0; media.dispatchEvent(new Event("loadedmetadata")); - expect(play).not.toHaveBeenCalled(); }); @@ -816,18 +684,15 @@ describe("ChatAudioPlayer", () => { const firstMedia = audio(first); const { play: playFirst, pause: pauseFirst } = mockActivePlayback(firstMedia); firstMedia.dispatchEvent(new Event("play")); - first.src = "https://example.com/superseded-resume-fresh.mp3"; await first.updateComplete; firstMedia.dispatchEvent(new Event("error")); - const second = await createPlayer("superseding-player"); audio(second).dispatchEvent(new Event("play")); expect(pauseFirst).toHaveBeenCalledOnce(); second.remove(); firstMedia.currentTime = 0; firstMedia.dispatchEvent(new Event("loadedmetadata")); - expect(playFirst).not.toHaveBeenCalled(); }); }); diff --git a/ui/src/pages/chat/components/chat-transcript-controller.test.tsx b/ui/src/pages/chat/components/chat-transcript-controller.test.tsx index 0833630c7ec0..f8b42da5aa65 100644 --- a/ui/src/pages/chat/components/chat-transcript-controller.test.tsx +++ b/ui/src/pages/chat/components/chat-transcript-controller.test.tsx @@ -8,11 +8,7 @@ import { flush } from "../../../test-helpers/solid-settle.ts"; import { projectChatFields } from "../chat-field-projection.ts"; import { makeChatHost } from "../chat-host.test-support.ts"; import { createTestTranscript, stubAnimationFrames } from "../chat-view.test-helpers.ts"; -import { - handleChatScrollTakeover, - saveChatSessionScrollPosition, - scheduleCommittedChatScroll, -} from "../scroll.ts"; +import { handleChatScrollTakeover, saveChatSessionScrollPosition } from "../scroll.ts"; import { SIDEBAR_GEOMETRY_COMMIT_EVENT } from "../sidebar-layout.ts"; import { render, @@ -178,23 +174,6 @@ it.each([false, true])("re-observes re-stamped rows (foreign host=%s)", async (f transcript.dispose(); }); -it("measures newly inserted rows after Solid connects them", async () => { - transcriptDomState.detachedRowHeight = 0; - const props = threadProps("pane-commentary-insert", "agent:main:session-a", [ - { role: "assistant", content: "commentary", timestamp: 1_000 }, - { role: "user", content: "next turn", timestamp: 2_000 }, - ]); - const { container, transcript, draw } = threadFixture(props); - draw(); - transcript.start(); - transcript.update(); - await flushDeferredRowPrune(); - emitRowSizes(transcriptRows(container), 100); - draw(); - - expect(transcriptSize(container)).toBe(200); -}); - it("updates the visible range on native scroll without a parent render", async () => { const flushFrames = stubAnimationFrames(); const props = threadProps("local-geometry", "agent:main:local-geometry", messages(40, "message")); @@ -223,37 +202,6 @@ it("updates the visible range on native scroll without a parent render", async ( expect(Number(transcriptRows(container)[0]?.dataset.index)).toBeGreaterThan(0); }); -it("reveals committed initial row sizes without waiting for an animation frame", async () => { - stubAnimationFrames(); - vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); - const host = new TranscriptTestHost(); - cleanup.push(() => host.remove()); - const transcript = (host.transcript = createChatTranscript(() => "native-measured-tail")); - const rows = numberedContentRows(6); - const scroller = document.createElement("div"); - scroller.className = "chat-thread"; - host.renderTranscript = () => { - const content = renderReadySession(transcript, "native-measured-tail", rows); - if (scroller.firstChild !== content) { - scroller.replaceChildren(content); - } - return scroller; - }; - document.body.append(host); - await host.settleUpdates(); - const extent = requireElement(host, ".chat-thread-inner"); - expect(transcriptRows(host).every((row) => observedElements.has(row))).toBe(true); - await Promise.resolve(); - flush(); - await host.settleUpdates(); - expect(extent.style.visibility).toBe(""); - expect(transcriptRows(host).every((row) => row.style.containIntrinsicBlockSize === "100px")).toBe( - true, - ); - expect(host.textContent).toContain("row 5"); - host.remove(); -}); - it("keeps retained MCP rows and the virtual row model atomic through teardown", async () => { const teardownPending = createDeferred(); transcriptDomState.measuredRowHeight = 180; @@ -409,22 +357,6 @@ it("retains history controls across callback and loading replacements", async () expect(first).toHaveBeenCalledOnce(); }); -it("renders the history tail immediately after an empty disconnected shell", () => { - const flushFrames = stubAnimationFrames(); - const props = threadProps("cold-load-tail", "agent:main:cold-load-tail", []); - const { container, transcript, draw } = threadFixture(props); - draw(); - geometry(container.querySelector(".chat-thread")!, { clientHeight: 600, scrollHeight: 600 }); - transcript.start(); - transcript.update(); - flushFrames(); - - draw({ ...props, messages: messages(40, "Cached history") }); - - expect(container.textContent).toContain("Cached history 39"); - expect(container.textContent).not.toContain("Cached history 0"); -}); - it.each([true, false])("settles a non-overflowing restore (initially loading=%s)", (loading) => { const flushFrames = stubAnimationFrames(); if (!loading) { @@ -498,13 +430,10 @@ it("retries a growing restore without pane commits and renders the settled reade transcript.scrollToOffset(2420, onSettled); await host.settleUpdates(); const initialCommits = host.committedRenders; - const retryCommits: number[] = []; for (const height of [600, 600, 600, 720, 720, 720, 720, 900, 900, 900, 900]) { scrollHeight = height; - const before = host.committedRenders; flushFrame(); await host.settleUpdates(); - retryCommits.push(host.committedRenders - before); expect(onSettled).not.toHaveBeenCalled(); } scrollHeight = 4800; @@ -514,7 +443,6 @@ it("retries a growing restore without pane commits and renders the settled reade expect(container.scrollTop).toBe(2420); expect(container.querySelector("output")?.textContent).toBe("Reading history"); expect.soft(host.committedRenders - initialCommits).toBe(0); - expect.soft(retryCommits).toEqual(Array(11).fill(0)); // jsdom does not emit native scroll events for scrollTop writes. Deliver the browser read-back. scroll(container); @@ -747,80 +675,6 @@ it("keeps smooth latest through settlement and idle until the target", async () expect(transcript.isProgrammaticScroll).toBe(false); }); -it.each([true, false])( - "leaves height-resize follow to page policy with reader lock=%s", - async (locked) => { - const flushFrames = stubAnimationFrames(); - const policy = makeChatHost({ chatHasAutoScrolled: true }); - const onViewportResize = vi.fn(() => - scheduleCommittedChatScroll(policy, false, false, { source: "resize" }), - ); - const transcript = createChatTranscript(() => `height-resize-${locked}`, { - onViewportResize, - canFollowEnd: () => !policy.chatFollowLocked, - onReaderScroll: (towardEnd) => handleChatScrollTakeover(policy, towardEnd), - }); - const rows = numberedContentRows(12); - const { container } = await mountTestTranscript(`height-resize-${locked}`, rows, transcript); - const total = transcriptSize(container); - geometry(container, { clientHeight: 600, scrollHeight: total + 88 }); - const scrollTo = vi.fn(); - container.scrollTo = scrollTo; - policy.chatScrollElement = () => container; - policy.chatScrollToEnd = (options) => transcript.scrollToEnd(options); - emitSize(container, 600); - scroll(container, total - 600); - if (locked) { - container.dispatchEvent(new WheelEvent("wheel", { deltaY: -88 })); - } - expect(policy.chatFollowLocked).toBe(locked); - scrollTo.mockClear(); - - for (const height of [560, 640]) { - geometry(container, { clientHeight: height }); - emitSize(container, height); - // The observer only reports geometry; follow waits for the policy frame. - expect(scrollTo).not.toHaveBeenCalled(); - flushFrames(); - if (locked) { - expect(scrollTo).not.toHaveBeenCalled(); - expect(container.scrollTop).toBe(total - 600); - } else { - expect(scrollTo).toHaveBeenLastCalledWith({ - top: total + 88 - height, - behavior: "auto", - }); - } - scrollTo.mockClear(); - } - expect(onViewportResize).toHaveBeenCalledTimes(2); - expect(policy.chatFollowLocked).toBe(locked); - }, -); - -it("keeps a reader above the padded real end stationary when a rendered row grows", async () => { - transcriptDomState.measuredRowHeight = 120; - const rows = numberedContentRows(12); - const { container, renderRows } = await mountTestTranscript("padded-row-resize", rows); - const total = transcriptSize(container); - container.style.padding = "28px 0 56px"; - container.scrollTo = (options?: ScrollToOptions | number) => applyScroll(container, options); - geometry(container, { clientHeight: 600, scrollHeight: total + 84 }); - emitSize(container, 600); - scroll(container, container.scrollHeight - container.clientHeight - 44); - const readerOffset = container.scrollTop; - expect(Math.max(total - container.clientHeight - readerOffset, 0)).toBe(0); - const row = requireElement(container, '[data-index="11"]'); - expect(observedElements.has(row)).toBe(true); - - geometry(row, { offsetHeight: 192 }); - geometry(container, { scrollHeight: total + 84 + 72 }); - emitSize(row, 192); - renderRows(rows); - expect(transcriptSize(container)).toBe(total + 72); - expect(container.scrollTop).toBe(readerOffset); -}); - it.each([false, true])( "keeps disclosure anchoring through geometry commit unless interrupted=%s", async (interrupt) => { diff --git a/ui/src/pages/chat/components/chat-transcript-message-index.test.ts b/ui/src/pages/chat/components/chat-transcript-message-index.test.ts index 4a2d22e9e11c..dfd6eebd5d59 100644 --- a/ui/src/pages/chat/components/chat-transcript-message-index.test.ts +++ b/ui/src/pages/chat/components/chat-transcript-message-index.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { chatItemGroups } from "../chat-agent-run-grouping.ts"; import { resetWorkingProgress } from "../chat-progress.ts"; import { buildChatItems, type BuildChatItemsProps } from "../chat-thread-build.ts"; @@ -29,11 +29,9 @@ function makeTool( turnBoundary?: boolean, ) { return { - role: "toolResult", + ...makeMessage(id, seq, { role: "toolResult", content: "Evidence", timestamp, runId }), toolName: "read", toolCallId: id, - content: "Evidence", - timestamp, __openclaw: { id, seq, runId, turnBoundary }, }; } @@ -56,11 +54,7 @@ function chatProps(overrides: Partial = {}): BuildChatItems const chatItems = (overrides: Partial = {}) => buildChatItems(chatProps(overrides)); -const chainOptions = { - sessionKey, - runWorking: false, - searchActive: false, -}; +const chainOptions = { sessionKey, runWorking: false, searchActive: false }; const labels = { assistantName: "Assistant", userId: "reader", userName: "Reader" }; type Chain = ReturnType; type Index = ReturnType; @@ -101,41 +95,6 @@ const replyFrameShape = (outcome: unknown, ...parts: unknown[]) => ({ parts, }); -it.each([false, true])("merges adjacent continuation work across runs while live=%s", (live) => { - const messages = [ - history[0], - { ...partial, content: "Checking the remaining work.", phase: "commentary" }, - ...["run-1", "announce:review", "announce:resume"].map((runId, index) => - makeTool(`tool-${index}`, 3 + index, runId, 30 + index, true), - ), - ]; - for (const persisted of [messages, structuredClone(messages)]) { - const items = chatItems({ - messages: persisted, - runId: live ? "announce:resume" : null, - runWorking: live, - streamStartedAt: live ? 40 : null, - }); - const options = { ...chainOptions, runWorking: live }; - const chain = chainFor(items, options); - const parts = transcriptParts(chain); - const activities = kind(parts, "activity-run"); - expect(activities).toHaveLength(1); - expect(activities[0]?.groups.map((group) => group.runId)).toEqual([ - "run-1", - "announce:review", - "announce:resume", - ]); - expect(messagesIn(kind(parts, "group"))).toContain(persisted[1]); - expect(parts.some((item) => item.kind === "stream-run")).toBe(live); - expect(chainFor(items, { ...options })).toBe(chain); - const index = indexFor(chain); - expect(new Set([0, 1, 2].map((tool) => index.messageRowKeysById.get(`tool-${tool}`)))).toEqual( - new Set([activities[0]?.key]), - ); - } -}); - it.each([ { key: sessionKey, working: true, projected: false, untagged: false }, { key: sessionKey, working: true, projected: true, untagged: false }, @@ -183,8 +142,6 @@ describe("completed reply frame ownership", () => { ]; } - const frames = framesOf; - it.each<[string, (string | undefined)[], boolean, string?, boolean?]>([ ["missing prompt", ["run-1"], false], ["resumed run without prompt", ["run-u", "run-1"], false], @@ -205,7 +162,7 @@ describe("completed reply frame ownership", () => { const original = structuredClone(messages); const options = { sessionKey: key }; const chain = projectHistory(messages, options); - const [frame] = frames(chain); + const [frame] = framesOf(chain); expect(chain.transcriptItems).toEqual([ ...(prompt ? [expect.objectContaining({ role: "user" })] : []), frame, @@ -239,7 +196,7 @@ describe("completed reply frame ownership", () => { if (!prompt) { for (const restored of [structuredClone(messages), [history[0], ...messages]]) { const next = projectHistory(restored, options); - expect(frames(next)[0]?.key).toBe(frame?.key); + expect(framesOf(next)[0]?.key).toBe(frame?.key); const nextIndex = indexFor(next); for (const id of ["work-0", "final"]) { expect(nextIndex.messageRowKeysById.get(id)).toBe(index.messageRowKeysById.get(id)); @@ -267,13 +224,13 @@ describe("completed reply frame ownership", () => { }); messages.splice(prompt ? 2 : 1, 0, inserted); const chain = projectHistory(messages); - expect(frames(chain).at(-1)?.outcome).toMatchObject(completedOutcome(messages.at(-1))); + expect(framesOf(chain).at(-1)?.outcome).toMatchObject(completedOutcome(messages.at(-1))); const work = (forwarded ? chain.transcriptItems : transcriptParts(chain)).find( (item) => item.kind === "work-group", ); expect(work).toBeDefined(); if (forwarded) { - expect(messagesIn(frames(chain))).not.toContain(inserted); + expect(messagesIn(framesOf(chain))).not.toContain(inserted); const standalone = phase ? kind(chain.transcriptItems, "group") : [work!]; expect(messagesIn(standalone)).toContain(inserted); } else { @@ -282,10 +239,12 @@ describe("completed reply frame ownership", () => { kind(transcriptParts(chain), "group").filter((item) => item.role === "assistant"), ), ).toEqual([inserted, messages.at(-1)]); - expect(frames(chain).map((frame) => frame.runId)).toEqual( + expect(framesOf(chain).map((frame) => frame.runId)).toEqual( runId === "run-1" ? ["run-1"] : ["run-other", "run-1"], ); - expect(runId === "run-1" ? frames(chain)[0]?.parts[0] : chain.transcriptItems[1]).toBe(work); + expect(runId === "run-1" ? framesOf(chain)[0]?.parts[0] : chain.transcriptItems[1]).toBe( + work, + ); } }); @@ -298,7 +257,7 @@ describe("completed reply frame ownership", () => { }); messages.splice(2, 0, steer); const chain = projectHistory(messages); - const [before, frame] = frames(chain); + const [before, frame] = framesOf(chain); expect(chain.transcriptItems).toEqual([ expect.objectContaining({ role: "user" }), before, @@ -368,119 +327,40 @@ it("reuses transcript structure for scroll renders and rebuilds for its inputs", expect(indexFor(settled, expanded, { ...labels, userName: "Renamed" })).not.toBe(expandedIndex); }); -const work = vi.hoisted(() => ({ chainCalls: 0, chainItems: 0, positionItems: 0 })); -vi.mock("../chat-turn-presentation.ts", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - coalesceStreamRuns: (...args: Parameters) => { - work.chainCalls++; - work.chainItems += args[0].length; - return original.coalesceStreamRuns(...args); - }, - }; -}); -vi.mock("./chat-position-projection.ts", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - projectChatPositions: (...args: Parameters) => { - function* counted() { - for (const item of args[0]) { - work.positionItems++; - yield item; - } - } - return original.projectChatPositions(counted(), args[1], args[2], args[3]); - }, - }; -}); - afterEach(() => resetChatThreadState()); -function pastMessages(size: number, tagged = true): unknown[] { - return Array.from({ length: size }, (_, i) => { - const phase = i % (tagged ? 6 : 4); - const toolPhase = phase === 2 || phase === 4; - const finalPhase = phase === (tagged ? 5 : 3); - return { - role: phase === 0 ? "user" : toolPhase ? "toolResult" : "assistant", - content: phase === 0 ? "Question" : finalPhase ? "Answer" : "Work", - ...(toolPhase ? { toolName: "read", toolCallId: `call-${i}` } : {}), - ...(phase === 1 || (tagged && phase === 3) - ? { phase: "commentary" } - : finalPhase - ? { phase: "final_answer", stopReason: "stop" } - : {}), - timestamp: i + 1, - __openclaw: { - id: `history-${i}`, - seq: i + 1, - ...(tagged ? { runId: `history-run-${Math.floor(i / 6)}` } : {}), - }, - }; - }); -} -function streamInput(size: number): BuildChatItemsProps { - const messages = pastMessages(size); - const liveMessage = (id: string, offset: number, fields: Record) => - makeMessage(id, size + offset, { timestamp: size + offset, runId: "live", ...fields }); - messages.push( - liveMessage("live-user", 1, { role: "user" }), - liveMessage("live-comment", 2, { phase: "commentary" }), - makeTool("live-tool", size + 3, "live", size + 3), - ); - return chatProps({ - paneId: "stream-projection", - messages, - stream: "Initial", - streamStartedAt: size + 4, - runId: "live", - runWorking: true, - }); -} - -it("keeps stream-only projection work bounded by the tail across history sizes", () => { - const measure = (size: number) => { - resetChatThreadState(); - const input = streamInput(size); - const expanded = new Map(); - let initialChain: Chain | undefined; - const project = (stream: string) => { - const items = buildCachedChatItems({ ...input, stream }); - const chain = chainFor(items, { ...chainOptions, runWorking: true }); - initialChain ??= chain; - expect(chain.transcriptItems).toBe(initialChain.transcriptItems); - expect(chain.collapsedItems).toBe(initialChain.collapsedItems); - return indexFor(chain, expanded, labels); - }; - const initial = project("Initial"); - const previews = new Map(); - const presentation = projectTranscriptRows(initial.rows, previews, []); - Object.assign(work, { chainCalls: 0, chainItems: 0, positionItems: 0 }); - for (const stream of ["One", "One two", "One two three"]) { - const next = project(stream); - expect(next.rows).toBe(initial.rows); - expect(next.positionIndex.markers).toBe(initial.positionIndex.markers); - expect(projectTranscriptRows(next.rows, previews, [])).toBe(presentation); - } - return { ...work }; - }; - const small = measure(20); - const large = measure(400); - expect(large).toEqual(small); - expect(measure(1_000)).toEqual(small); - expect(large.chainCalls).toBe(0); - expect(large.positionItems).toBeLessThanOrEqual(6); -}); - it.each([ { name: "dashboard frame and queued tail", framed: true, searchActive: false }, { name: "unframed stream without boundary", framed: false, searchActive: false }, { name: "search projection", framed: true, searchActive: true }, ])("recomputes streamed history: $name", ({ framed, searchActive }) => { - const input = streamInput(6); - input.messages = [...pastMessages(4, false), ...input.messages.slice(-3)]; + const historical = (seq: number, fields: Record) => + makeMessage(`history-${seq - 1}`, seq, { + timestamp: seq, + __openclaw: { id: `history-${seq - 1}`, seq }, + ...fields, + }); + const input = chatProps({ + paneId: "stream-projection", + messages: [ + historical(1, { role: "user", content: "Question" }), + historical(2, { content: "Work", phase: "commentary" }), + historical(3, { + role: "toolResult", + content: "Work", + toolName: "read", + toolCallId: "call-2", + }), + historical(4, { content: "Answer", phase: "final_answer", stopReason: "stop" }), + makeMessage("live-user", 7, { role: "user", timestamp: 7, runId: "live" }), + makeMessage("live-comment", 8, { phase: "commentary", timestamp: 8, runId: "live" }), + makeTool("live-tool", 9, "live", 9), + ], + stream: "Initial", + streamStartedAt: 10, + runId: "live", + runWorking: true, + }); if (!framed) { input.runId = null; } @@ -498,11 +378,8 @@ it.each([ } const options = { runWorking: true, searchActive }; const expanded = new Map(); - const retained: { - chain: Chain; - index: Index; - snapshot: unknown; - }[] = []; + const previews = new Map(); + const retained: { chain: Chain; index: Index; snapshot: unknown }[] = []; const texts = [ " ", "Private planning", @@ -535,36 +412,6 @@ it.each([ setExpansionState(expanded, workKey, false); } const index = indexFor(chain, expanded); - const fullChain = chainFor([...items], options); - const fullIndex = indexFor(fullChain, expanded); - const currentChain = (value: Chain) => ({ - ...value, - collapsedItems: value.collapsedItems.map((item) => - item.key === value.live?.item.key ? value.live.item : item, - ), - transcriptItems: value.transcriptItems.map((item) => - item.key === value.live?.item.key ? value.live.item : item, - ), - }); - const currentIndex = (value: Index) => ({ - ...value, - rows: value.rows.map((row) => - row.kind === "item" && row.key === value.liveItem?.key - ? { ...row, item: value.liveItem } - : row, - ), - positionIndex: { - ...value.positionIndex, - markers: value.positionIndex.markers.map((marker) => ({ - ...marker, - message: - value.positionIndex.liveMarkers?.find((candidate) => candidate.id === marker.id) - ?.message ?? marker.message, - })), - }, - }); - expect(currentChain(chain)).toEqual(currentChain(fullChain)); - expect(currentIndex(index)).toEqual(currentIndex(fullIndex)); expect(chainFor(items, options)).toBe(chain); expect(indexFor(chain, expanded)).toBe(index); if (live) { @@ -580,9 +427,10 @@ it.each([ expect(owner.boundaryId).toBeUndefined(); } if (framed && visible) { + const marker = index.positionIndex.markers.find((item) => item.id === "run:live"); expect( - currentIndex(index).positionIndex.markers.find((marker) => marker.id === "run:live") - ?.message, + index.positionIndex.liveMarkers?.find((item) => item.id === marker?.id)?.message ?? + marker?.message, ).toMatchObject({ content: [{ type: "text", text: stream }] }); } if ([3, 4, 6, 9].includes(delta)) { @@ -594,6 +442,9 @@ it.each([ expect(index.positionIndex.markerIdsByMessageId).toBe( previous.index.positionIndex.markerIdsByMessageId, ); + expect(projectTranscriptRows(index.rows, previews, [])).toBe( + projectTranscriptRows(previous.index.rows, previews, []), + ); for (const [position, row] of index.rows.entries()) { if (row.key === ownerKey) { break; @@ -707,11 +558,16 @@ describe("subagent handoff", () => { (part) => part.kind === "group" && part.messages.every((source) => source.message !== prose), ); const resumedRow = readRow("read-2", "announce:resume", 7); - const logs = logsOf([...waiting, resumedRow, answer]); + const resumed = projectHistory([...waiting, resumedRow, answer]); + const logs = framesOf(resumed).flatMap((frame) => kind(frame.parts, "activity-run")); expect(logs).toHaveLength(1); // The row the reader may have opened while waiting keeps its identity. expect(logs[0]).toMatchObject({ key: `activity:${waitingRow?.key}` }); expect(logs[0]?.groups.map((group) => group.runId)).toEqual(["run-1", "announce:resume"]); + const index = indexFor(resumed); + expect(new Set(["read-1", "read-2"].map((id) => index.messageRowKeysById.get(id)))).toEqual( + new Set([framesOf(resumed)[0]?.key]), + ); const spoke = makeMessage("spoke", 6, { timestamp: 45, runId: "announce:resume",