From 95ea82b1e57cc625a60b5fa661e3d23f88bf270b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 8 Aug 2026 03:36:35 -0700 Subject: [PATCH] fix(ci): restore deterministic main checks (#120501) * fix(plugin-sdk): stabilize session catalog baseline * test(doctor): isolate bind persistence inventory * fix(ci): prepare max-lines base during checkout * test(sessions): align freshness regression fixtures * style(tests): format context usage fixture * fix(ci): preserve token provenance and isolate fork credentials Finish the canonical token-provenance fixtures while preserving the latest maintainer-owned unavailable-usage ordering. Limit the diff-base GitHub token to manual non-release comparisons and guard that fork isolation invariant in the existing workflow regression test. Co-authored-by: Sarah Fortune * test(ui): identify actual managed image eviction * fix(ci): reuse the validated protocol comparison base Consume the immutable diff base already resolved and validated by preflight for every CI event. Remove the dead duplicate manual GitHub lookup and unreachable fallbacks, and guard the single authoritative path without changing fork credential isolation. * test(sessions): preserve stale total expectation * test(ui): arm reconnect deferral before disconnect --------- Co-authored-by: Sarah Fortune --- .github/workflows/ci.yml | 126 ++++++------------ .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- .../agent-session-loop-correctness.test.ts | 54 -------- .../agent-session.context-usage.test.ts | 39 +++++- ...nfig-flow.gateway-bind-persistence.test.ts | 84 +++++++----- src/plugin-sdk/api-baseline.test.ts | 5 + src/plugins/session-catalog.ts | 8 +- test/scripts/ci-workflow-guards.test.ts | 85 ++++++------ ui/src/e2e/chat-flow.media-files.e2e.test.ts | 5 +- .../session-management.sidebar.e2e.test.ts | 2 +- 10 files changed, 181 insertions(+), 229 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49f75503bab8..aeb30f567514 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -252,7 +252,9 @@ jobs: - name: Resolve exact diff base id: diff_base env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }} + GH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && github.token || '' }} PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }} RELEASE_GATE: ${{ inputs.release_gate }} run: | @@ -265,6 +267,18 @@ jobs: base_sha="$(node scripts/lib/merge-head-diff-base.mjs \ --base "$base_sha" --head HEAD --prefer-first-parent)" fi + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" != "true" ]; then + default_sha="$(git ls-remote origin "refs/heads/${DEFAULT_BRANCH}" | awk 'NR == 1 { print $1}')" + if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve the default branch head for the manual target." >&2 + exit 1 + fi + base_sha="$( + gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${head_sha}" \ + --jq '.merge_base_commit.sha' + )" + fi if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" = "true" ]; then merge_ref="refs/remotes/origin/release-gate-merge" timeout --signal=TERM --kill-after=10s 120s git fetch \ @@ -279,6 +293,10 @@ jobs: base_sha="$(git rev-parse "${merge_ref}^1")" head_sha="$(git rev-parse "$merge_ref")" fi + if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "Could not resolve an exact diff base for ${GITHUB_EVENT_NAME}." >&2 + exit 1 + fi echo "sha=$base_sha" >> "$GITHUB_OUTPUT" echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT" @@ -1030,10 +1048,15 @@ jobs: git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git" git -C "$workdir" config gc.auto 0 + fetch_refs=("+${CHECKOUT_SHA}:refs/remotes/origin/ci-target") + if [ -n "${CHECKOUT_BASE_SHA:-}" ]; then + fetch_refs+=("+${CHECKOUT_BASE_SHA}:refs/remotes/origin/ci-max-lines-base") + fi + timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \ -c protocol.version=2 \ fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \ - "+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1 + "${fetch_refs[@]}" || return 1 git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1 test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1 @@ -1533,6 +1556,8 @@ jobs: fail-fast: false max-parallel: 12 matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }} + env: + CHECKOUT_BASE_SHA: ${{ matrix.task == 'max-lines-ratchet' && needs.preflight.outputs.diff_base_revision || '' }} steps: - *linux_node_checkout_step - name: Prepare release-gate max-lines merge tree @@ -1577,7 +1602,7 @@ jobs: exit 1 fi git checkout --detach "$merge_sha" - echo "RATCHET_RELEASE_BASE_SHA=${frozen_base_sha}" >> "$GITHUB_ENV" + echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV" echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV" - name: Setup Node environment @@ -1590,41 +1615,10 @@ jobs: sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }} - - name: Resolve manual protocol base - id: protocol_manual_base - if: matrix.task == 'bundled-protocol' && github.event_name == 'workflow_dispatch' && !inputs.release_gate - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - GH_TOKEN: ${{ github.token }} - TARGET_SHA: ${{ needs.preflight.outputs.checkout_revision }} - shell: bash - run: | - set -euo pipefail - default_sha="$(git ls-remote origin "refs/heads/${DEFAULT_BRANCH}" | awk 'NR == 1 { print $1 }')" - if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "Could not resolve the default branch head for the manual protocol target." >&2 - exit 1 - fi - merge_base_sha="$( - gh api --method GET \ - "repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${TARGET_SHA}" \ - --jq '.merge_base_commit.sha' - )" - if [[ ! "$merge_base_sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "Could not resolve the manual protocol target merge base." >&2 - exit 1 - fi - echo "sha=${merge_base_sha}" >> "$GITHUB_OUTPUT" - - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) env: - GH_TOKEN: ${{ matrix.task == 'max-lines-ratchet' && github.token || '' }} OPENCLAW_TEST_PROJECTS_PARALLEL: 3 - PROTOCOL_MANUAL_BASE_SHA: ${{ steps.protocol_manual_base.outputs.sha }} PROTOCOL_SINCE_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }} - RATCHET_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - RATCHET_EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }} - RATCHET_MANUAL_TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }} RATCHET_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} TASK: ${{ matrix.task }} shell: bash @@ -1636,41 +1630,11 @@ jobs: process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1); ' "$1" } - resolve_manual_merge_base() { - local default_branch="${RATCHET_DEFAULT_BRANCH:-main}" - local default_sha - local merge_base_sha - default_sha="$(git ls-remote origin "refs/heads/${default_branch}" | awk 'NR == 1 { print $1 }')" - if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "Could not resolve the default branch head for the manual target." >&2 - return 1 - fi - merge_base_sha="$( - gh api --method GET \ - "repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${RATCHET_MANUAL_TARGET_SHA}" \ - --jq '.merge_base_commit.sha' - )" - if [[ ! "$merge_base_sha" =~ ^[0-9a-f]{40}$ ]]; then - echo "Could not resolve the manual target merge base." >&2 - return 1 - fi - printf '%s\n' "$merge_base_sha" - } case "$TASK" in bundled-protocol) pnpm test:bundled - if [[ -n "${PROTOCOL_SINCE_BASE_SHA:-}" ]]; then - git fetch --no-tags --no-recurse-submodules --depth=1 origin \ - "+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base" - elif [[ -n "${PROTOCOL_MANUAL_BASE_SHA:-}" ]]; then - PROTOCOL_SINCE_BASE_SHA="$PROTOCOL_MANUAL_BASE_SHA" - export PROTOCOL_SINCE_BASE_SHA - git fetch --no-tags --no-recurse-submodules --depth=1 origin \ - "+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base" - else - git fetch --no-tags --no-recurse-submodules --depth=1 origin \ - "+refs/heads/main:refs/remotes/origin/main" - fi + git fetch --no-tags --no-recurse-submodules --depth=1 origin \ + "+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base" pnpm protocol:check ;; contracts-plugins-ci-routing) @@ -1685,37 +1649,23 @@ jobs: echo "Current CI targets must provide check:max-lines-ratchet." >&2 exit 1 fi - base_sha="${RATCHET_EVENT_BASE_SHA:-${RATCHET_RELEASE_BASE_SHA:-}}" - if [[ "$base_sha" == "0000000000000000000000000000000000000000" ]]; then - base_sha="" + base_ref="${RATCHET_BASE_REF:-refs/remotes/origin/ci-max-lines-base}" + if ! git cat-file -e "${base_ref}^{commit}" 2>/dev/null; then + echo "Prepared max-lines base ${base_ref} is unavailable." >&2 + exit 1 fi - base_ref="" if [[ -n "${RATCHET_PR_HEAD_SHA:-}" ]]; then mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p') if [[ "${#merge_parents[@]}" != "2" || "${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA" ]]; then echo "Pull request checkout is not the expected two-parent merge tree." >&2 exit 1 fi - merge_base="${merge_parents[0]}" - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin \ - "+${merge_base}:refs/remotes/origin/ci-max-lines-base" - base_ref="refs/remotes/origin/ci-max-lines-base" - elif [[ -n "$base_sha" ]]; then - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin \ - "+${base_sha}:refs/remotes/origin/ci-max-lines-base" - base_ref="refs/remotes/origin/ci-max-lines-base" - elif [[ -n "${RATCHET_MANUAL_TARGET_SHA:-}" ]]; then - merge_base_sha="$(resolve_manual_merge_base)" - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin \ - "+${merge_base_sha}:refs/remotes/origin/ci-max-lines-base" - base_ref="refs/remotes/origin/ci-max-lines-base" - else - default_branch="${RATCHET_DEFAULT_BRANCH:-main}" - timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin \ - "+refs/heads/${default_branch}:refs/remotes/origin/ci-max-lines-base" - base_ref="refs/remotes/origin/ci-max-lines-base" + prepared_base="$(git rev-parse "$base_ref")" + if [[ "${merge_parents[0]}" != "$prepared_base" ]]; then + echo "Pull request merge base does not match the prepared preflight base." >&2 + exit 1 + fi fi - unset GH_TOKEN pnpm check:max-lines-ratchet --base "$base_ref" if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then node scripts/run-oxlint.mjs src ui/src packages extensions diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 00b4a65aff35..64f9a0c1353b 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -122,7 +122,7 @@ aa8a411ad37c1d1143b67376bf2d20255b9eedff61d80815f42e4f8ed7bd8e58 module/secret- 57dcb1462d4c4f9a98d934c4ca975b163d704758af9821a64001ff3ac05637c3 module/secret-input-runtime dc0ee07d392a85c218939000b28c0138f139215da00f5592b34a68ba8e29a25d module/secret-ref-runtime f97549081955e412d8eb64070c64bb5921bb1324744d824db05767d3adcce403 module/security-runtime -8f57eaf07620598f0a62cf603d450d9a9da2d4ea7a867f13aadc1212019e5eb9 module/session-catalog +b18d65a8abbed79bcb77e34679b7aeb03f2e9101271f48298b47eb839c33a379 module/session-catalog 50f5e344f98c27570b7a30e32a906b612e2383d21f102e88cd93e1d5425a6de9 module/session-discussion f112bdabc51ba8659b37d0a6f6a32a2b1d471e5b49b56e108bf750ec55a7ea71 module/session-store-runtime 36affbe151431a6141664b6838e20f2d121ff210d57a3c1b4b41a8818b5c81d8 module/setup diff --git a/src/agents/sessions/agent-session-loop-correctness.test.ts b/src/agents/sessions/agent-session-loop-correctness.test.ts index 7b47de81052d..946cc5874e73 100644 --- a/src/agents/sessions/agent-session-loop-correctness.test.ts +++ b/src/agents/sessions/agent-session-loop-correctness.test.ts @@ -158,60 +158,6 @@ describe("AgentSession loop correctness", () => { ); }); - it("does not pre-prompt compact from usage before a zero unavailable marker", async () => { - const model = { ...testModel, contextWindow: 1_000 }; - const sessionManager = SessionManager.inMemory(); - appendHistory( - sessionManager, - createAssistant(model, [{ type: "text", text: "old cumulative turn" }], "stop", 950), - ); - sessionManager.appendMessage({ role: "user", content: "CLI prompt", timestamp: Date.now() }); - sessionManager.appendMessage({ - ...createAssistant(model, [{ type: "text", text: "usage unavailable" }]), - usage: { - input: 0, - output: 0, - cacheRead: 0, - cacheWrite: 0, - totalTokens: 0, - contextUsage: { state: "unavailable" }, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, - }, - }); - const settingsManager = SettingsManager.inMemory({ - compaction: { enabled: true, reserveTokens: 100, keepRecentTokens: 20 }, - retry: { enabled: false }, - }); - const compactionEvents: AgentSessionEvent[] = []; - streamMocks.streamSimple.mockImplementation((activeModel: Model) => - createAssistantResultStream( - createAssistant(activeModel, [{ type: "text", text: "complete answer" }], "stop", 20), - ), - ); - const { session } = await createTestSession({ - model, - sessionManager, - settingsManager, - resourceLoader: createResourceLoader(createCompactionHandlers()), - }); - session.subscribe((event) => { - if (event.type === "compaction_start" || event.type === "compaction_end") { - compactionEvents.push(event); - } - }); - - expect(session.messages.at(-1)).toMatchObject({ - role: "assistant", - usage: { contextUsage: { state: "unavailable" } }, - }); - expect(session.getContextUsage()?.tokens).toBeLessThan(900); - await session.prompt("continue after CLI turn"); - - expect(streamMocks.streamSimple).toHaveBeenCalledOnce(); - expect(compactionEvents).toEqual([]); - expect(session.getLastAssistantText()).toBe("complete answer"); - }); - it("skips threshold maintenance when embedded auto-compaction is disabled", async () => { const settingsManager = SettingsManager.inMemory({ compaction: { enabled: false, reserveTokens: 0, keepRecentTokens: 1 }, diff --git a/src/agents/sessions/agent-session.context-usage.test.ts b/src/agents/sessions/agent-session.context-usage.test.ts index 19391d35a013..ee76b81e7602 100644 --- a/src/agents/sessions/agent-session.context-usage.test.ts +++ b/src/agents/sessions/agent-session.context-usage.test.ts @@ -1,8 +1,45 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import type { AgentMessage } from "../runtime/index.js"; import { AgentSession } from "./agent-session.js"; describe("AgentSession context usage", () => { + it("does not compact an unavailable marker when current context is below threshold", async () => { + const runAutoCompaction = vi.fn(); + const checkCompaction = ( + AgentSession.prototype as unknown as { + checkCompaction: (message: AgentMessage, skipAbortedCheck: boolean) => Promise; + } + ).checkCompaction; + + const compacted = await checkCompaction.call( + { + settingsManager: { + getCompactionSettings: () => ({ + enabled: true, + reserveTokens: 100, + keepRecentTokens: 20, + }), + }, + model: { provider: "test", id: "model", contextWindow: 1_000 }, + sessionManager: { getBranch: () => [] }, + getContextUsage: () => ({ tokens: 100 }), + runAutoCompaction, + }, + { + role: "assistant", + provider: "test", + model: "model", + stopReason: "stop", + timestamp: Date.now(), + usage: { contextUsage: { state: "unavailable" } }, + } as AgentMessage, + false, + ); + + expect(compacted).toBe(false); + expect(runAutoCompaction).not.toHaveBeenCalled(); + }); + it("preserves an earlier exact snapshot when unavailable usage precedes any compaction", () => { const messages = [ { diff --git a/src/commands/doctor-config-flow.gateway-bind-persistence.test.ts b/src/commands/doctor-config-flow.gateway-bind-persistence.test.ts index 394c0ec3a59c..e0509e59a425 100644 --- a/src/commands/doctor-config-flow.gateway-bind-persistence.test.ts +++ b/src/commands/doctor-config-flow.gateway-bind-persistence.test.ts @@ -1,50 +1,68 @@ // Verifies Doctor persists legacy gateway bind repairs through the real config writer. import fs from "node:fs/promises"; -import { describe, expect, it, vi } from "vitest"; -import { withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readConfigFileSnapshot } from "../config/config.js"; +import { withEnvOverride, withTempHome, writeOpenClawConfig } from "../config/test-helpers.js"; import { runInitialConfigWriteHealth } from "../flows/doctor-health-contribution-runners.config.js"; import type { DoctorHealthFlowContext } from "../flows/doctor-health-contribution-types.js"; import type { RuntimeEnv } from "../runtime.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js"; -import { migrateLegacyConfig } from "./doctor/shared/legacy-config-migrate.js"; describe("Doctor gateway bind persistence", () => { + afterEach(() => { + closeOpenClawStateDatabaseForTest(); + }); + it.each([ ["localhost", "loopback"], ["0.0.0.0", "lan"], ] as const)("persists gateway bind %s as %s", async (legacyBind, canonicalBind) => { await withTempHome(async (home) => { - const configPath = await writeOpenClawConfig(home, { - gateway: { mode: "local", bind: legacyBind }, + await withEnvOverride({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () => { + // This core writer regression needs the authoritative empty bundled-plugin inventory. + const configPath = await writeOpenClawConfig(home, { + gateway: { mode: "local", bind: legacyBind }, + }); + const runtime: RuntimeEnv = { + error: vi.fn(), + exit: vi.fn(), + log: vi.fn(), + }; + const options: DoctorOptions = { nonInteractive: true, repair: true }; + const prompter = createDoctorPrompter({ runtime, options }); + const configResult = await loadAndMaybeMigrateDoctorConfig({ + options, + confirm: (params) => prompter.confirm(params), + runtime, + prompter, + }); + const ctx: DoctorHealthFlowContext = { + runtime, + options, + prompter, + configResult, + cfg: configResult.cfg, + cfgForPersistence: structuredClone(configResult.cfg), + sourceConfigValid: configResult.sourceConfigValid ?? true, + configPath, + stateDirExistedAtStart: true, + ...(configResult.runWithPluginMetadataSnapshot + ? { runWithPluginMetadataSnapshot: configResult.runWithPluginMetadataSnapshot } + : {}), + ...(configResult.invalidatePluginMetadataSnapshot + ? { invalidatePluginMetadataSnapshot: configResult.invalidatePluginMetadataSnapshot } + : {}), + }; + + await runInitialConfigWriteHealth(ctx); + + const snapshot = await readConfigFileSnapshot(); + expect(snapshot.valid).toBe(true); + expect(snapshot.config.gateway?.bind).toBe(canonicalBind); + expect(await fs.readFile(configPath, "utf-8")).not.toContain(`"bind": "${legacyBind}"`); }); - const runtime: RuntimeEnv = { - error: vi.fn(), - exit: vi.fn(), - log: vi.fn(), - }; - const options: DoctorOptions = { nonInteractive: true, repair: true }; - const prompter = createDoctorPrompter({ runtime, options }); - const migration = migrateLegacyConfig({ gateway: { mode: "local", bind: legacyBind } }); - expect(migration.config).not.toBeNull(); - const cfg = migration.config!; - const configResult = { cfg, shouldWriteConfig: true }; - const ctx: DoctorHealthFlowContext = { - runtime, - options, - prompter, - configResult, - cfg, - cfgForPersistence: structuredClone(cfg), - sourceConfigValid: true, - configPath, - stateDirExistedAtStart: true, - }; - - await runInitialConfigWriteHealth(ctx); - - const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")); - expect(persisted.gateway?.bind).toBe(canonicalBind); - expect(persisted.gateway?.bind).not.toBe(legacyBind); }); }); }); diff --git a/src/plugin-sdk/api-baseline.test.ts b/src/plugin-sdk/api-baseline.test.ts index 353f3560a681..4cd329f65f45 100644 --- a/src/plugin-sdk/api-baseline.test.ts +++ b/src/plugin-sdk/api-baseline.test.ts @@ -28,6 +28,7 @@ const TEST_ENTRYPOINTS = [ "provider-selection-runtime", "provider-web-search-config-contract", "realtime-voice", + "session-catalog", "sqlite-runtime-testing", ] as const; @@ -194,6 +195,10 @@ describe("Plugin SDK API baseline", () => { expect(findDeclaration("ProviderSelection")).toContain( "export type ProviderSelection =", ); + expect(findDeclaration("SessionCatalogEntrySummary")).toContain( + "export interface SessionCatalogEntrySummary", + ); + expect(findDeclaration("SessionCatalogEntrySummary")).toContain("entry: SessionEntry;"); expect(rendered.json).not.toContain('"line":'); expect(rendered.jsonl).not.toContain('"sourceLine":'); }); diff --git a/src/plugins/session-catalog.ts b/src/plugins/session-catalog.ts index daf656a07253..07a02668d60c 100644 --- a/src/plugins/session-catalog.ts +++ b/src/plugins/session-catalog.ts @@ -7,6 +7,7 @@ import type { SessionsCatalogReadResult, } from "../../packages/gateway-protocol/src/schema/sessions-catalog.js"; import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { PluginRuntime } from "./runtime/types.js"; @@ -57,9 +58,10 @@ export type SessionCatalogCreateTarget = { agentRuntime: string; }; -export type SessionCatalogEntrySummary = ReturnType< - PluginRuntime["agent"]["session"]["listSessionEntries"] ->[number]; +export interface SessionCatalogEntrySummary { + sessionKey: string; + entry: SessionEntry; +} /** Shared, logically frozen store state for one request; copy locally before mutating. */ export type SessionCatalogEntrySnapshot = { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index fc256f986b28..76221487f08a 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -4307,11 +4307,18 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(parsedWorkflow.jobs.preflight.outputs.diff_base_revision).toBe( "${{ steps.diff_base.outputs.sha }}", ); - expect( - parsedWorkflow.jobs.preflight.steps.find( - (step: WorkflowStep) => step.name === "Resolve exact diff base", - ).run, - ).toContain("--prefer-first-parent"); + const diffBaseStep = parsedWorkflow.jobs.preflight.steps.find( + (step: WorkflowStep) => step.name === "Resolve exact diff base", + ); + expect(diffBaseStep.run).toContain("--prefer-first-parent"); + expect(diffBaseStep.env.DEFAULT_BRANCH).toBe("${{ github.event.repository.default_branch }}"); + expect(diffBaseStep.env.GH_TOKEN).toBe( + "${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && github.token || '' }}", + ); + expect(diffBaseStep.run).toContain( + '"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${head_sha}"', + ); + expect(diffBaseStep.run).toContain("Could not resolve an exact diff base"); const securityDiffBase = parsedWorkflow.jobs["security-fast"].steps.find( (step: WorkflowStep) => step.name === "Resolve security diff base", ).run; @@ -4422,48 +4429,44 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" it("runs the suppression-baseline max-lines ratchet against the exact tested tree", () => { const workflow = readCiWorkflow(); - const checksFastSteps = workflow.jobs["checks-fast-core"].steps; + const checksFastJob = workflow.jobs["checks-fast-core"]; + const checksFastSteps = checksFastJob.steps; + const checkout = checksFastSteps.find((step: WorkflowStep) => step.name === "Checkout"); const checksFastRun = checksFastSteps.find( (step: WorkflowStep) => step.name === "Run ${{ matrix.task }} (${{ matrix.runtime }})", ); const releaseGateMerge = checksFastSteps.find( (step: WorkflowStep) => step.name === "Prepare release-gate max-lines merge tree", ); - const protocolManualBase = checksFastSteps.find( - (step: WorkflowStep) => step.name === "Resolve manual protocol base", - ); + expect( + checksFastSteps.some((step: WorkflowStep) => step.name === "Resolve manual protocol base"), + ).toBe(false); expect(workflow.jobs["checks-fast-core"].permissions).toEqual({ contents: "read", "pull-requests": "read", }); + expect(checksFastJob.env.CHECKOUT_BASE_SHA).toBe( + "${{ matrix.task == 'max-lines-ratchet' && needs.preflight.outputs.diff_base_revision || '' }}", + ); + expect(checkout.run).toContain( + 'fetch_refs+=("+${CHECKOUT_BASE_SHA}:refs/remotes/origin/ci-max-lines-base")', + ); + expect(checkout.run).toContain('"${fetch_refs[@]}" || return 1'); expect(releaseGateMerge.if).toBe( "matrix.task == 'max-lines-ratchet' && github.event_name == 'workflow_dispatch' && inputs.release_gate", ); expect(checksFastRun.run).toContain("max-lines-ratchet)"); expect(checksFastRun.run).toContain('has_package_script "check:max-lines-ratchet"'); - expect(checksFastRun.env.RATCHET_EVENT_BASE_SHA).toBe( - "${{ github.event_name == 'push' && github.event.before || '' }}", - ); expect(checksFastRun.env.RATCHET_PR_HEAD_SHA).toBe( "${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}", ); - expect(checksFastRun.env.RATCHET_MANUAL_TARGET_SHA).toBe( - "${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }}", - ); - expect(checksFastRun.env.GH_TOKEN).toBe( - "${{ matrix.task == 'max-lines-ratchet' && github.token || '' }}", - ); - expect(protocolManualBase.if).toBe( - "matrix.task == 'bundled-protocol' && github.event_name == 'workflow_dispatch' && !inputs.release_gate", - ); - expect(protocolManualBase.env.GH_TOKEN).toBe("${{ github.token }}"); - expect(protocolManualBase.run).toContain( - '"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${TARGET_SHA}"', - ); - expect(protocolManualBase.run).toContain('echo "sha=${merge_base_sha}" >> "$GITHUB_OUTPUT"'); - expect(checksFastRun.env.PROTOCOL_MANUAL_BASE_SHA).toBe( - "${{ steps.protocol_manual_base.outputs.sha }}", + expect(checksFastRun.env).not.toHaveProperty("RATCHET_EVENT_BASE_SHA"); + expect(checksFastRun.env).not.toHaveProperty("RATCHET_MANUAL_TARGET_SHA"); + expect(checksFastRun.env).not.toHaveProperty("GH_TOKEN"); + expect(checksFastRun.env).not.toHaveProperty("PROTOCOL_MANUAL_BASE_SHA"); + expect(checksFastRun.env.PROTOCOL_SINCE_BASE_SHA).toBe( + "${{ needs.preflight.outputs.diff_base_revision }}", ); expect(releaseGateMerge.run).toContain( 'gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}"', @@ -4492,39 +4495,29 @@ printf '%s\n' "\${CURL_SUCCESS_IP:-203.0.113.7}" expect(releaseGateMerge.run).not.toContain(".base.sha"); expect(releaseGateMerge.run).toContain('git checkout --detach "$merge_sha"'); expect(releaseGateMerge.run).toContain( - 'echo "RATCHET_RELEASE_BASE_SHA=${frozen_base_sha}" >> "$GITHUB_ENV"', + 'echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV"', ); expect(releaseGateMerge.run).toContain( 'echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"', ); - expect( - checksFastRun.run.match( - /timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 origin \\/gu, - ), - ).toHaveLength(4); - expect(checksFastRun.run).toContain('git ls-remote origin "refs/heads/${default_branch}"'); - expect(checksFastRun.run).toContain( - '"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${RATCHET_MANUAL_TARGET_SHA}"', - ); - expect(checksFastRun.run).toContain('PROTOCOL_SINCE_BASE_SHA="$PROTOCOL_MANUAL_BASE_SHA"'); + expect(checksFastRun.run).not.toContain("PROTOCOL_MANUAL_BASE_SHA"); expect(checksFastRun.run).toContain( '"+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base"', ); - expect(checksFastRun.run).toContain("--jq '.merge_base_commit.sha'"); expect(checksFastRun.run).toContain( - '"+${merge_base_sha}:refs/remotes/origin/ci-max-lines-base"', - ); - expect(checksFastRun.run).toContain( - 'if [[ "$base_sha" == "0000000000000000000000000000000000000000" ]]', + 'base_ref="${RATCHET_BASE_REF:-refs/remotes/origin/ci-max-lines-base}"', ); + expect(checksFastRun.run).toContain('git cat-file -e "${base_ref}^{commit}"'); expect(checksFastRun.run).toContain( "mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p')", ); expect(checksFastRun.run).toContain('"${#merge_parents[@]}" != "2"'); expect(checksFastRun.run).toContain('"${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA"'); - expect(checksFastRun.run).toContain('"+${merge_base}:refs/remotes/origin/ci-max-lines-base"'); + expect(checksFastRun.run).toContain('prepared_base="$(git rev-parse "$base_ref")"'); + expect(checksFastRun.run).toContain('"${merge_parents[0]}" != "$prepared_base"'); expect(checksFastRun.run).not.toContain("ci-max-lines-target^"); - expect(checksFastRun.run).toContain("unset GH_TOKEN"); + expect(checksFastRun.run).not.toContain("resolve_manual_merge_base"); + expect(checksFastRun.run).not.toContain("+${merge_base}:refs/remotes/origin/ci-max-lines-base"); expect(checksFastRun.run).toContain('pnpm check:max-lines-ratchet --base "$base_ref"'); expect(checksFastRun.run).toContain( 'if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then', diff --git a/ui/src/e2e/chat-flow.media-files.e2e.test.ts b/ui/src/e2e/chat-flow.media-files.e2e.test.ts index 20ed3d505e7d..3de62bb2d75d 100644 --- a/ui/src/e2e/chat-flow.media-files.e2e.test.ts +++ b/ui/src/e2e/chat-flow.media-files.e2e.test.ts @@ -645,13 +645,14 @@ suite.define(() => { const overflowProof = await readBlobProof(); // Concurrent image fetches can resolve in any order. Find the real LRU // rather than assuming that creation order matches transcript order. + expect(overflowProof.revoked).toHaveLength(1); const evictedBlobUrl = expectDefined( - overflowProof.created.find((blobUrl) => blobUrl !== retainedRecentBlobUrl), + overflowProof.revoked.find((blobUrl) => blobUrl !== retainedRecentBlobUrl), "evicted managed image Blob URL", ); + expect(overflowProof.created).toContain(evictedBlobUrl); const evictedImageIndex = initialBlobUrls.indexOf(evictedBlobUrl); expect(evictedImageIndex).toBeGreaterThanOrEqual(0); - expect(overflowProof.revoked).toContain(evictedBlobUrl); expect(overflowProof.revoked).not.toContain(retainedRecentBlobUrl); const evictedPath = new URL( diff --git a/ui/src/e2e/session-management.sidebar.e2e.test.ts b/ui/src/e2e/session-management.sidebar.e2e.test.ts index 537dea2a0d01..cb74a34adca7 100644 --- a/ui/src/e2e/session-management.sidebar.e2e.test.ts +++ b/ui/src/e2e/session-management.sidebar.e2e.test.ts @@ -434,8 +434,8 @@ suite.define(() => { await expect.poll(() => sidebarRows.count()).toBe(3); const initialListCount = (await gateway.getRequests("sessions.list")).length; - await gateway.closeLatest(1006, "disconnect proof"); await gateway.deferNext("sessions.list"); + await gateway.closeLatest(1006, "disconnect proof"); await sidebarRow.waitFor({ state: "visible" }); await captureUiProof(page, "sidebar-sessions-during-reconnect.png");