From 73bba03e4c5a3ed9912e6df7c59f501a5d99bb49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Jul 2026 01:01:19 -0700 Subject: [PATCH] refactor: canonicalize session delivery state (#113225) * refactor: canonicalize session delivery state * test: canonicalize reply persistence fixtures * test: canonicalize talk delivery fixtures * test: canonicalize voice session routes * test: canonicalize attachment delivery fixtures * test: migrate gateway delivery fixtures * fix: skip invalid session delivery rows * test: align delivery SDK surface gates * fix: preserve legacy delivery precedence * test: canonicalize heartbeat delivery fixtures * fix: preserve delivery route prompt identity * test: canonicalize session delivery fixtures * fix: preserve recoverable legacy delivery routes * fix: canonicalize remaining session state * fix: preserve canonical session classification * style: format delivery state changes * test: refresh plugin SDK delivery baseline * test: avoid mutating session fixture input * style: simplify delivery identity check * style: simplify delivery origin spread * fix: preserve fresh delivery route metadata * test: assert canonical surface route switch * fix: canonicalize doctor file-store imports * fix: preserve transitional delivery migration state * fix: satisfy canonical delivery CI gates * ci: scope GitHub App token permissions * test: infer canonical delivery projections * test: canonicalize ACP requester delivery fixtures * test: canonicalize harness rollback fixture * style: apply pinned formatter --- .github/workflows/auto-response.yml | 2 + .github/workflows/labeler.yml | 4 + .github/workflows/pr-ci-sweeper.yml | 2 + .github/workflows/stale.yml | 2 + .../.generated/plugin-sdk-api-baseline.sha256 | 2 +- extensions/active-memory/session.ts | 12 +- extensions/feishu/src/doctor.test.ts | 9 +- extensions/matrix/src/exec-approvals.test.ts | 32 +- .../matrix/src/matrix/monitor/handler.test.ts | 54 ++- .../src/matrix/session-store-metadata.ts | 57 +-- extensions/matrix/src/session-route.test.ts | 59 ++- .../src/mattermost/model-picker.test.ts | 13 +- .../src/session-search-visibility.test.ts | 15 +- .../src/session-search-visibility.ts | 3 +- extensions/slack/src/approval-native.test.ts | 10 +- .../telegram/src/approval-native.test.ts | 33 +- ...e-context.session-recreate.test-support.ts | 12 +- extensions/telegram/src/bot.test.ts | 8 +- .../telegram/src/exec-approvals.test.ts | 16 +- .../auto-reply/web-auto-reply-utils.test.ts | 6 +- .../zalouser/doctor-contract-api.test.ts | 19 +- extensions/zalouser/doctor-contract-api.ts | 3 +- scripts/plugin-sdk-surface-report.mjs | 6 +- .../control-plane/manager.background-task.ts | 5 +- src/agents/acp-spawn.test.ts | 29 +- ...-command.live-model-switch.test-helpers.ts | 18 +- .../agent-command.live-model-switch.test.ts | 79 ++-- src/agents/agent-command.ts | 4 +- src/agents/command/delivery.test.ts | 25 +- src/agents/command/model-selection.ts | 18 +- src/agents/command/session-store.test.ts | 1 + src/agents/command/session.ts | 3 +- .../run.overflow-compaction.test.ts | 1 + .../run/attempt.session-lock.test.ts | 3 +- src/agents/internal-session-effects.test.ts | 1 + src/agents/internal-session-effects.ts | 1 + .../main-session-restart-recovery.test.ts | 36 +- .../openclaw-tools.session-status.test.ts | 69 ++- src/agents/sessions/session-manager.test.ts | 4 +- src/agents/subagent-announce-delivery.test.ts | 8 +- src/agents/subagent-announce-delivery.ts | 12 +- src/agents/subagent-announce-origin.ts | 8 +- .../subagent-announce.format.e2e.test.ts | 20 +- src/agents/subagent-announce.test.ts | 18 +- .../subagent-test-fixtures.test-helpers.ts | 23 +- .../media-generate-background-shared.test.ts | 36 +- src/agents/tools/session-status-tool.ts | 14 +- src/agents/tools/sessions-announce-target.ts | 3 +- src/agents/tools/sessions-list-tool.ts | 10 +- .../tools/sessions-send-tool.a2a.test.ts | 38 +- src/agents/tools/sessions.test.ts | 22 +- src/auto-reply/reply/agent-runner-core.ts | 3 +- .../reply/agent-runner-result-complete.ts | 3 +- .../reply/commands-diagnostics.test.ts | 3 +- src/auto-reply/reply/commands-diagnostics.ts | 22 +- src/auto-reply/reply/commands-dock.test.ts | 34 +- src/auto-reply/reply/commands-dock.ts | 17 +- .../reply/commands-session-store.test.ts | 5 + .../reply/completion-delivery-policy.ts | 9 +- .../reply/conversation-turn-capture.test.ts | 31 +- .../reply/directive-handling.model.test.ts | 2 + .../dispatch-from-config.base.test-utils.ts | 47 +- .../dispatch-from-config.harness-defaults.ts | 15 +- .../dispatch-from-config.prepare-context.ts | 3 +- ...dispatch-from-config.routing.test-utils.ts | 12 +- .../reply/effective-reply-route.test.ts | 35 +- src/auto-reply/reply/effective-reply-route.ts | 32 +- src/auto-reply/reply/followup-runner.ts | 14 +- .../reply/get-reply-run.media-only.test.ts | 89 ++-- src/auto-reply/reply/get-reply-run.ts | 28 +- src/auto-reply/reply/get-reply.ts | 13 +- src/auto-reply/reply/model-selection.test.ts | 1 + .../reply/prompt-session-context.ts | 14 +- src/auto-reply/reply/session-delivery.ts | 16 +- .../reply/session-entry-persistence.test.ts | 2 + .../reply/session-reset-model.test.ts | 2 + src/auto-reply/reply/session-route-reset.ts | 16 +- src/auto-reply/reply/session.test.ts | 7 +- src/auto-reply/reply/session.ts | 74 ++-- .../reply/test/session.test-support.ts | 31 +- src/auto-reply/status.test.ts | 5 +- src/channels/session.types.ts | 4 +- src/commands/agent.delivery.test.ts | 50 +-- src/commands/agent.session.test.ts | 6 +- src/commands/agent.test.ts | 13 +- src/commands/agents.delete.test.ts | 17 +- .../doctor-session-delivery-state.test.ts | 405 ++++++++++++++++++ src/commands/doctor-session-delivery-state.ts | 140 ++++++ src/commands/doctor-session-sqlite.test.ts | 3 +- .../doctor-session-transcripts.test.ts | 8 + src/commands/doctor-session-transcripts.ts | 22 + src/commands/doctor-state-integrity.test.ts | 3 +- src/commands/sandbox-explain.ts | 16 +- .../sessions.kind-classification.test.ts | 23 +- src/commands/sessions.test.ts | 15 +- src/commands/sessions.ts | 20 +- .../conversation-delivery-store.test.ts | 18 +- .../sessions/conversation-identity.test.ts | 21 +- src/config/sessions/conversation-identity.ts | 25 +- .../sessions/conversation-registry.test.ts | 18 +- src/config/sessions/delivery-info.test.ts | 82 ++-- src/config/sessions/metadata.ts | 83 ++-- .../sessions/origin-channel-switch.test.ts | 69 ++- src/config/sessions/runtime-types.ts | 2 +- .../session-accessor.conformance.test.ts | 34 +- .../session-accessor.entry-mutation.ts | 5 +- ...ssion-accessor.sqlite-cleanup-race.test.ts | 1 + .../sessions/session-accessor.sqlite-entry.ts | 5 +- ...ession-accessor.sqlite-message-cut.test.ts | 20 +- .../session-accessor.sqlite-session-row.ts | 37 +- src/config/sessions/session-accessor.test.ts | 19 +- .../session-registry-maintenance.test.ts | 2 +- src/config/sessions/store-entry.ts | 18 +- src/config/sessions/store-maintenance.ts | 5 +- src/config/sessions/store.pruning.test.ts | 6 +- src/config/sessions/transcript.test.ts | 15 +- src/config/sessions/types.ts | 22 +- .../delivery-target.issue-91613.test.ts | 13 +- .../isolated-agent/delivery-target.test.ts | 35 +- src/cron/isolated-agent/delivery-target.ts | 3 +- src/cron/isolated-agent/session.test.ts | 36 +- src/cron/isolated-agent/session.ts | 2 - src/cron/service/timer.test.ts | 7 +- src/cron/session-reaper.test.ts | 1 + .../server-methods/agent-run-handler.ts | 1 - .../server-methods/agent-session-patch.ts | 33 +- .../server-methods/agent-session-persist.ts | 5 +- .../server-methods/agent-session-prepare.ts | 3 +- .../server-methods/agent-session-reset.ts | 3 +- .../server-methods/agent.base.test-utils.ts | 17 +- .../agent.events-and-subagents.test-utils.ts | 11 +- .../agent.reset-and-identity.test-utils.ts | 6 +- .../server-methods/chat-origin-routing.ts | 42 +- .../server-methods/chat-restart-recovery.ts | 3 +- .../server-methods/chat-send-pre-admission.ts | 3 +- .../chat.directive-tags.test.ts | 221 ++++------ src/gateway/server-methods/tools-effective.ts | 27 +- src/gateway/server-methods/usage.ts | 11 +- src/gateway/server-node-events.test.ts | 11 +- src/gateway/server-node-events.ts | 16 +- src/gateway/server-restart-sentinel.test.ts | 24 +- src/gateway/server-restart-sentinel.ts | 8 +- ...erver.agent.gateway-server-agent-b.test.ts | 72 +--- ...er.agent.subagent-delivery-context.test.ts | 18 +- .../server.sessions.list-changed.test.ts | 18 +- .../server.sessions.reset-models.test.ts | 26 +- src/gateway/server.sessions.store-rpc.test.ts | 12 +- src/gateway/session-reset-service.ts | 9 +- src/gateway/session-utils-row.ts | 16 +- src/gateway/session-utils-search.ts | 5 +- .../session-utils.telegram-recreate.test.ts | 14 +- src/gateway/session-utils.test.ts | 109 +++-- src/gateway/session-utils.types.ts | 7 +- src/gateway/test-helpers.server.ts | 27 +- src/infra/approval-request-account-binding.ts | 10 +- .../exec-approval-session-target.test.ts | 18 +- ...tbeat-runner.returns-default-unset.test.ts | 33 +- src/infra/heartbeat-runner.test-utils.ts | 24 +- src/infra/outbound/agent-delivery.test.ts | 64 +-- .../outbound/delivery-queue.recovery.test.ts | 43 +- .../outbound-session.integration.test.ts | 7 +- src/infra/outbound/targets.test.ts | 62 ++- src/infra/outbound/targets.ts | 2 +- ...te-migrations.legacy-session-store.test.ts | 16 +- .../state-migrations.legacy-session-store.ts | 190 ++++++-- .../state-migrations.orphan-keys.test.ts | 1 + src/infra/state-migrations.test.ts | 6 +- src/plugin-sdk/session-store-runtime.ts | 9 + .../session-attachments.contract.test.ts | 24 +- src/plugins/session-entry-slot-keys.ts | 21 +- src/sessions/send-policy.ts | 4 +- .../session-key-case-preservation.test.ts | 84 ++-- src/state/openclaw-agent-db-schema.ts | 14 +- .../openclaw-agent-db-session-migrations.ts | 7 +- src/status/status-message.ts | 11 +- src/status/status-text.test.ts | 5 +- src/status/status-text.ts | 8 +- src/talk/agent-consult-runtime.test.ts | 46 +- src/talk/agent-consult-runtime.ts | 7 +- src/talk/client-voice-session.test.ts | 29 +- src/utils/delivery-context.shared.ts | 174 +++++--- src/utils/delivery-context.test.ts | 334 ++++----------- src/utils/delivery-context.ts | 1 + src/utils/delivery-context.types.ts | 26 +- .../sqlite-sessions-transcripts-flip-proof.ts | 3 +- 185 files changed, 2961 insertions(+), 1922 deletions(-) create mode 100644 src/commands/doctor-session-delivery-state.test.ts create mode 100644 src/commands/doctor-session-delivery-state.ts diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index b8567052620c..7c6ccdb4fe0a 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -37,6 +37,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token-fallback @@ -46,6 +47,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - name: Run Barnacle auto-response uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 4c5ff643427c..563072a535bf 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -50,6 +50,7 @@ jobs: permission-contents: read permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token-fallback @@ -60,6 +61,7 @@ jobs: permission-contents: read permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6 if: ${{ github.event.action != 'edited' || github.event.changes.base }} @@ -496,6 +498,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token-fallback @@ -505,6 +508,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} permission-issues: write permission-members: read + permission-metadata: read permission-pull-requests: write - name: Backfill PR labels uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 diff --git a/.github/workflows/pr-ci-sweeper.yml b/.github/workflows/pr-ci-sweeper.yml index 02374d70c54e..85a27e6e610d 100644 --- a/.github/workflows/pr-ci-sweeper.yml +++ b/.github/workflows/pr-ci-sweeper.yml @@ -42,6 +42,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-actions: write permission-checks: read + permission-issues: write permission-pull-requests: write - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 id: app-token-fallback @@ -51,6 +52,7 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} permission-actions: write permission-checks: read + permission-issues: write permission-pull-requests: write - name: Sweep dropped PR CI runs uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 4f5cd6c2e808..b35fe04dad23 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -259,6 +259,7 @@ jobs: app-id: "2971289" private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }} permission-issues: write + permission-metadata: read permission-pull-requests: write - name: Backfill stale closures uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 @@ -509,6 +510,7 @@ jobs: app-id: "2729701" private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-issues: write + permission-metadata: read - name: Lock closed issues after 48h of no activity uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 4b9ac2cd5200..fed408540268 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -116,7 +116,7 @@ b6b8edc50ecab8386c9acd8f374a207212b5a99c8f518538bbcf0c458dda3881 module/runtime 17a6a199714ba8308e62928c0491bcf9fd214c923c3879aa99a047a30138253d module/secret-ref-runtime 596a315d426121c9620b314e3a9a7f523840b46e007d94d0d5e83cdedf789d15 module/security-runtime 31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion -9d7d884330397701c7de9f5b6800b970b2b349027491704184cb1bd6cda1fd00 module/session-store-runtime +82f1f06f875097ee894348e74346fde79325c8fee7106b51438aaec1f539cbff module/session-store-runtime 7cad408673562b0ff33f60071698bea2c39d02c64f4f235851c5c2ceb946910d module/setup fbcb853789db5a1ad5d81e8094ed7f5bcea657ac2281e7887a19c1d6f1194571 module/setup-runtime cd431f6ba8327b81438b7a63b1963120f200f5abd145fb6aa7c5c561339cb0b1 module/setup-tools diff --git a/extensions/active-memory/session.ts b/extensions/active-memory/session.ts index 225ce13f3fc2..d6f8a93ac396 100644 --- a/extensions/active-memory/session.ts +++ b/extensions/active-memory/session.ts @@ -1,4 +1,8 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "openclaw/plugin-sdk/session-store-runtime"; import { normalizeOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime"; import { ACTIVE_MEMORY_DEBUG_PREFIX, @@ -119,9 +123,9 @@ function resolveRecallRunChannelContext(params: { agentId: params.agentId, sessionKey: resolvedSessionKey, }); - const rawStrongEntryChannel = - normalizeOptionalString(sessionEntry?.lastChannel) ?? - normalizeOptionalString(sessionEntry?.channel); + const rawStrongEntryChannel = normalizeOptionalString( + deliveryContextFromSession(sessionEntry)?.channel, + ); // Channel IDs containing ":" or "/" are scoped conversation IDs, not // runnable channel names. The same guard that // applies to explicit channelId (#76704) must also apply to channels @@ -130,7 +134,7 @@ function resolveRecallRunChannelContext(params: { rawStrongEntryChannel && isRunnableChannelName(rawStrongEntryChannel) ? rawStrongEntryChannel : undefined; - const weakEntryChannel = normalizeOptionalString(sessionEntry?.origin?.provider); + const weakEntryChannel = normalizeOptionalString(sessionDeliveryOrigin(sessionEntry)?.provider); return resolveReturnValue({ resolvedChannel: strongEntryChannel ?? weakEntryChannel, resolvedChannelStrength: strongEntryChannel diff --git a/extensions/feishu/src/doctor.test.ts b/extensions/feishu/src/doctor.test.ts index c99055e71c4e..6ae7888d1795 100644 --- a/extensions/feishu/src/doctor.test.ts +++ b/extensions/feishu/src/doctor.test.ts @@ -6,6 +6,7 @@ import { DatabaseSync } from "node:sqlite"; import { formatSqliteSessionFileMarker, listSessionEntries, + normalizeSessionDeliveryState, type SessionEntry, upsertSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; @@ -430,7 +431,9 @@ describe("Feishu doctor state repair", () => { sessionId: "sess-acp-bad", sessionFile: "sess-acp-bad.jsonl", updatedAt: Date.now(), - route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } }, + delivery: normalizeSessionDeliveryState({ + route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } }, + }), }, "agent:main:discord:direct:user": { sessionId: "sess-discord", @@ -492,7 +495,9 @@ describe("Feishu doctor state repair", () => { sessionId: "sess-codex-locked", agentHarnessId: "codex", modelSelectionLocked: true, - route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } }, + delivery: normalizeSessionDeliveryState({ + route: { channel: "feishu", target: { to: "ou_user", chatType: "direct" } }, + }), updatedAt: 1, }, }); diff --git a/extensions/matrix/src/exec-approvals.test.ts b/extensions/matrix/src/exec-approvals.test.ts index cf4f38c08702..d36b63777a45 100644 --- a/extensions/matrix/src/exec-approvals.test.ts +++ b/extensions/matrix/src/exec-approvals.test.ts @@ -4,7 +4,10 @@ import os from "node:os"; import path from "node:path"; import type { ExecApprovalRequest } from "openclaw/plugin-sdk/approval-runtime"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; import { normalizeMatrixApproverId } from "./approval-ids.js"; @@ -364,20 +367,19 @@ describe("matrix exec approvals", () => { entry: { sessionId: "main", updatedAt: 1, - origin: { - provider: "matrix", - accountId: "ops", - to: "room:!room:example.org", - nativeChannelId: "!room:example.org", - }, - deliveryContext: { - channel: "matrix", - to: "room:!room:example.org", - accountId: "ops", - }, - lastChannel: "slack", - lastTo: "channel:C999", - lastAccountId: "work", + delivery: normalizeSessionDeliveryState({ + origin: { + provider: "matrix", + accountId: "ops", + to: "room:!room:example.org", + nativeChannelId: "!room:example.org", + }, + context: { + channel: "matrix", + to: "room:!room:example.org", + accountId: "ops", + }, + }), }, }); const cfg = buildMultiAccountMatrixConfig({ sessionStorePath: storePath }); diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 006a996bb83c..1925448fd5fa 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -7,7 +7,13 @@ import { testing as sessionBindingTesting, registerSessionBindingAdapter, } from "openclaw/plugin-sdk/session-binding-runtime"; -import { getSessionEntry, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + deliveryContextFromSession, + getSessionEntry, + normalizeSessionDeliveryState, + sessionDeliveryOrigin, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { installMatrixMonitorTestRuntime } from "../../test-runtime.js"; import { MATRIX_OPENCLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js"; @@ -87,22 +93,22 @@ async function writeMatrixSessionMeta( sessionId: `sess-${sessionKey}`, updatedAt: Date.now(), }; - const existingOrigin = - typeof existing.origin === "object" && existing.origin !== null - ? (existing.origin as Record) - : {}; + const existingOrigin = sessionDeliveryOrigin(existing) ?? {}; await upsertSessionEntry({ storePath, sessionKey, entry: { ...existing, - origin: { - ...existingOrigin, - provider: "matrix", - surface: "matrix", - accountId: "ops", - ...origin, - }, + delivery: normalizeSessionDeliveryState({ + context: deliveryContextFromSession(existing), + origin: { + ...existingOrigin, + provider: "matrix", + surface: "matrix", + accountId: "ops", + ...origin, + }, + }), }, }); } @@ -1506,11 +1512,13 @@ describe("matrix monitor handler pairing account scope", () => { entry: { sessionId: "sess-main", updatedAt: Date.now(), - deliveryContext: { - channel: "matrix", - to: "room:!other:example.org", - accountId: "ops", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "matrix", + to: "room:!other:example.org", + accountId: "ops", + }, + }), }, }); const sendNotice = vi.fn(async () => "$notice"); @@ -1551,11 +1559,13 @@ describe("matrix monitor handler pairing account scope", () => { entry: { sessionId: "sess-bound", updatedAt: Date.now(), - deliveryContext: { - channel: "matrix", - to: "room:!other:example.org", - accountId: "ops", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "matrix", + to: "room:!other:example.org", + accountId: "ops", + }, + }), }, }); const sendNotice = vi.fn(async () => "$notice"); diff --git a/extensions/matrix/src/matrix/session-store-metadata.ts b/extensions/matrix/src/matrix/session-store-metadata.ts index 61747af5fd2b..0939a404cdfd 100644 --- a/extensions/matrix/src/matrix/session-store-metadata.ts +++ b/extensions/matrix/src/matrix/session-store-metadata.ts @@ -1,5 +1,10 @@ // Matrix plugin module implements session store metadata behavior. import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, + type SessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity } from "./target-ids.js"; function trimMaybeString(value: unknown): string | undefined { @@ -26,38 +31,17 @@ function resolveMatrixSessionAccountId(value: unknown): string | undefined { function resolveMatrixStoredRoomId(params: { deliveryTo?: unknown; - lastTo?: unknown; originNativeChannelId?: unknown; originTo?: unknown; }): string | undefined { return ( resolveMatrixRoomTargetId(params.deliveryTo) ?? - resolveMatrixRoomTargetId(params.lastTo) ?? resolveMatrixRoomTargetId(params.originNativeChannelId) ?? resolveMatrixRoomTargetId(params.originTo) ); } -type MatrixStoredSessionEntryLike = { - deliveryContext?: { - channel?: unknown; - to?: unknown; - accountId?: unknown; - }; - origin?: { - provider?: unknown; - from?: unknown; - to?: unknown; - nativeChannelId?: unknown; - nativeDirectUserId?: unknown; - accountId?: unknown; - chatType?: unknown; - }; - lastChannel?: unknown; - lastTo?: unknown; - lastAccountId?: unknown; - chatType?: unknown; -}; +type MatrixStoredSessionEntryLike = Pick; export function resolveMatrixStoredSessionMeta(entry?: MatrixStoredSessionEntryLike): { channel?: string; @@ -68,32 +52,27 @@ export function resolveMatrixStoredSessionMeta(entry?: MatrixStoredSessionEntryL if (!entry) { return null; } - const channel = - trimMaybeString(entry.deliveryContext?.channel) ?? - trimMaybeString(entry.lastChannel) ?? - trimMaybeString(entry.origin?.provider); + const deliveryContext = deliveryContextFromSession(entry); + const origin = sessionDeliveryOrigin(entry); + const channel = trimMaybeString(deliveryContext?.channel) ?? trimMaybeString(origin?.provider); const accountId = - resolveMatrixSessionAccountId( - entry.deliveryContext?.accountId ?? entry.lastAccountId ?? entry.origin?.accountId, - ) ?? undefined; + resolveMatrixSessionAccountId(deliveryContext?.accountId ?? origin?.accountId) ?? undefined; const roomId = resolveMatrixStoredRoomId({ - deliveryTo: entry.deliveryContext?.to, - lastTo: entry.lastTo, - originNativeChannelId: entry.origin?.nativeChannelId, - originTo: entry.origin?.to, + deliveryTo: deliveryContext?.to, + originNativeChannelId: origin?.nativeChannelId, + originTo: origin?.to, }); const chatType = - trimMaybeString(entry.origin?.chatType) ?? trimMaybeString(entry.chatType) ?? undefined; + trimMaybeString(origin?.chatType) ?? trimMaybeString(entry.chatType) ?? undefined; const directUserId = chatType === "direct" - ? (trimMaybeString(entry.origin?.nativeDirectUserId) ?? + ? (trimMaybeString(origin?.nativeDirectUserId) ?? resolveMatrixDirectUserId({ - from: trimMaybeString(entry.origin?.from), + from: trimMaybeString(origin?.from), to: (roomId ? `room:${roomId}` : undefined) ?? - trimMaybeString(entry.deliveryContext?.to) ?? - trimMaybeString(entry.lastTo) ?? - trimMaybeString(entry.origin?.to), + trimMaybeString(deliveryContext?.to) ?? + trimMaybeString(origin?.to), chatType, })) : undefined; diff --git a/extensions/matrix/src/session-route.test.ts b/extensions/matrix/src/session-route.test.ts index 5a06a960100e..7c5292ffb587 100644 --- a/extensions/matrix/src/session-route.test.ts +++ b/extensions/matrix/src/session-route.test.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "./runtime-api.js"; @@ -75,20 +78,16 @@ function createStoredDirectDmSession( sessionId: "sess-1", updatedAt: Date.now(), chatType: "direct", - origin: { - chatType: "direct", - from: params.from ?? "matrix:@alice:example.org", - to, - ...nativeMetadata, - ...accountMetadata, - }, - deliveryContext: { - channel: "matrix", - to, - ...accountMetadata, - }, - ...(params.lastTo ? { lastTo: params.lastTo } : {}), - ...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}), + delivery: normalizeSessionDeliveryState({ + origin: { + chatType: "direct", + from: params.from ?? "matrix:@alice:example.org", + to, + ...nativeMetadata, + ...accountMetadata, + }, + context: { channel: "matrix", to, ...accountMetadata }, + }), }; } @@ -97,21 +96,21 @@ function createStoredChannelSession(): SessionEntry { sessionId: "sess-1", updatedAt: Date.now(), chatType: "channel", - origin: { - chatType: "channel", - from: "matrix:channel:!ops:example.org", - to: "room:!ops:example.org", - nativeChannelId: "!ops:example.org", - nativeDirectUserId: "@alice:example.org", - accountId: "ops", - }, - deliveryContext: { - channel: "matrix", - to: "room:!ops:example.org", - accountId: "ops", - }, - lastTo: "room:!ops:example.org", - lastAccountId: "ops", + delivery: normalizeSessionDeliveryState({ + origin: { + chatType: "channel", + from: "matrix:channel:!ops:example.org", + to: "room:!ops:example.org", + nativeChannelId: "!ops:example.org", + nativeDirectUserId: "@alice:example.org", + accountId: "ops", + }, + context: { + channel: "matrix", + to: "room:!ops:example.org", + accountId: "ops", + }, + }), }; } diff --git a/extensions/mattermost/src/mattermost/model-picker.test.ts b/extensions/mattermost/src/mattermost/model-picker.test.ts index 2103723b7289..49dc95e8b0aa 100644 --- a/extensions/mattermost/src/mattermost/model-picker.test.ts +++ b/extensions/mattermost/src/mattermost/model-picker.test.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../../runtime-api.js"; import { @@ -232,7 +235,7 @@ describe("Mattermost model picker", () => { providerOverride: "anthropic", modelOverride: "claude-sonnet-4-5", chatType: "channel", - channel: "channel-1", + delivery: normalizeSessionDeliveryState({ context: { channel: "channel-1" } }), sessionId: "parent-session", updatedAt: 1, }, @@ -244,7 +247,9 @@ describe("Mattermost model picker", () => { entry: { parentSessionKey, chatType: "channel", - channel: "child-with-explicit-parent", + delivery: normalizeSessionDeliveryState({ + context: { channel: "child-with-explicit-parent" }, + }), sessionId: "child-session", updatedAt: 2, }, @@ -257,7 +262,7 @@ describe("Mattermost model picker", () => { providerOverride: "openai", modelOverride: "gpt-5", chatType: "channel", - channel: "direct-1", + delivery: normalizeSessionDeliveryState({ context: { channel: "direct-1" } }), sessionId: "direct-session", updatedAt: 3, }, diff --git a/extensions/memory-core/src/session-search-visibility.test.ts b/extensions/memory-core/src/session-search-visibility.test.ts index c984095133ea..294e179259af 100644 --- a/extensions/memory-core/src/session-search-visibility.test.ts +++ b/extensions/memory-core/src/session-search-visibility.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; +import { normalizeSessionDeliveryState } from "openclaw/plugin-sdk/session-store-runtime"; import * as sessionTranscriptHit from "openclaw/plugin-sdk/session-transcript-hit"; import { afterEach, describe, expect, it, vi } from "vitest"; import { replaceQmdSessionArtifactMappings } from "./qmd-session-artifacts.js"; @@ -15,6 +16,7 @@ type TestSessionEntry = { updatedAt: number; sessionFile: string; chatType?: "direct" | "group" | "channel"; + delivery?: ReturnType; origin?: { chatType?: "direct" | "group" | "channel" }; }; @@ -254,13 +256,24 @@ describe("filterMemorySearchHitsBySessionVisibility", () => { sessionId: "current", updatedAt: 2, sessionFile: "/tmp/sessions/current.jsonl", - origin: { chatType: "direct" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "user:current" }, + origin: { provider: "discord", chatType: "direct", to: "user:current" }, + }), }, "agent:main:explicit:phone:group:shadow": { sessionId: "explicit-private", updatedAt: 1, sessionFile: "/tmp/sessions/explicit-private.jsonl", chatType: "direct", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "user:explicit-private" }, + origin: { + provider: "discord", + chatType: "direct", + to: "user:explicit-private", + }, + }), }, }; const hit: MemorySearchResult = { diff --git a/extensions/memory-core/src/session-search-visibility.ts b/extensions/memory-core/src/session-search-visibility.ts index f251b1fb5c99..d9cfc51a07dd 100644 --- a/extensions/memory-core/src/session-search-visibility.ts +++ b/extensions/memory-core/src/session-search-visibility.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-runtim import type { MemorySearchResult } from "openclaw/plugin-sdk/memory-core-host-runtime-files"; import { resolveSessionAgentId } from "openclaw/plugin-sdk/memory-host-core"; import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; +import { sessionDeliveryOrigin } from "openclaw/plugin-sdk/session-store-runtime"; import { extractTranscriptIdentityFromSessionsMemoryHit, loadCombinedSessionStoreForGateway, @@ -56,7 +57,7 @@ function isPrivateConversation(params: { return false; } const key = params.key.trim().toLowerCase(); - const chatTypes = [params.entry.chatType, params.entry.origin?.chatType].filter( + const chatTypes = [params.entry.chatType, sessionDeliveryOrigin(params.entry)?.chatType].filter( (chatType): chatType is NonNullable => chatType !== undefined, ); if ( diff --git a/extensions/slack/src/approval-native.test.ts b/extensions/slack/src/approval-native.test.ts index 6ff1fc522111..84222fb1bce7 100644 --- a/extensions/slack/src/approval-native.test.ts +++ b/extensions/slack/src/approval-native.test.ts @@ -3,7 +3,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; import { slackApprovalCapability } from "./approval-native.js"; @@ -667,8 +670,9 @@ describe("slack native approval adapter", () => { entry: { sessionId: "sess", updatedAt: Date.now(), - lastChannel: "slack", - lastAccountId: "work", + delivery: normalizeSessionDeliveryState({ + context: { channel: "slack", accountId: "work" }, + }), }, }); diff --git a/extensions/telegram/src/approval-native.test.ts b/extensions/telegram/src/approval-native.test.ts index 69f468f1b839..682dbb38a183 100644 --- a/extensions/telegram/src/approval-native.test.ts +++ b/extensions/telegram/src/approval-native.test.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import type { SessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; @@ -135,12 +138,14 @@ describe("telegram native approval adapter", () => { entry: { sessionId: "sess", updatedAt: Date.now(), - deliveryContext: { - channel: "telegram", - to: "-1003841603622", - accountId: "default", - threadId: 928, - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "-1003841603622", + accountId: "default", + threadId: 928, + }, + }), }, }); @@ -177,12 +182,14 @@ describe("telegram native approval adapter", () => { entry: { sessionId: "sess", updatedAt: Date.now(), - deliveryContext: { - channel: "telegram", - to: "-1003841603622", - accountId: "default", - threadId: "928", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "-1003841603622", + accountId: "default", + threadId: "928", + }, + }), }, }); diff --git a/extensions/telegram/src/bot-message-context.session-recreate.test-support.ts b/extensions/telegram/src/bot-message-context.session-recreate.test-support.ts index cc2d19b0105c..cc5ca6b0ed07 100644 --- a/extensions/telegram/src/bot-message-context.session-recreate.test-support.ts +++ b/extensions/telegram/src/bot-message-context.session-recreate.test-support.ts @@ -7,6 +7,7 @@ import { } from "openclaw/plugin-sdk/runtime-config-snapshot"; import { deleteSessionEntry, + normalizeSessionDeliveryState, getSessionEntry, upsertSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; @@ -86,7 +87,7 @@ describe("Telegram direct session recreation after delete", () => { sessionId: "old-session", updatedAt: 1_700_000_000_000, chatType: "direct", - channel: "telegram", + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram" } }), }, }); await deleteSessionEntry({ storePath, sessionKey: TELEGRAM_DIRECT_KEY }); @@ -113,10 +114,13 @@ describe("Telegram direct session recreation after delete", () => { const entry = getSessionEntry({ storePath, sessionKey: TELEGRAM_DIRECT_KEY }); expect(context?.ctxPayload?.SessionKey).toBe(TELEGRAM_DIRECT_KEY); - expect(entry).toEqual( + expect(entry?.delivery).toEqual( expect.objectContaining({ - lastChannel: "telegram", - lastTo: "telegram:7463849194", + kind: "external", + context: expect.objectContaining({ + channel: "telegram", + to: "telegram:7463849194", + }), origin: expect.objectContaining({ provider: "telegram", chatType: "direct", diff --git a/extensions/telegram/src/bot.test.ts b/extensions/telegram/src/bot.test.ts index 08e289e5afdb..a3fbd0eac2ae 100644 --- a/extensions/telegram/src/bot.test.ts +++ b/extensions/telegram/src/bot.test.ts @@ -13,7 +13,11 @@ import { resetPluginStateStoreForTests, } from "openclaw/plugin-sdk/plugin-state-test-runtime"; import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime"; -import { listSessionEntries, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + listSessionEntries, + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { appendSessionTranscriptMessageByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { mockPinnedHostnameResolution } from "openclaw/plugin-sdk/test-env"; @@ -308,7 +312,7 @@ async function writeDirectTelegramTranscriptMessages(params: { entry: { sessionId: params.sessionId, chatType: "direct", - channel: "telegram", + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram" } }), updatedAt: 1, }, }); diff --git a/extensions/telegram/src/exec-approvals.test.ts b/extensions/telegram/src/exec-approvals.test.ts index 297c33f5ecd1..abaf70f0aaad 100644 --- a/extensions/telegram/src/exec-approvals.test.ts +++ b/extensions/telegram/src/exec-approvals.test.ts @@ -7,7 +7,10 @@ import type { TelegramAccountConfig, TelegramExecApprovalConfig, } from "openclaw/plugin-sdk/config-contracts"; -import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { closeOpenClawAgentDatabasesForTest } from "openclaw/plugin-sdk/sqlite-runtime-testing"; import { afterEach, describe, expect, it } from "vitest"; import { @@ -240,13 +243,10 @@ describe("telegram exec approvals", () => { entry: { sessionId: "main", updatedAt: 1, - origin: { - provider: "telegram", - accountId: "ops", - }, - lastChannel: "slack", - lastTo: "channel:C999", - lastAccountId: "work", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", accountId: "ops" }, + origin: { provider: "telegram", accountId: "ops" }, + }), }, }); const cfg = buildMultiAccountTelegramConfig({ sessionStorePath: storePath }); diff --git a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts index 833d4fd5aa63..89666ff8fac9 100644 --- a/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts +++ b/extensions/whatsapp/src/auto-reply/web-auto-reply-utils.test.ts @@ -6,12 +6,14 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing"; import { evaluateSessionFreshness, getSessionEntry, + normalizeSessionDeliveryState, resolveChannelResetConfig, resolveSessionKey, resolveSessionResetPolicy, resolveSessionResetType, resolveStorePath, resolveThreadFlag, + sessionDeliveryChannel, upsertSessionEntry, } from "openclaw/plugin-sdk/session-store-runtime"; import { withTempDir } from "openclaw/plugin-sdk/test-env"; @@ -107,7 +109,7 @@ function getSessionSnapshotForTest( resetType, resetOverride: resolveChannelResetConfig({ sessionCfg, - channel: entry?.lastChannel ?? entry?.channel, + channel: sessionDeliveryChannel(entry), }), }); const freshness = entry @@ -320,7 +322,7 @@ describe("getSessionSnapshot", () => { entry: { sessionId: "snapshot-session", updatedAt: new Date(2026, 0, 18, 3, 30, 0).getTime(), - lastChannel: "whatsapp", + delivery: normalizeSessionDeliveryState({ context: { channel: "whatsapp" } }), }, }); diff --git a/extensions/zalouser/doctor-contract-api.test.ts b/extensions/zalouser/doctor-contract-api.test.ts index 31d0656316ca..3f5ea77fa550 100644 --- a/extensions/zalouser/doctor-contract-api.test.ts +++ b/extensions/zalouser/doctor-contract-api.test.ts @@ -12,7 +12,11 @@ import type { OpenKeyedStoreOptions, PluginDoctorStateMigrationContext, } from "openclaw/plugin-sdk/runtime-doctor"; -import { listSessionEntries, upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime"; +import { + listSessionEntries, + normalizeSessionDeliveryState, + upsertSessionEntry, +} from "openclaw/plugin-sdk/session-store-runtime"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { stateMigrations } from "./doctor-contract-api.js"; import { setZalouserRuntime } from "./src/runtime.js"; @@ -180,7 +184,9 @@ describe("zalouser doctor state migration", () => { sessionId: "session-1", updatedAt: 1, chatType: "direct", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "zalouser", accountId: "default" }, + }), }, }); await upsertSessionEntry({ @@ -233,7 +239,14 @@ describe("zalouser doctor state migration", () => { env, storePath, sessionKey, - entry: { sessionId, updatedAt, chatType: "direct", lastAccountId: "default" }, + entry: { + sessionId, + updatedAt, + chatType: "direct", + delivery: normalizeSessionDeliveryState({ + context: { channel: "zalouser", accountId: "default" }, + }), + }, }); } const migration = findMigration("zalouser-direct-session-keys"); diff --git a/extensions/zalouser/doctor-contract-api.ts b/extensions/zalouser/doctor-contract-api.ts index 50bfede317ce..5928c8ef7a70 100644 --- a/extensions/zalouser/doctor-contract-api.ts +++ b/extensions/zalouser/doctor-contract-api.ts @@ -10,6 +10,7 @@ import { } from "openclaw/plugin-sdk/runtime-doctor"; import { deleteSessionEntry, + deliveryContextFromSession, listSessionEntries, resolveStorePath, upsertSessionEntry, @@ -112,7 +113,7 @@ function collectLegacyZalouserDmEntries( const canonicalKey = buildAgentSessionKey({ agentId: parsed.agentId, channel: "zalouser", - accountId: entry.lastAccountId?.trim() || fallbackAccountId, + accountId: deliveryContextFromSession(entry)?.accountId?.trim() || fallbackAccountId, peer: { kind: "direct", id: peerId }, dmScope: resolveZalouserDmSessionScope(config), identityLinks: config.session?.identityLinks, diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index a7434e7a7d03..26f63dc882fa 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -163,7 +163,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: closed attempt-terminal merge, normalization, and projection helper. // +3: harness-native MCP App preview helper and its runtime/catalog contracts. // +1: canonical unknown-value to Error coercion. - 4705, + // +6: canonical session delivery normalization, access, and projection helpers. + 4711, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -183,7 +184,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: channel partial-delivery error creator and structural guard. // +1: harness-native MCP App preview helper. // +1: canonical unknown-value to Error coercion. - 2849, + // +6: canonical session delivery normalization, access, and projection helpers. + 2855, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/acp/control-plane/manager.background-task.ts b/src/acp/control-plane/manager.background-task.ts index d2d46e192239..e11579ead44f 100644 --- a/src/acp/control-plane/manager.background-task.ts +++ b/src/acp/control-plane/manager.background-task.ts @@ -9,7 +9,7 @@ import { startTaskRunByRunId, } from "../../tasks/detached-task-runtime.js"; import { resolveRequiredCompletionTerminalResult } from "../../tasks/task-completion-contract.js"; -import type { DeliveryContext } from "../../utils/delivery-context.js"; +import { deliveryContextFromSession, type DeliveryContext } from "../../utils/delivery-context.js"; import { AcpRuntimeError } from "../runtime/errors.js"; import type { AcpSessionManagerDeps } from "./manager.types.js"; import { normalizeText } from "./runtime-options.js"; @@ -117,7 +117,8 @@ export function resolveBackgroundTaskContext(params: { })?.entry; return { requesterSessionKey, - requesterOrigin: parentEntry?.deliveryContext ?? childEntry?.deliveryContext, + requesterOrigin: + deliveryContextFromSession(parentEntry) ?? deliveryContextFromSession(childEntry), childSessionKey: params.sessionKey, runId: params.requestId, label: normalizeText(childEntry?.label), diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 240aa75f1d3b..4b392ff0a267 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -14,6 +14,7 @@ import { type SessionBindingPlacement, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { resolveThinkingDefault } from "./model-selection.js"; type SessionBindingAdapterCapabilities = NonNullable; @@ -2686,16 +2687,18 @@ describe("spawnAcpDirect", () => { hoisted.loadSessionStoreMock.mockReset().mockImplementation(() => { const store: Record< string, - { sessionId: string; updatedAt: number; deliveryContext?: unknown } + { sessionId: string; updatedAt: number; delivery?: SessionEntry["delivery"] } > = { "agent:main:subagent:parent": { sessionId: "parent-sess-1", updatedAt: Date.now(), - deliveryContext: { - channel: "discord", - to: "channel:parent-channel", - accountId: "default", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + }), }, }; return new Proxy(store, { @@ -2764,7 +2767,7 @@ describe("spawnAcpDirect", () => { { sessionId: string; updatedAt: number; - deliveryContext?: unknown; + delivery?: SessionEntry["delivery"]; spawnedBy?: string; spawnDepth?: number; subagentRole?: string; @@ -2774,11 +2777,13 @@ describe("spawnAcpDirect", () => { "agent:main:acp:child": { sessionId: "parent-sess-1", updatedAt: Date.now(), - deliveryContext: { - channel: "discord", - to: "channel:parent-channel", - accountId: "default", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "channel:parent-channel", + accountId: "default", + }, + }), spawnedBy: "agent:main:subagent:parent", spawnDepth: 1, subagentRole: "orchestrator", diff --git a/src/agents/agent-command.live-model-switch.test-helpers.ts b/src/agents/agent-command.live-model-switch.test-helpers.ts index 0d2c75a873df..73dbbf900a76 100644 --- a/src/agents/agent-command.live-model-switch.test-helpers.ts +++ b/src/agents/agent-command.live-model-switch.test-helpers.ts @@ -1,15 +1,25 @@ import type { SessionEntry } from "../config/sessions.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; -export function createCommandSessionEntry(overrides: Partial = {}): SessionEntry { - return { +export type CommandSessionEntryFixture = Partial & { + channel?: string; + deliveryContext?: DeliveryContext; + lastThreadId?: string | number; +}; + +export function createCommandSessionEntry( + overrides: CommandSessionEntryFixture = {}, +): SessionEntry { + return normalizeLegacySessionEntryDelivery({ sessionId: "session-1", updatedAt: 1, ...overrides, - }; + } as SessionEntry); } export function createCommandSessionFixture( - overrides: Partial = {}, + overrides: CommandSessionEntryFixture = {}, sessionKey = "agent:main:main", ): { entry: SessionEntry; store: Record } { const entry = createCommandSessionEntry({ diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts index b02cef56524e..f068d863a6bb 100644 --- a/src/agents/agent-command.live-model-switch.test.ts +++ b/src/agents/agent-command.live-model-switch.test.ts @@ -3,8 +3,13 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions.js"; +import { + deliveryContextFromSession, + normalizeSessionDeliveryState, +} from "../utils/delivery-context.shared.js"; import { buildTestAllowedModelSet, + type CommandSessionEntryFixture, buildTestConfiguredModelCatalog, buildTestModelAliasIndex, createChannelModelRuntimeConfig, @@ -782,7 +787,7 @@ function runInternalModelCommand(runId: string) { } function setupStoredSession( - overrides: Partial = {}, + overrides: CommandSessionEntryFixture = {}, storePath = "/tmp/openclaw-sessions.json", sessionKey = "agent:main:main", ): { entry: SessionEntry; store: Record } { @@ -794,7 +799,7 @@ function setupStoredSession( } function setupBareStoredSession( - overrides: Partial = {}, + overrides: CommandSessionEntryFixture = {}, storePath = "/tmp/openclaw-sessions.json", sessionKey = "agent:main:main", ): { entry: SessionEntry; store: Record } { @@ -950,17 +955,11 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { requestedChannel?: string; sessionEntry?: SessionEntry; }) => { - const context = params.sessionEntry?.deliveryContext; - const channel = - params.requestedChannel ?? - context?.channel ?? - params.sessionEntry?.lastChannel ?? - "internal"; - const to = params.explicitTo ?? context?.to ?? params.sessionEntry?.lastTo; - const accountId = - params.accountId ?? context?.accountId ?? params.sessionEntry?.lastAccountId; - const threadId = - params.explicitThreadId ?? context?.threadId ?? params.sessionEntry?.lastThreadId; + const context = deliveryContextFromSession(params.sessionEntry); + const channel = params.requestedChannel ?? context?.channel ?? "internal"; + const to = params.explicitTo ?? context?.to; + const accountId = params.accountId ?? context?.accountId; + const threadId = params.explicitThreadId ?? context?.threadId; return { baseDelivery: {}, resolvedChannel: channel, @@ -1050,13 +1049,13 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }, }, }; - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: Date.now(), authProfileOverride: "anthropic:stale-auto", authProfileOverrideSource: "auto", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); @@ -1101,12 +1100,12 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { provider: "openai", model: "gpt-5.4", }); - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, modelSelectionLocked: true, skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.isModelSelectionLockedMock.mockReturnValue(true); await expect(runBasicAgentCommand()).rejects.toMatchObject({ @@ -1128,7 +1127,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }, }, }; - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, agentHarnessId: "codex", @@ -1142,7 +1141,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { }, }, skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.isModelSelectionLockedMock.mockReturnValue(true); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); @@ -1168,7 +1167,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { setupSingleAttemptFallback(); state.resolvedSessionKeyMock = "agent:main:plugin-owned"; state.hasLegacyAutoFallbackWithoutOriginMock.mockReturnValue(true); - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, agentHarnessId: "codex", @@ -1177,7 +1176,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { modelOverride: "stale-fallback-model", modelOverrideSource: "auto", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude")); await runBasicAgentCommand(); @@ -1744,7 +1743,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.runtimeConfigMock = createChannelModelRuntimeConfig({ additionalModels: { "anthropic/fallback-model": {} }, }); - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, channel: "discord", @@ -1755,7 +1754,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { modelOverrideFallbackOriginProvider: "openai", modelOverrideFallbackOriginModel: "channel-model", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "channel-model")); await runBasicAgentCommand(); @@ -1777,13 +1776,13 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { model: "openai/parent-channel-model", }); state.resolvedSessionKeyMock = "agent:main:slack:channel:general:thread:thread-1"; - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, channel: "slack", groupId: "thread-1", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.runAgentAttemptMock.mockResolvedValue( makeSuccessResult("openai", "parent-channel-model"), ); @@ -1800,7 +1799,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.runtimeConfigMock = createChannelModelRuntimeConfig({ additionalModels: { "anthropic/stored-model": {} }, }); - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, channel: "discord", @@ -1809,7 +1808,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { modelOverride: "stored-model", modelOverrideSource: "user", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "stored-model")); await runBasicAgentCommand(); @@ -1824,13 +1823,13 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { state.runtimeConfigMock = createChannelModelRuntimeConfig({ additionalModels: { "openai/explicit-model": {} }, }); - state.sessionEntryMock = { + state.sessionEntryMock = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 1, channel: "discord", groupId: "channel-123", skillsSnapshot: { prompt: "", skills: [], version: 0 }, - }; + }); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "explicit-model")); await agentCommand({ @@ -2552,7 +2551,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); setupBareStoredSession(); - const freshEntry: SessionEntry = { + const freshEntry = createCommandSessionEntry({ sessionId: "session-1", updatedAt: 2, deliveryContext: { @@ -2560,7 +2559,7 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { to: "discord:dm:sqlite", accountId: "main", }, - }; + }); state.loadSessionEntryMock.mockReturnValue(freshEntry); state.deliverAgentCommandResultMock.mockImplementation(async (params: unknown) => { const resolver = ( @@ -2640,12 +2639,14 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { setupSingleAttemptFallback(); state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4")); setupBareStoredSession({ - deliveryContext: { - channel: "discord", - to: "discord:channel:general", - accountId: "main", - threadId: "thread-1", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "discord:channel:general", + accountId: "main", + threadId: "thread-1", + }, + }), }); state.deliverAgentCommandResultMock.mockResolvedValue({ deliverySucceeded: true }); @@ -2670,7 +2671,9 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => { explicitTo: undefined, requestedChannel: undefined, sessionEntry: expect.objectContaining({ - deliveryContext: expect.objectContaining({ to: "discord:channel:general" }), + delivery: expect.objectContaining({ + context: expect.objectContaining({ to: "discord:channel:general" }), + }), }), wantsDelivery: true, }), diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index 848b32ced276..520f4efa41cd 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -21,7 +21,7 @@ import { isAgentMediatedCompletionSourceTool } from "../sessions/input-provenanc import { resolveSendPolicy } from "../sessions/send-policy.js"; import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admission.js"; import { classifySessionStateActor } from "../sessions/session-state-events.js"; -import type { DeliveryContext } from "../utils/delivery-context.shared.js"; +import { sessionDeliveryChannel, type DeliveryContext } from "../utils/delivery-context.shared.js"; import { runWithAgentCommandRecoveryOwner } from "./agent-command-recovery-owner.js"; import { buildCurrentRunRestartRecoveryClaim, @@ -223,7 +223,7 @@ async function agentCommandInternal( cfg, entry: sessionEntry, sessionKey, - channel: sessionEntry?.channel, + channel: sessionDeliveryChannel(sessionEntry), chatType: sessionEntry?.chatType, }); if (sendPolicy === "deny") { diff --git a/src/agents/command/delivery.test.ts b/src/agents/command/delivery.test.ts index ae70b10efff3..47c971901eb8 100644 --- a/src/agents/command/delivery.test.ts +++ b/src/agents/command/delivery.test.ts @@ -9,6 +9,7 @@ import type { CliDeps } from "../../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../../config/config.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { createAgentRunRestartAbortError } from "../run-termination.js"; import { deliverAgentCommandResult } from "./delivery.js"; import type { AgentCommandOpts } from "./types.js"; @@ -497,11 +498,13 @@ describe("deliverAgentCommandResult payload normalization", () => { const resolveFreshSessionEntryForDelivery = vi.fn(async () => ({ sessionId: "session-1", updatedAt: 2, - deliveryContext: { - channel: "slack", - to: "#fresh", - accountId: "workspace-1", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "slack", + to: "#fresh", + accountId: "workspace-1", + }, + }), })); const delivered = await deliverAgentCommandResult({ @@ -554,11 +557,13 @@ describe("deliverAgentCommandResult payload normalization", () => { const resolveFreshSessionEntryForDelivery = vi.fn(async () => ({ sessionId: "session-2", updatedAt: 2, - deliveryContext: { - channel: "slack", - to: "#fresh", - accountId: "workspace-1", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "slack", + to: "#fresh", + accountId: "workspace-1", + }, + }), })); const delivered = await deliverAgentCommandResult({ diff --git a/src/agents/command/model-selection.ts b/src/agents/command/model-selection.ts index 53bb78e538dc..31137386f4a6 100644 --- a/src/agents/command/model-selection.ts +++ b/src/agents/command/model-selection.ts @@ -16,6 +16,10 @@ import { isModelSelectionLocked, repairProviderWrappedModelOverride, } from "../../sessions/model-overrides.js"; +import { + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { isDeliverableMessageChannel } from "../../utils/message-channel.js"; import { clearAutoFallbackPrimaryProbeSelection, @@ -239,20 +243,16 @@ export async function resolveEmbeddedModelSelection(params: { params.cfg.channels?.modelByChannel && !hasExplicitRunOverride ? resolveChannelModelOverride({ cfg: params.cfg, - channel: - currentRunModelChannel ?? - sessionEntry?.channel ?? - sessionEntry?.lastChannel ?? - sessionEntry?.origin?.provider, + channel: currentRunModelChannel ?? sessionDeliveryChannel(sessionEntry), groupId: channelOverrideGroupId, - groupChatType: sessionEntry?.chatType ?? sessionEntry?.origin?.chatType, + groupChatType: sessionEntry?.chatType ?? sessionDeliveryOrigin(sessionEntry)?.chatType, groupChannel: params.runContext.groupChannel ?? sessionEntry?.groupChannel, groupSubject: sessionEntry?.subject, parentSessionKey: sessionEntry?.parentSessionKey ?? params.sessionKey, directUserIds: [ - sessionEntry?.origin?.nativeDirectUserId, - sessionEntry?.origin?.from, - sessionEntry?.origin?.to, + sessionDeliveryOrigin(sessionEntry)?.nativeDirectUserId, + sessionDeliveryOrigin(sessionEntry)?.from, + sessionDeliveryOrigin(sessionEntry)?.to, ], }) : null; diff --git a/src/agents/command/session-store.test.ts b/src/agents/command/session-store.test.ts index 6ea80b38f0a1..a979a6dffaaa 100644 --- a/src/agents/command/session-store.test.ts +++ b/src/agents/command/session-store.test.ts @@ -2425,6 +2425,7 @@ describe("updateSessionStoreAfterAgentRun", () => { const replacementEntry: SessionEntry = { sessionId: "replacement-session-id", updatedAt: 2, + delivery: { kind: "none" }, modelProvider: "openai", model: "gpt-5.5", }; diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index fd1c1f56098d..da18b45122b9 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -38,6 +38,7 @@ import { } from "../../routing/session-key.js"; import { isModelSelectionLocked } from "../../sessions/model-overrides.js"; import { resolveSessionIdMatchSelection } from "../../sessions/session-id-resolution.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { listAgentIds, resolveDefaultAgentId } from "../agent-scope.js"; import { clearBootstrapSnapshotOnSessionRollover } from "../bootstrap-cache.js"; import { clearAllCliSessions } from "../cli-session.js"; @@ -411,7 +412,7 @@ export function resolveSession(opts: { const resetType = resolveSessionResetType({ sessionKey }); const channelReset = resolveChannelResetConfig({ sessionCfg, - channel: sessionEntry?.lastChannel ?? sessionEntry?.channel ?? sessionEntry?.origin?.provider, + channel: sessionDeliveryChannel(sessionEntry), }); const resetPolicy = resolveSessionResetPolicy({ sessionCfg, diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 1a24ce83fb20..40ac761e66b4 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -1143,6 +1143,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { const sessionKey = "agent:main:harness:codex:supervision:native-thread"; const initialEntry = { agentHarnessId: "codex", + delivery: { kind: "none" } as const, modelSelectionLocked: true, sessionId, updatedAt: Date.now(), diff --git a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts index 261f81785dbb..32a96d1ec25d 100644 --- a/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.session-lock.test.ts @@ -19,6 +19,7 @@ import { } from "../../../config/sessions/transcript-write-context.js"; import { appendExactAssistantMessageToSessionTranscript } from "../../../config/sessions/transcript.js"; import { OPENCLAW_TRANSCRIPT_ARTIFACT_API } from "../../../shared/transcript-only-openclaw-assistant.js"; +import { normalizeSessionDeliveryState } from "../../../utils/delivery-context.shared.js"; import { guardSessionManager } from "../../session-tool-result-guard-wrapper.js"; import { SessionWriteLockStaleError, @@ -2501,7 +2502,7 @@ describe("embedded attempt session lock lifecycle", () => { { sessionId, chatType: "direct", - channel: "discord", + delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), spawnedCwd: dir, updatedAt: Date.now(), }, diff --git a/src/agents/internal-session-effects.test.ts b/src/agents/internal-session-effects.test.ts index dc9cd155edff..9052e9fb722d 100644 --- a/src/agents/internal-session-effects.test.ts +++ b/src/agents/internal-session-effects.test.ts @@ -111,6 +111,7 @@ describe("internal session effects", () => { sessionId: target.sessionId, createdVia: "internal", createdActor: { type: "system" }, + delivery: { kind: "internal" }, createdAt: expect.any(Number), }); expect(listSessionEntries({ storePath })).toEqual([]); diff --git a/src/agents/internal-session-effects.ts b/src/agents/internal-session-effects.ts index 5c0bbaeb776e..f1cf17a933d7 100644 --- a/src/agents/internal-session-effects.ts +++ b/src/agents/internal-session-effects.ts @@ -103,6 +103,7 @@ export async function prepareInternalSessionEffectsSession(params: { const now = Date.now(); const entry = await upsertSessionEntry(scope, { ...buildSessionCreationStamp({ via: "internal", actor: { type: "system" } }), + delivery: { kind: "internal" }, sessionId: scope.sessionId, ...(isIncognitoOpenClawAgentSqlitePath(params.storePath, { agentId: params.agentId }) ? { incognito: true as const } diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index ec031cc44f6f..5a44720e46db 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -34,6 +34,7 @@ import { runExclusiveSessionLifecycleMutation, } from "../sessions/session-lifecycle-admission.js"; import { createDeferred } from "../test-utils/deferred.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { setActiveEmbeddedRunLifecycleGeneration } from "./embedded-agent-runner/run-state.js"; import { clearActiveEmbeddedRun, @@ -64,6 +65,7 @@ import { createAssistantToolCallMessage, createSessionEntry, createSessionStore, + type SessionEntryFixture, expectRecord, mockCallArg, waitForFast, @@ -182,20 +184,23 @@ async function makeSessionsDir(agentId = "main"): Promise { async function writeStorePath( storePath: string, - store: Record, + store: Record, ): Promise { await Promise.all( Object.entries(store).map(([sessionKey, entry]) => - replaceSessionEntry({ storePath, sessionKey }, entry), + replaceSessionEntry({ storePath, sessionKey }, createSessionEntry(entry)), ), ); } -async function writeStore(sessionsDir: string, store: Record): Promise { +async function writeStore( + sessionsDir: string, + store: Record, +): Promise { await writeStorePath(path.join(sessionsDir, "sessions.json"), store); } -function mainSessionEntry(overrides: Partial = {}): SessionEntry { +function mainSessionEntry(overrides: SessionEntryFixture = {}): SessionEntry { return createSessionEntry({ sessionId: "main-session", updatedAt: Date.now() - 10_000, @@ -205,10 +210,7 @@ function mainSessionEntry(overrides: Partial = {}): SessionEntry { }); } -function runningSessionEntry( - sessionId: string, - overrides: Partial = {}, -): SessionEntry { +function runningSessionEntry(sessionId: string, overrides: SessionEntryFixture = {}): SessionEntry { return createSessionEntry({ sessionId, updatedAt: Date.now() - 10_000, @@ -218,7 +220,7 @@ function runningSessionEntry( } function mainSessionStore( - overrides: Partial = {}, + overrides: SessionEntryFixture = {}, sessionKey = "agent:main:main", ): Record { return createSessionStore(mainSessionEntry(overrides), sessionKey); @@ -242,7 +244,7 @@ async function writeMainSession({ sessionsDir, sessionKey = "agent:main:main", ...entry -}: Partial & { sessionsDir: string; sessionKey?: string }): Promise { +}: SessionEntryFixture & { sessionsDir: string; sessionKey?: string }): Promise { await writeStore(sessionsDir, mainSessionStore(entry, sessionKey)); } @@ -4128,16 +4130,20 @@ describe("main-session-restart-recovery", () => { }); }); - it("sends a visible notice through the legacy route when no resumable transcript survives", async () => { + it("sends a visible notice through the canonical route when no resumable transcript survives", async () => { const sessionsDir = await makeSessionsDir(); await writeStore(sessionsDir, { "agent:main:demo-channel:room-1": { ...runningSessionEntry("main-session"), abortedLastRun: true, - lastChannel: "discord", - lastTo: "discord:channel:room-1", - lastAccountId: "default", - lastThreadId: "thread-1", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "discord:channel:room-1", + accountId: "default", + threadId: "thread-1", + }, + }), }, }); await writeTranscript(sessionsDir, "main-session", [ diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index 15c2ae3175ce..e14b206b6b3b 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -10,10 +10,12 @@ import { registerInternalHook, type InternalHookEvent, } from "../hooks/internal-hooks.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { resolvePreferredSessionKeyForSessionIdMatches } from "../sessions/session-id-resolution.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { buildTaskStatusSnapshot } from "../tasks/task-status.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { compactToolOutputHint } from "./tool-schema-hints.js"; const loadSessionStoreMock = vi.fn(); @@ -393,7 +395,13 @@ beforeAll(async () => { await getSessionStatusTool("agent:main:spawned").execute("warm-spawned-workspace-status", {}); }); -function resetSessionStore(store: Record) { +function resetSessionStore(inputStore: Record) { + const store = Object.fromEntries( + Object.entries(inputStore).map(([key, entry]) => [ + key, + normalizeLegacySessionEntryDelivery(entry), + ]), + ) as Record; buildStatusMessageMock.mockClear(); resolveQueueSettingsMock.mockClear(); resolveQueueSettingsMock.mockReturnValue({ mode: "interrupt" }); @@ -914,13 +922,15 @@ describe("session_status tool", () => { [sessionKey]: { sessionId: "s-discord-origin-webchat-active", updatedAt: 10, - origin: { provider: "discord", accountId: "bot-primary" }, - deliveryContext: { - channel: "discord", - to: "channel:1489550370136129537", - accountId: "bot-primary", - threadId: "thread-origin", - }, + delivery: normalizeSessionDeliveryState({ + origin: { provider: "discord", accountId: "bot-primary" }, + context: { + channel: "discord", + to: "channel:1489550370136129537", + accountId: "bot-primary", + threadId: "thread-origin", + }, + }), }, }); @@ -952,7 +962,11 @@ describe("session_status tool", () => { }; expect(details.ok).toBe(true); expect(details.sessionKey).toBe(sessionKey); - expect(details.origin).toEqual({ provider: "discord", accountId: "bot-primary" }); + expect(details.origin).toEqual({ + provider: "discord", + accountId: "bot-primary", + threadId: "thread-origin", + }); expect(details.active).toEqual({ channel: "webchat", to: "control-ui-conversation", @@ -986,10 +1000,12 @@ describe("session_status tool", () => { [targetKey]: { sessionId: "s-target", updatedAt: 10, - deliveryContext: { - channel: "discord", - to: "channel:1489550370136129537", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "channel:1489550370136129537", + }, + }), }, }); mockConfig = { @@ -1030,10 +1046,12 @@ describe("session_status tool", () => { [policyKey]: { sessionId: "s-policy", updatedAt: 5, - deliveryContext: { - channel: "telegram", - to: "telegram:direct:1234", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:direct:1234", + }, + }), }, [runKey]: { sessionId: "s-run", @@ -1388,13 +1406,13 @@ describe("session_status tool", () => { expect(saved.sessionId).toMatch(UUID_RE); }); - it("preserves an existing legacy main row when implicit fallback mutates model state", async () => { + it("preserves an existing canonical main row when implicit fallback mutates model state", async () => { resetSessionStore({ main: { sessionId: "legacy-main-session", updatedAt: 10, label: "Legacy Main", - lastChannel: "telegram", + delivery: { kind: "none" }, }, }); @@ -1416,7 +1434,7 @@ describe("session_status tool", () => { expect(savedStore.main).toMatchObject({ sessionId: "legacy-main-session", label: "Legacy Main", - lastChannel: "telegram", + delivery: { kind: "none" }, providerOverride: "anthropic", modelOverride: "claude-sonnet-4-6", liveModelSwitchPending: true, @@ -1986,12 +2004,15 @@ describe("session_status tool", () => { } }); - it("falls back to origin.provider when resolving queue settings", async () => { + it("uses canonical delivery state when resolving queue settings", async () => { resetSessionStore({ main: { sessionId: "status-origin-provider", updatedAt: 10, - origin: { provider: "quietchat" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "quietchat", to: "quietchat:status" }, + origin: { provider: "quietchat" }, + }), }, }); @@ -2001,7 +2022,9 @@ describe("session_status tool", () => { const queueArg = mockCallArg(resolveQueueSettingsMock) as Record; expect(queueArg.channel).toBe("quietchat"); - expectRecordFields(queueArg.sessionEntry, { origin: { provider: "quietchat" } }); + expect(queueArg.sessionEntry).toMatchObject({ + delivery: { kind: "external", origin: { provider: "quietchat" } }, + }); }); it("resolves sessionId inputs", async () => { diff --git a/src/agents/sessions/session-manager.test.ts b/src/agents/sessions/session-manager.test.ts index bf1c4f9941e1..f5236d1e1f82 100644 --- a/src/agents/sessions/session-manager.test.ts +++ b/src/agents/sessions/session-manager.test.ts @@ -502,7 +502,7 @@ describe("SessionManager.open", () => { await upsertSessionEntry( { agentId: "main", sessionKey, storePath }, { - channel: "dashboard", + delivery: { kind: "internal" }, sessionFile: marker, sessionId, updatedAt: 10, @@ -527,7 +527,7 @@ describe("SessionManager.open", () => { expect(branchedMarker).toContain(`sqlite:main:${branchedSessionId}:`); expect(branchedSessionId).not.toBe(sessionId); expect(loadSessionEntry({ agentId: "main", sessionKey, storePath })).toMatchObject({ - channel: "dashboard", + delivery: { kind: "internal" }, sessionFile: branchedMarker, sessionId: branchedSessionId, }); diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index 7ef4fdd9863e..37a2157e7135 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -7,6 +7,7 @@ import { testing as sessionBindingServiceTesting, registerSessionBindingAdapter, } from "../infra/outbound/session-binding-service.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; import type { @@ -555,7 +556,12 @@ describe("resolveAnnounceOrigin threaded route targets", () => { expected: { channel: "topicchat", to: "topicchat:room-a" }, }, ])("$name", ({ stored, requester, expected }) => { - expect(resolveAnnounceOrigin(stored, requester)).toEqual(expected); + expect( + resolveAnnounceOrigin( + normalizeLegacySessionEntryDelivery(stored as unknown as SessionEntry), + requester, + ), + ).toEqual(expected); }); }); diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index c612bef39b0b..b3e456a47dcd 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -41,7 +41,11 @@ import { parseCronRunScopeSuffix, } from "../sessions/session-key-utils.js"; import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js"; -import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js"; +import { + mergeDeliveryContext, + normalizeDeliveryContext, + sessionDeliveryChannel, +} from "../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL, isDeliverableMessageChannel, @@ -787,7 +791,7 @@ async function maybeSteerSubagentAnnounce(params: { const queueSettings = resolveQueueSettings({ cfg, - channel: entry?.channel ?? entry?.lastChannel ?? entry?.origin?.provider, + channel: sessionDeliveryChannel(entry), sessionEntry: entry, }); @@ -1782,9 +1786,7 @@ async function sendSubagentAnnounceDirectly(params: { const requesterQueueSettings = resolveQueueSettings({ cfg, channel: - requesterEntry?.channel ?? - requesterEntry?.lastChannel ?? - requesterEntry?.origin?.provider ?? + sessionDeliveryChannel(requesterEntry) ?? requesterSessionOrigin?.channel ?? directOrigin?.channel, sessionEntry: requesterEntry, diff --git a/src/agents/subagent-announce-origin.ts b/src/agents/subagent-announce-origin.ts index 3867f6310514..da5c5e4e5d9b 100644 --- a/src/agents/subagent-announce-origin.ts +++ b/src/agents/subagent-announce-origin.ts @@ -6,6 +6,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { getLoadedChannelPluginForRead } from "../channels/plugins/registry-loaded.js"; import type { ChannelId } from "../channels/plugins/types.public.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { stripTargetKindPrefix, stripTargetProviderPrefix, @@ -16,10 +17,7 @@ import { mergeDeliveryContext, normalizeDeliveryContext, } from "../utils/delivery-context.shared.js"; -import type { - DeliveryContext, - DeliveryContextSessionSource, -} from "../utils/delivery-context.types.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; import { isInternalMessageChannel } from "../utils/message-channel.js"; export type { DeliveryContext } from "../utils/delivery-context.types.js"; @@ -60,7 +58,7 @@ function shouldStripThreadFromAnnounceEntry( /** Resolve the delivery origin for a subagent completion announcement. */ export function resolveAnnounceOrigin( - entry?: DeliveryContextSessionSource, + entry?: Pick, requesterOrigin?: DeliveryContext, ): DeliveryContext | undefined { const normalizedRequester = normalizeDeliveryContext(requesterOrigin); diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 1b2dc2e06743..3663c878f13e 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -14,6 +14,7 @@ import { testing as sessionBindingServiceTesting, registerSessionBindingAdapter, } from "../infra/outbound/session-binding-service.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import * as hookRunnerGlobal from "../plugins/hook-runner-global.js"; import type { HookRunner } from "../plugins/hooks.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -62,7 +63,13 @@ type MockSubagentRun = { error?: string; }; }; -type SessionEntryFixture = Omit & { updatedAt?: number }; +type SessionEntryFixture = Partial> & { + updatedAt?: number; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; type SessionStoreFixture = Record; function visibleAgentResponse(runId = "run-main") { @@ -292,18 +299,15 @@ function setMessageToolGroupReplyConfig(): void { }); } -function toSessionEntry( - sessionKey: string, - entry?: Partial, -): SessionEntry | undefined { +function toSessionEntry(sessionKey: string, entry?: SessionEntryFixture): SessionEntry | undefined { if (!entry) { return undefined; } - return { + return normalizeLegacySessionEntryDelivery({ + ...entry, sessionId: entry.sessionId ?? sessionKey, updatedAt: entry.updatedAt ?? Date.now(), - ...entry, - }; + } as SessionEntry); } function loadSessionStoreFixture(): Record { diff --git a/src/agents/subagent-announce.test.ts b/src/agents/subagent-announce.test.ts index b0579f689f33..ff0ff568f38a 100644 --- a/src/agents/subagent-announce.test.ts +++ b/src/agents/subagent-announce.test.ts @@ -1,6 +1,7 @@ // Subagent announce flow tests cover the seam-level orchestration between wait // outcomes, requester lookup, delivery, and cleanup. import { beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import type { EmbeddedAgentQueueMessageOutcome } from "./embedded-agent-runner/runs.js"; import { createSubagentAnnounceDeliveryRuntimeMock } from "./subagent-announce.test-support.js"; @@ -436,7 +437,7 @@ describe("subagent announce seam flow", () => { }); }); - it("uses origin.provider for channel-specific queue settings in active announce delivery", async () => { + it("steers active announcements despite channel-specific followup mode", async () => { mockConfig = { session: { mainKey: "main", @@ -454,7 +455,7 @@ describe("subagent announce seam flow", () => { "agent:main:main": { sessionId: "session-origin-provider-steer", updatedAt: Date.now(), - origin: { provider: "discord" }, + delivery: { kind: "none" }, }, })); isEmbeddedAgentRunActiveMock.mockReturnValue(true); @@ -470,6 +471,7 @@ describe("subagent announce seam flow", () => { childRunId: "run-origin-provider-steer", requesterSessionKey: "agent:main:main", requesterDisplayKey: "main", + requesterOrigin: { channel: "discord" }, task: "do thing", timeoutMs: 10, cleanup: "keep", @@ -556,14 +558,18 @@ describe("subagent announce seam flow", () => { expect(params.threadId).toBeUndefined(); }); - it("falls back to stored delivery target when mocked completion origins omit to", async () => { + it("uses the stored canonical delivery target when mocked completion origins omit to", async () => { loadSessionStoreMock.mockImplementation(() => ({ "agent:main:main": { sessionId: "session-tg-group", updatedAt: Date.now(), - lastChannel: "telegram", - lastTo: "-1001234567890", - lastAccountId: "bot:123", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "-1001234567890", + accountId: "bot:123", + }, + }), }, })); diff --git a/src/agents/subagent-test-fixtures.test-helpers.ts b/src/agents/subagent-test-fixtures.test-helpers.ts index e34eae04be2a..90e6861ca1c6 100644 --- a/src/agents/subagent-test-fixtures.test-helpers.ts +++ b/src/agents/subagent-test-fixtures.test-helpers.ts @@ -1,5 +1,8 @@ import { expect, vi } from "vitest"; import type { InternalSessionEntry } from "../config/sessions.js"; +import type { SessionOrigin } from "../config/sessions/types.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; import type { AgentInternalEvent } from "./internal-events.js"; import type { RegisterSubagentRunParams } from "./subagent-registry-run-manager.js"; import type { SubagentRunRecord } from "./subagent-registry.types.js"; @@ -42,8 +45,18 @@ export function mockGatewayMethods( mock.mockImplementation(createGatewayMethodMock(responses, fallback)); } +export type SessionEntryFixture = Partial & { + channel?: string; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + export function createSessionStore( - overrides: Partial = {}, + overrides: SessionEntryFixture = {}, sessionKey = "agent:main:subagent:child", ): Record { return { @@ -51,14 +64,12 @@ export function createSessionStore( }; } -export function createSessionEntry( - overrides: Partial = {}, -): InternalSessionEntry { - return { +export function createSessionEntry(overrides: SessionEntryFixture = {}): InternalSessionEntry { + return normalizeLegacySessionEntryDelivery({ sessionId: "sess-child", updatedAt: 1, ...overrides, - }; + } as InternalSessionEntry); } export function createAssistantToolCallMessage(content: unknown[]) { diff --git a/src/agents/tools/media-generate-background-shared.test.ts b/src/agents/tools/media-generate-background-shared.test.ts index 0a0fb837be80..afa5cea41e0f 100644 --- a/src/agents/tools/media-generate-background-shared.test.ts +++ b/src/agents/tools/media-generate-background-shared.test.ts @@ -4,6 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../../config/sessions/types.js"; import { resetGeneratedMediaTaskActivityForTests } from "../../tasks/task-runtime.test-helpers.js"; import { hasPendingGeneratedMediaTaskForSessionKey } from "../../tasks/task-status-access.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; const subagentAnnounceDeliveryMocks = vi.hoisted(() => ({ deliverSubagentAnnouncement: vi.fn(), @@ -17,6 +19,10 @@ const detachedTaskRuntimeMocks = vi.hoisted(() => ({ failTaskRunByRunId: vi.fn(), recordTaskRunProgressByRunId: vi.fn(), })); + +const requesterEntry = (context: DeliveryContext): Partial => ({ + delivery: normalizeSessionDeliveryState({ context }), +}); const taskRegistryDeliveryRuntimeMocks = vi.hoisted(() => ({ sendMessage: vi.fn(), })); @@ -812,11 +818,7 @@ describe("createMediaGenerationTaskLifecycle", () => { it("pins a missing requester target from session state when the task starts", async () => { subagentAnnounceDeliveryMocks.loadRequesterSessionEntry.mockReturnValue({ - entry: { - lastChannel: "telegram", - lastTo: "5866004662", - lastAccountId: "bot-1", - }, + entry: requesterEntry({ channel: "telegram", to: "5866004662", accountId: "bot-1" }), }); subagentAnnounceDeliveryMocks.deliverSubagentAnnouncement.mockResolvedValueOnce({ delivered: true, @@ -835,11 +837,7 @@ describe("createMediaGenerationTaskLifecycle", () => { }); subagentAnnounceDeliveryMocks.loadRequesterSessionEntry.mockReturnValue({ - entry: { - lastChannel: "telegram", - lastTo: "other-peer", - lastAccountId: "bot-1", - }, + entry: requesterEntry({ channel: "telegram", to: "other-peer", accountId: "bot-1" }), }); await lifecycle.wakeTaskCompletion({ handle, @@ -860,11 +858,7 @@ describe("createMediaGenerationTaskLifecycle", () => { it("does not pin a session target from another account", () => { subagentAnnounceDeliveryMocks.loadRequesterSessionEntry.mockReturnValue({ - entry: { - lastChannel: "telegram", - lastTo: "peer-b", - lastAccountId: "bot-b", - }, + entry: requesterEntry({ channel: "telegram", to: "peer-b", accountId: "bot-b" }), }); const lifecycle = createImageMediaLifecycle(); @@ -894,11 +888,7 @@ describe("createMediaGenerationTaskLifecycle", () => { it("does not pin a stored thread from a different requester target", () => { subagentAnnounceDeliveryMocks.loadRequesterSessionEntry.mockReturnValue({ - entry: { - lastChannel: "telegram", - lastTo: "room-b", - lastThreadId: 99, - }, + entry: requesterEntry({ channel: "telegram", to: "room-b", threadId: 99 }), }); const lifecycle = createImageMediaLifecycle(); @@ -917,11 +907,7 @@ describe("createMediaGenerationTaskLifecycle", () => { it("pins the external session route for an internal requester origin", () => { subagentAnnounceDeliveryMocks.loadRequesterSessionEntry.mockReturnValue({ - entry: { - lastChannel: "telegram", - lastTo: "room-a", - lastAccountId: "bot-1", - }, + entry: requesterEntry({ channel: "telegram", to: "room-a", accountId: "bot-1" }), }); const lifecycle = createImageMediaLifecycle(); diff --git a/src/agents/tools/session-status-tool.ts b/src/agents/tools/session-status-tool.ts index e74f305e0292..f68aacb42200 100644 --- a/src/agents/tools/session-status-tool.ts +++ b/src/agents/tools/session-status-tool.ts @@ -42,6 +42,8 @@ import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task- import { deliveryContextFromSession, normalizeDeliveryContext, + sessionDeliveryChannel, + sessionDeliveryOrigin, type DeliveryContext, } from "../../utils/delivery-context.shared.js"; import { @@ -331,10 +333,10 @@ function buildSessionStatusRouteDetails(params: { }): SessionStatusRouteDetails { const origin = compactOriginDetails({ provider: - readStringValue(params.entry.origin?.provider) ?? + readStringValue(sessionDeliveryOrigin(params.entry)?.provider) ?? inferOriginProviderFromSessionKey(params.sessionKey), - accountId: readStringValue(params.entry.origin?.accountId), - threadId: params.entry.origin?.threadId, + accountId: readStringValue(sessionDeliveryOrigin(params.entry)?.accountId), + threadId: sessionDeliveryOrigin(params.entry)?.threadId, }); const deliveryContext = normalizeStatusDeliveryContext(deliveryContextFromSession(params.entry)); const active = params.isLiveRunSession @@ -1009,11 +1011,7 @@ export function createSessionStatusTool(opts?: { parentSessionKey: statusSessionEntry.parentSessionKey, sessionScope: cfg.session?.scope, storePath, - statusChannel: - statusSessionEntry.channel ?? - statusSessionEntry.lastChannel ?? - statusSessionEntry.origin?.provider ?? - "unknown", + statusChannel: sessionDeliveryChannel(statusSessionEntry) ?? "unknown", workspaceDir: statusSessionEntry.spawnedWorkspaceDir, provider: providerForCard, model: defaultModelForCard, diff --git a/src/agents/tools/sessions-announce-target.ts b/src/agents/tools/sessions-announce-target.ts index 8a225aff955f..402c1dd47d09 100644 --- a/src/agents/tools/sessions-announce-target.ts +++ b/src/agents/tools/sessions-announce-target.ts @@ -7,7 +7,6 @@ import { normalizeOptionalStringifiedId } from "@openclaw/normalization-core/str import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import type { CallGatewayOptions } from "../../gateway/call.js"; import { parseThreadSessionSuffix } from "../../sessions/session-key-utils.js"; -import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import type { GatewaySessionListRow } from "./sessions-helpers.js"; import type { AnnounceTarget } from "./sessions-send-helpers.js"; import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js"; @@ -51,7 +50,7 @@ export async function resolveAnnounceTarget(params: { sessions.find((entry) => entry?.key === params.sessionKey) ?? sessions.find((entry) => entry?.key === params.displayKey); - const context = deliveryContextFromSession(match); + const context = match?.deliveryContext; const threadId = normalizeOptionalStringifiedId(context?.threadId ?? fallbackThreadId); if (context?.channel && context.to) { return { channel: context.channel, to: context.to, accountId: context.accountId, threadId }; diff --git a/src/agents/tools/sessions-list-tool.ts b/src/agents/tools/sessions-list-tool.ts index 3be74775ac3f..aacaefb50d85 100644 --- a/src/agents/tools/sessions-list-tool.ts +++ b/src/agents/tools/sessions-list-tool.ts @@ -17,7 +17,6 @@ import { readSessionTitleFieldsFromTranscriptAsync } from "../../gateway/session import { deriveSessionTitle } from "../../gateway/session-utils.js"; import { isIncognitoSessionKey, resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import { getSessionStateVersions } from "../../sessions/session-state-events.js"; -import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; import { optionalNonNegativeIntegerSchema, optionalPositiveIntegerSchema, @@ -285,14 +284,11 @@ export function createSessionsListTool(opts?: { mainKey, }); - const entryChannel = typeof entry.channel === "string" ? entry.channel : undefined; - const entryOrigin = - entry.origin && typeof entry.origin === "object" - ? (entry.origin as Record) - : undefined; + const entryChannel = readStringValue(entry.channel); + const entryOrigin = entry.origin as Record | undefined; const originChannel = typeof entryOrigin?.provider === "string" ? entryOrigin.provider : undefined; - const deliveryContext = deliveryContextFromSession(entry); + const deliveryContext = entry.deliveryContext; const deliveryChannel = readStringValue(deliveryContext?.channel); const lastChannel = deliveryChannel ?? readStringValue(entry.lastChannel); const derivedChannel = deriveChannel({ diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index ebc404d3b0d5..b0769530da73 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -283,34 +283,18 @@ describe("runSessionsSendA2AFlow announce delivery", () => { expect(gatewayCalls.find((call) => call.method === "send")).toBeUndefined(); }); - it.each([ - { - source: "deliveryContext.accountId", - accountId: "thinker", - session: { - key: "agent:main:discord:channel:target-room", - kind: "group", + it("uses the projected delivery context for the Discord announce account", async () => { + const accountId = "thinker"; + const session = { + key: "agent:main:discord:channel:target-room", + kind: "group", + channel: "discord", + deliveryContext: { channel: "discord", - deliveryContext: { - channel: "discord", - to: "channel:target-room", - accountId: "thinker", - }, - } satisfies GatewaySessionListRow, - }, - { - source: "lastAccountId", - accountId: "scout", - session: { - key: "agent:main:discord:channel:target-room", - kind: "group", - channel: "discord", - lastChannel: "discord", - lastTo: "channel:target-room", - lastAccountId: "scout", - } satisfies GatewaySessionListRow, - }, - ])("uses Discord session $source for announce accountId", async ({ accountId, session }) => { + to: "channel:target-room", + accountId, + }, + } satisfies GatewaySessionListRow; sessionListRows = [session]; await runSessionsSendA2AFlow({ diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 77b442f12e7b..b6f24f3a77fa 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -444,17 +444,17 @@ describe("resolveAnnounceTarget", () => { expect(requireGatewayRequest().method).toBe("sessions.list"); }); - it("falls back to origin provider and accountId from sessions.list when legacy route fields are absent", async () => { + it("hydrates provider and accountId from the canonical delivery projection", async () => { callGatewayMock.mockResolvedValueOnce({ sessions: [ { key: "agent:main:whatsapp:group:123@g.us", - origin: { - provider: "whatsapp", + deliveryContext: { + channel: "whatsapp", + to: "123@g.us", accountId: "work", + threadId: 271, }, - lastTo: "123@g.us", - lastThreadId: 271, }, ], }); @@ -498,24 +498,14 @@ describe("resolveAnnounceTarget", () => { }); }); - it("hydrates announce delivery from explicit external context over stale webchat session fields", async () => { + it("hydrates announce delivery from the canonical external projection", async () => { callGatewayMock.mockResolvedValueOnce({ sessions: [ { key: "agent:main:feishu:direct:ou_user", - channel: "webchat", - lastChannel: "webchat", - lastTo: "session:dashboard", - route: { - channel: "webchat", - target: { to: "session:dashboard" }, - }, deliveryContext: { channel: "feishu", to: "user:ou_user", - }, - origin: { - provider: "feishu", accountId: "work", threadId: "thread-77", }, diff --git a/src/auto-reply/reply/agent-runner-core.ts b/src/auto-reply/reply/agent-runner-core.ts index da9ea64a8280..10ed7b14747d 100644 --- a/src/auto-reply/reply/agent-runner-core.ts +++ b/src/auto-reply/reply/agent-runner-core.ts @@ -18,6 +18,7 @@ import type { TypingMode } from "../../config/types.js"; import { logVerbose } from "../../globals.js"; import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { type DeliveryContext, normalizeDeliveryContext, @@ -133,7 +134,7 @@ export function resolveSourceReplyPolicy(params: { params.sessionCtx.OriginatingChannel ?? params.sessionCtx.Surface ?? params.sessionCtx.Provider ?? - params.sessionEntry?.channel, + sessionDeliveryChannel(params.sessionEntry), chatType: params.sessionEntry?.chatType, }); return resolveSourceReplyVisibilityPolicy({ diff --git a/src/auto-reply/reply/agent-runner-result-complete.ts b/src/auto-reply/reply/agent-runner-result-complete.ts index 8d9a34009143..e82e59c80ed4 100644 --- a/src/auto-reply/reply/agent-runner-result-complete.ts +++ b/src/auto-reply/reply/agent-runner-result-complete.ts @@ -6,6 +6,7 @@ import { isCliProvider } from "../../agents/model-selection.js"; import type { SessionEntry } from "../../config/sessions.js"; import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../heartbeat.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; import type { ReplyPayload } from "../types.js"; @@ -340,7 +341,7 @@ export async function completeReplyAgentRun(input: { sessionCtx.OriginatingChannel ?? sessionCtx.Surface ?? sessionCtx.Provider ?? - activeSessionEntry?.channel, + sessionDeliveryChannel(activeSessionEntry), finalTextLength: assistantFinalText.trim().length, }); } diff --git a/src/auto-reply/reply/commands-diagnostics.test.ts b/src/auto-reply/reply/commands-diagnostics.test.ts index 40547d7f99bf..a3c13ea75c2a 100644 --- a/src/auto-reply/reply/commands-diagnostics.test.ts +++ b/src/auto-reply/reply/commands-diagnostics.test.ts @@ -6,6 +6,7 @@ import { createPluginRegistry } from "../../plugins/registry.js"; import type { PluginRuntime } from "../../plugins/runtime/types.js"; import { createBundledPluginRecord } from "../../plugins/status.test-fixtures.js"; import type { OpenClawPluginCommandDefinition, PluginCommandContext } from "../../plugins/types.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; type PluginCommandHandler = OpenClawPluginCommandDefinition["handler"]; import type { MsgContext } from "../templating.js"; @@ -456,7 +457,7 @@ describe("diagnostics command", () => { sessionId: "discord-session", sessionFile: "/tmp/discord.jsonl", updatedAt: 2, - channel: "discord", + delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), }, }, }), diff --git a/src/auto-reply/reply/commands-diagnostics.ts b/src/auto-reply/reply/commands-diagnostics.ts index 8aa8b9ab8b9a..df2cab5ff533 100644 --- a/src/auto-reply/reply/commands-diagnostics.ts +++ b/src/auto-reply/reply/commands-diagnostics.ts @@ -13,6 +13,10 @@ import type { } from "../../interactive/payload.js"; import { executePluginCommand, matchPluginCommand } from "../../plugins/commands.js"; import type { PluginCommandDiagnosticsSession, PluginCommandResult } from "../../plugins/types.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import type { ReplyPayload } from "../types.js"; import { rejectNonOwnerCommand } from "./command-gates.js"; import { @@ -490,14 +494,12 @@ function buildCodexDiagnosticsSessions( channel: resolveDiagnosticsSessionChannel(entry, params, sessionKey), channelId: resolveDiagnosticsSessionChannelId(entry, params, sessionKey), accountId: - normalizeOptionalString(entry.deliveryContext?.accountId) ?? - normalizeOptionalString(entry.origin?.accountId) ?? - normalizeOptionalString(entry.lastAccountId) ?? + normalizeOptionalString(deliveryContextFromSession(entry)?.accountId) ?? + normalizeOptionalString(sessionDeliveryOrigin(entry)?.accountId) ?? (sessionKey === params.sessionKey ? (params.ctx.AccountId ?? undefined) : undefined), messageThreadId: - entry.deliveryContext?.threadId ?? - entry.origin?.threadId ?? - entry.lastThreadId ?? + deliveryContextFromSession(entry)?.threadId ?? + sessionDeliveryOrigin(entry)?.threadId ?? (sessionKey === params.sessionKey && (typeof params.ctx.MessageThreadId === "string" || typeof params.ctx.MessageThreadId === "number") @@ -516,10 +518,8 @@ function resolveDiagnosticsSessionChannel( sessionKey: string, ): string | undefined { return ( - normalizeOptionalString(entry.deliveryContext?.channel) ?? - normalizeOptionalString(entry.origin?.provider) ?? - normalizeOptionalString(entry.channel) ?? - normalizeOptionalString(entry.lastChannel) ?? + normalizeOptionalString(deliveryContextFromSession(entry)?.channel) ?? + normalizeOptionalString(sessionDeliveryOrigin(entry)?.provider) ?? (sessionKey === params.sessionKey ? params.command.channel : undefined) ); } @@ -530,7 +530,7 @@ function resolveDiagnosticsSessionChannelId( sessionKey: string, ) { return ( - normalizeOptionalString(entry.origin?.nativeChannelId) ?? + normalizeOptionalString(sessionDeliveryOrigin(entry)?.nativeChannelId) ?? (sessionKey === params.sessionKey ? params.command.channelId : undefined) ); } diff --git a/src/auto-reply/reply/commands-dock.test.ts b/src/auto-reply/reply/commands-dock.test.ts index c9bc4ffad6b4..f9eda5e4fe4e 100644 --- a/src/auto-reply/reply/commands-dock.test.ts +++ b/src/auto-reply/reply/commands-dock.test.ts @@ -5,6 +5,10 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import { + normalizeSessionDeliveryState, + projectSessionDeliveryFields, +} from "../../utils/delivery-context.shared.js"; import type { MsgContext } from "../templating.js"; import { handleDockCommand } from "./commands-dock.js"; import { buildCommandTestParams } from "./commands.test-harness.js"; @@ -37,9 +41,9 @@ function buildDockParams(commandBody: string, ctxOverrides?: Partial const sessionEntry = { sessionId: "session-dock", updatedAt: 1, - lastChannel: "telegram", - lastTo: "42", - lastAccountId: "primary", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "42", accountId: "primary" }, + }), }; const params = buildCommandTestParams( commandBody, @@ -87,9 +91,11 @@ describe("handleDockCommand", () => { reply: { text: "Docked replies to discord." }, }); const updatedEntry = params.sessionStore?.[params.sessionKey]; - expect(updatedEntry?.lastChannel).toBe("discord"); - expect(updatedEntry?.lastTo).toBe("UserCase123"); - expect(updatedEntry?.lastAccountId).toBe("default"); + expect(projectSessionDeliveryFields(updatedEntry?.delivery)).toMatchObject({ + lastChannel: "discord", + lastTo: "UserCase123", + lastAccountId: "default", + }); }); it("accepts generated underscore aliases such as Telegram native /dock_discord", async () => { @@ -98,8 +104,10 @@ describe("handleDockCommand", () => { const result = await handleDockCommand(params, true); expect(result?.shouldContinue).toBe(false); - expect(params.sessionEntry?.lastChannel).toBe("discord"); - expect(params.sessionEntry?.lastTo).toBe("UserCase123"); + expect(projectSessionDeliveryFields(params.sessionEntry?.delivery)).toMatchObject({ + lastChannel: "discord", + lastTo: "UserCase123", + }); }); it("does not claim unrelated slash commands", async () => { @@ -119,7 +127,9 @@ describe("handleDockCommand", () => { text: "Cannot dock to discord: add this sender and a discord:... peer to session.identityLinks.", }, }); - expect(params.sessionEntry?.lastChannel).toBe("telegram"); + expect(projectSessionDeliveryFields(params.sessionEntry?.delivery).lastChannel).toBe( + "telegram", + ); }); it("rejects group-session docking before it can reroute replies to a linked DM", async () => { @@ -137,8 +147,10 @@ describe("handleDockCommand", () => { shouldContinue: false, reply: { text: "Cannot dock to discord: docking is only available from direct chats." }, }); - expect(params.sessionEntry?.lastChannel).toBe("telegram"); - expect(params.sessionEntry?.lastTo).toBe("42"); + expect(projectSessionDeliveryFields(params.sessionEntry?.delivery)).toMatchObject({ + lastChannel: "telegram", + lastTo: "42", + }); }); it("fails closed when no session entry can be persisted", async () => { diff --git a/src/auto-reply/reply/commands-dock.ts b/src/auto-reply/reply/commands-dock.ts index 4c7adf219b53..6a40b259f16e 100644 --- a/src/auto-reply/reply/commands-dock.ts +++ b/src/auto-reply/reply/commands-dock.ts @@ -5,6 +5,10 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { normalizeTrimmedStringList } from "@openclaw/normalization-core/string-normalization"; import { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; +import { + normalizeSessionDeliveryState, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { resolveTextCommand } from "../commands-registry.js"; import { resolveCommandSurfaceChannel } from "./channel-context.js"; import { persistSessionEntry } from "./commands-session-store.js"; @@ -171,13 +175,18 @@ export const handleDockCommand: CommandHandler = async (params, allowTextCommand }; } - sessionEntry.lastChannel = targetChannel; - sessionEntry.lastTo = target.peerId; - sessionEntry.lastAccountId = resolveTargetChannelAccountId(params, targetChannel); + sessionEntry.delivery = normalizeSessionDeliveryState({ + context: { + channel: targetChannel, + to: target.peerId, + accountId: resolveTargetChannelAccountId(params, targetChannel), + }, + origin: sessionDeliveryOrigin(sessionEntry), + }); params.sessionEntry = sessionEntry; const persisted = await persistSessionEntry({ ...params, - touchedFields: ["lastChannel", "lastTo", "lastAccountId"], + touchedFields: ["delivery"], }); if (!persisted) { return { diff --git a/src/auto-reply/reply/commands-session-store.test.ts b/src/auto-reply/reply/commands-session-store.test.ts index 31ce2c8c4010..9b86482902ab 100644 --- a/src/auto-reply/reply/commands-session-store.test.ts +++ b/src/auto-reply/reply/commands-session-store.test.ts @@ -85,6 +85,7 @@ describe("commands session store persistence", () => { const otherEntry: SessionEntry = { sessionId: "other-session", updatedAt: 2, + delivery: { kind: "none" }, }; const seedEntry = { ...entry }; await persistSessionEntry({ @@ -137,6 +138,7 @@ describe("commands session store persistence", () => { const otherEntry: SessionEntry = { sessionId: "other-session", updatedAt: 2, + delivery: { kind: "none" }, }; const concurrentUpdatedAt = 300; const concurrentEntry = { @@ -205,6 +207,7 @@ describe("commands session store persistence", () => { const rotatedEntry: SessionEntry = { sessionId: "session-2", updatedAt: 3, + delivery: { kind: "none" }, queueMode: "interrupt", }; await replaceSessionEntry({ storePath, sessionKey }, rotatedEntry); @@ -276,6 +279,7 @@ describe("commands session store persistence", () => { const concurrentEntry: SessionEntry = { ...initialEntry, updatedAt: 2, + delivery: { kind: "none" }, groupActivationNeedsSystemIntro: false, }; await replaceSessionEntry({ storePath, sessionKey }, concurrentEntry); @@ -348,6 +352,7 @@ describe("commands session store persistence", () => { const otherEntry: SessionEntry = { sessionId: "other-session", updatedAt: 3, + delivery: { kind: "none" }, }; await replaceSessionEntry({ storePath, sessionKey }, persistedEntry); await replaceSessionEntry({ storePath, sessionKey: otherKey }, otherEntry); diff --git a/src/auto-reply/reply/completion-delivery-policy.ts b/src/auto-reply/reply/completion-delivery-policy.ts index 7cad6fde3427..d0d0f386bb47 100644 --- a/src/auto-reply/reply/completion-delivery-policy.ts +++ b/src/auto-reply/reply/completion-delivery-policy.ts @@ -1,7 +1,9 @@ // Resolves whether completed replies should send visibly or stay tool-only. import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { deriveSessionChatTypeFromKey } from "../../sessions/session-chat-type-shared.js"; +import { sessionDeliveryOrigin } from "../../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import type { SourceReplyDeliveryMode } from "../source-reply-delivery-mode.types.js"; import { resolveSourceReplyDeliveryMode } from "./source-reply-delivery-mode.js"; @@ -10,10 +12,7 @@ type CompletionChatType = ChatType | "unknown"; type DurableCompletionDeliveryMode = "automatic" | "host_owned"; -type CompletionDeliverySessionEntry = { - chatType?: string | null; - origin?: { chatType?: string | null } | null; -}; +type CompletionDeliverySessionEntry = Pick; function resolveCompletionChatType(params: { requesterSessionKey?: string | null; @@ -23,7 +22,7 @@ function resolveCompletionChatType(params: { requesterSessionOrigin?: DeliveryContext; }): CompletionChatType { const explicit = normalizeChatType( - params.requesterEntry?.chatType ?? params.requesterEntry?.origin?.chatType ?? undefined, + params.requesterEntry?.chatType ?? sessionDeliveryOrigin(params.requesterEntry)?.chatType, ); if (explicit) { return explicit; diff --git a/src/auto-reply/reply/conversation-turn-capture.test.ts b/src/auto-reply/reply/conversation-turn-capture.test.ts index 3dd606e4757f..5a058b7ef6d5 100644 --- a/src/auto-reply/reply/conversation-turn-capture.test.ts +++ b/src/auto-reply/reply/conversation-turn-capture.test.ts @@ -12,6 +12,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { buildConversationRef } from "../../routing/conversation-ref.js"; import { registerPendingConversationTurn } from "../../sessions/conversation-turns.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import type { FinalizedRuntimeMsgContext } from "../templating.js"; import { capturePendingConversationTurnReply } from "./conversation-turn-capture.js"; @@ -33,12 +34,14 @@ async function setupReefConversation() { sessionId, updatedAt: 100, chatType: "direct", - deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, - origin: { - provider: "reef", - accountId: "default", - nativeDirectUserId: "peer-agent", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, + origin: { + provider: "reef", + accountId: "default", + nativeDirectUserId: "peer-agent", + }, + }), }, ); return { @@ -480,13 +483,15 @@ describe("conversation turn capture", () => { updatedAt: 100, chatType: "channel", groupId: "ops-room", - deliveryContext: { - channel: "discord", - accountId: "default", - to: "channel:ops-room", - threadId: "user-context", - }, - origin: { provider: "discord", accountId: "default", nativeChannelId: "ops-room" }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + accountId: "default", + to: "channel:ops-room", + threadId: "user-context", + }, + origin: { provider: "discord", accountId: "default", nativeChannelId: "ops-room" }, + }), }, ); const conversationRef = buildConversationRef({ diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index d8cc9aaadcee..a1255ddcc98e 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -410,6 +410,7 @@ function createSessionEntry(overrides?: Partial): SessionEntry { return { sessionId: "s1", updatedAt: Date.now(), + delivery: { kind: "none" }, ...overrides, }; } @@ -2064,6 +2065,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { const rotatedEntry: SessionEntry = { sessionId: "s2", updatedAt: sessionEntry.updatedAt + 1, + delivery: { kind: "none" }, elevatedLevel: "full", }; await replaceSessionEntry({ sessionKey, storePath }, rotatedEntry); diff --git a/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts index ce15087493cf..2d482c0ba941 100644 --- a/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.base.test-utils.ts @@ -13,6 +13,7 @@ import { createChannelTestPluginBase, createTestRegistry, } from "../../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { settleReplyDispatcher } from "../dispatch-dispatcher.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; import type { MsgContext } from "../templating.js"; @@ -1706,14 +1707,9 @@ describe("dispatchReplyFromConfig", () => { mocks.routeReply.mockClear(); installThreadingTestPlugin({ id: "telegram" }); sessionStoreMocks.currentEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:999", - accountId: "acc-1", - }, - lastChannel: "telegram", - lastTo: "telegram:999", - lastAccountId: "acc-1", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "telegram:999", accountId: "acc-1" }, + }), }; const cfg = emptyConfig; const dispatcher = createDispatcher(); @@ -1764,22 +1760,21 @@ describe("dispatchReplyFromConfig", () => { mocks.routeReply.mockClear(); installThreadingTestPlugin({ id: "feishu" }); sessionStoreMocks.currentEntry = { - route: { - channel: "feishu", - accountId: "work", - target: { to: "user:ou_123", chatType: "channel" }, - thread: { id: "thread:om_123", source: "explicit" }, - }, chatType: "channel", - deliveryContext: { - channel: "feishu", - to: "user:ou_123", - accountId: "work", - threadId: "thread:om_123", - }, - lastChannel: "feishu", - lastTo: "user:ou_123", - lastAccountId: "work", + delivery: normalizeSessionDeliveryState({ + route: { + channel: "feishu", + accountId: "work", + target: { to: "user:ou_123", chatType: "channel" }, + thread: { id: "thread:om_123", source: "explicit" }, + }, + context: { + channel: "feishu", + to: "user:ou_123", + accountId: "work", + threadId: "thread:om_123", + }, + }), }; const cfg = emptyConfig; const dispatcher = createDispatcher(); @@ -1844,9 +1839,9 @@ describe("dispatchReplyFromConfig", () => { setNoAbort(); mocks.routeReply.mockClear(); sessionStoreMocks.currentEntry = { - lastChannel: "discord", - lastTo: "channel:123", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "channel:123", accountId: "default" }, + }), }; const cfg = emptyConfig; const dispatcher = createDispatcher(); diff --git a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts index 5ad56708dfbf..0deedd791f25 100644 --- a/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts +++ b/src/auto-reply/reply/dispatch-from-config.harness-defaults.ts @@ -13,6 +13,10 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { isNativeCommandTurn, resolveCommandTurnContext } from "../command-turn-context.js"; import type { FinalizedMsgContext } from "../templating.js"; import { normalizeVerboseLevel } from "../thinking.js"; @@ -72,8 +76,7 @@ function resolveHarnessDefaultChannel(params: { typeof params.ctx.OriginatingChannel === "string" ? params.ctx.OriginatingChannel : undefined; return ( - params.entry?.channel ?? - params.entry?.origin?.provider ?? + sessionDeliveryChannel(params.entry) ?? originatingChannel ?? params.ctx.Provider ?? params.ctx.Surface @@ -125,9 +128,9 @@ function resolveChannelModelCandidate(params: { groupSubject: params.entry?.subject ?? params.ctx.GroupSubject, parentSessionKey: params.parentSessionKey, directUserIds: [ - params.entry?.origin?.nativeDirectUserId, - params.entry?.origin?.from, - params.entry?.origin?.to, + sessionDeliveryOrigin(params.entry)?.nativeDirectUserId, + sessionDeliveryOrigin(params.entry)?.from, + sessionDeliveryOrigin(params.entry)?.to, params.ctx.OriginatingTo, params.ctx.From, params.ctx.SenderId, @@ -269,7 +272,7 @@ export function resolveHarnessSourceVisibleRepliesDefault(params: { return resolveCandidateDefault(selectedModelCandidate); } const sourceProvider = normalizeOptionalString( - params.entry?.origin?.provider ?? params.ctx.Provider ?? params.ctx.Surface, + sessionDeliveryOrigin(params.entry)?.provider ?? params.ctx.Provider ?? params.ctx.Surface, ); if (sourceProvider) { const sourceDefault = resolveCandidateDefault({ provider: sourceProvider }); diff --git a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts index 1e16b459db90..122c4e1e6287 100644 --- a/src/auto-reply/reply/dispatch-from-config.prepare-context.ts +++ b/src/auto-reply/reply/dispatch-from-config.prepare-context.ts @@ -31,6 +31,7 @@ import { } from "../../plugins/conversation-binding.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSilentReplyPolicyFromPolicies } from "../../shared/silent-reply-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import type { ReplyPayload } from "../reply-payload.js"; import { resolveConversationBindingContextFromMessage } from "./conversation-binding-input.js"; import { capturePendingConversationTurnReply } from "./conversation-turn-capture.js"; @@ -198,7 +199,7 @@ export async function prepareDispatchOperationContext(state: PrepareDispatchDeli sessionKey: sessionStoreEntry.sessionKey ?? sessionKey, channel: (shouldRouteToOriginating ? routeReplyChannel : undefined) ?? - sessionStoreEntry.entry?.channel ?? + sessionDeliveryChannel(sessionStoreEntry.entry) ?? replyRoute.channel ?? ctx.Surface ?? ctx.Provider ?? diff --git a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts index 777ef7e3c96a..bb2260c6cb7b 100644 --- a/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts +++ b/src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts @@ -1,6 +1,7 @@ // Imported by dispatch-from-config.test.ts to keep its mocked suite in one Vitest module graph. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import { @@ -38,14 +39,9 @@ describe("dispatchReplyFromConfig", () => { mocks.routeReply.mockClear(); installThreadingTestPlugin({ id: "telegram" }); sessionStoreMocks.currentEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:999", - accountId: "acc-1", - }, - lastChannel: "telegram", - lastTo: "telegram:999", - lastAccountId: "acc-1", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "telegram:999", accountId: "acc-1" }, + }), }; const cfg = { session: { diff --git a/src/auto-reply/reply/effective-reply-route.test.ts b/src/auto-reply/reply/effective-reply-route.test.ts index 402520a8fcb5..a8d45a93924d 100644 --- a/src/auto-reply/reply/effective-reply-route.test.ts +++ b/src/auto-reply/reply/effective-reply-route.test.ts @@ -1,13 +1,27 @@ // Tests effective reply route selection from context, session, and fallback state. import { describe, expect, it } from "vitest"; +import type { SessionEntry, SessionOrigin } from "../../config/sessions/types.js"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { isSystemEventProvider, resolveEffectiveReplyRoute } from "./effective-reply-route.js"; type EffectiveReplyRouteParams = Parameters[0]; type EffectiveReplyRouteContext = EffectiveReplyRouteParams["ctx"]; type EffectiveReplyRouteEntry = NonNullable; +type LegacyDeliveryFixture = Partial & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; +}; const ctx = (params: EffectiveReplyRouteContext): EffectiveReplyRouteContext => params; -const entry = (params: EffectiveReplyRouteEntry): EffectiveReplyRouteEntry => params; +const entry = (params: LegacyDeliveryFixture): EffectiveReplyRouteEntry => + normalizeLegacySessionEntryDelivery(params as SessionEntry); describe("resolveEffectiveReplyRoute", () => { it("uses live origin context for normal providers", () => { @@ -313,16 +327,15 @@ describe("resolveEffectiveReplyRoute", () => { expect( resolveEffectiveReplyRoute({ ctx: ctx({ Provider: "exec-event" }), - entry: entry({ - deliveryContext: { - channel: "telegram", - to: "chat:persisted", - accountId: "persisted-account", - }, - lastChannel: "slack", - lastTo: "last-to", - lastAccountId: "last-account", - }), + entry: { + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "chat:persisted", + accountId: "persisted-account", + }, + }), + }, }), ).toEqual({ channel: "telegram", diff --git a/src/auto-reply/reply/effective-reply-route.ts b/src/auto-reply/reply/effective-reply-route.ts index 2257af09c12e..ecf5ded78f4b 100644 --- a/src/auto-reply/reply/effective-reply-route.ts +++ b/src/auto-reply/reply/effective-reply-route.ts @@ -3,6 +3,11 @@ import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { SessionEntry } from "../../config/sessions/types.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; import type { InputProvenance } from "../../sessions/input-provenance.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, + sessionDeliveryRoute, +} from "../../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; import type { FinalizedMsgContext } from "../templating.js"; @@ -19,10 +24,7 @@ type EffectiveReplyRouteContext = Pick< >; /** Persisted session fields used as route fallback/inheritance. */ -type EffectiveReplyRouteEntry = Pick< - SessionEntry, - "deliveryContext" | "lastChannel" | "lastTo" | "lastAccountId" | "route" | "chatType" | "origin" ->; +type EffectiveReplyRouteEntry = Pick; /** Effective channel target selected for source reply delivery. */ type EffectiveReplyRoute = { @@ -49,11 +51,11 @@ function isSessionsSendInterSessionHandoff(inputProvenance: InputProvenance | un function resolveTrustedInheritedThreadId( entry: EffectiveReplyRouteEntry | undefined, ): string | number | undefined { - const deliveryThreadId = entry?.deliveryContext?.threadId; + const deliveryThreadId = deliveryContextFromSession(entry)?.threadId; if (deliveryThreadId == null) { return undefined; } - const routeThread = entry?.route?.thread; + const routeThread = sessionDeliveryRoute(entry)?.thread; if ( routeThread?.id != null && (routeThread.source === "explicit" || @@ -75,13 +77,15 @@ export function resolveEffectiveReplyRoute(params: { normalizeMessageChannel(params.ctx.Provider) ?? normalizeMessageChannel(params.ctx.Surface) ?? normalizeMessageChannel(params.ctx.OriginatingChannel); - const persistedDeliveryContext = params.entry?.deliveryContext; + const persistedDeliveryContext = deliveryContextFromSession(params.entry); + const persistedRoute = sessionDeliveryRoute(params.entry); + const persistedOrigin = sessionDeliveryOrigin(params.entry); const persistedDeliveryChannel = normalizeMessageChannel(persistedDeliveryContext?.channel); const liveChatType = normalizeChatType(params.ctx.ChatType); const persistedChatType = - params.entry?.route?.target?.chatType ?? + persistedRoute?.target?.chatType ?? params.entry?.chatType ?? - normalizeChatType(params.entry?.origin?.chatType); + normalizeChatType(persistedOrigin?.chatType); if ( isSessionsSendInterSessionHandoff(params.ctx.InputProvenance) && currentSurface === INTERNAL_MESSAGE_CHANNEL && @@ -107,7 +111,7 @@ export function resolveEffectiveReplyRoute(params: { ...(liveChatType ? { chatType: liveChatType } : {}), }; } - const persistedChannel = persistedDeliveryContext?.channel ?? params.entry?.lastChannel; + const persistedChannel = persistedDeliveryContext?.channel; const liveChannel = params.ctx.OriginatingChannel; const canInheritPersistedTuple = !liveChannel || @@ -117,14 +121,10 @@ export function resolveEffectiveReplyRoute(params: { channel: liveChannel ?? persistedChannel, to: params.ctx.OriginatingTo ?? - (canInheritPersistedTuple - ? (persistedDeliveryContext?.to ?? params.entry?.lastTo) - : undefined), + (canInheritPersistedTuple ? persistedDeliveryContext?.to : undefined), accountId: params.ctx.AccountId ?? - (canInheritPersistedTuple - ? (persistedDeliveryContext?.accountId ?? params.entry?.lastAccountId) - : undefined), + (canInheritPersistedTuple ? persistedDeliveryContext?.accountId : undefined), ...(chatType ? { chatType } : {}), }; } diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index a992b94b8ac5..55ace0109be5 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -53,6 +53,7 @@ import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { getReplyPayloadMetadata, @@ -1644,7 +1645,9 @@ export function createFollowupRunner(params: { entry: activeSessionEntry, sessionKey: run.runtimePolicySessionKey ?? replySessionKey, channel: - queued.originatingChannel ?? run.messageProvider ?? activeSessionEntry?.channel, + queued.originatingChannel ?? + run.messageProvider ?? + sessionDeliveryChannel(activeSessionEntry), chatType: activeSessionEntry?.chatType, }), }); @@ -1697,7 +1700,9 @@ export function createFollowupRunner(params: { entry: activeSessionEntry, sessionKey: run.runtimePolicySessionKey ?? replySessionKey, channel: - queued.originatingChannel ?? run.messageProvider ?? activeSessionEntry?.channel, + queued.originatingChannel ?? + run.messageProvider ?? + sessionDeliveryChannel(activeSessionEntry), chatType: activeSessionEntry?.chatType, }), }); @@ -1722,7 +1727,10 @@ export function createFollowupRunner(params: { } warnPrivateMessageToolFinal({ sessionKey: replySessionKey, - channel: queued.originatingChannel ?? run.messageProvider ?? activeSessionEntry?.channel, + channel: + queued.originatingChannel ?? + run.messageProvider ?? + sessionDeliveryChannel(activeSessionEntry), finalTextLength: assistantFinalText.trim().length, }); const retryEnqueued = diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 6c749e4f52d2..3b836427cf57 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -11,6 +11,7 @@ import { import type { SessionEntry } from "../../config/sessions.js"; import { HEARTBEAT_RUN_SCOPE } from "../../infra/heartbeat-run-scope.js"; import { MESSAGE_TOOL_ONLY_DELIVERY_HINT } from "../../plugin-sdk/message-tool-delivery-hints.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { finalizeInboundContextForSdk } from "./inbound-context.js"; import { createReplyOperation } from "./reply-run-registry.js"; import { buildChannelSourceTurnId } from "./source-turn-id.js"; @@ -2936,18 +2937,18 @@ describe("runPreparedReply media-only handling", () => { updatedAt: 1, systemSent: true, chatType: "channel", - channel: "discord", groupId: "guild-1", groupChannel: "#ops", - lastChannel: "discord", - lastTo: "channel-1", - origin: { - provider: "discord", - surface: "discord", - chatType: "channel", - to: "channel-1", - }, - } as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "channel-1" }, + origin: { + provider: "discord", + surface: "discord", + chatType: "channel", + to: "channel-1", + }, + }), + }, }), ); @@ -3000,15 +3001,15 @@ describe("runPreparedReply media-only handling", () => { updatedAt: 1, systemSent: true, chatType: "group", - channel: "telegram", - lastChannel: "telegram", - lastTo: "-100123", - origin: { - provider: "telegram", - surface: "telegram", - chatType: "group", - to: "-100123", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123" }, + origin: { + provider: "telegram", + surface: "telegram", + chatType: "group", + to: "-100123", + }, + }), }; await runPreparedReply( @@ -3228,15 +3229,15 @@ describe("runPreparedReply media-only handling", () => { updatedAt: 1, systemSent: true, chatType: "group", - channel: "telegram", - lastChannel: "telegram", - lastTo: "-100123", - origin: { - provider: "telegram", - surface: "telegram", - chatType: "group", - to: "-100123", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123" }, + origin: { + provider: "telegram", + surface: "telegram", + chatType: "group", + to: "-100123", + }, + }), }; await runPreparedReply( @@ -3579,23 +3580,21 @@ describe("runPreparedReply media-only handling", () => { sessionId: "session-1", updatedAt: 1, chatType: "direct", - channel: "matrix", - lastChannel: "slack", - lastTo: "user:U1", - lastAccountId: "work", - deliveryContext: { - channel: "slack", - to: "user:U1", - accountId: "work", - }, - origin: { - provider: "matrix", - surface: "matrix", - chatType: "direct", - to: "room:origin", - accountId: "origin", - }, - } as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "slack", + to: "user:U1", + accountId: "work", + }, + origin: { + provider: "matrix", + surface: "matrix", + chatType: "direct", + to: "room:origin", + accountId: "origin", + }, + }), + }, }), ); diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index bf0dabc32dd3..55c069ed17f4 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -59,6 +59,10 @@ import { import { createLazyImportLoader } from "../../shared/lazy-promise.js"; import type { SilentReplyConversationType } from "../../shared/silent-reply-policy.js"; import { resolveSkillWorkshopConfig } from "../../skills/workshop/config.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; @@ -346,8 +350,10 @@ function resolvePromptSessionContextForSystemEvent(params: { return sessionCtx; } + const origin = sessionDeliveryOrigin(sessionEntry); + const deliveryContext = deliveryContextFromSession(sessionEntry); const persistedChatType = - normalizeChatType(sessionEntry.chatType) ?? normalizeChatType(sessionEntry.origin?.chatType); + normalizeChatType(sessionEntry.chatType) ?? normalizeChatType(origin?.chatType); const liveChatType = normalizeChatType(sessionCtx.ChatType); const effectiveChatType = liveChatType ?? persistedChatType; const persistedProvider = resolvePersistedPromptProvider(sessionEntry); @@ -392,26 +398,12 @@ function resolvePromptSessionContextForSystemEvent(params: { setIfMissing("GroupSpace", normalizeOptionalString(sessionEntry.space)); } setIfMissing("OriginatingChannel", persistedProvider); - setIfMissing( - "OriginatingTo", - normalizeOptionalString( - sessionEntry.lastTo ?? sessionEntry.deliveryContext?.to ?? sessionEntry.origin?.to, - ), - ); + setIfMissing("OriginatingTo", normalizeOptionalString(deliveryContext?.to ?? origin?.to)); setIfMissing( "AccountId", - normalizeOptionalString( - sessionEntry.lastAccountId ?? - sessionEntry.deliveryContext?.accountId ?? - sessionEntry.origin?.accountId, - ), - ); - setIfMissing( - "MessageThreadId", - sessionEntry.lastThreadId ?? - sessionEntry.deliveryContext?.threadId ?? - sessionEntry.origin?.threadId, + normalizeOptionalString(deliveryContext?.accountId ?? origin?.accountId), ); + setIfMissing("MessageThreadId", deliveryContext?.threadId ?? origin?.threadId); return changed ? next : sessionCtx; } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index aca1026401ab..9ab4df8d8c5f 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -30,6 +30,10 @@ import { ModelSelectionLockedError, } from "../../sessions/model-overrides.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import type { GetReplyOptions } from "../get-reply-options.types.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../heartbeat.js"; @@ -612,8 +616,7 @@ export async function getReplyFromConfig( cfg, channel: groupResolution?.channel ?? - sessionEntry.channel ?? - sessionEntry.origin?.provider ?? + sessionDeliveryChannel(sessionEntry) ?? (typeof finalized.OriginatingChannel === "string" ? finalized.OriginatingChannel : undefined) ?? @@ -625,9 +628,9 @@ export async function getReplyFromConfig( groupSubject: sessionEntry.subject ?? sessionCtx.GroupSubject ?? finalized.GroupSubject, parentSessionKey: sessionCtx.ModelParentSessionKey ?? sessionCtx.ParentSessionKey, directUserIds: [ - sessionEntry.origin?.nativeDirectUserId, - sessionEntry.origin?.from, - sessionEntry.origin?.to, + sessionDeliveryOrigin(sessionEntry)?.nativeDirectUserId, + sessionDeliveryOrigin(sessionEntry)?.from, + sessionDeliveryOrigin(sessionEntry)?.to, finalized.OriginatingTo, finalized.From, finalized.SenderId, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index e26e66d15aeb..64b852be0895 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -752,6 +752,7 @@ describe("resolveContextTokens", () => { const makeEntry = (overrides: Partial = {}): SessionEntry => ({ sessionId: "session-id", updatedAt: Date.now(), + delivery: { kind: "none" }, ...overrides, }); diff --git a/src/auto-reply/reply/prompt-session-context.ts b/src/auto-reply/reply/prompt-session-context.ts index 1184451bcf15..0cce5fe78985 100644 --- a/src/auto-reply/reply/prompt-session-context.ts +++ b/src/auto-reply/reply/prompt-session-context.ts @@ -1,5 +1,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { SessionEntry } from "../../config/sessions/types.js"; +import { + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; function normalizePromptRouteChannel(raw?: string | null): string | undefined { const normalized = normalizeOptionalString(raw); @@ -11,16 +15,12 @@ export function normalizeToolProgressDetail(value: unknown): "explain" | "raw" | } export function resolvePersistedPromptProvider(entry?: SessionEntry): string | undefined { - return ( - normalizePromptRouteChannel(entry?.origin?.provider) ?? - normalizePromptRouteChannel(entry?.channel) ?? - normalizePromptRouteChannel(entry?.lastChannel) ?? - normalizePromptRouteChannel(entry?.deliveryContext?.channel) - ); + return normalizePromptRouteChannel(sessionDeliveryChannel(entry)); } export function resolvePersistedPromptSurface(entry?: SessionEntry): string | undefined { return ( - normalizePromptRouteChannel(entry?.origin?.surface) ?? resolvePersistedPromptProvider(entry) + normalizePromptRouteChannel(sessionDeliveryOrigin(entry)?.surface) ?? + resolvePersistedPromptProvider(entry) ); } diff --git a/src/auto-reply/reply/session-delivery.ts b/src/auto-reply/reply/session-delivery.ts index 82d96ed99e4a..fad4ae5e430c 100644 --- a/src/auto-reply/reply/session-delivery.ts +++ b/src/auto-reply/reply/session-delivery.ts @@ -220,26 +220,14 @@ export function maybeRetireLegacyMainDeliveryRoute(params: { if (!activeDirectRouteKey || activeDirectRouteKey !== legacyRouteKey) { return undefined; } - if ( - legacyMain.route === undefined && - legacyMain.deliveryContext === undefined && - legacyMain.lastChannel === undefined && - legacyMain.lastTo === undefined && - legacyMain.lastAccountId === undefined && - legacyMain.lastThreadId === undefined - ) { + if (legacyMain.delivery?.kind !== "external") { return undefined; } return { key: canonicalMainSessionKey, entry: { ...legacyMain, - route: undefined, - deliveryContext: undefined, - lastChannel: undefined, - lastTo: undefined, - lastAccountId: undefined, - lastThreadId: undefined, + delivery: { kind: "none" }, }, }; } diff --git a/src/auto-reply/reply/session-entry-persistence.test.ts b/src/auto-reply/reply/session-entry-persistence.test.ts index cf88eda6d5e4..ca8f0edeb2c9 100644 --- a/src/auto-reply/reply/session-entry-persistence.test.ts +++ b/src/auto-reply/reply/session-entry-persistence.test.ts @@ -73,6 +73,7 @@ describe("persistReplySessionEntry", () => { sessionId: "session-2", updatedAt: 400, thinkingLevel: "medium", + delivery: { kind: "none" }, }; await replaceSessionEntry({ sessionKey: "main", storePath }, currentEntry); @@ -136,6 +137,7 @@ describe("persistReplySessionEntry", () => { ...initialEntry, updatedAt: 400, archivedAt: 300, + delivery: { kind: "none" }, }; await replaceSessionEntry({ sessionKey: "main", storePath }, archivedEntry); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts index 60e9a7adb94c..f78ef16368ce 100644 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ b/src/auto-reply/reply/session-reset-model.test.ts @@ -29,6 +29,7 @@ function createResetFixture(entry: Partial = {}) { const sessionEntry: SessionEntry = { sessionId: "s1", updatedAt: Date.now(), + delivery: { kind: "none" }, ...entry, }; return { @@ -291,6 +292,7 @@ describe("applyResetModelOverride", () => { const rotatedEntry: SessionEntry = { sessionId: "s2", updatedAt: fixture.sessionEntry.updatedAt + 1, + delivery: { kind: "none" }, providerOverride: "openai", modelOverride: "gpt-4o-mini", modelOverrideSource: "user", diff --git a/src/auto-reply/reply/session-route-reset.ts b/src/auto-reply/reply/session-route-reset.ts index c4302cb5511f..78357e7b11c7 100644 --- a/src/auto-reply/reply/session-route-reset.ts +++ b/src/auto-reply/reply/session-route-reset.ts @@ -1,7 +1,11 @@ -import type { SessionEntry } from "../../config/sessions.js"; +import type { SessionOrigin } from "../../config/sessions.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { normalizeDeliveryChannelRoute } from "../../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; -export function stripThreadFromSessionRoute(route: SessionEntry["route"]): SessionEntry["route"] { +export function stripThreadFromSessionRoute( + route: ChannelRouteRef | undefined, +): ChannelRouteRef | undefined { const normalized = normalizeDeliveryChannelRoute(route); if (!normalized?.thread) { return normalized; @@ -11,8 +15,8 @@ export function stripThreadFromSessionRoute(route: SessionEntry["route"]): Sessi } export function stripThreadIdFromDeliveryContext( - context: SessionEntry["deliveryContext"], -): SessionEntry["deliveryContext"] { + context: DeliveryContext | undefined, +): DeliveryContext | undefined { if (!context || context.threadId == null || context.threadId === "") { return context; } @@ -20,7 +24,9 @@ export function stripThreadIdFromDeliveryContext( return Object.keys(rest).length > 0 ? rest : undefined; } -export function stripThreadIdFromOrigin(origin: SessionEntry["origin"]): SessionEntry["origin"] { +export function stripThreadIdFromOrigin( + origin: SessionOrigin | undefined, +): SessionOrigin | undefined { if (!origin || origin.threadId == null || origin.threadId === "") { return origin; } diff --git a/src/auto-reply/reply/session.test.ts b/src/auto-reply/reply/session.test.ts index 29f87fb08fdc..fe4fc159be70 100644 --- a/src/auto-reply/reply/session.test.ts +++ b/src/auto-reply/reply/session.test.ts @@ -5645,8 +5645,7 @@ describe("initSessionState internal channel routing preservation", () => { OriginatingTo: "session:dashboard", Surface: "webchat", }, - expected: { lastChannel: "webchat", lastTo: "session:dashboard" }, - expectedDelivery: { channel: "webchat", to: "session:dashboard" }, + expected: { delivery: { kind: "internal" } }, }, { name: "keeps persisted external route when OriginatingChannel is non-deliverable", @@ -5678,14 +5677,14 @@ describe("initSessionState internal channel routing preservation", () => { OriginatingChannel: "sessions_send", OriginatingTo: "session:handoff", }, - expected: { lastChannel: "sessions_send", lastTo: "session:handoff" }, + expected: { delivery: { kind: "internal" } }, }, { name: "keeps webchat channel for webchat/main sessions", prefix: "preserve-webchat-main-", sessionKey: "agent:main:main", ctx: { Body: "hello", OriginatingChannel: "webchat" }, - expected: { lastChannel: "webchat" }, + expected: { delivery: { kind: "internal" } }, }, { name: "preserves external route for main session when webchat accesses without destination (fixes #47745)", diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 255cd3174b42..32a6c85f20be 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -85,7 +85,12 @@ import { classifySessionStateActor, registerMainSessionGroupWatch, } from "../../sessions/session-state-events.js"; -import { normalizeSessionDeliveryFields } from "../../utils/delivery-context.shared.js"; +import { + deliveryContextFromSession, + normalizeSessionDeliveryState, + sessionDeliveryOrigin, + sessionDeliveryRoute, +} from "../../utils/delivery-context.shared.js"; import { resolveCommandTurnTargetSessionKey } from "../command-turn-context.js"; import { normalizeCommandBody } from "../commands-registry.js"; import type { @@ -858,66 +863,61 @@ async function initSessionStateAttemptLocked( // Otherwise a heartbeat target like "group:..." or a synthetic sender like // "heartbeat" leaks into the shared session and later user replies route to // the wrong chat. + const baseDeliveryContext = deliveryContextFromSession(baseEntry); + const baseDeliveryRoute = sessionDeliveryRoute(baseEntry); + const baseDeliveryOrigin = sessionDeliveryOrigin(baseEntry); const lastChannelRaw = isSystemEvent - ? baseEntry?.lastChannel + ? baseDeliveryContext?.channel : resolveLastChannelRaw({ originatingChannelRaw, - persistedLastChannel: baseEntry?.lastChannel, + persistedLastChannel: baseDeliveryContext?.channel, sessionKey, isInterSession, }); const lastToRaw = isSystemEvent - ? baseEntry?.lastTo + ? baseDeliveryContext?.to : resolveLastToRaw({ originatingChannelRaw, originatingToRaw: ctx.OriginatingTo, toRaw: ctx.To, - persistedLastTo: baseEntry?.lastTo, - persistedLastChannel: baseEntry?.lastChannel, + persistedLastTo: baseDeliveryContext?.to, + persistedLastChannel: baseDeliveryContext?.channel, sessionKey, isInterSession, }); const lastAccountIdRaw = isSystemEvent - ? baseEntry?.lastAccountId + ? baseDeliveryContext?.accountId : resolveSessionDefaultAccountId({ cfg, channelRaw: lastChannelRaw, accountIdRaw: ctx.AccountId, - persistedLastAccountId: baseEntry?.lastAccountId, + persistedLastAccountId: baseDeliveryContext?.accountId, }); // Only fall back to persisted threadId for thread sessions. Non-thread // sessions (e.g. DM without topics) must not inherit a stale threadId from a // previous interaction that happened inside a topic/thread. const lastThreadIdRaw = isSystemEvent - ? baseEntry?.lastThreadId + ? baseDeliveryContext?.threadId : (ctx.MessageThreadId ?? ctx.TransportThreadId ?? - (isThread ? baseEntry?.lastThreadId : undefined)); - const deliveryFields = isSystemEvent - ? normalizeSessionDeliveryFields({ - route: isThread ? baseEntry?.route : stripThreadFromSessionRoute(baseEntry?.route), - channel: baseEntry?.channel, - lastChannel: baseEntry?.lastChannel, - lastTo: baseEntry?.lastTo, - lastAccountId: baseEntry?.lastAccountId, - lastThreadId: - baseEntry?.lastThreadId ?? - baseEntry?.deliveryContext?.threadId ?? - baseEntry?.origin?.threadId, - deliveryContext: baseEntry?.deliveryContext, + (isThread ? baseDeliveryContext?.threadId : undefined)); + const delivery = isSystemEvent + ? normalizeSessionDeliveryState({ + route: isThread ? baseDeliveryRoute : stripThreadFromSessionRoute(baseDeliveryRoute), + context: isThread + ? baseDeliveryContext + : stripThreadIdFromDeliveryContext(baseDeliveryContext), + origin: isThread ? baseDeliveryOrigin : stripThreadIdFromOrigin(baseDeliveryOrigin), }) - : normalizeSessionDeliveryFields({ - deliveryContext: { + : normalizeSessionDeliveryState({ + context: { channel: lastChannelRaw, to: lastToRaw, accountId: lastAccountIdRaw, threadId: lastThreadIdRaw, }, + origin: baseDeliveryOrigin, }); - const lastChannel = deliveryFields.lastChannel ?? lastChannelRaw; - const lastTo = deliveryFields.lastTo ?? lastToRaw; - const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw; - const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw; const creationStamp = !entry && ctx.SessionCreation ? buildSessionCreationStamp(ctx.SessionCreation) : undefined; sessionEntry = { @@ -974,20 +974,13 @@ async function initSessionStateAttemptLocked( queueDrop: baseEntry?.queueDrop, displayName: persistedDisplayName ?? baseEntry?.displayName, chatType: baseEntry?.chatType, - channel: baseEntry?.channel, + delivery, groupId: baseEntry?.groupId, subject: baseEntry?.subject, groupChannel: baseEntry?.groupChannel, space: baseEntry?.space, groupActivation: entry?.groupActivation, groupActivationNeedsSystemIntro: entry?.groupActivationNeedsSystemIntro, - route: deliveryFields.route, - deliveryContext: deliveryFields.deliveryContext, - // Track originating channel for subagent announce routing. - lastChannel, - lastTo, - lastAccountId, - lastThreadId, }; const metaPatch = deriveSessionMetaPatch({ ctx: sessionCtxForState, @@ -1002,10 +995,11 @@ async function initSessionStateAttemptLocked( if (isSystemEvent && !isThread) { sessionEntry = { ...sessionEntry, - route: stripThreadFromSessionRoute(sessionEntry.route), - lastThreadId: undefined, - deliveryContext: stripThreadIdFromDeliveryContext(sessionEntry.deliveryContext), - origin: stripThreadIdFromOrigin(sessionEntry.origin), + delivery: normalizeSessionDeliveryState({ + route: stripThreadFromSessionRoute(sessionDeliveryRoute(sessionEntry)), + context: stripThreadIdFromDeliveryContext(deliveryContextFromSession(sessionEntry)), + origin: stripThreadIdFromOrigin(sessionDeliveryOrigin(sessionEntry)), + }), }; } if (!sessionEntry.chatType) { diff --git a/src/auto-reply/reply/test/session.test-support.ts b/src/auto-reply/reply/test/session.test-support.ts index 5eb18059cabd..3f78899dc8ca 100644 --- a/src/auto-reply/reply/test/session.test-support.ts +++ b/src/auto-reply/reply/test/session.test-support.ts @@ -9,14 +9,25 @@ import { replaceSessionEntry, upsertSessionEntry, } from "../../../config/sessions/session-accessor.js"; +import { normalizeLegacySessionEntryDelivery } from "../../../infra/state-migrations.legacy-session-store.js"; +import { projectSessionDeliveryFields } from "../../../utils/delivery-context.shared.js"; import { finalizeInboundContext } from "../inbound-context.js"; import { initSessionState as initSessionStateRaw } from "../session.js"; -export const initSessionState = ( +type ProjectedSessionEntry = SessionEntry & ReturnType; + +function projectSessionEntry(entry: SessionEntry): ProjectedSessionEntry { + return { ...entry, ...projectSessionDeliveryFields(entry.delivery) }; +} + +export const initSessionState = async ( params: Omit[0], "ctx"> & { ctx: Record; }, -) => initSessionStateRaw({ ...params, ctx: finalizeInboundContext(params.ctx) }); +) => { + const result = await initSessionStateRaw({ ...params, ctx: finalizeInboundContext(params.ctx) }); + return { ...result, sessionEntry: projectSessionEntry(result.sessionEntry) }; +}; export async function writeSessionStore( storePath: string, @@ -25,18 +36,22 @@ export async function writeSessionStore( await fs.mkdir(path.dirname(storePath), { recursive: true }); for (const [sessionKey, entry] of Object.entries(store)) { const patch = entry as Partial; + const canonical = normalizeLegacySessionEntryDelivery(patch as SessionEntry); if (typeof patch.sessionId === "string" && patch.sessionId.trim()) { - await replaceSessionEntry({ storePath, sessionKey }, patch as SessionEntry); + await replaceSessionEntry({ storePath, sessionKey }, canonical); } else { - await upsertSessionEntry({ storePath, sessionKey }, patch); + await upsertSessionEntry({ storePath, sessionKey }, canonical); } } } -export function readSessionStore(storePath: string): Record { +export function readSessionStore(storePath: string): Record { const entries = Object.fromEntries( - listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]), - ) as Record; + listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [ + sessionKey, + projectSessionEntry(entry), + ]), + ) as Record; return new Proxy(entries, { get(target, prop, receiver) { if (typeof prop !== "string" || prop in target) { @@ -44,7 +59,7 @@ export function readSessionStore(storePath: string): Record { sessionEntry: { sessionId: "abc", updatedAt: 0, - channel: "discord", + delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), groupId: "123", }, sessionKey: "agent:main:main", @@ -875,7 +876,7 @@ describe("buildStatusMessage", () => { sessionEntry: { sessionId: "channel-context-window", updatedAt: 0, - channel: "discord", + delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), groupId: "123", totalTokens: 49_000, totalTokensFresh: true, diff --git a/src/channels/session.types.ts b/src/channels/session.types.ts index 7c3aacd3aee1..b739c355e4e7 100644 --- a/src/channels/session.types.ts +++ b/src/channels/session.types.ts @@ -1,11 +1,11 @@ // Shared channel session recording contracts for inbound dispatch and metadata writers. import type { MsgContext } from "../auto-reply/templating.js"; -import type { GroupKeyResolution, SessionEntry } from "../config/sessions/types.js"; +import type { GroupKeyResolution } from "../config/sessions/types.js"; import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; export type InboundLastRouteUpdate = { sessionKey: string; - channel: SessionEntry["lastChannel"]; + channel: string; to: string; accountId?: string; threadId?: string | number; diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 3d4938ed7d66..7542061b165e 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -6,6 +6,8 @@ import type { CliDeps } from "../cli/deps.js"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import type { RuntimeEnv } from "../runtime.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; const mocks = vi.hoisted(() => ({ deliverOutboundPayloads: vi.fn(async () => []), @@ -70,6 +72,14 @@ vi.mock("../infra/outbound/targets.js", async () => { }); describe("deliverAgentCommandResult", () => { + function sessionEntry(context: DeliveryContext): SessionEntry { + return { + sessionId: "fixture", + updatedAt: 1, + delivery: normalizeSessionDeliveryState({ context }), + }; + } + function createRuntime(): RuntimeEnv { return { log: vi.fn(), @@ -130,9 +140,7 @@ describe("deliverAgentCommandResult", () => { accountId: "kev", to: "+15551234567", }, - sessionEntry: { - lastAccountId: "default", - } as SessionEntry, + sessionEntry: sessionEntry({ accountId: "default" }), }); expect(readDeliveryCall().accountId).toBe("kev"); @@ -145,10 +153,7 @@ describe("deliverAgentCommandResult", () => { deliver: true, channel: "whatsapp", }, - sessionEntry: { - lastAccountId: "legacy", - lastChannel: "whatsapp", - } as SessionEntry, + sessionEntry: sessionEntry({ accountId: "legacy", channel: "whatsapp" }), }); expect(readDeliveryCall().accountId).toBe("legacy"); @@ -163,9 +168,7 @@ describe("deliverAgentCommandResult", () => { to: "+15551234567", deliveryTargetMode: "explicit", }, - sessionEntry: { - lastAccountId: "legacy", - } as SessionEntry, + sessionEntry: sessionEntry({ accountId: "legacy" }), }); const targetCall = readResolveTargetCall(); @@ -181,10 +184,7 @@ describe("deliverAgentCommandResult", () => { deliver: true, channel: "whatsapp", }, - sessionEntry: { - lastAccountId: "legacy", - lastChannel: "telegram", - } as SessionEntry, + sessionEntry: sessionEntry({ accountId: "legacy", channel: "telegram" }), }); const targetCall = readResolveTargetCall(); @@ -198,10 +198,7 @@ describe("deliverAgentCommandResult", () => { message: "hello", deliver: true, }, - sessionEntry: { - lastChannel: "telegram", - lastTo: "123", - } as SessionEntry, + sessionEntry: sessionEntry({ channel: "telegram", to: "123" }), }); const targetCall = readResolveTargetCall(); @@ -219,11 +216,7 @@ describe("deliverAgentCommandResult", () => { replyChannel: "slack", replyAccountId: "ops", }, - sessionEntry: { - lastChannel: "telegram", - lastTo: "123", - lastAccountId: "legacy", - } as SessionEntry, + sessionEntry: sessionEntry({ channel: "telegram", to: "123", accountId: "legacy" }), }); const targetCall = readResolveTargetCall(); @@ -258,11 +251,7 @@ describe("deliverAgentCommandResult", () => { accountId: "work", }, }, - sessionEntry: { - lastChannel: "slack", - lastTo: "U_WRONG", - lastAccountId: "wrong", - } as SessionEntry, + sessionEntry: sessionEntry({ channel: "slack", to: "U_WRONG", accountId: "wrong" }), }); const targetCall = readResolveTargetCall(); @@ -280,10 +269,7 @@ describe("deliverAgentCommandResult", () => { messageChannel: "whatsapp", }, }, - sessionEntry: { - lastChannel: "slack", - lastTo: "U_WRONG", - } as SessionEntry, + sessionEntry: sessionEntry({ channel: "slack", to: "U_WRONG" }), }); const targetCall = readResolveTargetCall(); diff --git a/src/commands/agent.session.test.ts b/src/commands/agent.session.test.ts index 21ae132fecbc..b30588eaf2ac 100644 --- a/src/commands/agent.session.test.ts +++ b/src/commands/agent.session.test.ts @@ -15,6 +15,7 @@ import { resolveSessionTranscriptFile } from "../config/sessions/transcript.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildOutboundSessionContext } from "../infra/outbound/session-context.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { @@ -148,7 +149,10 @@ describe("agent session resolution", () => { main: { sessionId: "origin-provider-reset", updatedAt: Date.now() - 30 * 60_000, - origin: { provider: "quietchat" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "quietchat" }, + origin: { provider: "quietchat" }, + }), }, }); const cfg = mockConfig(home, store); diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 39ab63c4843e..ebf1f812ae46 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -40,6 +40,10 @@ import { createOutboundTestPlugin, createTestRegistry, } from "../test-utils/channel-plugins.js"; +import { + deliveryContextFromSession, + normalizeSessionDeliveryState, +} from "../utils/delivery-context.shared.js"; import { getAgentHarnessPluginMocks } from "./agent-command-state.test-mocks.js"; import { agentCommand, agentCommandFromIngress, testing as agentCommandTesting } from "./agent.js"; import { createThrowingTestRuntime } from "./test-runtime-config-helpers.js"; @@ -1976,8 +1980,9 @@ describe("agentCommand", () => { [sessionKey]: { sessionId: "wechat-session", updatedAt: Date.now(), - lastChannel: "telegram", - lastTo: "+1555", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "+1555" }, + }), }, }); mockConfig(home, store); @@ -1998,10 +2003,10 @@ describe("agentCommand", () => { ); const deliveryCall = vi.mocked(deliverAgentCommandResult).mock.calls.at(-1)?.[0] as - | { opts?: { to?: string }; sessionEntry?: { lastTo?: string } } + | { opts?: { to?: string }; sessionEntry?: Pick } | undefined; expect(deliveryCall?.opts?.to).toBeUndefined(); - expect(deliveryCall?.sessionEntry?.lastTo).toBe("+1555"); + expect(deliveryContextFromSession(deliveryCall?.sessionEntry)?.to).toBe("+1555"); }); }); diff --git a/src/commands/agents.delete.test.ts b/src/commands/agents.delete.test.ts index a83f6b195679..226d0ef1b2f0 100644 --- a/src/commands/agents.delete.test.ts +++ b/src/commands/agents.delete.test.ts @@ -111,10 +111,10 @@ async function arrangeAgentsDeleteTest(params: { const storeAgentId = resolveFixtureStoreAgentId(cfg, deletedAgentId); const storePath = resolveStorePath(cfg.session?.store, { agentId: deletedAgentId }); for (const [sessionKey, entry] of Object.entries(params.sessions)) { - await replaceSessionEntry( - { agentId: storeAgentId, sessionKey, storePath }, - entry as SessionEntry, - ); + await replaceSessionEntry({ agentId: storeAgentId, sessionKey, storePath }, { + ...entry, + delivery: { kind: "none" }, + } as SessionEntry); } await fs.mkdir(path.join(params.stateDir, `workspace-${deletedAgentId}`), { recursive: true }); await fs.mkdir(path.join(params.stateDir, "agents", deletedAgentId, "agent"), { @@ -144,7 +144,14 @@ function expectSessionStore( entry, ]), ), - ).toEqual(sessions); + ).toEqual( + Object.fromEntries( + Object.entries(sessions).map(([sessionKey, entry]) => [ + sessionKey, + { ...entry, delivery: { kind: "none" } }, + ]), + ), + ); } function readJsonLogs(): Array> { diff --git a/src/commands/doctor-session-delivery-state.test.ts b/src/commands/doctor-session-delivery-state.test.ts new file mode 100644 index 000000000000..576e581f05ca --- /dev/null +++ b/src/commands/doctor-session-delivery-state.test.ts @@ -0,0 +1,405 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, + resolveOpenClawAgentSqlitePath, +} from "../state/openclaw-agent-db.js"; +import { repairCanonicalSessionDeliveryStates } from "./doctor-session-delivery-state.js"; + +const tempDirs = createTempDirTracker(); + +afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + tempDirs.cleanup(); +}); + +function insertSessionRow( + env: NodeJS.ProcessEnv, + sessionKey: string, + entry: Record, +): void { + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + database.db + .prepare( + "INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, ?, ?)", + ) + .run(sessionKey, String(entry.sessionId), JSON.stringify(entry), Number(entry.updatedAt)); + const legacyContext = entry.deliveryContext as Record | undefined; + database.db + .prepare( + "INSERT INTO session_windows (session_id, session_key, session_scope, created_at, updated_at, channel, account_id) VALUES (?, ?, 'conversation', ?, ?, ?, ?)", + ) + .run( + String(entry.sessionId), + sessionKey, + Number(entry.updatedAt), + Number(entry.updatedAt), + typeof entry.channel === "string" + ? entry.channel + : typeof legacyContext?.channel === "string" + ? legacyContext.channel + : null, + typeof entry.lastAccountId === "string" + ? entry.lastAccountId + : typeof legacyContext?.accountId === "string" + ? legacyContext.accountId + : null, + ); +} + +function readEntryJson(env: NodeJS.ProcessEnv, sessionKey: string): string { + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + const row = database.db + .prepare("SELECT entry_json FROM session_nodes WHERE session_key = ?") + .get(sessionKey) as { entry_json: string }; + return row.entry_json; +} + +describe("doctor canonical session delivery state", () => { + it("keeps bare channel and origin metadata below explicit delivery context", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-fallback-order-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:explicit", { + sessionId: "explicit-session", + updatedAt: 10, + channel: "slack", + deliveryContext: { channel: "telegram", to: "current", accountId: "current-bot" }, + origin: { provider: "discord", to: "stale", accountId: "stale-bot" }, + }); + insertSessionRow(env, "agent:main:origin-only", { + sessionId: "origin-session", + updatedAt: 20, + origin: { provider: "discord", to: "origin-recipient", accountId: "origin-bot" }, + }); + insertSessionRow(env, "agent:main:orphan-account", { + sessionId: "orphan-account-session", + updatedAt: 30, + channel: "slack", + deliveryContext: { channel: "telegram", to: "current", accountId: "current-bot" }, + lastAccountId: "stale-slack-bot", + }); + insertSessionRow(env, "agent:main:whitespace-last", { + sessionId: "whitespace-last-session", + updatedAt: 40, + channel: "slack", + deliveryContext: { channel: "telegram", to: "current", accountId: "current-bot" }, + lastTo: " ", + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 4, + repaired: 4, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:explicit")).delivery.context).toEqual({ + channel: "telegram", + to: "current", + accountId: "current-bot", + }); + expect(JSON.parse(readEntryJson(env, "agent:main:origin-only")).delivery.context).toEqual({ + channel: "discord", + to: "origin-recipient", + accountId: "origin-bot", + }); + for (const key of ["agent:main:orphan-account", "agent:main:whitespace-last"]) { + expect(JSON.parse(readEntryJson(env, key)).delivery.context).toEqual({ + channel: "telegram", + to: "current", + accountId: "current-bot", + }); + } + }); + + it("preserves shipped last-route precedence over stale explicit context", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-precedence-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:precedence", { + sessionId: "precedence-session", + updatedAt: 10, + deliveryContext: { channel: "telegram", to: "old-recipient", accountId: "bot" }, + lastChannel: "telegram", + lastTo: "new-recipient", + lastAccountId: "bot", + }); + insertSessionRow(env, "agent:main:partial-precedence", { + sessionId: "partial-precedence-session", + updatedAt: 20, + channel: "slack", + lastChannel: "", + deliveryContext: { channel: "telegram", to: "old-recipient" }, + lastTo: "C-new", + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 2, + repaired: 2, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:precedence")).delivery.context).toEqual({ + channel: "telegram", + to: "new-recipient", + accountId: "bot", + }); + expect( + JSON.parse(readEntryJson(env, "agent:main:partial-precedence")).delivery.context, + ).toEqual({ + channel: "slack", + to: "C-new", + }); + }); + + it("recovers a legacy route after an unrelated runtime write stamps delivery none", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-none-stamp-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:stamped-none", { + sessionId: "stamped-none-session", + updatedAt: 10, + delivery: { kind: "none" }, + lastChannel: "telegram", + lastTo: "-100123", + lastAccountId: "bot", + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 1, + repaired: 1, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:stamped-none")).delivery).toMatchObject({ + kind: "external", + context: { channel: "telegram", to: "-100123", accountId: "bot" }, + }); + }); + + it("preserves explicit legacy channel ownership without a recipient", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-channel-only-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:channel-only", { + sessionId: "channel-only-session", + updatedAt: 10, + lastChannel: "slack", + lastAccountId: "work", + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 1, + repaired: 1, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:channel-only")).delivery).toMatchObject({ + kind: "external", + context: { channel: "slack", accountId: "work" }, + }); + }); + + it("recovers an external legacy route after an internal transition stamp", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-internal-stamp-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:stamped-internal", { + sessionId: "stamped-internal-session", + updatedAt: 10, + delivery: { kind: "internal" }, + lastChannel: "telegram", + lastTo: "-100456", + lastAccountId: "bot", + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 1, + repaired: 1, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:stamped-internal")).delivery).toMatchObject({ + kind: "external", + context: { channel: "telegram", to: "-100456", accountId: "bot" }, + }); + }); + + it("promotes legacy internal origin chat type before removing origin", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-internal-chat-type-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:internal-chat", { + sessionId: "internal-chat-session", + updatedAt: 10, + channel: "webchat", + origin: { provider: "webchat", chatType: "direct" }, + }); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 1, + repaired: 1, + scannedStores: 1, + }); + expect(JSON.parse(readEntryJson(env, "agent:main:internal-chat"))).toMatchObject({ + chatType: "direct", + delivery: { kind: "internal" }, + }); + }); + + it("skips structurally invalid row JSON while repairing valid sessions", () => { + const stateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-invalid-row-")); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + insertSessionRow(env, "agent:main:legacy", { + sessionId: "legacy-session", + updatedAt: 10, + deliveryContext: { channel: "telegram", to: "-1001" }, + }); + openOpenClawAgentDatabase({ agentId: "main", env }) + .db.prepare( + "INSERT INTO session_nodes (session_key, current_session_id, entry_json, updated_at) VALUES (?, ?, ?, ?)", + ) + .run("agent:main:invalid", "invalid-session", "null", 20); + + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env })).toEqual({ + found: 1, + repaired: 1, + scannedStores: 1, + }); + expect(readEntryJson(env, "agent:main:invalid")).toBe("null"); + }); + + it("migrates a copied realistic store without touching the source or canonical row bytes", () => { + const sourceStateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-source-")); + const sourceEnv = { ...process.env, OPENCLAW_STATE_DIR: sourceStateDir }; + const canonicalEntry = { + sessionId: "canonical-session", + updatedAt: 30, + delivery: { + kind: "external", + route: { channel: "telegram", target: { to: "-1002" } }, + context: { channel: "telegram", to: "-1002" }, + origin: { provider: "telegram", to: "-1002" }, + }, + }; + insertSessionRow(sourceEnv, "agent:main:legacy", { + sessionId: "legacy-session", + updatedAt: 10, + route: { + channel: "webchat", + accountId: "work", + target: { to: "session:dashboard" }, + thread: { id: "thread-1" }, + }, + deliveryContext: { channel: "telegram", to: "-1001" }, + origin: { + provider: "telegram", + to: "-1001", + chatType: "group", + accountId: "work", + threadId: "thread-1", + }, + channel: "webchat", + lastChannel: "webchat", + lastTo: "session:dashboard", + lastAccountId: "work", + lastThreadId: "thread-1", + }); + insertSessionRow(sourceEnv, "agent:main:internal", { + sessionId: "internal-session", + updatedAt: 20, + channel: "webchat", + lastChannel: "webchat", + lastTo: "session:control", + }); + insertSessionRow(sourceEnv, "agent:main:origin-stale", { + sessionId: "origin-stale-session", + updatedAt: 25, + deliveryContext: { channel: "telegram", to: "current-recipient" }, + origin: { provider: "telegram", to: "stale-recipient", accountId: "bot" }, + lastChannel: "webchat", + lastAccountId: "bot", + lastThreadId: "topic-1", + }); + insertSessionRow(sourceEnv, "agent:main:canonical", canonicalEntry); + const canonicalJson = JSON.stringify(canonicalEntry); + const sourceLegacyJson = readEntryJson(sourceEnv, "agent:main:legacy"); + const sourcePath = resolveOpenClawAgentSqlitePath({ agentId: "main", env: sourceEnv }); + closeOpenClawAgentDatabasesForTest(); + + const copiedStateDir = fs.realpathSync(tempDirs.make("openclaw-delivery-copy-")); + const copiedEnv = { ...process.env, OPENCLAW_STATE_DIR: copiedStateDir }; + const copiedPath = resolveOpenClawAgentSqlitePath({ agentId: "main", env: copiedEnv }); + fs.mkdirSync(path.dirname(copiedPath), { recursive: true }); + fs.copyFileSync(sourcePath, copiedPath); + + expect(repairCanonicalSessionDeliveryStates({ apply: false, cfg: {}, env: copiedEnv })).toEqual( + { + found: 3, + repaired: 0, + scannedStores: 1, + }, + ); + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env: copiedEnv })).toEqual({ + found: 3, + repaired: 3, + scannedStores: 1, + }); + expect(repairCanonicalSessionDeliveryStates({ apply: true, cfg: {}, env: copiedEnv })).toEqual({ + found: 0, + repaired: 0, + scannedStores: 1, + }); + + const migrated = JSON.parse(readEntryJson(copiedEnv, "agent:main:legacy")) as Record< + string, + unknown + >; + expect(migrated.delivery).toEqual({ + kind: "external", + route: { + channel: "telegram", + accountId: "work", + target: { to: "-1001" }, + thread: { id: "thread-1" }, + }, + context: { + channel: "telegram", + to: "-1001", + accountId: "work", + threadId: "thread-1", + }, + origin: { + provider: "telegram", + to: "-1001", + chatType: "group", + accountId: "work", + threadId: "thread-1", + }, + }); + for (const key of [ + "route", + "deliveryContext", + "origin", + "channel", + "lastChannel", + "lastTo", + "lastAccountId", + "lastThreadId", + ]) { + expect(migrated).not.toHaveProperty(key); + } + expect(JSON.parse(readEntryJson(copiedEnv, "agent:main:internal")).delivery).toEqual({ + kind: "internal", + }); + expect( + JSON.parse(readEntryJson(copiedEnv, "agent:main:origin-stale")).delivery.context, + ).toMatchObject({ + channel: "telegram", + to: "current-recipient", + accountId: "bot", + threadId: "topic-1", + }); + expect(readEntryJson(copiedEnv, "agent:main:canonical")).toBe(canonicalJson); + expect( + openOpenClawAgentDatabase({ agentId: "main", env: copiedEnv }) + .db.prepare("SELECT channel, account_id FROM session_windows WHERE session_id = ?") + .get("legacy-session"), + ).toEqual({ channel: "telegram", account_id: "work" }); + + closeOpenClawAgentDatabasesForTest(); + expect(readEntryJson(sourceEnv, "agent:main:legacy")).toBe(sourceLegacyJson); + }); +}); diff --git a/src/commands/doctor-session-delivery-state.ts b/src/commands/doctor-session-delivery-state.ts new file mode 100644 index 000000000000..d4afab287e3a --- /dev/null +++ b/src/commands/doctor-session-delivery-state.ts @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import type { DatabaseSync } from "node:sqlite"; +import { resolveAllAgentSessionStoreCandidateTargetsSync } from "../config/sessions/targets.js"; +import type { SessionEntry } from "../config/sessions/types.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; +import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../state/openclaw-agent-db.generated.js"; +import { + closeOpenClawAgentDatabaseByPath, + isOpenClawAgentDatabaseOpen, + runOpenClawAgentWriteTransaction, +} from "../state/openclaw-agent-db.js"; +import { + deliveryContextFromSession, + sessionDeliveryChannel, +} from "../utils/delivery-context.shared.js"; +import { resolveTargetSqlitePath } from "./doctor-session-sqlite-readers.js"; + +export type SessionDeliveryStateRepairReport = { + found: number; + repaired: number; + scannedStores: number; +}; + +type DeliveryRewrite = { + accountId: string | null; + channel: string | null; + currentSessionId: string; + entryJson: string; + sessionKey: string; +}; + +/** Scan or rewrite legacy delivery fields inside existing session row JSON. */ +export function repairCanonicalSessionDeliveryStates(params: { + apply: boolean; + cfg: OpenClawConfig; + env: NodeJS.ProcessEnv; +}): SessionDeliveryStateRepairReport { + const targets = listExistingAgentDatabaseTargets(params.cfg, params.env); + let found = 0; + let repaired = 0; + for (const target of targets) { + const inspected = withOpenClawAgentDatabaseReadOnly( + (database) => collectDeliveryRewrites(database.db), + { agentId: target.agentId, env: params.env, path: target.sqlitePath }, + ); + if (!inspected.found) { + continue; + } + found += inspected.value.length; + if (!params.apply || inspected.value.length === 0) { + continue; + } + const wasOpen = isOpenClawAgentDatabaseOpen(target.sqlitePath); + try { + repaired += runOpenClawAgentWriteTransaction( + (database) => applyDeliveryRewrites(database.db), + { agentId: target.agentId, env: params.env, path: target.sqlitePath }, + { operationLabel: "doctor.canonicalize-session-delivery-state" }, + ); + } finally { + if (!wasOpen) { + closeOpenClawAgentDatabaseByPath(target.sqlitePath); + } + } + } + return { found, repaired, scannedStores: targets.length }; +} + +function listExistingAgentDatabaseTargets( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv, +): Array<{ agentId: string; sqlitePath: string }> { + const seenPaths = new Set(); + return resolveAllAgentSessionStoreCandidateTargetsSync(cfg, { env }).flatMap((target) => { + const sqlitePath = resolveTargetSqlitePath(target); + if (seenPaths.has(sqlitePath) || !fs.existsSync(sqlitePath)) { + return []; + } + seenPaths.add(sqlitePath); + return [{ agentId: target.agentId, sqlitePath }]; + }); +} + +function collectDeliveryRewrites(database: DatabaseSync): DeliveryRewrite[] { + const db = getNodeSqliteKysely(database); + const rows = executeSqliteQuerySync( + database, + db.selectFrom("session_nodes").select(["session_key", "current_session_id", "entry_json"]), + ).rows; + return rows.flatMap((row) => { + let parsed: unknown; + try { + parsed = JSON.parse(row.entry_json); + } catch { + return []; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return []; + } + const entry = parsed as SessionEntry; + const normalizedEntry = normalizeLegacySessionEntryDelivery(entry); + const entryJson = JSON.stringify(normalizedEntry); + return entryJson === row.entry_json + ? [] + : [ + { + accountId: deliveryContextFromSession(normalizedEntry)?.accountId ?? null, + channel: sessionDeliveryChannel(normalizedEntry) ?? null, + currentSessionId: row.current_session_id, + entryJson, + sessionKey: row.session_key, + }, + ]; + }); +} + +function applyDeliveryRewrites(database: DatabaseSync): number { + const db = getNodeSqliteKysely(database); + const rewrites = collectDeliveryRewrites(database); + for (const rewrite of rewrites) { + executeSqliteQuerySync( + database, + db + .updateTable("session_nodes") + .set({ entry_json: rewrite.entryJson }) + .where("session_key", "=", rewrite.sessionKey), + ); + executeSqliteQuerySync( + database, + db + .updateTable("session_windows") + .set({ account_id: rewrite.accountId, channel: rewrite.channel }) + .where("session_id", "=", rewrite.currentSessionId), + ); + } + return rewrites.length; +} diff --git a/src/commands/doctor-session-sqlite.test.ts b/src/commands/doctor-session-sqlite.test.ts index ef1681671822..a44bb7a16f43 100644 --- a/src/commands/doctor-session-sqlite.test.ts +++ b/src/commands/doctor-session-sqlite.test.ts @@ -26,6 +26,7 @@ import { recordOpenClawDatabaseQuarantine, } from "../state/openclaw-quarantine-store.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; +import { sessionDeliveryRoute } from "../utils/delivery-context.shared.js"; import { assertSafeSessionSqliteMigrationMove, createSessionSqliteMigrationFailureIssue, @@ -425,7 +426,7 @@ describe("runDoctorSessionSqlite", () => { storePath: store.storePath, }); // The SQLite runtime does no read repair, so import must store canonical shapes. - expect(typeof imported?.entry.route).not.toBe("string"); + expect(typeof sessionDeliveryRoute(imported?.entry)).not.toBe("string"); const events = loadSqliteTranscriptEventsSync({ agentId: "main", sessionId: "session-1", diff --git a/src/commands/doctor-session-transcripts.test.ts b/src/commands/doctor-session-transcripts.test.ts index a8ba30bedf38..db90b952ee3b 100644 --- a/src/commands/doctor-session-transcripts.test.ts +++ b/src/commands/doctor-session-transcripts.test.ts @@ -8,6 +8,7 @@ import { SessionManager } from "../agents/sessions/session-manager.js"; const note = vi.hoisted(() => vi.fn()); const repairReservedIncognitoSessionKeys = vi.hoisted(() => vi.fn()); +const repairCanonicalSessionDeliveryStates = vi.hoisted(() => vi.fn()); const runDoctorSessionSqlite = vi.hoisted(() => vi.fn()); const withDoctorSqliteMaintenanceLock = vi.hoisted(() => vi.fn()); @@ -23,6 +24,10 @@ vi.mock("./doctor-session-incognito-key-repair.js", () => ({ repairReservedIncognitoSessionKeys, })); +vi.mock("./doctor-session-delivery-state.js", () => ({ + repairCanonicalSessionDeliveryStates, +})); + vi.mock("./doctor-sqlite-maintenance-lock.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -100,6 +105,9 @@ describe("doctor session transcript repair", () => { beforeEach(async () => { note.mockClear(); repairReservedIncognitoSessionKeys.mockReset().mockReturnValue({ found: 0, repaired: 0 }); + repairCanonicalSessionDeliveryStates + .mockReset() + .mockReturnValue({ found: 0, repaired: 0, scannedStores: 0 }); runDoctorSessionSqlite.mockReset(); withDoctorSqliteMaintenanceLock .mockReset() diff --git a/src/commands/doctor-session-transcripts.ts b/src/commands/doctor-session-transcripts.ts index 6c820c37c33e..e621c8536dbf 100644 --- a/src/commands/doctor-session-transcripts.ts +++ b/src/commands/doctor-session-transcripts.ts @@ -20,6 +20,10 @@ import { import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HealthFinding, HealthRepairEffect } from "../flows/health-checks.js"; import { shortenHomePath } from "../utils.js"; +import { + repairCanonicalSessionDeliveryStates, + type SessionDeliveryStateRepairReport, +} from "./doctor-session-delivery-state.js"; import { repairReservedIncognitoSessionKeys, type ReservedIncognitoKeyRepairReport, @@ -510,6 +514,11 @@ async function noteSessionSqliteMigrationHealth(params: { // --session-sqlite subcommand remains the diagnostic/proof surface. const { runDoctorSessionSqlite } = await import("./doctor-session-sqlite.js"); let reservedKeyReport: ReservedIncognitoKeyRepairReport = { found: 0, repaired: 0 }; + let deliveryReport: SessionDeliveryStateRepairReport = { + found: 0, + repaired: 0, + scannedStores: 0, + }; const runSessionSqlite = async () => { const report = await runDoctorSessionSqlite({ allAgents: true, @@ -523,6 +532,11 @@ async function noteSessionSqliteMigrationHealth(params: { cfg: params.cfg ?? {}, env: params.env, }); + deliveryReport = repairCanonicalSessionDeliveryStates({ + apply: params.shouldRepair, + cfg: params.cfg ?? {}, + env: params.env, + }); return report; }; let report: Awaited>; @@ -552,6 +566,14 @@ async function noteSessionSqliteMigrationHealth(params: { "Session SQLite", ); } + if (deliveryReport.found > 0) { + note( + params.shouldRepair + ? `- Canonicalized delivery state for ${deliveryReport.repaired} durable session row(s).` + : `- Found ${deliveryReport.found} durable session row(s) with legacy delivery fields. Run "openclaw doctor --fix" to canonicalize them.`, + "Session SQLite", + ); + } if ( report.totals.legacyEntries === 0 && report.totals.unreferencedJsonlFiles === 0 && diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts index 2d07ec9ed690..ffc137027c31 100644 --- a/src/commands/doctor-state-integrity.test.ts +++ b/src/commands/doctor-state-integrity.test.ts @@ -848,8 +848,7 @@ describe("doctor state integrity oauth dir checks", () => { const entry: SessionEntry = { sessionId: "session", updatedAt: 1, - lastTo: "heartbeat", - origin: { label: "heartbeat" }, + delivery: { kind: "internal" }, }; expect(resolveHeartbeatMainSessionRepairCandidate({ entry })).toBeNull(); }); diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 6a5380a888ec..ad65a042295b 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -40,6 +40,7 @@ import { resolveAgentIdFromSessionKey, } from "../routing/session-key.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; +import { sessionDeliveryChannel } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; type SandboxExplainOptions = { @@ -113,21 +114,10 @@ function resolveActiveChannel(params: { entry?: SessionEntry; sessionKey: string; }): string | undefined { - const legacyEntry = params.entry as - | (SessionEntry & { lastProvider?: string; provider?: string }) - | undefined; - const candidate = ( - params.entry?.lastChannel ?? - params.entry?.channel ?? - // Legacy keys (pre-rename). - legacyEntry?.lastProvider ?? - legacyEntry?.provider ?? - "" - ).trim(); + const candidate = (sessionDeliveryChannel(params.entry) ?? "").trim(); const normalizedCandidate = normalizeOptionalLowercaseString(candidate); if (!normalizedCandidate) { - // Empty session-store channel fields can still be recovered from legacy key - // shapes, which keeps explain useful for old persisted sessions. + // Empty canonical delivery can still be recovered from the session key. return inferProviderFromSessionKey({ cfg: params.cfg, sessionKey: params.sessionKey, diff --git a/src/commands/sessions.kind-classification.test.ts b/src/commands/sessions.kind-classification.test.ts index 533aa6f0724f..fddd79655877 100644 --- a/src/commands/sessions.kind-classification.test.ts +++ b/src/commands/sessions.kind-classification.test.ts @@ -1,6 +1,7 @@ // Session kind classification tests cover chat, ACP, and agent session metadata classification. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionEntry } from "../config/sessions/types.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { mockSessionsConfig, resetMockSessionsConfig, @@ -68,11 +69,13 @@ function buildAcpSpawnChildEntry(): SessionEntry { sessionId: "spawn-child-session-id", updatedAt: Date.now() - 2 * 60_000, spawnedBy: TELEGRAM_GROUP_KEY, - deliveryContext: { - channel: "telegram", - to: "-1003967207344", - threadId: 323, - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "-1003967207344", + threadId: 323, + }, + }), // No chatType — ACP spawn-child entries don't carry one. The classifier // must infer "this came from a group" from spawnedBy / deliveryContext. }; @@ -88,10 +91,12 @@ function buildAcpDirectEntry(): SessionEntry { return { sessionId: "dm-session-id", updatedAt: Date.now() - 5 * 60_000, - deliveryContext: { - channel: "telegram", - to: "+15555550123", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "+15555550123", + }, + }), }; } diff --git a/src/commands/sessions.test.ts b/src/commands/sessions.test.ts index 13ed24dc4ae8..e7ac0b37ba7e 100644 --- a/src/commands/sessions.test.ts +++ b/src/commands/sessions.test.ts @@ -1,5 +1,6 @@ // Sessions command tests cover listing, details, filtering, and transcript display behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { cleanupStore, makeRuntime, @@ -317,12 +318,14 @@ describe("sessionsCommand", () => { "agent:main:main": { sessionId: "telegram-main", updatedAt: Date.now() - 60_000, - origin: { - provider: "telegram", - chatType: "direct", - to: "telegram:42", - accountId: "default", - }, + delivery: normalizeSessionDeliveryState({ + origin: { + provider: "telegram", + chatType: "direct", + to: "telegram:42", + accountId: "default", + }, + }), }, }, "sessions-runtime-policy-alias", diff --git a/src/commands/sessions.ts b/src/commands/sessions.ts index 0421ced58a68..fdc165cc7937 100644 --- a/src/commands/sessions.ts +++ b/src/commands/sessions.ts @@ -33,6 +33,10 @@ import { classifySessionKind, type SessionKind } from "../sessions/classify-sess import { isAcpSessionKey } from "../sessions/session-key-utils.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { resolveAgentRuntimeLabel } from "../status/agent-runtime-label.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../utils/delivery-context.shared.js"; import { resolveSessionStoreTargetsOrExit } from "./session-store-targets.js"; import { resolveSessionDisplayModelRef, @@ -281,21 +285,17 @@ function resolveDisplayRuntimePolicySessionKey(params: { entry: SessionEntry; }): string | undefined { const { cfg, entry, key } = params; - const origin = entry.origin; - const deliveryContext = entry.deliveryContext; + const origin = sessionDeliveryOrigin(entry); + const deliveryContext = deliveryContextFromSession(entry); const chatType = normalizeChatType(origin?.chatType ?? entry.chatType); if (chatType !== "direct") { return undefined; } const channel = normalizeOptionalString( - origin?.provider ?? - deliveryContext?.channel ?? - entry.lastChannel ?? - entry.channel ?? - origin?.surface, + origin?.provider ?? deliveryContext?.channel ?? origin?.surface, ); - const to = normalizeOptionalString(origin?.to ?? deliveryContext?.to ?? entry.lastTo); + const to = normalizeOptionalString(origin?.to ?? deliveryContext?.to); const from = normalizeOptionalString(origin?.from); const nativeDirectUserId = normalizeOptionalString(origin?.nativeDirectUserId); const peerId = @@ -312,9 +312,7 @@ function resolveDisplayRuntimePolicySessionKey(params: { SessionKey: key, Provider: channel, Surface: normalizeOptionalString(origin?.surface), - AccountId: normalizeOptionalString( - origin?.accountId ?? deliveryContext?.accountId ?? entry.lastAccountId, - ), + AccountId: normalizeOptionalString(origin?.accountId ?? deliveryContext?.accountId), ChatType: chatType, NativeDirectUserId: nativeDirectUserId, SenderId: peerId, diff --git a/src/config/sessions/conversation-delivery-store.test.ts b/src/config/sessions/conversation-delivery-store.test.ts index 0d19ceecd01d..16f261286617 100644 --- a/src/config/sessions/conversation-delivery-store.test.ts +++ b/src/config/sessions/conversation-delivery-store.test.ts @@ -1,8 +1,10 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; import { buildConversationRef } from "../../routing/conversation-ref.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { beginConversationDeliveryOperation, findConversationTurnDeliveryByReplyTarget, @@ -14,7 +16,21 @@ import { markConversationDeliveryUnknown, } from "./conversation-delivery-store.js"; import { resolveConversation } from "./conversation-registry.js"; -import { deleteSessionEntryLifecycle, upsertSessionEntry } from "./session-accessor.js"; +import { + deleteSessionEntryLifecycle, + upsertSessionEntry as upsertCanonicalSessionEntry, +} from "./session-accessor.js"; +import type { SessionEntry, SessionOrigin } from "./types.js"; + +type LegacyDeliveryFixture = Partial & { + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; +}; + +const upsertSessionEntry = ( + scope: Parameters[0], + entry: LegacyDeliveryFixture, +) => upsertCanonicalSessionEntry(scope, normalizeLegacySessionEntryDelivery(entry as SessionEntry)); async function withConversationStore( run: (params: { diff --git a/src/config/sessions/conversation-identity.test.ts b/src/config/sessions/conversation-identity.test.ts index d80de4ab1cf7..1a29b7a61110 100644 --- a/src/config/sessions/conversation-identity.test.ts +++ b/src/config/sessions/conversation-identity.test.ts @@ -1,9 +1,26 @@ import { describe, expect, it } from "vitest"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { buildConversationIdentity, conversationIdentityFromMsgContext, - conversationIdentityFromSessionEntry, + conversationIdentityFromSessionEntry as conversationIdentityFromCanonicalSessionEntry, } from "./conversation-identity.js"; +import type { SessionEntry, SessionOrigin } from "./types.js"; + +type LegacyDeliveryFixture = SessionEntry & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; + lastAccountId?: string; + lastChannel?: string; +}; + +function conversationIdentityFromSessionEntry(entry: LegacyDeliveryFixture) { + return conversationIdentityFromCanonicalSessionEntry(normalizeLegacySessionEntryDelivery(entry)); +} function directEntry(peer: string) { return { @@ -210,7 +227,7 @@ describe("conversation identity", () => { it.each([ { fallback: { origin: { provider: "reef", accountId: "work" } }, label: "origin" }, - { fallback: { lastAccountId: "work" }, label: "last route" }, + { fallback: { lastChannel: "reef", lastAccountId: "work" }, label: "last route" }, ])("fills an omitted delivery account from the persisted $label", ({ fallback }) => { const identity = conversationIdentityFromSessionEntry({ sessionId: "session-main", diff --git a/src/config/sessions/conversation-identity.ts b/src/config/sessions/conversation-identity.ts index 716a16f331a2..6045bdf88cc6 100644 --- a/src/config/sessions/conversation-identity.ts +++ b/src/config/sessions/conversation-identity.ts @@ -10,6 +10,7 @@ import { deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, + sessionDeliveryOrigin, } from "../../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { resolveGroupSessionKey } from "./group.js"; @@ -65,7 +66,7 @@ function resolvePairedOriginPeerId(params: { if (params.kind !== "direct") { return undefined; } - const origin = params.entry.origin; + const origin = sessionDeliveryOrigin(params.entry); const originFrom = normalizeText(origin?.from); const originTo = normalizeText(origin?.to); const originChannel = normalizeText(origin?.provider)?.toLowerCase(); @@ -158,20 +159,14 @@ export function buildConversationIdentity(params: { export function conversationIdentityFromSessionEntry( entry: SessionEntry, ): ConversationIdentity | null { - // Explicit route snapshots own their populated fields, while persisted - // origin/last-route facts fill gaps such as an omitted account id. - const deliveryContext = mergeDeliveryContext( - normalizeDeliveryContext(entry.deliveryContext), - deliveryContextFromSession(entry), - ); + const deliveryContext = deliveryContextFromSession(entry); + const origin = sessionDeliveryOrigin(entry); const kind = normalizeKind(entry.chatType); const routeTarget = normalizeText(deliveryContext?.to); const deliveryTarget = - routeTarget ?? (kind === "direct" ? normalizeText(entry.origin?.from) : undefined); + routeTarget ?? (kind === "direct" ? normalizeText(origin?.from) : undefined); const routeOwnsTarget = Boolean(routeTarget); - const channel = routeOwnsTarget - ? deliveryContext?.channel - : (normalizeText(entry.origin?.provider) ?? normalizeText(entry.channel)); + const channel = routeOwnsTarget ? deliveryContext?.channel : normalizeText(origin?.provider); // Outbound routes can use an alias for delivery while `origin.from` carries // the canonical peer. Trust it only when both snapshots are fully paired. const pairedOriginPeerId = routeTarget @@ -184,14 +179,14 @@ export function conversationIdentityFromSessionEntry( : undefined; return buildConversationIdentity({ channel, - accountId: routeOwnsTarget ? deliveryContext?.accountId : entry.origin?.accountId, + accountId: routeOwnsTarget ? deliveryContext?.accountId : origin?.accountId, kind, // Native ids remain descriptive metadata and cannot redirect a stored conversation ref. peerId: pairedOriginPeerId ?? deliveryTarget, deliveryTarget, - threadId: routeOwnsTarget ? deliveryContext?.threadId : entry.origin?.threadId, - nativeChannelId: entry.origin?.nativeChannelId, - nativeDirectUserId: entry.origin?.nativeDirectUserId, + threadId: routeOwnsTarget ? deliveryContext?.threadId : origin?.threadId, + nativeChannelId: origin?.nativeChannelId, + nativeDirectUserId: origin?.nativeDirectUserId, label: entry.displayName ?? entry.label, }); } diff --git a/src/config/sessions/conversation-registry.test.ts b/src/config/sessions/conversation-registry.test.ts index 7a534d184e2b..d9f4aa8cd53f 100644 --- a/src/config/sessions/conversation-registry.test.ts +++ b/src/config/sessions/conversation-registry.test.ts @@ -2,22 +2,38 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { executeSqliteQuerySync } from "../../infra/kysely-sync.js"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { buildConversationIdentity } from "./conversation-identity.js"; import { listConversations, registerConversationAddresses, resolveConversation, } from "./conversation-registry.js"; -import { deleteSessionEntryLifecycle, upsertSessionEntry } from "./session-accessor.js"; +import { + deleteSessionEntryLifecycle, + upsertSessionEntry as upsertCanonicalSessionEntry, +} from "./session-accessor.js"; import { getSessionKysely, resolveSqliteReadScope, toDatabaseOptions, } from "./session-accessor.sqlite-scope.js"; +import type { SessionEntry, SessionOrigin } from "./types.js"; + +type LegacyDeliveryFixture = Partial & { + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; +}; + +const upsertSessionEntry = ( + scope: Parameters[0], + entry: LegacyDeliveryFixture, +) => upsertCanonicalSessionEntry(scope, normalizeLegacySessionEntryDelivery(entry as SessionEntry)); describe("conversation registry", () => { let tempDir: string; diff --git a/src/config/sessions/delivery-info.test.ts b/src/config/sessions/delivery-info.test.ts index d3e8f44004c3..1e6b2039c23e 100644 --- a/src/config/sessions/delivery-info.test.ts +++ b/src/config/sessions/delivery-info.test.ts @@ -1,21 +1,41 @@ // Session delivery info tests cover persisted delivery metadata. import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { parseSessionThreadInfo } from "./thread-info.js"; -import type { SessionEntry } from "./types.js"; +import type { SessionEntry, SessionOrigin } from "./types.js"; + +type SessionEntryFixture = SessionEntry & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; const storeState = vi.hoisted(() => { const state = { - store: {} as Record, - stores: {} as Record>, + store: {} as Record, + stores: {} as Record>, // Mirrors the accessor view contract: raw exact-key get, enumeration only via entries(). openSessionEntryReadView: vi.fn((scope: { storePath?: string }) => { const store = state.stores[scope.storePath ?? ""] ?? state.store; return { get: (sessionKey: string) => - Object.hasOwn(store, sessionKey) ? store[sessionKey] : undefined, - entries: () => Object.entries(store).map(([sessionKey, entry]) => ({ sessionKey, entry })), + Object.hasOwn(store, sessionKey) + ? normalizeLegacySessionEntryDelivery(store[sessionKey] as SessionEntry) + : undefined, + entries: () => + Object.entries(store).map(([sessionKey, entry]) => ({ + sessionKey, + entry: normalizeLegacySessionEntryDelivery(entry), + })), }; }), }; @@ -45,7 +65,7 @@ vi.mock("./targets.js", () => ({ let extractDeliveryInfo: typeof import("./delivery-info.js").extractDeliveryInfo; -const buildEntry = (deliveryContext: SessionEntry["deliveryContext"]): SessionEntry => ({ +const buildEntry = (deliveryContext: DeliveryContext): SessionEntryFixture => ({ sessionId: "session-1", updatedAt: Date.now(), deliveryContext, @@ -100,31 +120,31 @@ describe("extractDeliveryInfo", () => { }); it("reads borrowed accessor views for direct session keys", () => { - const sessionKey = "agent:main:webchat:dm:user-123"; + const sessionKey = "agent:main:telegram:dm:user-123"; storeState.store[sessionKey] = buildEntry({ - channel: "webchat", - to: "webchat:user-123", + channel: "telegram", + to: "telegram:user-123", accountId: "default", }); const result = extractDeliveryInfo(sessionKey); - expect(result.deliveryContext?.to).toBe("webchat:user-123"); + expect(result.deliveryContext?.to).toBe("telegram:user-123"); expect(storeState.openSessionEntryReadView).toHaveBeenCalledWith({ storePath: "/tmp/sessions.json", }); }); it("does not enumerate the store when an exact routable key is present", () => { - const sessionKey = "agent:main:webchat:dm:user-123"; + const sessionKey = "agent:main:telegram:dm:user-123"; // Enumeration trap: the accessor-view mock lists rows via Object.entries, so // building the normalized fallback index on this cheap path throws here and // extractDeliveryInfo would return no delivery context. storeState.store = new Proxy( { [sessionKey]: buildEntry({ - channel: "webchat", - to: "webchat:user-123", + channel: "telegram", + to: "telegram:user-123", accountId: "default", }), }, @@ -139,8 +159,8 @@ describe("extractDeliveryInfo", () => { expect(result).toEqual({ deliveryContext: { - channel: "webchat", - to: "webchat:user-123", + channel: "telegram", + to: "telegram:user-123", accountId: "default", }, threadId: undefined, @@ -148,10 +168,10 @@ describe("extractDeliveryInfo", () => { }); it("returns deliveryContext for direct session keys", () => { - const sessionKey = "agent:main:webchat:dm:user-123"; + const sessionKey = "agent:main:telegram:dm:user-123"; storeState.store[sessionKey] = buildEntry({ - channel: "webchat", - to: "webchat:user-123", + channel: "telegram", + to: "telegram:user-123", accountId: "default", }); @@ -159,8 +179,8 @@ describe("extractDeliveryInfo", () => { expect(result).toEqual({ deliveryContext: { - channel: "webchat", - to: "webchat:user-123", + channel: "telegram", + to: "telegram:user-123", accountId: "default", }, threadId: undefined, @@ -189,12 +209,12 @@ describe("extractDeliveryInfo", () => { }); it("looks up deliveryContext in per-agent session stores", () => { - const sessionKey = "agent:worker:webchat:dm:user-456"; + const sessionKey = "agent:worker:telegram:dm:user-456"; storeState.stores["/tmp/sessions.json"] = {}; storeState.stores["/tmp/worker-sessions.json"] = { [sessionKey]: buildEntry({ - channel: "webchat", - to: "webchat:user-456", + channel: "telegram", + to: "telegram:user-456", accountId: "worker-account", }), }; @@ -203,8 +223,8 @@ describe("extractDeliveryInfo", () => { expect(result).toEqual({ deliveryContext: { - channel: "webchat", - to: "webchat:user-456", + channel: "telegram", + to: "telegram:user-456", accountId: "worker-account", }, threadId: undefined, @@ -212,7 +232,7 @@ describe("extractDeliveryInfo", () => { }); it("continues across per-agent stores until it finds a routable deliveryContext", () => { - const sessionKey = "agent:shadow:webchat:dm:user-789"; + const sessionKey = "agent:shadow:telegram:dm:user-789"; storeState.stores["/tmp/sessions.json"] = { [sessionKey]: { sessionId: "stale-shadow", @@ -221,8 +241,8 @@ describe("extractDeliveryInfo", () => { }; storeState.stores["/tmp/shadow-sessions.json"] = { [sessionKey]: buildEntry({ - channel: "webchat", - to: "webchat:user-789", + channel: "telegram", + to: "telegram:user-789", accountId: "shadow-account", }), }; @@ -231,8 +251,8 @@ describe("extractDeliveryInfo", () => { expect(result).toEqual({ deliveryContext: { - channel: "webchat", - to: "webchat:user-789", + channel: "telegram", + to: "telegram:user-789", accountId: "shadow-account", }, threadId: undefined, @@ -246,8 +266,8 @@ describe("extractDeliveryInfo", () => { channel: "telegram", to: "group:98765", accountId: "main", + threadId: "55", }); - storeState.store[baseKey].lastThreadId = "55"; const result = extractDeliveryInfo(topicKey); diff --git a/src/config/sessions/metadata.ts b/src/config/sessions/metadata.ts index f29adba26596..83648dc730aa 100644 --- a/src/config/sessions/metadata.ts +++ b/src/config/sessions/metadata.ts @@ -7,12 +7,15 @@ import type { MsgContext } from "../../auto-reply/templating.js"; import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveConversationLabel } from "../../channels/conversation-label.js"; import { getLoadedChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { deliveryContextFromChannelRoute, deliveryContextFromSession, mergeDeliveryContext, normalizeDeliveryContext, - normalizeSessionDeliveryFields, + normalizeSessionDeliveryState, + sessionDeliveryOrigin, + sessionDeliveryRoute, } from "../../utils/delivery-context.shared.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { @@ -28,7 +31,7 @@ function isSystemEventProvider(provider?: string): boolean { } // Origin updates merge sparse channel metadata without deleting previously known fields. -const mergeOrigin = ( +const mergeSessionOrigin = ( existing: SessionOrigin | undefined, next: SessionOrigin | undefined, ): SessionOrigin | undefined => { @@ -153,13 +156,6 @@ export function deriveSessionOrigin( return Object.keys(origin).length > 0 ? origin : undefined; } -export function snapshotSessionOrigin(entry?: SessionEntry): SessionOrigin | undefined { - if (!entry?.origin) { - return undefined; - } - return { ...entry.origin }; -} - function deriveGroupSessionPatch(params: { ctx: MsgContext; sessionKey: string; @@ -193,7 +189,6 @@ function deriveGroupSessionPatch(params: { const patch: Partial = { chatType: resolution.chatType ?? "group", - channel, groupId: resolution.id, }; if (nextSubject) { @@ -226,6 +221,7 @@ export function deriveSessionMetaPatch(params: { sessionKey: string; existing?: SessionEntry; groupResolution?: GroupKeyResolution | null; + preserveExistingDeliveryRoute?: boolean; skipSystemEventOrigin?: boolean; }): Partial | null { const groupPatch = deriveGroupSessionPatch(params); @@ -237,9 +233,50 @@ export function deriveSessionMetaPatch(params: { } const patch: Partial = groupPatch ? { ...groupPatch } : {}; - const mergedOrigin = mergeOrigin(params.existing?.origin, origin); + const existingOrigin = sessionDeliveryOrigin(params.existing); + const mergedOrigin = mergeSessionOrigin(existingOrigin, origin); if (mergedOrigin) { - patch.origin = mergedOrigin; + if (!patch.chatType && mergedOrigin.chatType) { + patch.chatType = mergedOrigin.chatType; + } + const nextProvider = origin?.provider; + const nextOwnsExternalRoute = Boolean( + nextProvider && + nextProvider !== INTERNAL_MESSAGE_CHANNEL && + !isInternalNonDeliveryChannel(nextProvider) && + !isSystemEventProvider(nextProvider), + ); + const existingRoute = sessionDeliveryRoute(params.existing); + const existingRouteAccountId = + existingRoute?.accountId ?? deliveryContextFromSession(params.existing)?.accountId; + const freshRouteOwnsNextProvider = + params.preserveExistingDeliveryRoute === true && + nextProvider != null && + existingRoute?.channel === nextProvider && + (origin?.accountId == null || existingRouteAccountId === origin.accountId); + const deliveryIdentityChanged = + nextOwnsExternalRoute && + !freshRouteOwnsNextProvider && + (!existingOrigin || + (existingOrigin.provider != null && nextProvider !== existingOrigin.provider) || + (existingOrigin.surface != null && + origin?.surface != null && + origin.surface !== existingOrigin.surface) || + (existingOrigin.accountId != null && + origin?.accountId != null && + origin.accountId !== existingOrigin.accountId)); + patch.delivery = normalizeSessionDeliveryState({ + route: deliveryIdentityChanged ? undefined : sessionDeliveryRoute(params.existing), + context: deliveryIdentityChanged + ? { + channel: mergedOrigin.provider, + to: mergedOrigin.to, + accountId: mergedOrigin.accountId, + threadId: mergedOrigin.threadId, + } + : deliveryContextFromSession(params.existing), + origin: mergedOrigin, + }); } return Object.keys(patch).length > 0 ? patch : null; @@ -261,11 +298,11 @@ function removeThreadFromDeliveryContext(context?: DeliveryContext): DeliveryCon * store and the SQLite accessor so both backends apply one routing policy. */ export function deriveLastRoutePatch(params: { - channel?: SessionEntry["lastChannel"]; + channel?: string; to?: string; accountId?: string; threadId?: string | number; - route?: SessionEntry["route"]; + route?: ChannelRouteRef; deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: GroupKeyResolution | null; @@ -306,30 +343,26 @@ export function deriveLastRoutePatch(params: { ? removeThreadFromDeliveryContext(deliveryContextFromSession(existing)) : deliveryContextFromSession(existing); const merged = mergeDeliveryContext(mergedInput, fallbackContext); - const normalized = normalizeSessionDeliveryFields({ + const delivery = normalizeSessionDeliveryState({ route: params.route, - deliveryContext: { + context: { channel: merged?.channel, to: merged?.to, accountId: merged?.accountId, threadId: merged?.threadId, }, + origin: sessionDeliveryOrigin(existing), }); + const nextEntry = existing ? { ...existing, delivery } : ({ delivery } as SessionEntry); const metaPatch = ctx ? deriveSessionMetaPatch({ ctx, sessionKey: params.sessionKey, - existing, + existing: nextEntry, groupResolution: params.groupResolution, + preserveExistingDeliveryRoute: routeContext != null, }) : null; - const basePatch: Partial = { - route: normalized.route, - deliveryContext: normalized.deliveryContext, - lastChannel: normalized.lastChannel, - lastTo: normalized.lastTo, - lastAccountId: normalized.lastAccountId, - lastThreadId: normalized.lastThreadId, - }; + const basePatch: Partial = { delivery }; return metaPatch ? { ...basePatch, ...metaPatch } : basePatch; } diff --git a/src/config/sessions/origin-channel-switch.test.ts b/src/config/sessions/origin-channel-switch.test.ts index ac7acc77847d..1a4ac7e60670 100644 --- a/src/config/sessions/origin-channel-switch.test.ts +++ b/src/config/sessions/origin-channel-switch.test.ts @@ -3,18 +3,28 @@ import { describe, expect, it } from "vitest"; import type { MsgContext } from "../../auto-reply/templating.js"; import { buildChannelInboundEventContext } from "../../channels/inbound-event/context.js"; -import { deriveSessionMetaPatch } from "./metadata.js"; -import type { SessionEntry } from "./types.js"; +import { + normalizeSessionDeliveryState, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; +import { deriveLastRoutePatch, deriveSessionMetaPatch } from "./metadata.js"; +import type { SessionEntry, SessionOrigin } from "./types.js"; const sessionKey = "agent:user"; -function applyOrigin(existing: SessionEntry | undefined, ctx: Partial): SessionEntry { +type ProjectedSessionEntry = SessionEntry & { origin?: SessionOrigin }; + +function applyOrigin( + existing: SessionEntry | undefined, + ctx: Partial, +): ProjectedSessionEntry { const patch = deriveSessionMetaPatch({ ctx: ctx as MsgContext, sessionKey, existing, }); - return { ...existing, ...patch } as SessionEntry; + const entry = { ...existing, ...patch } as SessionEntry; + return { ...entry, origin: sessionDeliveryOrigin(entry) }; } const slackTurn = { @@ -131,15 +141,33 @@ describe("session origin across a channel switch", () => { expect(afterAccountSwitch.origin?.threadId).toBeUndefined(); }); + it("clears a stored route when only the same-account surface identity changes", () => { + const existing = applyOrigin(undefined, slackTurn); + const afterSurfaceSwitch = applyOrigin(existing, { + ...slackTurn, + Surface: "slack-canvas", + To: "slack:D222SLACK", + }); + + expect(afterSurfaceSwitch.origin?.surface).toBe("slack-canvas"); + expect(afterSurfaceSwitch.delivery).toMatchObject({ + kind: "external", + route: { channel: "slack-canvas", target: { to: "slack:D222SLACK" } }, + }); + }); + it("preserves sparse existing channel metadata when optional identity fields are first populated", () => { const existing = { sessionId: "session-1", updatedAt: 1, - origin: { - provider: "slack", - nativeChannelId: "D111SLACK", - threadId: "1700000000.000100", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "slack", to: "slack:D111SLACK" }, + origin: { + provider: "slack", + nativeChannelId: "D111SLACK", + threadId: "1700000000.000100", + }, + }), } satisfies SessionEntry; const slackFollowUp = { Provider: "slack", @@ -157,6 +185,29 @@ describe("session origin across a channel switch", () => { expect(afterFollowUp.origin?.nativeChannelId).toBe("D111SLACK"); expect(afterFollowUp.origin?.threadId).toBe("1700000000.000100"); }); + + it("preserves a fresh rich route when the inbound identity switches providers", () => { + const patch = deriveLastRoutePatch({ + sessionKey, + existing: applyOrigin(undefined, slackTurn), + route: { + channel: "telegram", + accountId: "telegram-bot-1", + target: { to: "chat:42", rawTo: "@forty-two", chatType: "group" }, + thread: { id: 456, kind: "topic", source: "turn" }, + }, + ctx: telegramTurn as MsgContext, + }); + + expect(patch.delivery).toMatchObject({ + kind: "external", + route: { + channel: "telegram", + target: { to: "chat:42", rawTo: "@forty-two", chatType: "group" }, + thread: { id: 456, kind: "topic", source: "turn" }, + }, + }); + }); }); // Drive the production inbound-event context builder so the bug premise itself is proven, not diff --git a/src/config/sessions/runtime-types.ts b/src/config/sessions/runtime-types.ts index 93a7b623559d..de9c61864c32 100644 --- a/src/config/sessions/runtime-types.ts +++ b/src/config/sessions/runtime-types.ts @@ -20,7 +20,7 @@ export type RecordSessionMetaFromInbound = (params: { export type UpdateLastRoute = (params: { storePath: string; sessionKey: string; - channel?: SessionEntry["lastChannel"]; + channel?: string; to?: string; accountId?: string; threadId?: string | number; diff --git a/src/config/sessions/session-accessor.conformance.test.ts b/src/config/sessions/session-accessor.conformance.test.ts index 4a0a2081bd6f..8d0242e8b1c3 100644 --- a/src/config/sessions/session-accessor.conformance.test.ts +++ b/src/config/sessions/session-accessor.conformance.test.ts @@ -15,6 +15,7 @@ import { } from "../../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; import { appendSqliteTrajectoryRuntimeEvents } from "../../trajectory/runtime-store.sqlite.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { readSessionArchiveContentSync } from "./archive-compression.js"; import { appendTranscriptEvent, @@ -1160,13 +1161,14 @@ describe("sqlite session normalization", () => { { agentHarnessId: "codex", chatType: "group", - channel: "discord", - deliveryContext: { - accountId: "acct-1", - channel: "discord", - threadId: "thread-1", - to: "group-1", - }, + delivery: normalizeSessionDeliveryState({ + context: { + accountId: "acct-1", + channel: "discord", + threadId: "thread-1", + to: "group-1", + }, + }), displayName: "Example group", endedAt: 90, model: "gpt-5.5", @@ -1256,7 +1258,7 @@ describe("sqlite session normalization", () => { }; await upsertSqliteSessionEntry(scope, { - channel: "telegram", + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram" } }), chatType: "group", createdVia: "channel", createdActor: { type: "human", id: "telegram-sender" }, @@ -1277,7 +1279,7 @@ describe("sqlite session normalization", () => { ); await upsertSqliteSessionEntry(scope, { - channel: "telegram", + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram" } }), chatType: "group", displayName: "telegram:g-bucephalus-+-topics", sessionId: newSessionId, @@ -1334,6 +1336,7 @@ describe("sqlite session normalization", () => { expect(loadSqliteSessionEntry(scope)).toEqual({ sessionId: "restored-session", updatedAt: 20, + delivery: { kind: "none" }, }); }); @@ -1768,11 +1771,13 @@ describe("sqlite session normalization", () => { storePath: paths.sqlitePath, }, { - deliveryContext: { - accountId: "acct-1", - channel: "matrix", - to: "!MixedCase:example.org", - }, + delivery: normalizeSessionDeliveryState({ + context: { + accountId: "acct-1", + channel: "matrix", + to: "!MixedCase:example.org", + }, + }), sessionId: "legacy-alias-session", updatedAt: 10, }, @@ -1901,6 +1906,7 @@ describe("sqlite session normalization", () => { entry_json: JSON.stringify({ sessionId: "minimal-session", sessionStartedAt: 123, + delivery: { kind: "none" }, updatedAt: 123, }), node_updated_at: 123, diff --git a/src/config/sessions/session-accessor.entry-mutation.ts b/src/config/sessions/session-accessor.entry-mutation.ts index 84d613b472de..8a243e48a913 100644 --- a/src/config/sessions/session-accessor.entry-mutation.ts +++ b/src/config/sessions/session-accessor.entry-mutation.ts @@ -1,6 +1,7 @@ import { isDeepStrictEqual } from "node:util"; import type { MsgContext } from "../../auto-reply/templating.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js"; import type { DeliveryContext } from "../../utils/delivery-context.types.js"; import { @@ -364,7 +365,7 @@ export type UpdateSessionLastRouteParams = { /** Account owning the delivery route when the channel is multi-account. */ accountId?: string; /** Delivery channel id persisted as the last route channel. */ - channel?: SessionEntry["lastChannel"]; + channel?: string; /** Set false to only patch existing entries; missing sessions stay absent. */ createIfMissing?: boolean; /** Optional inbound context whose session metadata is derived alongside the route. */ @@ -374,7 +375,7 @@ export type UpdateSessionLastRouteParams = { /** Group routing resolution for group-owned session keys. */ groupResolution?: GroupKeyResolution | null; /** Canonical channel route persisted as the session route slot. */ - route?: SessionEntry["route"]; + route?: ChannelRouteRef; /** Canonical or alias session key for the routed conversation. */ sessionKey: string; /** Explicit store target for file-backed stores and SQLite migration adapters. */ diff --git a/src/config/sessions/session-accessor.sqlite-cleanup-race.test.ts b/src/config/sessions/session-accessor.sqlite-cleanup-race.test.ts index a9bf3901a0bb..f59453f2aebf 100644 --- a/src/config/sessions/session-accessor.sqlite-cleanup-race.test.ts +++ b/src/config/sessions/session-accessor.sqlite-cleanup-race.test.ts @@ -104,6 +104,7 @@ describe("SQLite lifecycle cleanup races", () => { const currentEntry: SessionEntry = { sessionId: "current-planned-session", updatedAt: Date.now(), + delivery: { kind: "none" }, }; const currentEvent = { type: "session", diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index 35de584d8a91..29c5d2345aa4 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -4,6 +4,7 @@ import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, } from "../../infra/kysely-sync.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js"; import { openOpenClawAgentDatabase, @@ -467,11 +468,11 @@ export async function recordSqliteInboundSessionMeta(params: { export async function updateSqliteSessionLastRoute(params: { storePath: string; sessionKey: string; - channel?: SessionEntry["lastChannel"]; + channel?: string; to?: string; accountId?: string; threadId?: string | number; - route?: SessionEntry["route"]; + route?: ChannelRouteRef; deliveryContext?: DeliveryContext; ctx?: MsgContext; groupResolution?: GroupKeyResolution | null; diff --git a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts index 5684fbc6e0ac..51f784953ade 100644 --- a/src/config/sessions/session-accessor.sqlite-message-cut.test.ts +++ b/src/config/sessions/session-accessor.sqlite-message-cut.test.ts @@ -2,6 +2,10 @@ import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js"; +import { + deliveryContextFromSession, + normalizeSessionDeliveryState, +} from "../../utils/delivery-context.shared.js"; import { appendTranscriptEvent, appendTranscriptMessage, @@ -40,10 +44,10 @@ async function createSession(options: { activeLeafTarget?: string } = {}) { createdVia: "operator", createdActor: { type: "human", id: "profile-1" }, createdAt: 1_000, - deliveryContext: { channel: "telegram", to: "chat-123" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "chat-123" }, + }), forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" }, - lastChannel: "telegram", - lastTo: "chat-123", lifecycleRevision: "source-lifecycle-revision", modelOverride: "gpt-5", modelOverrideSource: "user", @@ -220,7 +224,11 @@ describe("SQLite session message cuts", () => { forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" }, previousSessionId: "message-cut-source", }); - expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" }); + expect(deliveryContextFromSession(result.entry)).toEqual({ + channel: "telegram", + to: "chat-123", + accountId: undefined, + }); }); it("rewinds the stored row when its canonical key differs", async () => { @@ -279,9 +287,7 @@ describe("SQLite session message cuts", () => { expect(loadSessionEntry(scope)?.sessionId).toBe(scope.sessionId); expect(result.entry.lifecycleRevision).not.toBe("source-lifecycle-revision"); expect(result.entry.cliSessionBindings).toBeUndefined(); - expect(result.entry.deliveryContext).toBeUndefined(); - expect(result.entry.lastChannel).toBeUndefined(); - expect(result.entry.lastTo).toBeUndefined(); + expect(deliveryContextFromSession(result.entry)).toBeUndefined(); expect(result.entry.parentSessionKey).toBe(canonicalSourceKey); expect(result.entry.previousSessionId).toBeUndefined(); expect(result.entry.forkedFromParent).toBeUndefined(); diff --git a/src/config/sessions/session-accessor.sqlite-session-row.ts b/src/config/sessions/session-accessor.sqlite-session-row.ts index 88f419099b8d..bb720045b8a8 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.ts @@ -1,3 +1,7 @@ +import { + deliveryContextFromSession, + sessionDeliveryChannel, +} from "../../utils/delivery-context.shared.js"; import { normalizeSqliteChatType, normalizeSqliteText, @@ -7,14 +11,30 @@ import { normalizeSqliteStatus } from "./session-accessor.sqlite-status.js"; import type { SessionEntry } from "./types.js"; export function normalizeSqliteSessionEntryTimestamp(entry: SessionEntry): SessionEntry { + const raw = entry as unknown as Record; + const hasLegacyDeliveryFields = [ + "route", + "deliveryContext", + "origin", + "channel", + "lastChannel", + "lastTo", + "lastAccountId", + "lastThreadId", + ].some((key) => key in raw); + const delivery = + entry.delivery ?? (hasLegacyDeliveryFields ? undefined : { kind: "none" as const }); if (typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)) { - return entry; + if (entry.delivery === delivery) { + return entry; + } + return delivery ? { ...entry, delivery } : entry; } const updatedAt = typeof entry.sessionStartedAt === "number" && Number.isFinite(entry.sessionStartedAt) ? entry.sessionStartedAt : Date.now(); - return { ...entry, updatedAt }; + return delivery ? { ...entry, delivery, updatedAt } : { ...entry, updatedAt }; } export function bindSqliteSessionRoot(params: { @@ -136,20 +156,11 @@ function finiteSqliteNumber(value: unknown): number | null { } function resolveSqliteSessionChannel(entry: SessionEntry): string | null { - return ( - normalizeSqliteText(entry.channel) ?? - normalizeSqliteText(entry.deliveryContext?.channel) ?? - normalizeSqliteText(entry.lastChannel) ?? - normalizeSqliteText(entry.origin?.provider) - ); + return normalizeSqliteText(sessionDeliveryChannel(entry)); } function resolveSqliteSessionAccountId(entry: SessionEntry): string | null { - return ( - normalizeSqliteText(entry.deliveryContext?.accountId) ?? - normalizeSqliteText(entry.lastAccountId) ?? - normalizeSqliteText(entry.origin?.accountId) - ); + return normalizeSqliteText(deliveryContextFromSession(entry)?.accountId); } function resolveSqliteSessionDisplayName(entry: SessionEntry): string | null { diff --git a/src/config/sessions/session-accessor.test.ts b/src/config/sessions/session-accessor.test.ts index fea04603b53d..6a9602c063cf 100644 --- a/src/config/sessions/session-accessor.test.ts +++ b/src/config/sessions/session-accessor.test.ts @@ -9,6 +9,10 @@ import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { appendSqliteTrajectoryRuntimeEvents } from "../../trajectory/runtime-store.sqlite.js"; import type { TrajectoryEvent } from "../../trajectory/types.js"; +import { + deliveryContextFromSession, + sessionDeliveryRoute, +} from "../../utils/delivery-context.shared.js"; import { readSessionArchiveContentSync } from "./archive-compression.js"; import { applySessionEntryReplacements, @@ -524,8 +528,9 @@ describe("session accessor seam", () => { }; const recorded = await recordInboundSessionMeta({ storePath, sessionKey, ctx }); - expect(recorded?.origin?.provider).toBe("webchat"); + expect(recorded?.delivery).toEqual({ kind: "internal" }); expect(recorded).toMatchObject({ + chatType: "direct", createdVia: "channel", createdActor: { type: "human", id: "webchat:user-1" }, createdAt: expect.any(Number), @@ -545,9 +550,9 @@ describe("session accessor seam", () => { // Detached result: caller mutations must never leak into cached store state. if (recorded) { - recorded.origin = { provider: "mutated" }; + recorded.delivery = { kind: "none" }; } - expect(loadSessionEntry({ sessionKey, storePath })?.origin?.provider).toBe("webchat"); + expect(loadSessionEntry({ sessionKey, storePath })?.delivery).toEqual({ kind: "internal" }); const operatorKey = "agent:main:dashboard:operator-created"; const operator = await recordInboundSessionMeta({ @@ -604,7 +609,7 @@ describe("session accessor seam", () => { }, }); const afterMeta = loadSessionEntry({ sessionKey, storePath }); - expect(afterMeta?.origin?.provider).toBe("webchat"); + expect(afterMeta?.delivery).toEqual({ kind: "internal" }); // Inbound metadata must not count as activity; idle reset relies on // updatedAt moving only for real session turns. expect(afterMeta?.updatedAt).toBe(anchorUpdatedAt); @@ -615,10 +620,10 @@ describe("session accessor seam", () => { channel: "webchat", to: "webchat:user-2", }); - expect(routed?.lastChannel).toBe("webchat"); + expect(routed?.delivery).toEqual({ kind: "internal" }); const afterRoute = loadSessionEntry({ sessionKey, storePath }); - expect(afterRoute?.lastTo).toBe("webchat:user-2"); - expect(afterRoute?.route).toEqual({ channel: "webchat", target: { to: "webchat:user-2" } }); + expect(deliveryContextFromSession(afterRoute)).toBeUndefined(); + expect(sessionDeliveryRoute(afterRoute)).toBeUndefined(); expect(afterRoute?.updatedAt).toBe(anchorUpdatedAt); }); diff --git a/src/config/sessions/session-registry-maintenance.test.ts b/src/config/sessions/session-registry-maintenance.test.ts index 7005db1a745c..b40b6fc5de88 100644 --- a/src/config/sessions/session-registry-maintenance.test.ts +++ b/src/config/sessions/session-registry-maintenance.test.ts @@ -21,7 +21,7 @@ afterAll(async () => { }); function sessionEntry(sessionId: string, updatedAt: number): SessionEntry { - return { sessionId, updatedAt }; + return { sessionId, updatedAt, delivery: { kind: "none" } }; } async function createStore(entries: Record): Promise { diff --git a/src/config/sessions/store-entry.ts b/src/config/sessions/store-entry.ts index b4be43d75541..4e75fb6d4a9f 100644 --- a/src/config/sessions/store-entry.ts +++ b/src/config/sessions/store-entry.ts @@ -5,6 +5,10 @@ import { parseThreadSessionSuffix, requiresFoldedSessionKeyAliasProof, } from "../../sessions/session-key-utils.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import type { SessionEntry } from "./types.js"; export function normalizeStoreSessionKey(sessionKey: string): string { @@ -46,13 +50,9 @@ function normalizeEntryTarget(value: unknown): string { } function entryDeliveryTargets(entry: SessionEntry | undefined): string[] { - const candidates = [ - entry?.deliveryContext?.to, - entry?.lastTo, - entry?.origin?.nativeChannelId, - entry?.origin?.to, - entry?.groupId, - ]; + const context = deliveryContextFromSession(entry); + const origin = sessionDeliveryOrigin(entry); + const candidates = [context?.to, origin?.nativeChannelId, origin?.to, entry?.groupId]; return candidates.map(normalizeEntryTarget).filter(Boolean); } @@ -67,9 +67,7 @@ function normalizeEntryThreadId(value: unknown): string { } function entryThreadId(entry: SessionEntry | undefined): string { - return normalizeEntryThreadId( - entry?.deliveryContext?.threadId ?? entry?.lastThreadId ?? entry?.origin?.threadId, - ); + return normalizeEntryThreadId(deliveryContextFromSession(entry)?.threadId); } /** Tail-preserved keys like Matrix rooms need delivery-target proof before a diff --git a/src/config/sessions/store-maintenance.ts b/src/config/sessions/store-maintenance.ts index 7741222d1405..f7bbc1ba13b0 100644 --- a/src/config/sessions/store-maintenance.ts +++ b/src/config/sessions/store-maintenance.ts @@ -12,6 +12,7 @@ import { isSubagentSessionKey, parseAgentSessionKey, } from "../../sessions/session-key-utils.js"; +import { sessionDeliveryOrigin } from "../../utils/delivery-context.shared.js"; import type { SessionMaintenanceConfig, SessionMaintenanceMode } from "../types.base.js"; import { parseSessionThreadInfoFast } from "./thread-info.js"; import type { SessionEntry } from "./types.js"; @@ -410,7 +411,9 @@ function isProtectedSessionMaintenanceEntry( if (isExternalGroupOrChannelSessionKey(sessionKey)) { return true; } - const chatType = normalizeLowercaseStringOrEmpty(entry?.chatType ?? entry?.origin?.chatType); + const chatType = normalizeLowercaseStringOrEmpty( + entry?.chatType ?? sessionDeliveryOrigin(entry)?.chatType, + ); return chatType === "group" || chatType === "channel" || chatType === "thread"; } diff --git a/src/config/sessions/store.pruning.test.ts b/src/config/sessions/store.pruning.test.ts index d267f38f495f..a77c0f8bd26d 100644 --- a/src/config/sessions/store.pruning.test.ts +++ b/src/config/sessions/store.pruning.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js"; import { createFixtureSuite } from "../../test-utils/fixture-suite.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { enforceSessionDiskBudget } from "./disk-budget.js"; import { applyFileBackedSessionStoreMaintenance } from "./store-maintenance-operations.js"; import { @@ -840,7 +841,10 @@ describe("isProtectedSessionMaintenanceEntry", () => { expect( isProtectedSessionMaintenanceEntry("agent:main:cron:job:run:123", { ...makeEntry(Date.now()), - origin: { chatType: "group" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "group:test" }, + origin: { chatType: "group" }, + }), }), ).toBe(false); }); diff --git a/src/config/sessions/transcript.test.ts b/src/config/sessions/transcript.test.ts index 0ed577c051c2..ef25302329c5 100644 --- a/src/config/sessions/transcript.test.ts +++ b/src/config/sessions/transcript.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { repairToolUseResultPairing } from "../../agents/session-transcript-repair.js"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; import * as transcriptEvents from "../../sessions/transcript-events.js"; import type { InternalSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { @@ -44,6 +45,8 @@ import { } from "./transcript.js"; import type { SessionEntry } from "./types.js"; +type SessionEntryFixture = Partial & { channel?: string }; + describe("appendAssistantMessageToSessionTranscript", () => { beforeAll(async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "transcript-warm-")); @@ -83,26 +86,26 @@ describe("appendAssistantMessageToSessionTranscript", () => { }; }; - async function writeTranscriptStore(entry: Partial = {}) { + async function writeTranscriptStore(entry: SessionEntryFixture = {}) { await replaceSessionEntry( { agentId: "main", sessionKey, storePath: fixture.storePath() }, - { + normalizeLegacySessionEntryDelivery({ sessionId, chatType: "direct", - channel: "discord", updatedAt: 1, + channel: "discord", ...entry, - }, + } as SessionEntry), ); } async function writeTranscriptSessionEntry(params: { - entry: Partial & Pick; + entry: SessionEntryFixture & Pick; sessionKey: string; }) { await replaceSessionEntry( { agentId: "main", sessionKey: params.sessionKey, storePath: fixture.storePath() }, - { updatedAt: 1, ...params.entry }, + normalizeLegacySessionEntryDelivery({ updatedAt: 1, ...params.entry } as SessionEntry), ); } diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index 3c4db0ab0e4b..da3a22dc83fa 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -9,7 +9,6 @@ import { normalizeOptionalString, type FastMode } from "@openclaw/normalization- import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js"; import type { ChatType } from "../../channels/chat-type.js"; -import type { ChannelId } from "../../channels/plugins/channel-id.types.js"; import type { CronScheduledToolPolicy } from "../../cron/scheduled-tool-policy.js"; import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import type { Skill } from "../../skills/loading/skill-contract.js"; @@ -42,6 +41,17 @@ export type SessionOrigin = { threadId?: string | number; }; +/** Canonical persisted delivery ownership for one session. */ +export type SessionDeliveryState = + | { kind: "none" } + | { kind: "internal" } + | { + kind: "external"; + route: ChannelRouteRef; + context: DeliveryContext; + origin: SessionOrigin; + }; + export type { AcpSessionRuntimeOptions, SessionAcpIdentity, SessionAcpMeta }; export type CliSessionReseedReceipt = { @@ -515,20 +525,14 @@ export type SessionEntry = SessionRestartRecoveryState & /** User-defined organization bucket for session lists; unrelated to chat groupId/groupChannel. */ category?: string; displayName?: string; - channel?: string; + /** Canonical delivery state. Legacy delivery fields are migrated by `openclaw doctor --fix`. */ + delivery?: SessionDeliveryState; groupId?: string; subject?: string; groupChannel?: string; space?: string; - origin?: SessionOrigin; - route?: ChannelRouteRef; - deliveryContext?: DeliveryContext; /** Last ambient room message durably appended to this transcript, keyed by channel scope. */ ambientTranscriptWatermarks?: Record; - lastChannel?: ChannelId; - lastTo?: string; - lastAccountId?: string; - lastThreadId?: string | number; skillsSnapshot?: SessionSkillSnapshot; systemPromptReport?: SessionSystemPromptReport; /** diff --git a/src/cron/isolated-agent/delivery-target.issue-91613.test.ts b/src/cron/isolated-agent/delivery-target.issue-91613.test.ts index eac12cb3acb7..c7621c0a7a7f 100644 --- a/src/cron/isolated-agent/delivery-target.issue-91613.test.ts +++ b/src/cron/isolated-agent/delivery-target.issue-91613.test.ts @@ -13,6 +13,7 @@ import { } from "../../infra/outbound/targets.test-helpers.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; const { extractDeliveryInfoMock } = vi.hoisted(() => ({ extractDeliveryInfoMock: vi.fn(), @@ -197,10 +198,14 @@ function setLastSessionEntry(params: { setMainSessionEntry({ sessionId: params.sessionId, updatedAt: 1000, - lastChannel: params.lastChannel, - lastTo: params.lastTo, - ...(params.lastThreadId ? { lastThreadId: params.lastThreadId } : {}), - ...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}), + delivery: normalizeSessionDeliveryState({ + context: { + channel: params.lastChannel, + to: params.lastTo, + threadId: params.lastThreadId, + accountId: params.lastAccountId, + }, + }), }); } diff --git a/src/cron/isolated-agent/delivery-target.test.ts b/src/cron/isolated-agent/delivery-target.test.ts index 111faf628972..5cbccdf494cc 100644 --- a/src/cron/isolated-agent/delivery-target.test.ts +++ b/src/cron/isolated-agent/delivery-target.test.ts @@ -11,9 +11,11 @@ import { parseTelegramTargetForTest, telegramMessagingForTest, } from "../../infra/outbound/targets.test-helpers.js"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; import { buildChannelOutboundSessionRoute } from "../../plugin-sdk/core.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; const { extractDeliveryInfoMock } = vi.hoisted(() => ({ extractDeliveryInfoMock: vi.fn(), @@ -228,10 +230,21 @@ const DEFAULT_TARGET = { to: "room:default", }; -type SessionStore = Record; +type SessionStore = Record< + string, + SessionEntry & { + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; + } +>; function setSessionStore(store: SessionStore) { - vi.mocked(loadSessionEntry).mockImplementation(({ sessionKey }) => store[sessionKey]); + const canonical = Object.fromEntries( + Object.entries(store).map(([key, entry]) => [key, normalizeLegacySessionEntryDelivery(entry)]), + ); + vi.mocked(loadSessionEntry).mockImplementation(({ sessionKey }) => canonical[sessionKey]); } function setMainSessionEntry(entry?: SessionStore[string]) { @@ -249,10 +262,14 @@ function setLastSessionEntry(params: { setMainSessionEntry({ sessionId: params.sessionId, updatedAt: 1000, - lastChannel: params.lastChannel, - lastTo: params.lastTo, - ...(params.lastThreadId ? { lastThreadId: params.lastThreadId } : {}), - ...(params.lastAccountId ? { lastAccountId: params.lastAccountId } : {}), + delivery: normalizeSessionDeliveryState({ + context: { + channel: params.lastChannel, + to: params.lastTo, + threadId: params.lastThreadId, + accountId: params.lastAccountId, + }, + }), }); } @@ -415,9 +432,9 @@ describe("resolveDeliveryTarget", () => { setMainSessionEntry({ sessionId: "sess-1", updatedAt: 1000, - lastChannel: "forum", - lastTo: "room:default", - lastAccountId: "session-account", + delivery: normalizeSessionDeliveryState({ + context: { channel: "forum", to: "room:default", accountId: "session-account" }, + }), }); const cfg = makeForumBoundCfg(); diff --git a/src/cron/isolated-agent/delivery-target.ts b/src/cron/isolated-agent/delivery-target.ts index ea1277bc5ca4..00c981320a66 100644 --- a/src/cron/isolated-agent/delivery-target.ts +++ b/src/cron/isolated-agent/delivery-target.ts @@ -18,6 +18,7 @@ import { resolveSessionDeliveryTarget } from "../../infra/outbound/targets-sessi import type { OutboundChannel } from "../../infra/outbound/targets.js"; import { normalizeAccountId } from "../../routing/session-key.js"; import { createLazyImportLoader } from "../../shared/lazy-promise.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { resolveCronStoredDeliveryContext } from "../delivery-context.js"; import { resolveCronAgentSessionKey } from "./session-key.js"; @@ -174,7 +175,7 @@ export async function resolveDeliveryTarget( ? ({ sessionId: threadSessionKey ?? mainSessionKey, updatedAt: 0, - deliveryContext: storedDeliveryContext, + delivery: normalizeSessionDeliveryState({ context: storedDeliveryContext }), } satisfies SessionEntry) : undefined; const threadEntry = threadSessionKey diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts index 2209c0b0e025..ddc27841ffcf 100644 --- a/src/cron/isolated-agent/session.test.ts +++ b/src/cron/isolated-agent/session.test.ts @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import type { SessionOrigin } from "../../config/sessions/types.js"; +import { normalizeLegacySessionEntryDelivery } from "../../infra/state-migrations.legacy-session-store.js"; +import { projectSessionDeliveryFields } from "../../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; vi.mock("../../config/sessions/paths.js", () => ({ resolveStorePath: vi.fn().mockReturnValue("/tmp/test-store.json"), @@ -43,7 +47,16 @@ import { resolveCronSession } from "./session.js"; const NOW_MS = 1_737_600_000_000; type SessionStore = Record; -type MockSessionStoreEntry = Partial; +type MockSessionStoreEntry = Partial & { + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; +type ProjectedSessionEntry = SessionEntry & ReturnType; function resolveWithStoredEntry(params?: { sessionKey?: string; @@ -55,11 +68,15 @@ function resolveWithStoredEntry(params?: { const sessionKey = params?.sessionKey ?? "webhook:stable-key"; const sourceSessionKey = params?.sourceSessionKey; const store: SessionStore = params?.entry - ? ({ [sourceSessionKey ?? sessionKey]: params.entry as SessionEntry } as SessionStore) + ? { + [sourceSessionKey ?? sessionKey]: normalizeLegacySessionEntryDelivery( + params.entry as SessionEntry, + ), + } : {}; vi.mocked(evaluateSessionFreshness).mockReturnValue({ fresh: params?.fresh ?? true }); - return resolveCronSession({ + const result = resolveCronSession({ cfg: {} as OpenClawConfig, sessionKey, sourceSessionKey, @@ -68,6 +85,13 @@ function resolveWithStoredEntry(params?: { forceNew: params?.forceNew, store, }); + return { + ...result, + sessionEntry: { + ...result.sessionEntry, + ...projectSessionDeliveryFields(result.sessionEntry.delivery), + } as ProjectedSessionEntry, + }; } describe("resolveCronSession", () => { @@ -495,7 +519,7 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.authProfileOverrideCompactionCount).toBe(3); }); - it("preserves ambient session context for non-isolated expiration rollovers", () => { + it("preserves non-delivery ambient session context for non-isolated expiration rollovers", () => { const result = resolveWithStoredEntry({ entry: { sessionId: "existing-session-id-321", @@ -513,8 +537,8 @@ describe("resolveCronSession", () => { expect(result.sessionEntry.elevatedLevel).toBe("full"); expect(result.sessionEntry.sendPolicy).toBe("deny"); expect(result.sessionEntry.queueMode).toBe("collect"); - expect(result.sessionEntry.channel).toBe("discord"); - expect(result.sessionEntry.origin).toEqual({ provider: "discord", to: "old-channel" }); + expect(result.sessionEntry.channel).toBeUndefined(); + expect(result.sessionEntry.origin).toBeUndefined(); }); it("clears delivery routing metadata when session is stale", () => { diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts index a645a784bd32..42f52c51308b 100644 --- a/src/cron/isolated-agent/session.ts +++ b/src/cron/isolated-agent/session.ts @@ -45,12 +45,10 @@ const AMBIENT_SESSION_CONTEXT_FIELDS = [ "queueDebounceMs", "queueCap", "queueDrop", - "channel", "groupId", "subject", "groupChannel", "space", - "origin", "acp", ] as const satisfies readonly (keyof SessionEntry)[]; diff --git a/src/cron/service/timer.test.ts b/src/cron/service/timer.test.ts index 553dd87a0a5a..eeaad135ad57 100644 --- a/src/cron/service/timer.test.ts +++ b/src/cron/service/timer.test.ts @@ -13,6 +13,7 @@ import * as taskExecutor from "../../tasks/task-executor.js"; import { findTaskByRunId, listTaskRecordsUnsorted } from "../../tasks/task-registry.js"; import { resetTaskRegistryForTests } from "../../tasks/task-runtime.test-helpers.js"; import { formatTaskStatusDetail } from "../../tasks/task-status.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; const { logger, makeStorePath } = setupCronServiceSuite({ prefix: "cron-service-timer-seam", @@ -122,9 +123,9 @@ describe("cron service timer seam coverage", () => { { sessionId: "main-pr-router-session", updatedAt: now, - lastChannel: "discord", - lastTo: "channel-1", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "channel-1", accountId: "default" }, + }), }, ); diff --git a/src/cron/session-reaper.test.ts b/src/cron/session-reaper.test.ts index 8cf0bf8489e6..e502ad2a0052 100644 --- a/src/cron/session-reaper.test.ts +++ b/src/cron/session-reaper.test.ts @@ -183,6 +183,7 @@ describe("sweepCronRunSessions", () => { [sessionKey]: { sessionId: "pending-run", updatedAt: now - 25 * 3_600_000, + delivery: { kind: "none" }, cronRunContinuation: { lifecycleRevision: "revision-1", phase: "ready" }, }, }; diff --git a/src/gateway/server-methods/agent-run-handler.ts b/src/gateway/server-methods/agent-run-handler.ts index 8429e85726cf..eb21fde5fa84 100644 --- a/src/gateway/server-methods/agent-run-handler.ts +++ b/src/gateway/server-methods/agent-run-handler.ts @@ -311,7 +311,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({ normalizedSpawned, requestDeliveryHint, requestLabel: request.label, - recipientChannel, pluginOwnerId: freshEntry === undefined ? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId) diff --git a/src/gateway/server-methods/agent-session-patch.ts b/src/gateway/server-methods/agent-session-patch.ts index f828dd80d6bb..dd0c2194d4c6 100644 --- a/src/gateway/server-methods/agent-session-patch.ts +++ b/src/gateway/server-methods/agent-session-patch.ts @@ -13,8 +13,11 @@ import { hasProviderOwnedSession } from "../../config/sessions/entry-freshness.j import { isRecoverableTerminalSessionStatus } from "../../config/sessions/terminal-status.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { + deliveryContextFromSession, mergeDeliveryContext, - normalizeSessionDeliveryFields, + normalizeSessionDeliveryState, + sessionDeliveryOrigin, + sessionDeliveryRoute, type DeliveryContext, } from "../../utils/delivery-context.shared.js"; import { canonicalizeSpawnedByForAgent, loadSessionEntryReadOnly } from "../session-utils.js"; @@ -48,7 +51,6 @@ export function buildAgentSessionPatch(params: { normalizedSpawned: { groupId?: string; groupChannel?: string; groupSpace?: string }; requestDeliveryHint: DeliveryContext | undefined; requestLabel?: string; - recipientChannel?: string; pluginOwnerId?: string; expectedExistingSessionId?: string; hasRestoredCronContinuation: boolean; @@ -114,17 +116,16 @@ export function buildAgentSessionPatch(params: { (trustRequestSelectors ? params.normalizedSpawned.groupSpace : undefined), }; - const deliveryFields = normalizeSessionDeliveryFields(params.freshEntry); const effectiveDelivery = mergeDeliveryContext( - deliveryFields.deliveryContext, + deliveryContextFromSession(params.freshEntry), params.requestDeliveryHint, ); - const effectiveDeliveryFields = normalizeSessionDeliveryFields({ - route: deliveryFields.route, - deliveryContext: effectiveDelivery, + const delivery = normalizeSessionDeliveryState({ + route: sessionDeliveryRoute(params.freshEntry), + context: effectiveDelivery, + origin: sessionDeliveryOrigin(params.freshEntry), }); const labelValue = normalizeOptionalString(params.requestLabel) || params.freshEntry?.label; - const channelValue = params.freshEntry?.channel ?? params.recipientChannel?.trim(); const freshSessionRotatedSinceLoad = Boolean( params.initialEntry?.sessionId && params.freshEntry?.sessionId && @@ -218,23 +219,9 @@ export function buildAgentSessionPatch(params: { } : {}), ...automaticRecoveryClearPatch, - ...(effectiveDeliveryFields.route ? { route: effectiveDeliveryFields.route } : {}), - ...(effectiveDeliveryFields.deliveryContext - ? { deliveryContext: effectiveDeliveryFields.deliveryContext } - : {}), - ...(effectiveDeliveryFields.lastChannel - ? { lastChannel: effectiveDeliveryFields.lastChannel } - : {}), - ...(effectiveDeliveryFields.lastTo ? { lastTo: effectiveDeliveryFields.lastTo } : {}), - ...(effectiveDeliveryFields.lastAccountId - ? { lastAccountId: effectiveDeliveryFields.lastAccountId } - : {}), - ...(effectiveDeliveryFields.lastThreadId != null - ? { lastThreadId: effectiveDeliveryFields.lastThreadId } - : {}), + delivery, ...(labelValue ? { label: labelValue } : {}), ...(freshSpawnedBy ? { spawnedBy: freshSpawnedBy } : {}), - ...(channelValue ? { channel: channelValue } : {}), groupId: nextGroup.groupId, groupChannel: nextGroup.groupChannel, space: nextGroup.groupSpace, diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index 6085f86a971a..6ae0b76b6b46 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -25,6 +25,7 @@ import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-even import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { recordSessionCreated } from "../../sessions/session-state-events.js"; import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { formatForLog } from "../ws-log.js"; import { assertExpectedExistingSession, @@ -322,7 +323,7 @@ export async function persistAgentSessionPhase(params: { cfg: params.cfg, entry: merged, sessionKey: params.canonicalSessionKey, - channel: merged.channel, + channel: sessionDeliveryChannel(merged), chatType: merged.chatType, }) === "deny" ) { @@ -483,7 +484,7 @@ export async function persistAgentSessionPhase(params: { cfg: params.cfg, entry: sessionEntry, sessionKey: params.canonicalSessionKey, - channel: sessionEntry?.channel, + channel: sessionDeliveryChannel(sessionEntry), chatType: sessionEntry?.chatType, }) === "deny" ) { diff --git a/src/gateway/server-methods/agent-session-prepare.ts b/src/gateway/server-methods/agent-session-prepare.ts index a238a502fc99..3abf35048bfd 100644 --- a/src/gateway/server-methods/agent-session-prepare.ts +++ b/src/gateway/server-methods/agent-session-prepare.ts @@ -27,6 +27,7 @@ import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-m import { isRecoverableTerminalSessionStatus } from "../../config/sessions/terminal-status.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { parseCronRunScopeSuffix } from "../../sessions/session-key-utils.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { loadSessionEntry } from "../session-utils.js"; import { respondDeletedAgentSession, @@ -206,7 +207,7 @@ export function prepareAgentSession(params: { resetType: resolveSessionResetType({ sessionKey: canonicalKey }), resetOverride: resolveChannelResetConfig({ sessionCfg: cfg.session, - channel: entry?.lastChannel ?? entry?.channel ?? params.recipientChannel, + channel: sessionDeliveryChannel(entry) ?? params.recipientChannel, }), }); const lifecycleTimestamps = entry diff --git a/src/gateway/server-methods/agent-session-reset.ts b/src/gateway/server-methods/agent-session-reset.ts index f13b6e8215db..37d37c399785 100644 --- a/src/gateway/server-methods/agent-session-reset.ts +++ b/src/gateway/server-methods/agent-session-reset.ts @@ -11,6 +11,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { resolveAgentDeliveryPlanWithSessionRoute } from "../../infra/outbound/agent-delivery.js"; import { defaultRuntime } from "../../runtime.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { performGatewaySessionReset } from "../session-reset-service.js"; import { loadSessionEntry } from "../session-utils.js"; import type { TrustedSessionCreation } from "./session-creation-provenance.js"; @@ -179,7 +180,7 @@ export async function resolveBareSessionResetResult(params: { cfg: params.cfg, entry: params.sessionEntry, sessionKey: params.sessionKey, - channel: params.sessionEntry?.channel, + channel: sessionDeliveryChannel(params.sessionEntry), chatType: params.sessionEntry?.chatType, }); if (sendPolicy === "deny") { diff --git a/src/gateway/server-methods/agent.base.test-utils.ts b/src/gateway/server-methods/agent.base.test-utils.ts index 1654c3700a96..e37f68e2a8b6 100644 --- a/src/gateway/server-methods/agent.base.test-utils.ts +++ b/src/gateway/server-methods/agent.base.test-utils.ts @@ -11,6 +11,7 @@ import { runExclusiveSessionLifecycleMutation, } from "../../sessions/session-lifecycle-admission.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { getAgentTestMocks, makeContext, @@ -131,12 +132,16 @@ describe("gateway agent handler", () => { accountId: "work", threadId: "topic-42", }); - expect(persistedEntry?.deliveryContext).toEqual({ - channel: "whatsapp", - to: "user:+15551234567", - accountId: "work", - threadId: "topic-42", - }); + expect(persistedEntry?.delivery).toEqual( + normalizeSessionDeliveryState({ + context: { + channel: "whatsapp", + to: "user:+15551234567", + accountId: "work", + threadId: "topic-42", + }, + }), + ); }); it.each(["webchat", "LAST"])( diff --git a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts index dce0e9c186cd..8b6b5eeb97c7 100644 --- a/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts +++ b/src/gateway/server-methods/agent.events-and-subagents.test-utils.ts @@ -16,6 +16,7 @@ import { resetGatewayWorkAdmission } from "../../process/gateway-work-admission. import { getDetachedTaskLifecycleRuntime } from "../../tasks/detached-task-runtime.js"; import { findTaskByRunId } from "../../tasks/task-registry.js"; import { setDetachedTaskLifecycleRuntime } from "../../tasks/task-runtime.test-helpers.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { getAgentTestMocks, makeContext, @@ -958,14 +959,16 @@ describe("gateway agent handler", () => { it("keeps origin messageChannel as webchat while delivery channel uses last session channel", async () => { mockMainSessionEntry({ sessionId: "existing-session-id", - lastChannel: "telegram", - lastTo: "12345", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "12345" }, + }), }); mocks.updateSessionStore.mockImplementation(async (_path, updater) => { const store: Record = { "agent:main:main": buildExistingMainStoreEntry({ - lastChannel: "telegram", - lastTo: "12345", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "12345" }, + }), }), }; return await updater(store); diff --git a/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts b/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts index 490f848e8bad..b2a92d09ed02 100644 --- a/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts +++ b/src/gateway/server-methods/agent.reset-and-identity.test-utils.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { AVATAR_MAX_BYTES } from "../../shared/avatar-policy.js"; import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { REAL_PNG, REAL_PNG_DATA_URL, @@ -610,8 +611,9 @@ describe("gateway agent handler", () => { entry: { sessionId: key === sessionKey ? "wechat-session-id" : "main-session-id", updatedAt: Date.now(), - lastChannel: "openclaw-weixin", - lastTo: "o9cq802hhmfc@im.wechat", + delivery: normalizeSessionDeliveryState({ + context: { channel: "openclaw-weixin", to: "o9cq802hhmfc@im.wechat" }, + }), }, canonicalKey: key, })); diff --git a/src/gateway/server-methods/chat-origin-routing.ts b/src/gateway/server-methods/chat-origin-routing.ts index 510255aff7db..dccb54e689de 100644 --- a/src/gateway/server-methods/chat-origin-routing.ts +++ b/src/gateway/server-methods/chat-origin-routing.ts @@ -4,13 +4,16 @@ import { } from "../../../packages/gateway-protocol/src/client-info.js"; import { CHAT_SEND_SESSION_KEY_MAX_LENGTH } from "../../../packages/gateway-protocol/src/schema.js"; import { listAgentIds } from "../../agents/agent-scope.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { getSessionBindingService } from "../../infra/outbound/session-binding-service.js"; -import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { isPluginOwnedSessionBindingRecord } from "../../plugins/conversation-binding.js"; import { normalizeAgentId, scopeLegacySessionKeyToAgent } from "../../routing/session-key.js"; import { parseAgentSessionKey } from "../../sessions/session-key-utils.js"; -import { deliveryContextFromSession } from "../../utils/delivery-context.shared.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL, isGatewayCliClient, @@ -37,24 +40,7 @@ const CHANNEL_AGNOSTIC_SESSION_SCOPES = new Set([ ]); const CHANNEL_SCOPED_SESSION_SHAPES = new Set(["direct", "dm", "group", "channel"]); -type ChatSendDeliveryEntry = { - route?: ChannelRouteRef; - deliveryContext?: { - channel?: string; - to?: string; - accountId?: string; - threadId?: string | number; - }; - origin?: { - provider?: string; - accountId?: string; - threadId?: string | number; - }; - lastChannel?: string; - lastTo?: string; - lastAccountId?: string; - lastThreadId?: string | number; -}; +type ChatSendDeliveryEntry = Pick; type ChatSendOriginatingRoute = { originatingChannel: string; @@ -212,19 +198,13 @@ export function resolveChatSendOriginatingRoute(params: { } const sessionDeliveryContext = deliveryContextFromSession(params.entry); + const sessionOrigin = sessionDeliveryOrigin(params.entry); const routeChannelCandidate = normalizeMessageChannel( - sessionDeliveryContext?.channel ?? params.entry?.lastChannel ?? params.entry?.origin?.provider, + sessionDeliveryContext?.channel ?? sessionOrigin?.provider, ); - const routeToCandidate = sessionDeliveryContext?.to ?? params.entry?.lastTo; - const routeAccountIdCandidate = - sessionDeliveryContext?.accountId ?? - params.entry?.lastAccountId ?? - params.entry?.origin?.accountId ?? - undefined; - const routeThreadIdCandidate = - sessionDeliveryContext?.threadId ?? - params.entry?.lastThreadId ?? - params.entry?.origin?.threadId; + const routeToCandidate = sessionDeliveryContext?.to; + const routeAccountIdCandidate = sessionDeliveryContext?.accountId ?? sessionOrigin?.accountId; + const routeThreadIdCandidate = sessionDeliveryContext?.threadId ?? sessionOrigin?.threadId; if (params.sessionKey.length > CHAT_SEND_SESSION_KEY_MAX_LENGTH) { return { originatingChannel: INTERNAL_MESSAGE_CHANNEL, explicitDeliverRoute: false }; } diff --git a/src/gateway/server-methods/chat-restart-recovery.ts b/src/gateway/server-methods/chat-restart-recovery.ts index 3005b493528b..d0fa1a1424eb 100644 --- a/src/gateway/server-methods/chat-restart-recovery.ts +++ b/src/gateway/server-methods/chat-restart-recovery.ts @@ -24,6 +24,7 @@ import { findRestartRecoveryUnsafeChatAdmissionHook } from "../../plugins/restar import { isCronSessionKey, isSubagentSessionKey } from "../../routing/session-key.js"; import { isAgentHarnessSessionKey } from "../../sessions/agent-harness-session-key.js"; import { isAcpSessionKey } from "../../sessions/session-key-utils.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; import type { GatewayRecoveryRuntime } from "../server-instance-runtime.types.js"; import type { GatewayRequestContext } from "./types.js"; @@ -290,7 +291,7 @@ export function resolveRestartSafeChatAdmission(params: { now: params.now, resetOverride: resolveChannelResetConfig({ sessionCfg: params.cfg.session, - channel: params.entry?.lastChannel ?? params.entry?.channel, + channel: sessionDeliveryChannel(params.entry), }), resetType: resolveSessionResetType({ sessionKey: params.sessionKey }), sessionCfg: params.cfg.session, diff --git a/src/gateway/server-methods/chat-send-pre-admission.ts b/src/gateway/server-methods/chat-send-pre-admission.ts index d4b09f1ca5f7..66c23e491f4b 100644 --- a/src/gateway/server-methods/chat-send-pre-admission.ts +++ b/src/gateway/server-methods/chat-send-pre-admission.ts @@ -3,6 +3,7 @@ import { resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveSessionWorkStartError } from "../../config/sessions.js"; import { SESSION_ROUTING_CHANGED_ERROR_REASON } from "../../config/sessions/main-session.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { chatAbortMarkerTimestampMs } from "../server-chat-state.js"; import { PENDING_CHAT_SEND_DEDUPE_PREFIX } from "../server-shared.js"; import { loadSessionEntry } from "../session-utils.js"; @@ -72,7 +73,7 @@ export async function runChatSendPreAdmission(params: { cfg, entry, sessionKey, - channel: entry?.channel, + channel: sessionDeliveryChannel(entry), chatType: entry?.chatType, }); if (sendPolicy === "deny") { diff --git a/src/gateway/server-methods/chat.directive-tags.test.ts b/src/gateway/server-methods/chat.directive-tags.test.ts index 460c011a4a0a..8fa541af120b 100644 --- a/src/gateway/server-methods/chat.directive-tags.test.ts +++ b/src/gateway/server-methods/chat.directive-tags.test.ts @@ -31,6 +31,7 @@ import { getAgentRunContext } from "../../infra/agent-events.js"; import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js"; import { createDeferred } from "../../test-utils/deferred.js"; import { withEnvAsync } from "../../test-utils/env.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { createChatRunState } from "../server-chat-state.js"; import type { GatewayRequestContext } from "./types.js"; @@ -4321,16 +4322,14 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-origin-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:6812765697", - accountId: "default", - threadId: 42, - }, - lastChannel: "telegram", - lastTo: "telegram:6812765697", - lastAccountId: "default", - lastThreadId: 42, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:6812765697", + accountId: "default", + threadId: 42, + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4411,14 +4410,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-feishu-origin-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "feishu", - to: "ou_feishu_direct_123", - accountId: "default", - }, - lastChannel: "feishu", - lastTo: "ou_feishu_direct_123", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "feishu", to: "ou_feishu_direct_123", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4444,14 +4438,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-per-account-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:6812765697", - accountId: "account-a", - }, - lastChannel: "telegram", - lastTo: "telegram:6812765697", - lastAccountId: "account-a", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:6812765697", + accountId: "account-a", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4477,14 +4470,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-legacy-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:6812765697", - accountId: "default", - }, - lastChannel: "telegram", - lastTo: "telegram:6812765697", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:6812765697", + accountId: "default", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4510,16 +4502,14 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-legacy-thread-channel-peer-routing-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:6812765697", - accountId: "default", - threadId: "42", - }, - lastChannel: "telegram", - lastTo: "telegram:6812765697", - lastAccountId: "default", - lastThreadId: "42", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:6812765697", + accountId: "default", + threadId: "42", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4546,14 +4536,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-main-no-cross-route-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "discord", - to: "discord:1234567890", - accountId: "default", - }, - lastChannel: "discord", - lastTo: "discord:1234567890", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "discord:1234567890", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4578,14 +4563,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-main-ui-routes-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "whatsapp", - to: "whatsapp:+8613800138000", - accountId: "default", - }, - lastChannel: "whatsapp", - lastTo: "whatsapp:+8613800138000", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4617,14 +4601,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-main-ui-deliver-no-route-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "telegram", - to: "telegram:200482621", - accountId: "default", - }, - lastChannel: "telegram", - lastTo: "telegram:200482621", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "telegram:200482621", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4659,14 +4638,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.mainSessionKey = "work"; mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "whatsapp", - to: "whatsapp:+8613800138000", - accountId: "default", - }, - lastChannel: "whatsapp", - lastTo: "whatsapp:+8613800138000", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4695,16 +4673,19 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); }); - it("chat.send falls back to origin provider metadata for configured main CLI delivery inheritance", async () => { + it("chat.send inherits canonical origin-backed routing for configured main CLI sessions", async () => { await createTranscriptFixture("openclaw-chat-send-config-main-origin-provider-routes-"); mockState.mainSessionKey = "work"; mockState.finalText = "ok"; mockState.sessionEntry = { - origin: { - provider: "whatsapp", - accountId: "default", - }, - lastTo: "whatsapp:+8613800138000", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + origin: { provider: "whatsapp", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4733,17 +4714,20 @@ describe("chat directive tag stripping for non-streaming final payloads", () => }); }); - it("chat.send falls back to origin thread metadata for configured main CLI delivery inheritance", async () => { + it("chat.send inherits canonical origin-backed thread routing for configured main CLI sessions", async () => { await createTranscriptFixture("openclaw-chat-send-config-main-origin-thread-routes-"); mockState.mainSessionKey = "work"; mockState.finalText = "ok"; mockState.sessionEntry = { - origin: { - provider: "telegram", - accountId: "default", - threadId: "42", - }, - lastTo: "telegram:6812765697", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "telegram", + to: "telegram:6812765697", + accountId: "default", + threadId: "42", + }, + origin: { provider: "telegram", accountId: "default", threadId: "42" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4779,14 +4763,13 @@ describe("chat directive tag stripping for non-streaming final payloads", () => mockState.mainSessionKey = "work"; mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "whatsapp", - to: "whatsapp:+8613800138000", - accountId: "default", - }, - lastChannel: "whatsapp", - lastTo: "whatsapp:+8613800138000", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "whatsapp", + to: "whatsapp:+8613800138000", + accountId: "default", + }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4814,14 +4797,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-custom-no-cross-route-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "discord", - to: "discord:1234567890", - accountId: "default", - }, - lastChannel: "discord", - lastTo: "discord:1234567890", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "discord:1234567890", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4847,14 +4825,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-no-deliver-internal-surface-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "discord", - to: "user:1234567890", - accountId: "default", - }, - lastChannel: "discord", - lastTo: "user:1234567890", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "user:1234567890", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4879,14 +4852,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-webchat-channel-scoped-no-inherit-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "imessage", - to: "+8619800001234", - accountId: "default", - }, - lastChannel: "imessage", - lastTo: "+8619800001234", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "imessage", to: "+8619800001234", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); @@ -4922,14 +4890,9 @@ describe("chat directive tag stripping for non-streaming final payloads", () => await createTranscriptFixture("openclaw-chat-send-ui-channel-scoped-inherit-"); mockState.finalText = "ok"; mockState.sessionEntry = { - deliveryContext: { - channel: "imessage", - to: "+8619800001234", - accountId: "default", - }, - lastChannel: "imessage", - lastTo: "+8619800001234", - lastAccountId: "default", + delivery: normalizeSessionDeliveryState({ + context: { channel: "imessage", to: "+8619800001234", accountId: "default" }, + }), }; const respond = vi.fn(); const context = createChatContext(); diff --git a/src/gateway/server-methods/tools-effective.ts b/src/gateway/server-methods/tools-effective.ts index b07286ab7924..b869a0566ab2 100644 --- a/src/gateway/server-methods/tools-effective.ts +++ b/src/gateway/server-methods/tools-effective.ts @@ -18,6 +18,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { toErrorObject } from "../../infra/errors.js"; import { logDebug, logWarn } from "../../logger.js"; import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js"; +import { sessionDeliveryOrigin } from "../../utils/delivery-context.shared.js"; import { getConnectedNodePluginToolsVersion } from "../node-plugin-tool-snapshot.js"; import { applyFinalEffectiveToolPolicy, @@ -505,6 +506,7 @@ function resolveTrustedToolsEffectiveContext(params: { } const delivery = deliveryContextFromSession(loaded.entry); + const origin = sessionDeliveryOrigin(loaded.entry); const resolvedModel = resolveSessionModelRef(loaded.cfg, loaded.entry, sessionAgentId); const workspaceDir = normalizeOptionalString(loaded.entry.spawnedWorkspaceDir) ?? @@ -525,33 +527,24 @@ function resolveTrustedToolsEffectiveContext(params: { nodePluginToolsVersion, modelProvider: resolvedModel.provider, modelId: resolvedModel.model, - messageProvider: - delivery?.channel ?? - loaded.entry.lastChannel ?? - loaded.entry.channel ?? - loaded.entry.origin?.provider, - accountId: delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, + messageProvider: delivery?.channel ?? origin?.provider, + accountId: delivery?.accountId ?? origin?.accountId, currentChannelId: delivery?.to, currentThreadTs: delivery?.threadId != null ? stringifyRouteThreadId(delivery.threadId) - : loaded.entry.lastThreadId != null - ? stringifyRouteThreadId(loaded.entry.lastThreadId) - : loaded.entry.origin?.threadId != null - ? stringifyRouteThreadId(loaded.entry.origin.threadId) - : undefined, + : origin?.threadId != null + ? stringifyRouteThreadId(origin.threadId) + : undefined, groupId: loaded.entry.groupId, groupChannel: loaded.entry.groupChannel, groupSpace: loaded.entry.space, spawnedBy: normalizeOptionalString(loaded.entry.spawnedBy), replyToMode: resolveReplyToMode( loaded.cfg, - delivery?.channel ?? - loaded.entry.lastChannel ?? - loaded.entry.channel ?? - loaded.entry.origin?.provider, - delivery?.accountId ?? loaded.entry.lastAccountId ?? loaded.entry.origin?.accountId, - loaded.entry.chatType ?? loaded.entry.origin?.chatType, + delivery?.channel ?? origin?.provider, + delivery?.accountId ?? origin?.accountId, + loaded.entry.chatType ?? origin?.chatType, ), }; } diff --git a/src/gateway/server-methods/usage.ts b/src/gateway/server-methods/usage.ts index 7b38dd0d3091..d3ead271276f 100644 --- a/src/gateway/server-methods/usage.ts +++ b/src/gateway/server-methods/usage.ts @@ -57,6 +57,10 @@ import type { SessionsUsageAggregates, SessionsUsageResult, } from "../../shared/usage-types.js"; +import { + sessionDeliveryChannel, + sessionDeliveryOrigin, +} from "../../utils/delivery-context.shared.js"; import { runTasksWithConcurrency } from "../../utils/run-with-concurrency.js"; import { listGatewayAgentsBasic } from "../agent-list.js"; import { @@ -1466,8 +1470,9 @@ export const usageHandlers: GatewayRequestHandlers = { } } - const channel = merged.storeEntry?.channel ?? merged.storeEntry?.origin?.provider; - const chatType = merged.storeEntry?.chatType ?? merged.storeEntry?.origin?.chatType; + const channel = sessionDeliveryChannel(merged.storeEntry); + const chatType = + merged.storeEntry?.chatType ?? sessionDeliveryOrigin(merged.storeEntry)?.chatType; if (usage) { if (usage.messageCounts) { @@ -1598,7 +1603,7 @@ export const usageHandlers: GatewayRequestHandlers = { agentId, channel, chatType, - origin: merged.storeEntry?.origin, + origin: sessionDeliveryOrigin(merged.storeEntry), modelOverride: merged.storeEntry?.modelOverride, providerOverride: merged.storeEntry?.providerOverride, modelProvider: merged.storeEntry?.modelProvider, diff --git a/src/gateway/server-node-events.test.ts b/src/gateway/server-node-events.test.ts index 1f2781b0d675..40de52494c41 100644 --- a/src/gateway/server-node-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -4,10 +4,12 @@ import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { PROTOCOL_VERSION } from "../../packages/gateway-protocol/src/index.js"; import type { OpenClawConfig } from "../config/config.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { prepareGatewaySuspend, resumeGatewaySuspend, } from "../infra/gateway-suspend-coordinator.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import { getActiveGatewayRootWorkCount, resetGatewayWorkAdmission, @@ -46,10 +48,11 @@ const buildSessionLookup = ( updatedAt: entry.updatedAt ?? Date.now(), model: entry.model, modelProvider: entry.modelProvider, - lastChannel: entry.lastChannel, - lastTo: entry.lastTo, - lastAccountId: entry.lastAccountId, - lastThreadId: entry.lastThreadId, + delivery: normalizeLegacySessionEntryDelivery({ + ...entry, + sessionId: entry.sessionId ?? `sid-${sessionKey}`, + updatedAt: entry.updatedAt ?? Date.now(), + } as SessionEntry).delivery, label: entry.label, spawnedBy: entry.spawnedBy, parentSessionKey: entry.parentSessionKey, diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 7c4a4da99327..b1301ee71f1c 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -23,6 +23,7 @@ import { NODE_PRESENCE_ACTIVITY_EVENT, normalizeNodePresenceAliveReason, } from "../shared/node-presence.js"; +import { deliveryContextFromSession } from "../utils/delivery-context.shared.js"; import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js"; import { agentCommandFromIngress, @@ -419,10 +420,7 @@ async function touchSessionStore(params: { reasoningLevel: params.entry?.reasoningLevel, systemSent: params.entry?.systemSent, sendPolicy: params.entry?.sendPolicy, - lastChannel: params.entry?.lastChannel, - lastTo: params.entry?.lastTo, - lastAccountId: params.entry?.lastAccountId, - lastThreadId: params.entry?.lastThreadId, + delivery: params.entry?.delivery, }), }); } @@ -785,11 +783,11 @@ export const handleNodeEvent = async ( } if (deliverRequested && (!channel || !to)) { - const entryChannel = - typeof entry?.lastChannel === "string" - ? normalizeChannelId(entry.lastChannel) - : undefined; - const entryTo = normalizeOptionalString(entry?.lastTo) ?? ""; + const entryContext = deliveryContextFromSession(entry); + const entryChannel = entryContext?.channel + ? normalizeChannelId(entryContext.channel) + : undefined; + const entryTo = normalizeOptionalString(entryContext?.to) ?? ""; if (!channel && entryChannel) { channel = entryChannel; } diff --git a/src/gateway/server-restart-sentinel.test.ts b/src/gateway/server-restart-sentinel.test.ts index 0644f31ca8fc..636ac098a445 100644 --- a/src/gateway/server-restart-sentinel.test.ts +++ b/src/gateway/server-restart-sentinel.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; import type { RestartSentinelPayload } from "../infra/restart-sentinel.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; type RestartSentinel = NonNullable< Awaited> @@ -291,7 +292,8 @@ vi.mock("./session-utils.js", () => ({ loadSessionEntry: mocks.loadSessionEntry, })); -vi.mock("../utils/delivery-context.shared.js", () => ({ +vi.mock("../utils/delivery-context.shared.js", async (importOriginal) => ({ + ...(await importOriginal()), deliveryContextFromSession: mocks.deliveryContextFromSession, mergeDeliveryContext: mocks.mergeDeliveryContext, normalizeSessionDeliveryFields: mocks.normalizeSessionDeliveryFields, @@ -2614,7 +2616,10 @@ describe("scheduleRestartSentinelWake", () => { entry: { sessionId: "agent:main:group", updatedAt: 0, - origin: { provider: "telegram", chatType: "group" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram" }, + origin: { provider: "telegram", chatType: "group" }, + }), }, store: {}, storePath: "/tmp/sessions.json", @@ -2659,7 +2664,10 @@ describe("scheduleRestartSentinelWake", () => { entry: { sessionId: "agent:main:telegram:group:-1003826723328:topic:13757", updatedAt: 0, - origin: { provider: "telegram", chatType: "group" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram" }, + origin: { provider: "telegram", chatType: "group" }, + }), }, store: {}, storePath: "/tmp/sessions.json", @@ -3294,7 +3302,10 @@ describe("scheduleRestartSentinelWake", () => { entry: { sessionId: "agent:main:matrix:channel:!lowercased:example.org:thread:$thread-event", updatedAt: 0, - origin: { provider: "matrix", accountId: "acct-thread", threadId: "$thread-event" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "matrix", accountId: "acct-thread", threadId: "$thread-event" }, + origin: { provider: "matrix", accountId: "acct-thread", threadId: "$thread-event" }, + }), }, store: {}, storePath: "/tmp/sessions.json", @@ -3307,8 +3318,9 @@ describe("scheduleRestartSentinelWake", () => { entry: { sessionId: "agent:main:matrix:channel:!lowercased:example.org", updatedAt: 0, - lastChannel: "matrix", - lastTo: "room:!MixedCase:example.org", + delivery: normalizeSessionDeliveryState({ + context: { channel: "matrix", to: "room:!MixedCase:example.org" }, + }), }, store: {}, storePath: "/tmp/sessions.json", diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index ac4b5f668c94..f302c0c55d74 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -47,6 +47,7 @@ import { removeCronRunContinuationSessionIfIdle } from "../tasks/cron-run-contin import { deliveryContextFromSession, mergeDeliveryContext, + sessionDeliveryOrigin, } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel.js"; import { deliverQueuedGeneratedMediaAgentTurn } from "./server-restart-sentinel-agent-delivery.js"; @@ -506,14 +507,17 @@ async function loadRestartSentinelStartupTask(params: { const sentinelContext = payload.deliveryContext; let sessionDeliveryContext = deliveryContextFromSession(entry); - let chatType = entry?.origin?.chatType ?? "direct"; + let chatType = sessionDeliveryOrigin(entry)?.chatType ?? "direct"; if ( !hasRoutableDeliveryContext(sessionDeliveryContext) && baseSessionKey && baseSessionKey !== sessionKey ) { const { entry: baseEntry } = loadSessionEntry(baseSessionKey); - chatType = entry?.origin?.chatType ?? baseEntry?.origin?.chatType ?? "direct"; + chatType = + sessionDeliveryOrigin(entry)?.chatType ?? + sessionDeliveryOrigin(baseEntry)?.chatType ?? + "direct"; sessionDeliveryContext = mergeDeliveryContext( sessionDeliveryContext, deliveryContextFromSession(baseEntry), diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.test.ts index c2dd8bd96f6e..342c9bfacd68 100644 --- a/src/gateway/server.agent.gateway-server-agent-b.test.ts +++ b/src/gateway/server.agent.gateway-server-agent-b.test.ts @@ -12,6 +12,7 @@ import { createChannelTestPluginBase, createDirectOutboundTestAdapter, } from "../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { readAgentCommandCall } from "./agent-command.test-helpers.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; @@ -140,8 +141,9 @@ async function writeMainSessionEntry(params: { main: { sessionId: params.sessionId, updatedAt: Date.now(), - lastChannel: params.lastChannel, - lastTo: params.lastTo, + delivery: normalizeSessionDeliveryState({ + context: { channel: params.lastChannel, to: params.lastTo }, + }), }, }, }); @@ -369,50 +371,6 @@ describe("gateway server agent", () => { expect(res.error?.code).toBe("INVALID_REQUEST"); }); - test("agent errors when deliver=true and last channel is webchat", async () => { - testState.allowFrom = ["+1555"]; - await writeMainSessionEntry({ - sessionId: "sess-main-webchat", - lastChannel: "webchat", - lastTo: "+1555", - }); - const res = await rpcReq(ws, "agent", { - message: "hi", - sessionKey: "main", - channel: "last", - deliver: true, - bestEffortDeliver: false, - idempotencyKey: "idem-agent-webchat", - }); - expect(res.ok).toBe(false); - expect(res.error?.code).toBe("INVALID_REQUEST"); - expect(res.error?.message).toMatch(/Channel is required|runtime not initialized/); - expect(vi.mocked(agentCommand)).not.toHaveBeenCalled(); - }); - - test("agent downgrades to session-only delivery when best-effort is enabled and last channel is webchat", async () => { - testState.allowFrom = ["+1555"]; - await writeMainSessionEntry({ - sessionId: "sess-main-webchat-best-effort", - lastChannel: "webchat", - lastTo: "+1555", - }); - const res = await rpcReq(ws, "agent", { - message: "hi", - sessionKey: "main", - channel: "last", - deliver: true, - bestEffortDeliver: true, - idempotencyKey: "idem-agent-webchat-best-effort", - }); - expect(res.ok).toBe(true); - await expectAgentRoutingCall({ - channel: "webchat", - deliver: false, - runId: "idem-agent-webchat-best-effort", - }); - }); - test("agent downgrades to session-only when multiple channels are configured but no external target resolves", async () => { const registry = createRegistry([ { @@ -445,28 +403,6 @@ describe("gateway server agent", () => { }); }); - test("agent uses webchat for internal runs when last provider is webchat", async () => { - await writeMainSessionEntry({ - sessionId: "sess-main-webchat-internal", - lastChannel: "webchat", - lastTo: "+1555", - }); - const res = await rpcReq(ws, "agent", { - message: "hi", - sessionKey: "main", - channel: "last", - deliver: false, - idempotencyKey: "idem-agent-webchat-internal", - }); - expect(res.ok).toBe(true); - - await expectAgentRoutingCall({ - channel: "webchat", - deliver: false, - runId: "idem-agent-webchat-internal", - }); - }); - test("write-scoped callers cannot reset conversations via agent", async () => { await withGatewayServer(async ({ port: portValue }) => { await useTempSessionStorePath(); diff --git a/src/gateway/server.agent.subagent-delivery-context.test.ts b/src/gateway/server.agent.subagent-delivery-context.test.ts index ab3a82ca2d5f..2e6373517fef 100644 --- a/src/gateway/server.agent.subagent-delivery-context.test.ts +++ b/src/gateway/server.agent.subagent-delivery-context.test.ts @@ -3,10 +3,12 @@ import { describe, expect, test } from "vitest"; import type { ChannelPlugin } from "../channels/plugins/types.public.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { createChannelTestPluginBase, createDirectOutboundTestAdapter, } from "../test-utils/channel-plugins.js"; +import { projectSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { setRegistry } from "./server.agent.gateway-server-agent.mocks.js"; import { createRegistry } from "./server.e2e-registry-helpers.js"; import { installConnectedSessionStoreGatewaySuite } from "./test-helpers.connected-session-store.js"; @@ -44,19 +46,7 @@ const defaultRegistry = createRegistry([ }, ]); -type StoredEntry = { - route?: { - channel?: string; - accountId?: string; - target?: { to?: string; rawTo?: string; chatType?: string }; - thread?: { id?: string | number; kind?: string; source?: string }; - }; - deliveryContext?: { channel?: string; to?: string; threadId?: string; accountId?: string }; - lastChannel?: string; - lastTo?: string; - lastThreadId?: string | number; - lastAccountId?: string; -}; +type StoredEntry = SessionEntry & ReturnType; type StoreEntries = Parameters[0]["entries"]; @@ -80,7 +70,7 @@ async function readStoredSessionEntry(key: string): Promise { if (!entry) { throw new Error(`expected stored entry ${key}`); } - return entry; + return { ...entry, ...projectSessionDeliveryFields(entry.delivery) }; } async function sendAgentRequest(params: Record): Promise { diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts index efbaca48f5c6..b86b4e39d820 100644 --- a/src/gateway/server.sessions.list-changed.test.ts +++ b/src/gateway/server.sessions.list-changed.test.ts @@ -16,6 +16,10 @@ import { setActivePluginRegistry, } from "../plugins/runtime.js"; import { createPluginRecord } from "../plugins/status.test-fixtures.js"; +import { + normalizeSessionDeliveryState, + projectSessionDeliveryFields, +} from "../utils/delivery-context.shared.js"; import { buildGatewaySessionRow } from "./session-utils.js"; import { embeddedRunMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { @@ -804,16 +808,15 @@ test("sessions.changed mutation events include live usage metadata", async () => }); test("sessions.changed mutation events include live session setting metadata", async () => { + const delivery = normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-100123", accountId: "acct-1", threadId: 42 }, + }); const sessionSettings = { verboseLevel: "on", responseUsage: "full", fastMode: true, - lastChannel: "telegram", - lastTo: "-100123", - lastAccountId: "acct-1", - lastThreadId: 42, } satisfies SessionStoreEntryOptions; - await writeMainSessionStore(sessionSettings); + await writeMainSessionStore({ ...sessionSettings, delivery }); const result = await invokeSessionsPatch({ key: "main", @@ -822,6 +825,11 @@ test("sessions.changed mutation events include live session setting metadata", a expectMainPatchBroadcast(result, { ...sessionSettings, + deliveryContext: projectSessionDeliveryFields(delivery).deliveryContext, + lastChannel: projectSessionDeliveryFields(delivery).lastChannel, + lastTo: projectSessionDeliveryFields(delivery).lastTo, + lastAccountId: projectSessionDeliveryFields(delivery).lastAccountId, + lastThreadId: projectSessionDeliveryFields(delivery).lastThreadId, // An explicit session override resolves to the same effective mode and the // sessions.changed builder carries the row-built channel-aware value. effectiveResponseUsage: "full", diff --git a/src/gateway/server.sessions.reset-models.test.ts b/src/gateway/server.sessions.reset-models.test.ts index c114f444dfbf..4af56a086b51 100644 --- a/src/gateway/server.sessions.reset-models.test.ts +++ b/src/gateway/server.sessions.reset-models.test.ts @@ -6,8 +6,10 @@ import path from "node:path"; import { expect, test } from "vitest"; import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/session-accessor.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js"; import { listSessionStateEventsSince } from "../sessions/session-state-events.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { testState, writeSessionStore } from "./test-helpers.js"; import { setupGatewaySessionsTestHarness, @@ -21,7 +23,7 @@ type ResetSessionEntry = { sessionId?: string; sessionFile?: string; chatType?: string; - channel?: string; + delivery?: SessionEntry["delivery"]; groupId?: string; subject?: string; groupChannel?: string; @@ -75,12 +77,6 @@ type ResetSessionEntry = { >; cliSessionIds?: Record; claudeCliSessionId?: string; - deliveryContext?: { - channel?: string; - to?: string; - accountId?: string; - threadId?: string; - }; label?: string; }; @@ -122,7 +118,15 @@ test("sessions.reset stamps provenance when it materializes a missing row", asyn const ownedChildMetadata = { chatType: "group", - channel: "discord", + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "discord:child", + accountId: "acct-1", + threadId: "thread-1", + }, + origin: { provider: "discord", chatType: "group" }, + }), groupId: "group-1", subject: "Ops Thread", groupChannel: "dev", @@ -166,12 +170,6 @@ const ownedChildMetadata = { }, }, claudeCliSessionId: "cli-session-123", - deliveryContext: { - channel: "discord", - to: "discord:child", - accountId: "acct-1", - threadId: "thread-1", - }, label: "owned child", } satisfies SessionEntryOverrides & ResetSessionEntry; diff --git a/src/gateway/server.sessions.store-rpc.test.ts b/src/gateway/server.sessions.store-rpc.test.ts index b4348807aa84..7a0a7f8da531 100644 --- a/src/gateway/server.sessions.store-rpc.test.ts +++ b/src/gateway/server.sessions.store-rpc.test.ts @@ -11,6 +11,7 @@ import { persistSessionTranscriptTurn, } from "../config/sessions/session-accessor.js"; import type { CronJob } from "../cron/types.js"; +import { deliveryContextFromSession } from "../utils/delivery-context.shared.js"; import { agentDiscoveryMock, rpcReq, testState, writeSessionStore } from "./test-helpers.js"; import { directSessionReq as directSessionHandlerReq, @@ -569,8 +570,7 @@ test("lists and patches session store via sessions.* RPC", async () => { sessionId: string; modelProvider?: string; model?: string; - lastAccountId?: string; - lastThreadId?: string | number; + delivery?: import("../config/sessions/types.js").SessionDeliveryState; }; }>("sessions.reset", { key: "agent:main:main" }); expect(reset.ok).toBe(true); @@ -578,11 +578,11 @@ test("lists and patches session store via sessions.* RPC", async () => { expect(reset.payload?.entry.sessionId).toBe("sess-main"); expect(reset.payload?.entry.modelProvider).toBe("openai"); expect(reset.payload?.entry.model).toBe("gpt-test-a"); - expect(reset.payload?.entry.lastAccountId).toBe("work"); - expect(reset.payload?.entry.lastThreadId).toBe("1737500000.123456"); + expect(deliveryContextFromSession(reset.payload?.entry)?.accountId).toBe("work"); + expect(deliveryContextFromSession(reset.payload?.entry)?.threadId).toBe("1737500000.123456"); const entryAfterReset = loadSessionEntry({ sessionKey: "agent:main:main", storePath }); - expect(entryAfterReset?.lastAccountId).toBe("work"); - expect(entryAfterReset?.lastThreadId).toBe("1737500000.123456"); + expect(deliveryContextFromSession(entryAfterReset)?.accountId).toBe("work"); + expect(deliveryContextFromSession(entryAfterReset)?.threadId).toBe("1737500000.123456"); // Retained history stays in the same SQLite transcript behind the reset boundary. const resetTranscript = await loadTranscriptRows({ sessionId: "sess-main", diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 23a5e7928fe2..bec24b522340 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -32,7 +32,6 @@ import { cleanupBrowserSessionsForLifecycleEnd } from "../browser-lifecycle-clea import { getRuntimeConfig } from "../config/io.js"; import { resolveSessionWorkStartError, - snapshotSessionOrigin, type SessionEntry, deleteSessionEntryLifecycle, resetSessionEntryLifecycle, @@ -1409,20 +1408,14 @@ export async function performGatewaySessionReset(params: { subagentControlScope: currentEntry?.subagentControlScope, label: currentEntry?.label, displayName: currentEntry?.displayName, - channel: currentEntry?.channel, + delivery: currentEntry?.delivery, groupId: currentEntry?.groupId, subject: currentEntry?.subject, groupChannel: currentEntry?.groupChannel, space: currentEntry?.space, - origin: snapshotSessionOrigin(currentEntry), - deliveryContext: currentEntry?.deliveryContext, cliSessionBindings: currentEntry?.cliSessionBindings, cliSessionIds: currentEntry?.cliSessionIds, claudeCliSessionId: currentEntry?.claudeCliSessionId, - lastChannel: currentEntry?.lastChannel, - lastTo: currentEntry?.lastTo, - lastAccountId: currentEntry?.lastAccountId, - lastThreadId: currentEntry?.lastThreadId, usageFamilyKey: currentEntry?.usageFamilyKey, usageFamilySessionIds: currentEntry?.usageFamilySessionIds, // Do not carry the cached skills catalog across /new. Long-lived channel diff --git a/src/gateway/session-utils-row.ts b/src/gateway/session-utils-row.ts index bdf648cb4a18..1560249d7f18 100644 --- a/src/gateway/session-utils-row.ts +++ b/src/gateway/session-utils-row.ts @@ -28,7 +28,7 @@ import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.j import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js"; import { resolveNonNegativeNumber } from "../shared/number-coercion.js"; import { getUserProfileListItem } from "../state/user-profiles.js"; -import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js"; +import { projectSessionDeliveryFields } from "../utils/delivery-context.shared.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js"; import { sessionHasAutomation } from "./session-automation-index.js"; import { resolveStoredSessionKeyForAgentStore } from "./session-store-key.js"; @@ -117,12 +117,13 @@ export function buildGatewaySessionRow(params: { : undefined; const updatedAt = entry?.updatedAt ?? null; const parsed = parseGroupKey(key); - const channel = entry?.channel ?? parsed?.channel; + const deliveryFields = projectSessionDeliveryFields(entry?.delivery); + const channel = deliveryFields.channel ?? parsed?.channel; const subject = entry?.subject; const groupChannel = entry?.groupChannel; const space = entry?.space; const id = parsed?.id; - const origin = entry?.origin; + const origin = deliveryFields.origin; const originLabel = origin?.label; const parsedAgent = parseAgentSessionKey(key); const isDashboardSession = parsedAgent?.rest.startsWith("dashboard:") === true; @@ -148,7 +149,6 @@ export function buildGatewaySessionRow(params: { // Dashboard origin labels identify the authenticated sender. Using them as // titles leaks account names into the sidebar while the generated title is pending. (isDashboardSession ? undefined : originLabel); - const deliveryFields = normalizeSessionDeliveryFields(entry); const sessionAgentId = normalizeAgentId( parsedAgent?.agentId ?? params.agentId ?? resolveDefaultAgentId(cfg), ); @@ -498,10 +498,10 @@ export function buildGatewaySessionRow(params: { contextTokens, contextBudgetStatus: entry?.contextBudgetStatus, deliveryContext: deliveryFields.deliveryContext, - lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel, - lastTo: deliveryFields.lastTo ?? entry?.lastTo, - lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId, - lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId, + lastChannel: deliveryFields.lastChannel, + lastTo: deliveryFields.lastTo, + lastAccountId: deliveryFields.lastAccountId, + lastThreadId: deliveryFields.lastThreadId, compactionCheckpointCount, latestCompactionCheckpoint, pluginExtensions: pluginExtensions.length > 0 ? pluginExtensions : undefined, diff --git a/src/gateway/session-utils-search.ts b/src/gateway/session-utils-search.ts index acf4c5e3ca00..896d3f2e2505 100644 --- a/src/gateway/session-utils-search.ts +++ b/src/gateway/session-utils-search.ts @@ -10,6 +10,7 @@ import { getSessionDisplaySubagentRunByChildSessionKey } from "../agents/subagen import { buildGroupDisplayName, type SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { sessionDeliveryChannel, sessionDeliveryOrigin } from "../utils/delivery-context.shared.js"; import type { SessionListRowContext, SessionListRowContextProvider, @@ -35,7 +36,7 @@ export function resolveSessionListSearchDisplayName( return entry.displayName; } const parsed = parseGroupKey(key); - const channel = entry?.channel ?? parsed?.channel; + const channel = sessionDeliveryChannel(entry) ?? parsed?.channel; if (isGroupOrChannelDisplaySession(entry, parsed) && channel) { return buildGroupDisplayName({ provider: channel, @@ -46,7 +47,7 @@ export function resolveSessionListSearchDisplayName( key, }); } - return entry?.label ?? entry?.origin?.label; + return entry?.label ?? sessionDeliveryOrigin(entry)?.label; } function addSessionListSearchModelFields( diff --git a/src/gateway/session-utils.telegram-recreate.test.ts b/src/gateway/session-utils.telegram-recreate.test.ts index c7f487d6c3a1..4db7506a5a5f 100644 --- a/src/gateway/session-utils.telegram-recreate.test.ts +++ b/src/gateway/session-utils.telegram-recreate.test.ts @@ -14,6 +14,10 @@ import { } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../utils/delivery-context.shared.js"; import { listSessionsFromStore } from "./session-utils.js"; const TELEGRAM_DIRECT_KEY = "agent:main:telegram:direct:7463849194"; @@ -69,7 +73,7 @@ describe("Telegram direct session recreation after delete", () => { sessionId: "old-session", updatedAt: 1_700_000_000_000, chatType: "direct", - channel: "telegram", + delivery: { kind: "none" }, }, ); await deleteSessionEntryLifecycle({ @@ -110,10 +114,10 @@ describe("Telegram direct session recreation after delete", () => { opts: {}, }); - expect(entry?.lastChannel).toBe("telegram"); - expect(entry?.lastTo).toBe("telegram:7463849194"); - expect(entry?.origin?.chatType).toBe("direct"); - expect(entry?.origin?.provider).toBe("telegram"); + expect(deliveryContextFromSession(entry)?.channel).toBe("telegram"); + expect(deliveryContextFromSession(entry)?.to).toBe("telegram:7463849194"); + expect(sessionDeliveryOrigin(entry)?.chatType).toBe("direct"); + expect(sessionDeliveryOrigin(entry)?.provider).toBe("telegram"); expect(listed.sessions.map((session) => session.key)).toContain(TELEGRAM_DIRECT_KEY); }); }); diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index 8d893f6cd231..94587a4ccc8f 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -19,6 +19,7 @@ import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plug import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { withStateDirEnv as withRawStateDirEnv } from "../test-helpers/state-dir-env.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { registerSessionAutomationSource } from "./session-automation-index.js"; import { buildGatewaySessionEventFields } from "./session-event-payload.js"; import { capArrayByJsonBytes } from "./session-transcript-readers.js"; @@ -378,7 +379,7 @@ describe("gateway session utils", () => { test("session lists separate archived rows and sort pinned sessions first", () => { const cfg = createModelDefaultsConfig({ primary: "openai/gpt-5.4" }); - const store = { + const store: Record = { recent: { sessionId: "recent", updatedAt: 30 }, pinned: { sessionId: "pinned", updatedAt: 10, pinnedAt: 40, icon: "name:spark" }, archived: { sessionId: "archived", updatedAt: 20, archivedAt: 50 }, @@ -443,19 +444,25 @@ describe("gateway session utils", () => { test("session list search includes direct-session origin display labels", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const store = { + const store: Record = { "agent:main:telegram:direct:42": { + sessionId: "direct-42", chatType: "direct", - channel: "telegram", - origin: { label: "openclaw-tui" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "42" }, + origin: { label: "openclaw-tui" }, + }), updatedAt: 2, - } as SessionEntry, + }, "agent:main:telegram:direct:99": { + sessionId: "direct-99", chatType: "direct", - channel: "telegram", - origin: { label: "other-direct" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "99" }, + origin: { label: "other-direct" }, + }), updatedAt: 1, - } as SessionEntry, + }, }; const listed = listSessionsFromStore({ @@ -1209,11 +1216,15 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow displayName falls through to origin label for direct sessions", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const entry = { + const entry: SessionEntry = { + sessionId: "direct-42", + updatedAt: 1, chatType: "direct", - channel: "telegram", - origin: { label: "openclaw-tui" }, - } as SessionEntry; + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "42" }, + origin: { label: "openclaw-tui" }, + }), + }; const row = buildGatewaySessionRow({ cfg, storePath: "", @@ -1226,11 +1237,12 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow keeps dashboard sender identity out of the session title", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const entry = { + const entry: SessionEntry = { + sessionId: "dashboard-1", + updatedAt: 1, chatType: "direct", - channel: "webchat", - origin: { label: "Peter", provider: "webchat", chatType: "direct" }, - } as SessionEntry; + delivery: { kind: "internal" as const }, + }; const row = buildGatewaySessionRow({ cfg, storePath: "", @@ -1253,12 +1265,16 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow displayName prefers the human chat title for group sessions", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const entry = { + const entry: SessionEntry = { + sessionId: "group-99", + updatedAt: 1, chatType: "group", - channel: "telegram", subject: "Engineering", - origin: { label: "openclaw-tui" }, - } as SessionEntry; + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "group:99" }, + origin: { label: "openclaw-tui" }, + }), + }; const row = buildGatewaySessionRow({ cfg, storePath: "", @@ -1271,12 +1287,14 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow group displayName prefers #channel and falls back to the token", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const channelEntry = { + const channelEntry: SessionEntry = { + sessionId: "channel-C1", + updatedAt: 1, chatType: "channel", - channel: "slack", + delivery: normalizeSessionDeliveryState({ context: { channel: "slack", to: "channel:C1" } }), groupChannel: "general", space: "Acme", - } as SessionEntry; + }; const channelRow = buildGatewaySessionRow({ cfg, storePath: "", @@ -1296,7 +1314,12 @@ describe("gateway session utils", () => { }); expect(labeledRow.displayName).toBe("Team room"); - const opaque = { chatType: "group", channel: "telegram" } as SessionEntry; + const opaque: SessionEntry = { + sessionId: "group-opaque", + updatedAt: 1, + chatType: "group", + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram", to: "group:99" } }), + }; const opaqueRow = buildGatewaySessionRow({ cfg, storePath: "", @@ -1309,7 +1332,7 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow projects worktree and execNode bindings", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const entry = { + const entry: SessionEntry = { sessionId: "s1", updatedAt: 1, spawnedCwd: "/state/worktrees/abc/wt-1234", @@ -1331,12 +1354,16 @@ describe("gateway session utils", () => { test("buildGatewaySessionRow prefers entry.label over origin.label for direct sessions", () => { const cfg = { agents: { list: [{ id: "main", default: true }] } } as OpenClawConfig; - const entry = { + const entry: SessionEntry = { + sessionId: "direct-labeled", + updatedAt: 1, chatType: "direct", - channel: "telegram", label: "Alice", - origin: { label: "openclaw-tui" }, - } as SessionEntry; + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "42" }, + origin: { label: "openclaw-tui" }, + }), + }; const row = buildGatewaySessionRow({ cfg, storePath: "", @@ -1372,7 +1399,11 @@ describe("gateway session utils", () => { responseUsage: { default: "off", discord: "full", telegram: "tokens" }, }, } as OpenClawConfig; - const discordEntry = { sessionId: "d1", updatedAt: 1, channel: "discord" } as SessionEntry; + const discordEntry: SessionEntry = { + sessionId: "d1", + updatedAt: 1, + delivery: normalizeSessionDeliveryState({ context: { channel: "discord" } }), + }; const discordRow = buildGatewaySessionRow({ cfg, storePath: "", @@ -1382,7 +1413,11 @@ describe("gateway session utils", () => { }); expect(discordRow.effectiveResponseUsage).toBe("full"); - const telegramEntry = { sessionId: "t1", updatedAt: 1, channel: "telegram" } as SessionEntry; + const telegramEntry: SessionEntry = { + sessionId: "t1", + updatedAt: 1, + delivery: normalizeSessionDeliveryState({ context: { channel: "telegram" } }), + }; const telegramRow = buildGatewaySessionRow({ cfg, storePath: "", @@ -1393,7 +1428,11 @@ describe("gateway session utils", () => { expect(telegramRow.effectiveResponseUsage).toBe("tokens"); // A channel with no entry falls back to the config "default" (off). - const slackEntry = { sessionId: "x1", updatedAt: 1, channel: "slack" } as SessionEntry; + const slackEntry: SessionEntry = { + sessionId: "x1", + updatedAt: 1, + delivery: normalizeSessionDeliveryState({ context: { channel: "slack" } }), + }; const slackRow = buildGatewaySessionRow({ cfg, storePath: "", @@ -1886,7 +1925,11 @@ describe("gateway session utils", () => { const loaded = loadSessionEntry("agent:main:main", { clone: false }); - expect(loaded.entry).toEqual({ sessionId: "sess-main", updatedAt: 7 }); + expect(loaded.entry).toEqual({ + sessionId: "sess-main", + updatedAt: 7, + delivery: { kind: "none" }, + }); }); } finally { resetConfigRuntimeState(); diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index ec10b4f1266c..1df9d54f7d6d 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -15,6 +15,7 @@ import type { SessionCompactionCheckpoint, SessionEntry, SessionGoal, + SessionOrigin, } from "../config/sessions/types.js"; import type { PluginSessionExtensionProjection } from "../plugins/host-hooks.js"; import type { FastModeSource } from "../shared/fast-mode.js"; @@ -90,7 +91,7 @@ export type GatewaySessionRow = { groupChannel?: string; space?: string; chatType?: ChatType; - origin?: SessionEntry["origin"]; + origin?: SessionOrigin; updatedAt: number | null; archived?: boolean; archivedAt?: number; @@ -161,10 +162,10 @@ export type GatewaySessionRow = { contextTokens?: number; contextBudgetStatus?: SessionEntry["contextBudgetStatus"]; deliveryContext?: DeliveryContext; - lastChannel?: SessionEntry["lastChannel"]; + lastChannel?: string; lastTo?: string; lastAccountId?: string; - lastThreadId?: SessionEntry["lastThreadId"]; + lastThreadId?: string | number; compactionCheckpointCount?: number; latestCompactionCheckpoint?: SessionCompactionCheckpointPreview; pluginExtensions?: PluginSessionExtensionProjection[]; diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index e14379cca2c0..83fcc64913c9 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -17,6 +17,7 @@ import { } from "../config/sessions/session-accessor.js"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; import { clearSessionStoreCacheForTest } from "../config/sessions/store-writer-state.js"; +import type { SessionOrigin } from "../config/sessions/types.js"; import { resetAgentEventsForTest } from "../infra/agent-events.js"; import { loadOrCreateDeviceIdentity, @@ -34,9 +35,11 @@ import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck, } from "../infra/restart.js"; +import { normalizeLegacySessionEntryDelivery } from "../infra/state-migrations.legacy-session-store.js"; import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js"; import { rawDataToString } from "../infra/ws.js"; import { resetLogger, setLoggerOverride } from "../logging.js"; +import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; import { clearGatewaySubagentRuntime } from "../plugins/runtime/gateway-bindings.test-fixtures.js"; import { resetGatewayWorkAdmission } from "../process/gateway-work-admission.js"; import { @@ -53,6 +56,7 @@ import { } from "../tasks/task-runtime.test-helpers.js"; import { captureEnv } from "../test-utils/env.js"; import { getDeterministicFreePortBlock } from "../test-utils/ports.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { buildDeviceAuthPayloadV3 } from "./device-auth.js"; import type { GatewayServerOptions } from "./server.js"; @@ -205,7 +209,19 @@ async function persistTestSessionConfig(): Promise { } export async function writeSessionStore(params: { - entries: Record>; + entries: Record< + string, + Partial & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; + } + >; storePath?: string; agentId?: string; mainKey?: string; @@ -232,12 +248,15 @@ export async function writeSessionStore(params: { mainKey: params.mainKey, }); const upserts = upsertsByAgentId.get(agentId) ?? []; + const canonicalEntry = normalizeLegacySessionEntryDelivery({ + ...entry, + sessionId: entry.sessionId, + updatedAt: entry.updatedAt ?? 0, + } as SessionEntry); upserts.push({ sessionKey: storeKey, entry: { - ...entry, - sessionId: entry.sessionId, - updatedAt: entry.updatedAt ?? 0, + ...canonicalEntry, sessionFile: formatSqliteSessionFileMarker({ agentId, sessionId: entry.sessionId, diff --git a/src/infra/approval-request-account-binding.ts b/src/infra/approval-request-account-binding.ts index e567e64ce6b7..7c104c39948d 100644 --- a/src/infra/approval-request-account-binding.ts +++ b/src/infra/approval-request-account-binding.ts @@ -6,6 +6,10 @@ import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { normalizeOptionalAccountId } from "../routing/account-id.js"; import { parseAgentSessionKey } from "../routing/session-key.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../utils/delivery-context.shared.js"; import { normalizeMessageChannel } from "../utils/message-channel.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; @@ -58,8 +62,10 @@ function resolvePersistedApprovalRequestSessionBinding(params: { return null; } const { entry } = persisted; - const channel = normalizeOptionalChannel(entry.origin?.provider ?? entry.lastChannel); - const accountId = normalizeOptionalAccountId(entry.origin?.accountId ?? entry.lastAccountId); + const origin = sessionDeliveryOrigin(entry); + const context = deliveryContextFromSession(entry); + const channel = normalizeOptionalChannel(context?.channel ?? origin?.provider); + const accountId = normalizeOptionalAccountId(context?.accountId ?? origin?.accountId); return channel || accountId ? { channel, accountId } : null; } diff --git a/src/infra/exec-approval-session-target.test.ts b/src/infra/exec-approval-session-target.test.ts index b2926146e87f..8e84c4e726b6 100644 --- a/src/infra/exec-approval-session-target.test.ts +++ b/src/infra/exec-approval-session-target.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry } from "../config/sessions.js"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; +import type { SessionOrigin } from "../config/sessions/types.js"; import { parseRawSessionConversationRef, parseThreadSessionSuffix, @@ -22,6 +23,7 @@ import { } from "./exec-approval-session-target.js"; import type { ExecApprovalRequest } from "./exec-approvals.js"; import type { PluginApprovalRequest } from "./plugin-approvals.js"; +import { normalizeLegacySessionEntryDelivery } from "./state-migrations.legacy-session-store.js"; vi.mock("../channels/plugins/session-conversation.js", () => ({ resolveSessionConversationRef(sessionKey: string | undefined | null) { @@ -63,9 +65,17 @@ const baseRequest: ExecApprovalRequest = { expiresAtMs: 6000, }; +type SessionEntryFixture = Partial & { + origin?: SessionOrigin; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + async function writeStoreFile( storePath: string, - entries: Record>, + entries: Record, ): Promise { fs.mkdirSync(path.dirname(storePath), { recursive: true }); await Promise.all( @@ -75,11 +85,11 @@ async function writeStoreFile( storePath, sessionKey, }, - { + normalizeLegacySessionEntryDelivery({ sessionId: entry.sessionId ?? sessionKey, updatedAt: entry.updatedAt ?? Date.now(), ...entry, - } as SessionEntry, + } as SessionEntry), ), ), ); @@ -145,7 +155,7 @@ describe("exec approval session target", () => { type PlaceholderStoreCase = { name: string; relativeStoreDir: string; - entries: Record>; + entries: Record; request: ExecApprovalRequest; expected: ReturnType; }; diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index ad38ce3ba274..648dc6b3e47a 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -21,6 +21,7 @@ import { createTestRegistry, } from "../test-utils/channel-plugins.js"; import { typedCases } from "../test-utils/typed-cases.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { type HeartbeatDeps, isHeartbeatEnabledForAgent, @@ -454,15 +455,16 @@ describe("resolveHeartbeatDeliveryTarget", () => { sessionId: "sid", updatedAt: Date.now(), }; + const entryWithDelivery = (channel: string, to: string) => ({ + ...baseEntry, + delivery: normalizeSessionDeliveryState({ context: { channel, to } }), + }); it("resolves target variants across route and allowlist rules", () => { const cases: Array<{ name: string; cfg: OpenClawConfig; - entry: typeof baseEntry & { - lastChannel?: "whatsapp" | "telegram" | "webchat"; - lastTo?: string; - }; + entry: typeof baseEntry & { delivery?: ReturnType }; expected: ReturnType; }> = [ { @@ -480,7 +482,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "target defaults to none when unset", cfg: {}, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "120363401234567890@g.us" }, + entry: entryWithDelivery("whatsapp", "120363401234567890@g.us"), expected: { channel: "none", reason: "target-none", @@ -509,7 +511,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "skip webchat last route", cfg: {}, - entry: { ...baseEntry, lastChannel: "webchat", lastTo: "web" }, + entry: entryWithDelivery("webchat", "web"), expected: { channel: "none", reason: "target-none", @@ -524,7 +526,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agents: { defaults: { heartbeat: { target: "whatsapp", to: "+1999" } } }, channels: { whatsapp: { allowFrom: ["120363401234567890@g.us", "+1666"] } }, }, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1222" }, + entry: entryWithDelivery("whatsapp", "+1222"), expected: { channel: "none", reason: "no-target", @@ -539,11 +541,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { agents: { defaults: { heartbeat: { target: "last" } } }, channels: { whatsapp: { allowFrom: ["120363401234567890@g.us"] } }, }, - entry: { - ...baseEntry, - lastChannel: "whatsapp", - lastTo: "whatsapp:120363401234567890@G.US", - }, + entry: entryWithDelivery("whatsapp", "whatsapp:120363401234567890@G.US"), expected: { channel: "whatsapp", to: "120363401234567890@g.us", @@ -581,7 +579,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "allow direct target by default", cfg: { agents: { defaults: { heartbeat: { target: "last" } } } }, - entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + entry: entryWithDelivery("telegram", "5232990709"), expected: { channel: "telegram", to: "5232990709", @@ -594,7 +592,7 @@ describe("resolveHeartbeatDeliveryTarget", () => { { name: "block direct target when directPolicy is block", cfg: { agents: { defaults: { heartbeat: { target: "last", directPolicy: "block" } } } }, - entry: { ...baseEntry, lastChannel: "telegram", lastTo: "5232990709" }, + entry: entryWithDelivery("telegram", "5232990709"), expected: { channel: "none", reason: "dm-blocked", @@ -680,7 +678,12 @@ describe("resolveHeartbeatDeliveryTarget", () => { expect( resolveHeartbeatDeliveryTarget({ cfg, - entry: { ...baseEntry, lastChannel: "whatsapp", lastTo: "+1999" }, + entry: { + ...baseEntry, + delivery: normalizeSessionDeliveryState({ + context: { channel: "whatsapp", to: "+1999" }, + }), + }, heartbeat, }), ).toEqual({ diff --git a/src/infra/heartbeat-runner.test-utils.ts b/src/infra/heartbeat-runner.test-utils.ts index 4657bd27dfe7..7d0fa6aa4e70 100644 --- a/src/infra/heartbeat-runner.test-utils.ts +++ b/src/infra/heartbeat-runner.test-utils.ts @@ -14,6 +14,8 @@ import { resolveCronJobsStorePath } from "../cron/store.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; import type { HeartbeatDeps } from "./heartbeat-runner.js"; // Heartbeat test utilities seed session stores and temporary heartbeat prompts @@ -22,6 +24,9 @@ type HeartbeatSessionSeed = Partial & { lastChannel: string; lastProvider: string; lastTo: string; + deliveryContext?: DeliveryContext; + lastAccountId?: string; + lastThreadId?: string | number; }; type HeartbeatReplyFn = NonNullable; @@ -76,12 +81,29 @@ export async function seedSessionStore( sessionKey: string, session: HeartbeatSessionSeed, ): Promise { + const { + deliveryContext, + lastAccountId, + lastChannel, + lastProvider: _lastProvider, + lastThreadId, + lastTo, + ...entry + } = session; await replaceSessionEntry( { storePath, sessionKey }, { sessionId: session.sessionId ?? "sid", updatedAt: session.updatedAt ?? Date.now(), - ...session, + ...entry, + delivery: normalizeSessionDeliveryState({ + context: { + channel: deliveryContext?.channel ?? lastChannel, + to: deliveryContext?.to ?? lastTo, + accountId: deliveryContext?.accountId ?? lastAccountId, + threadId: deliveryContext?.threadId ?? lastThreadId, + }, + }), }, ); } diff --git a/src/infra/outbound/agent-delivery.test.ts b/src/infra/outbound/agent-delivery.test.ts index 9f7df2278a23..cc334ff4ebb4 100644 --- a/src/infra/outbound/agent-delivery.test.ts +++ b/src/infra/outbound/agent-delivery.test.ts @@ -20,16 +20,15 @@ const mocks = vi.hoisted(() => ({ resolveSessionDeliveryTarget: vi.fn( (params: { entry?: { - deliveryContext?: { - channel?: string; - to?: string; - accountId?: string; - threadId?: string | number; + delivery?: { + kind?: string; + context?: { + channel?: string; + to?: string; + accountId?: string; + threadId?: string | number; + }; }; - lastChannel?: string; - lastTo?: string; - lastAccountId?: string; - lastThreadId?: string | number; }; requestedChannel?: string; explicitTo?: string; @@ -39,12 +38,8 @@ const mocks = vi.hoisted(() => ({ turnSourceAccountId?: string; turnSourceThreadId?: string | number; }) => { - const sessionContext = params.entry?.deliveryContext ?? { - channel: params.entry?.lastChannel, - to: params.entry?.lastTo, - accountId: params.entry?.lastAccountId, - threadId: params.entry?.lastThreadId, - }; + const sessionContext = + params.entry?.delivery?.kind === "external" ? (params.entry.delivery.context ?? {}) : {}; const lastChannel = params.turnSourceChannel ?? sessionContext.channel; const lastTo = params.turnSourceChannel ? params.turnSourceTo : sessionContext.to; const lastAccountId = params.turnSourceChannel @@ -114,6 +109,9 @@ vi.mock("../../utils/message-channel.js", () => ({ })); import type { OpenClawConfig } from "../../config/config.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; +import { normalizeLegacySessionEntryDelivery } from "../state-migrations.legacy-session-store.js"; let resolveAgentDeliveryPlanWithSessionRoute: typeof import("./agent-delivery.js").resolveAgentDeliveryPlanWithSessionRoute; let resolveAgentExplicitRecipientSession: typeof import("./agent-delivery.js").resolveAgentExplicitRecipientSession; let resolveAgentOutboundTarget: typeof import("./agent-delivery.js").resolveAgentOutboundTarget; @@ -147,15 +145,29 @@ beforeEach(() => { }); async function buildDeliveryPlan( - params: Omit[0], "cfg" | "agentId">, + params: Omit< + Parameters[0], + "cfg" | "agentId" | "sessionEntry" + > & { sessionEntry?: SessionEntry & { deliveryContext?: DeliveryContext } }, ) { return await resolveAgentDeliveryPlanWithSessionRoute({ cfg: {} as OpenClawConfig, agentId: "agent", ...params, + sessionEntry: params.sessionEntry + ? normalizeLegacySessionEntryDelivery(params.sessionEntry) + : undefined, }); } +function sessionEntry(context: DeliveryContext): SessionEntry { + return normalizeLegacySessionEntryDelivery({ + sessionId: "fixture", + updatedAt: 1, + deliveryContext: context, + } as unknown as SessionEntry); +} + describe("agent delivery helpers", () => { it.each([ { @@ -306,11 +318,7 @@ describe("agent delivery helpers", () => { cfg: {} as OpenClawConfig, agentId: "agent", currentSessionKey: "agent:main", - sessionEntry: { - sessionId: "s4", - updatedAt: 4, - deliveryContext: { channel: "workspace", to: "channel:C999" }, - }, + sessionEntry: sessionEntry({ channel: "workspace", to: "channel:C999" }), requestedChannel: "workspace", explicitTo: "workspace:channel:C123:thread:1700000000.000100", accountId: "work", @@ -976,15 +984,11 @@ describe("agent delivery helpers", () => { const plan = await resolveAgentDeliveryPlanWithSessionRoute({ cfg: {} as OpenClawConfig, agentId: "agent", - sessionEntry: { - sessionId: "s-thread", - updatedAt: 5, - deliveryContext: { - channel: "workspace", - to: "channel:C999", - threadId: "old-thread", - }, - }, + sessionEntry: sessionEntry({ + channel: "workspace", + to: "channel:C999", + threadId: "old-thread", + }), requestedChannel: "workspace", explicitTo: "channel:C123", accountId: undefined, diff --git a/src/infra/outbound/delivery-queue.recovery.test.ts b/src/infra/outbound/delivery-queue.recovery.test.ts index b711f3c00f5f..234785bab9dd 100644 --- a/src/infra/outbound/delivery-queue.recovery.test.ts +++ b/src/infra/outbound/delivery-queue.recovery.test.ts @@ -17,6 +17,7 @@ import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { buildConversationRef } from "../../routing/conversation-ref.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; import { openOpenClawStateDatabase } from "../../state/openclaw-state-db.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { OutboundDeliveryError, PlatformMessageNotDispatchedError, @@ -169,12 +170,14 @@ describe("delivery-queue recovery", () => { sessionId: "reef-session", updatedAt: 100, chatType: "direct", - deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, - origin: { - provider: "reef", - accountId: "default", - nativeDirectUserId: "peer-agent", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, + origin: { + provider: "reef", + accountId: "default", + nativeDirectUserId: "peer-agent", + }, + }), }, ); beginConversationDeliveryOperation(scope, { @@ -236,12 +239,14 @@ describe("delivery-queue recovery", () => { sessionId: "reef-session", updatedAt: 100, chatType: "direct", - deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, - origin: { - provider: "reef", - accountId: "default", - nativeDirectUserId: "peer-agent", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, + origin: { + provider: "reef", + accountId: "default", + nativeDirectUserId: "peer-agent", + }, + }), }, ); beginConversationDeliveryOperation(scope, { @@ -299,12 +304,14 @@ describe("delivery-queue recovery", () => { sessionId: "reef-session", updatedAt: 100, chatType: "direct", - deliveryContext: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, - origin: { - provider: "reef", - accountId: "default", - nativeDirectUserId: "peer-agent", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "reef", accountId: "default", to: "reef:peer-agent" }, + origin: { + provider: "reef", + accountId: "default", + nativeDirectUserId: "peer-agent", + }, + }), }, ); beginConversationDeliveryOperation(scope, { diff --git a/src/infra/outbound/outbound-session.integration.test.ts b/src/infra/outbound/outbound-session.integration.test.ts index 0ad29ff2ce64..6622f816821d 100644 --- a/src/infra/outbound/outbound-session.integration.test.ts +++ b/src/infra/outbound/outbound-session.integration.test.ts @@ -9,6 +9,7 @@ import { } from "../../config/sessions/conversation-registry.js"; import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { bindOutboundSessionEntry } from "./outbound-session.js"; describe("outbound session persistence", () => { @@ -32,8 +33,10 @@ describe("outbound session persistence", () => { sessionId: "shared-main-session", updatedAt: 100, chatType: "direct", - deliveryContext: { channel: "discord", accountId: "default", to: "user:operator" }, - origin: { provider: "discord", accountId: "default", from: "discord:operator" }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", accountId: "default", to: "user:operator" }, + origin: { provider: "discord", accountId: "default", from: "discord:operator" }, + }), }, ); const identity = buildConversationIdentity({ diff --git a/src/infra/outbound/targets.test.ts b/src/infra/outbound/targets.test.ts index 9b7d431d311f..af5c98b33dc2 100644 --- a/src/infra/outbound/targets.test.ts +++ b/src/infra/outbound/targets.test.ts @@ -3,12 +3,15 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions/types.js"; +import type { ChannelRouteRef } from "../../plugin-sdk/channel-route.js"; import { getActivePluginRegistry, setActivePluginRegistry } from "../../plugins/runtime.js"; +import type { DeliveryContext } from "../../utils/delivery-context.types.js"; +import { normalizeLegacySessionEntryDelivery } from "../state-migrations.legacy-session-store.js"; import { - resolveHeartbeatDeliveryTarget, - resolveHeartbeatDeliveryTargetWithSessionRoute, + resolveHeartbeatDeliveryTarget as resolveCanonicalHeartbeatDeliveryTarget, + resolveHeartbeatDeliveryTargetWithSessionRoute as resolveCanonicalHeartbeatDeliveryTargetWithSessionRoute, resolveOutboundTarget, - resolveSessionDeliveryTarget, + resolveSessionDeliveryTarget as resolveCanonicalSessionDeliveryTarget, } from "./targets.js"; import type { SessionDeliveryTarget } from "./targets.js"; import { @@ -27,6 +30,51 @@ const mocks = vi.hoisted(() => ({ resolveOutboundChannelPlugin: vi.fn(), })); +type LegacyDeliveryFixture = SessionEntry & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: { provider?: string; accountId?: string; threadId?: string | number }; + channel?: string; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + +function resolveSessionDeliveryTarget( + params: Omit[0], "entry"> & { + entry?: LegacyDeliveryFixture; + }, +) { + return resolveCanonicalSessionDeliveryTarget({ + ...params, + entry: params.entry ? normalizeLegacySessionEntryDelivery(params.entry) : undefined, + }); +} + +function resolveHeartbeatDeliveryTarget( + params: Omit[0], "entry"> & { + entry?: LegacyDeliveryFixture; + }, +) { + return resolveCanonicalHeartbeatDeliveryTarget({ + ...params, + entry: params.entry ? normalizeLegacySessionEntryDelivery(params.entry) : undefined, + }); +} + +async function resolveHeartbeatDeliveryTargetWithSessionRoute( + params: Omit< + Parameters[0], + "entry" + > & { entry?: LegacyDeliveryFixture }, +) { + return await resolveCanonicalHeartbeatDeliveryTargetWithSessionRoute({ + ...params, + entry: params.entry ? normalizeLegacySessionEntryDelivery(params.entry) : undefined, + }); +} + vi.mock("./channel-resolution.js", () => ({ normalizeDeliverableOutboundChannel: mocks.normalizeDeliverableOutboundChannel, resolveOutboundChannelPlugin: mocks.resolveOutboundChannelPlugin, @@ -542,10 +590,10 @@ describe("resolveSessionDeliveryTarget", () => { expect(resolved.reason).toBe("target-none"); }); - const resolveHeartbeatTarget = (entry: SessionEntry, directPolicy?: "allow" | "block") => + const resolveHeartbeatTarget = (entry: LegacyDeliveryFixture, directPolicy?: "allow" | "block") => resolveHeartbeatDeliveryTarget({ cfg: {}, - entry, + entry: normalizeLegacySessionEntryDelivery(entry), heartbeat: { target: "last", ...(directPolicy ? { directPolicy } : {}), @@ -554,7 +602,7 @@ describe("resolveSessionDeliveryTarget", () => { const expectHeartbeatTarget = (params: { name: string; - entry: SessionEntry; + entry: LegacyDeliveryFixture; directPolicy?: "allow" | "block"; expectedChannel: string; expectedTo?: string; @@ -678,7 +726,7 @@ describe("resolveSessionDeliveryTarget", () => { }, ] satisfies Array<{ name: string; - entry: NonNullable[0]["entry"]>; + entry: LegacyDeliveryFixture; directPolicy?: "allow" | "block"; expectedChannel: string; expectedTo?: string; diff --git a/src/infra/outbound/targets.ts b/src/infra/outbound/targets.ts index a923ee16d7ee..bc3c7bb7497f 100644 --- a/src/infra/outbound/targets.ts +++ b/src/infra/outbound/targets.ts @@ -546,7 +546,7 @@ export function resolveHeartbeatSenderContext(params: { const sender = resolveHeartbeatSenderId({ allowFrom, deliveryTo: params.delivery.to, - lastTo: params.entry?.lastTo, + lastTo: deliveryContextFromSession(params.entry)?.to, provider, }); diff --git a/src/infra/state-migrations.legacy-session-store.test.ts b/src/infra/state-migrations.legacy-session-store.test.ts index f0ddd3be6851..90e8cf0b92f5 100644 --- a/src/infra/state-migrations.legacy-session-store.test.ts +++ b/src/infra/state-migrations.legacy-session-store.test.ts @@ -83,10 +83,15 @@ it("normalizes file-era rows and drops malformed entries", async () => { expect(store.malformed).toBeUndefined(); expect(store["agent:main:main"]).toMatchObject({ sessionId: "session-1", - channel: "slack", - lastChannel: "telegram", + delivery: { + kind: "external", + context: { channel: "telegram" }, + origin: { provider: "telegram" }, + }, pluginExtensions: { demo: { valid: { ok: true } } }, }); + expect(store["agent:main:main"]).not.toHaveProperty("channel"); + expect(store["agent:main:main"]).not.toHaveProperty("lastChannel"); expect(store["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined(); }); }); @@ -113,8 +118,13 @@ it("normalizes compatibility writes before persistence", async () => { expect(persisted.malformed).toBeUndefined(); expect(persisted["agent:main:main"]).toMatchObject({ sessionId: "session-1", - channel: "slack", + delivery: { + kind: "external", + context: { channel: "slack" }, + origin: { provider: "slack" }, + }, }); + expect(persisted["agent:main:main"]).not.toHaveProperty("channel"); expect(persisted["agent:main:main"]?.pendingFinalDeliveryAttemptCount).toBeUndefined(); }); }); diff --git a/src/infra/state-migrations.legacy-session-store.ts b/src/infra/state-migrations.legacy-session-store.ts index f49af6aff319..ce2c68f7670a 100644 --- a/src/infra/state-migrations.legacy-session-store.ts +++ b/src/infra/state-migrations.legacy-session-store.ts @@ -26,7 +26,11 @@ import { } from "../config/sessions/store-maintenance.js"; import { applySessionStoreMigrations } from "../config/sessions/store-migrations.js"; import { runExclusiveSessionStoreWrite } from "../config/sessions/store-writer.js"; -import { normalizeSessionRuntimeModelFields, type SessionEntry } from "../config/sessions/types.js"; +import { + normalizeSessionRuntimeModelFields, + type SessionEntry, + type SessionOrigin, +} from "../config/sessions/types.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { ChannelRouteRef } from "../plugin-sdk/channel-route.js"; import { isPluginJsonValue, type PluginJsonValue } from "../plugins/host-hook-json.js"; @@ -38,10 +42,18 @@ import { } from "../sessions/agent-harness-session-key.js"; import { createLazyRuntimeModule } from "../shared/lazy-runtime.js"; import { + deliveryContextFromChannelRoute, + isCanonicalSessionDeliveryState, + mergeDeliveryContext, normalizeDeliveryChannelRoute, normalizeDeliveryContext, - normalizeSessionDeliveryFields, + normalizeSessionDeliveryState, } from "../utils/delivery-context.shared.js"; +import type { DeliveryContext } from "../utils/delivery-context.types.js"; +import { + INTERNAL_MESSAGE_CHANNEL, + isInternalNonDeliveryChannel, +} from "../utils/message-channel-constants.js"; import { writeTextAtomic } from "./json-files.js"; import { readSessionStoreJson5 } from "./state-migrations.fs.js"; @@ -521,56 +533,140 @@ export async function updateLegacySessionStore( ); } -function sameDeliveryChannelRoute( - left: ChannelRouteRef | undefined, - right: ChannelRouteRef | undefined, -): boolean { - return ( - (left?.channel ?? undefined) === (right?.channel ?? undefined) && - (left?.accountId ?? undefined) === (right?.accountId ?? undefined) && - (left?.target?.to ?? undefined) === (right?.target?.to ?? undefined) && - (left?.target?.rawTo ?? undefined) === (right?.target?.rawTo ?? undefined) && - (left?.target?.chatType ?? undefined) === (right?.target?.chatType ?? undefined) && - (left?.thread?.id ?? undefined) === (right?.thread?.id ?? undefined) && - (left?.thread?.kind ?? undefined) === (right?.thread?.kind ?? undefined) && - (left?.thread?.source ?? undefined) === (right?.thread?.source ?? undefined) +type LegacySessionDeliveryEntry = SessionEntry & { + route?: ChannelRouteRef; + deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; + lastChannel?: string; + lastTo?: string; + lastAccountId?: string; + lastThreadId?: string | number; +}; + +const LEGACY_SESSION_DELIVERY_KEYS = [ + "route", + "deliveryContext", + "origin", + "channel", + "lastChannel", + "lastTo", + "lastAccountId", + "lastThreadId", +] as const; + +function isInternalContext(context?: DeliveryContext): boolean { + return Boolean( + context?.channel && + (context.channel === INTERNAL_MESSAGE_CHANNEL || isInternalNonDeliveryChannel(context.channel)), ); } +function hasExternalTarget(context?: DeliveryContext): boolean { + return Boolean( + context?.channel && + context.channel !== INTERNAL_MESSAGE_CHANNEL && + !isInternalNonDeliveryChannel(context.channel) && + context.to, + ); +} + +function mergeExternalOverInternal( + external?: DeliveryContext, + internal?: DeliveryContext, +): DeliveryContext | undefined { + return normalizeDeliveryContext({ + channel: external?.channel, + to: external?.to, + accountId: external?.accountId ?? internal?.accountId, + threadId: external?.threadId ?? internal?.threadId, + }); +} + /** Canonicalizes file-era delivery fields before doctor imports a row into SQLite. */ export function normalizeLegacySessionEntryDelivery(entry: SessionEntry): SessionEntry { - const entryRoute = normalizeDeliveryChannelRoute(entry.route); - const normalized = normalizeSessionDeliveryFields({ - route: entryRoute, - channel: entry.channel, - lastChannel: entry.lastChannel, - lastTo: entry.lastTo, - lastAccountId: entry.lastAccountId, - lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId, - deliveryContext: entry.deliveryContext, - }); - const nextDelivery = normalized.deliveryContext; - const sameDelivery = - (entry.deliveryContext?.channel ?? undefined) === nextDelivery?.channel && - (entry.deliveryContext?.to ?? undefined) === nextDelivery?.to && - (entry.deliveryContext?.accountId ?? undefined) === nextDelivery?.accountId && - (entry.deliveryContext?.threadId ?? undefined) === nextDelivery?.threadId; - const sameLast = - sameDeliveryChannelRoute(entryRoute, normalized.route) && - entry.lastChannel === normalized.lastChannel && - entry.lastTo === normalized.lastTo && - entry.lastAccountId === normalized.lastAccountId && - entry.lastThreadId === normalized.lastThreadId; - if (sameDelivery && sameLast) { + const legacy = entry as LegacySessionDeliveryEntry; + const hasLegacyFields = LEGACY_SESSION_DELIVERY_KEYS.some((key) => key in legacy); + if (isCanonicalSessionDeliveryState(entry.delivery) && !hasLegacyFields) { return entry; } - return { - ...entry, - route: normalized.route, - deliveryContext: nextDelivery, - lastChannel: normalized.lastChannel, - lastTo: normalized.lastTo, - lastAccountId: normalized.lastAccountId, - lastThreadId: normalized.lastThreadId, - }; + + const route = normalizeDeliveryChannelRoute(legacy.route); + const routeContext = deliveryContextFromChannelRoute(route); + const explicitContext = normalizeDeliveryContext(legacy.deliveryContext); + const lastChannel = normalizeDeliveryContext({ channel: legacy.lastChannel })?.channel; + const storedChannel = normalizeDeliveryContext({ channel: legacy.channel })?.channel; + const originChannel = normalizeDeliveryContext({ channel: legacy.origin?.provider })?.channel; + const normalizedLastFields = normalizeDeliveryContext({ + to: legacy.lastTo, + accountId: legacy.lastAccountId, + threadId: legacy.lastThreadId, + }); + const hasNormalizedLastFields = Boolean( + normalizedLastFields?.to || + normalizedLastFields?.accountId || + normalizedLastFields?.threadId != null, + ); + const lastCandidate = normalizeDeliveryContext({ + channel: + lastChannel ?? (hasNormalizedLastFields ? (storedChannel ?? originChannel) : undefined), + to: normalizedLastFields?.to, + accountId: normalizedLastFields?.accountId, + threadId: normalizedLastFields?.threadId, + }); + const lastContext = + isInternalContext(lastCandidate) || + hasExternalTarget(lastCandidate) || + (lastChannel != null && lastCandidate?.channel != null) || + (lastCandidate?.channel && lastCandidate.channel === explicitContext?.channel) + ? lastCandidate + : undefined; + const channelContext = normalizeDeliveryContext({ channel: legacy.channel }); + const originContext = normalizeDeliveryContext({ + channel: legacy.origin?.provider, + to: legacy.origin?.to, + accountId: legacy.origin?.accountId, + threadId: legacy.origin?.threadId, + }); + const fallbackContext = mergeDeliveryContext( + lastContext, + mergeDeliveryContext(explicitContext, mergeDeliveryContext(channelContext, originContext)), + ); + const internalFallbackContext = isInternalContext(routeContext) + ? mergeDeliveryContext(routeContext, lastContext) + : isInternalContext(lastContext) + ? lastContext + : isInternalContext(channelContext) + ? mergeDeliveryContext(channelContext, lastContext) + : undefined; + const hasInternalFallback = + internalFallbackContext !== undefined && hasExternalTarget(explicitContext); + const context = hasInternalFallback + ? mergeExternalOverInternal(explicitContext, internalFallbackContext) + : mergeDeliveryContext(routeContext, fallbackContext); + const migratedDelivery = normalizeSessionDeliveryState({ + route: hasInternalFallback ? undefined : route, + context, + origin: legacy.origin, + }); + const recoverLegacyDelivery = + isCanonicalSessionDeliveryState(entry.delivery) && + ((entry.delivery.kind === "none" && migratedDelivery.kind !== "none") || + (entry.delivery.kind === "internal" && migratedDelivery.kind === "external")); + const delivery = + isCanonicalSessionDeliveryState(entry.delivery) && !recoverLegacyDelivery + ? entry.delivery + : migratedDelivery; + const next = { ...entry, delivery } as LegacySessionDeliveryEntry; + const legacyChatType = legacy.origin?.chatType; + if ( + next.chatType == null && + (legacyChatType === "direct" || legacyChatType === "group" || legacyChatType === "channel") + ) { + next.chatType = legacyChatType; + } + for (const key of LEGACY_SESSION_DELIVERY_KEYS) { + delete next[key]; + } + return next; } diff --git a/src/infra/state-migrations.orphan-keys.test.ts b/src/infra/state-migrations.orphan-keys.test.ts index e4d9b339f7ef..845032336210 100644 --- a/src/infra/state-migrations.orphan-keys.test.ts +++ b/src/infra/state-migrations.orphan-keys.test.ts @@ -167,6 +167,7 @@ describe("migrateOrphanedSessionKeys", () => { expect(store["agent:voice:metadata"]).toEqual({ updatedAt: 1500, groupActivation: "always", + delivery: { kind: "none" }, }); expect(store["voice:15550001111"]).toBeUndefined(); expect(result.changes).toHaveLength(1); diff --git a/src/infra/state-migrations.test.ts b/src/infra/state-migrations.test.ts index 57686e14e1d4..e9204c9ca750 100644 --- a/src/infra/state-migrations.test.ts +++ b/src/infra/state-migrations.test.ts @@ -1424,7 +1424,11 @@ describe("state migrations", () => { >; for (const { legacyKey, canonicalKey, sessionId, runtimeSessionName } of cases) { expect(store[legacyKey]).toBeUndefined(); - expect(store[canonicalKey]).toEqual({ sessionId, updatedAt: 10 }); + expect(store[canonicalKey]).toEqual({ + sessionId, + updatedAt: 10, + delivery: { kind: "none" }, + }); expect( readAcpSessionMetaForEntry({ sessionKey: canonicalKey, diff --git a/src/plugin-sdk/session-store-runtime.ts b/src/plugin-sdk/session-store-runtime.ts index 3245779f34c0..42be66194e88 100644 --- a/src/plugin-sdk/session-store-runtime.ts +++ b/src/plugin-sdk/session-store-runtime.ts @@ -51,6 +51,15 @@ import { } from "./session-store-runtime-internal.js"; import type { SessionTranscriptEvent } from "./session-transcript-runtime.js"; +export { + deliveryContextFromSession, + normalizeSessionDeliveryState, + projectSessionDeliveryFields, + sessionDeliveryChannel, + sessionDeliveryOrigin, + sessionDeliveryRoute, +} from "../utils/delivery-context.shared.js"; + const SQLITE_SESSION_STORE_BACKUP_SUFFIXES = ["", "-wal", "-shm", "-journal"] as const; const LEGACY_TRANSCRIPT_INSPECTION_MAX_BYTES = 16 * 1024 * 1024; // Beta.5 Codex resolves and loads synchronously; beta.5 Feishu dedupes targets diff --git a/src/plugins/contracts/session-attachments.contract.test.ts b/src/plugins/contracts/session-attachments.contract.test.ts index dfe40489d76b..e4a99400ce46 100644 --- a/src/plugins/contracts/session-attachments.contract.test.ts +++ b/src/plugins/contracts/session-attachments.contract.test.ts @@ -12,6 +12,7 @@ import { replaceSessionEntry } from "../../config/sessions/session-accessor.js"; import { withTempConfig } from "../../gateway/test-temp-config.js"; import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; +import { normalizeSessionDeliveryState } from "../../utils/delivery-context.shared.js"; import { sendPluginSessionAttachment } from "../host-hook-attachments.js"; import { clearPluginLoaderCache } from "../loader.test-fixtures.js"; import { createEmptyPluginRegistry } from "../registry-empty.js"; @@ -45,7 +46,7 @@ type SessionAttachmentRequest = Parameters[0 type TestSessionEntry = { sessionId?: string; updatedAt?: number; - deliveryContext?: Record; + delivery?: SessionEntry["delivery"]; }; vi.mock("../../channels/plugins/index.js", () => ({ @@ -93,7 +94,9 @@ async function withSessionStore( async function writeSessionEntry( storePath: string, - entry: TestSessionEntry = { deliveryContext: DEFAULT_TELEGRAM_ROUTE }, + entry: TestSessionEntry = { + delivery: normalizeSessionDeliveryState({ context: DEFAULT_TELEGRAM_ROUTE }), + }, key = MAIN_SESSION_KEY, ) { await replaceSessionEntry({ storePath, sessionKey: key }, { @@ -157,11 +160,9 @@ describe("plugin session attachments", () => { it("sends validated files through the session delivery route with portable hints", async () => { await withSessionStore(async ({ storePath, filePath }) => { await writeSessionEntry(storePath, { - deliveryContext: { - ...DEFAULT_TELEGRAM_ROUTE, - accountId: "default", - threadId: 42, - }, + delivery: normalizeSessionDeliveryState({ + context: { ...DEFAULT_TELEGRAM_ROUTE, accountId: "default", threadId: 42 }, + }), }); mockSuccessfulAttachmentDelivery(); @@ -229,7 +230,7 @@ describe("plugin session attachments", () => { it("keeps shipped Slack thread hints compatible", async () => { await withSessionStore(async ({ storePath, filePath }) => { await writeSessionEntry(storePath, { - deliveryContext: { channel: "slack", to: "C123" }, + delivery: normalizeSessionDeliveryState({ context: { channel: "slack", to: "C123" } }), }); mockSuccessfulAttachmentDelivery(); @@ -444,10 +445,9 @@ describe("plugin session attachments", () => { it("rejects unloaded bundled gateway-mode channels before attachment delivery", async () => { await withSessionStore(async ({ storePath, filePath }) => { await writeSessionEntry(storePath, { - deliveryContext: { - channel: "whatsapp", - to: "+15551234567", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "whatsapp", to: "+15551234567" }, + }), }); setActivePluginRegistry(createEmptyPluginRegistry()); workflowMocks.getChannelPlugin.mockReturnValue( diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index fb0391f1a585..e26439264819 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -160,18 +160,11 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "label", "category", "displayName", - "channel", + "delivery", "groupId", "subject", "groupChannel", "space", - "origin", - "route", - "deliveryContext", - "lastChannel", - "lastTo", - "lastAccountId", - "lastThreadId", "skillsSnapshot", "systemPromptReport", "pluginDebugEntries", @@ -194,6 +187,16 @@ type SessionEntryReservedSlotSetValue = [MissingSessionEntryReservedSlotKey] ext const SESSION_ENTRY_RESERVED_SLOT_KEYS = new Set( SESSION_ENTRY_RESERVED_SLOT_KEY_LIST, ); +const RETIRED_SESSION_DELIVERY_SLOT_KEYS = new Set([ + "channel", + "origin", + "route", + "deliveryContext", + "lastChannel", + "lastTo", + "lastAccountId", + "lastThreadId", +]); const OBJECT_PROTOTYPE_RESERVED_SLOT_KEYS = new Set([ "prototype", ...Object.getOwnPropertyNames(Object.prototype), @@ -217,7 +220,7 @@ export function normalizeSessionEntrySlotKey( error: "sessionEntrySlotKey must be an identifier-style field name", }; } - if (SESSION_ENTRY_RESERVED_SLOT_KEYS.has(key)) { + if (SESSION_ENTRY_RESERVED_SLOT_KEYS.has(key) || RETIRED_SESSION_DELIVERY_SLOT_KEYS.has(key)) { return { ok: false, error: `sessionEntrySlotKey is reserved by SessionEntry: ${key}`, diff --git a/src/sessions/send-policy.ts b/src/sessions/send-policy.ts index 432e11052788..2fc9f0543ada 100644 --- a/src/sessions/send-policy.ts +++ b/src/sessions/send-policy.ts @@ -6,6 +6,7 @@ import { import { normalizeChatType } from "../channels/chat-type.js"; import type { SessionChatType, SessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { sessionDeliveryChannel } from "../utils/delivery-context.shared.js"; import { hasAmbiguousCanonicalSessionPeerShape, parseCanonicalSessionPeerShape, @@ -104,8 +105,7 @@ export function resolveSendPolicy(params: { const getChannel = () => { channel ??= normalizeMatchValue(params.channel) ?? - normalizeMatchValue(params.entry?.channel) ?? - normalizeMatchValue(params.entry?.lastChannel) ?? + normalizeMatchValue(sessionDeliveryChannel(params.entry)) ?? deriveChannelFromKey(params.sessionKey); return channel; }; diff --git a/src/sessions/session-key-case-preservation.test.ts b/src/sessions/session-key-case-preservation.test.ts index 0a09a04d227b..8e470083bb66 100644 --- a/src/sessions/session-key-case-preservation.test.ts +++ b/src/sessions/session-key-case-preservation.test.ts @@ -6,6 +6,10 @@ import { } from "../config/sessions/store-entry.js"; import type { SessionEntry } from "../config/sessions/types.js"; import { buildAgentPeerSessionKey } from "../routing/session-key.js"; +import { + deliveryContextFromSession, + normalizeSessionDeliveryState, +} from "../utils/delivery-context.shared.js"; import { normalizeSessionKeyPreservingOpaquePeerIds, normalizeSessionPeerId, @@ -18,8 +22,11 @@ const ROOM_LOWER_KEY = "agent:main:matrix:channel:!mixedroomabcdef:example.org"; const ROOM_MIXED_THREAD_KEY = `${ROOM_MIXED_KEY}:thread:$ThreadRootAbC`; const ROOM_LOWER_THREAD_KEY = `${ROOM_LOWER_KEY}:thread:$threadrootabc`; const ROOM_LOWER_ROOM_PRESERVED_THREAD_KEY = `${ROOM_LOWER_KEY}:thread:$ThreadRootAbC`; -const entry = (to: string, updatedAt: number): SessionEntry => - ({ updatedAt, deliveryContext: { channel: "matrix", to } }) as unknown as SessionEntry; +const entry = (to: string, updatedAt: number): SessionEntry => ({ + sessionId: `session-${updatedAt}`, + updatedAt, + delivery: normalizeSessionDeliveryState({ context: { channel: "matrix", to } }), +}); // Regression matrix for the generic opt-in case-preservation registry // (openclaw/openclaw#75670 — Matrix room ids; #82853 — Signal groups). @@ -279,7 +286,7 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code expect(r.legacyKeys).not.toContain(ROOM_LOWER_KEY); expect(r.legacyKeys).toEqual([]); // exact mixed-case entry wins over the fresher distinct sibling - expect(r.existing?.deliveryContext?.to).toBe("room:!MixedRoomAbCdEf:example.org"); + expect(deliveryContextFromSession(r.existing)?.to).toBe("room:!MixedRoomAbCdEf:example.org"); }); it("keeps fresher Matrix aliases that normalize to the same opaque key", () => { @@ -346,18 +353,22 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code }; const r = resolveSessionStoreEntry({ store, sessionKey: ROOM_MIXED_KEY }); expect(r.legacyKeys).toContain(ROOM_LOWER_KEY); - expect(r.existing?.deliveryContext?.to).toBe("room:!MixedRoomAbCdEf:example.org"); + expect(deliveryContextFromSession(r.existing)?.to).toBe("room:!MixedRoomAbCdEf:example.org"); }); it("recognizes lowercased Matrix artifacts with inbound origin room metadata", () => { const store: Record = { [ROOM_LOWER_KEY]: { + sessionId: "matrix-origin", updatedAt: 50, - origin: { - provider: "matrix", - nativeChannelId: "!MixedRoomAbCdEf:example.org", - }, - } as unknown as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { channel: "matrix", to: "room:!MixedRoomAbCdEf:example.org" }, + origin: { + provider: "matrix", + nativeChannelId: "!MixedRoomAbCdEf:example.org", + }, + }), + }, }; const r = resolveSessionStoreEntry({ store, sessionKey: ROOM_MIXED_KEY }); @@ -371,12 +382,12 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code const lowerAliasKey = "agent:main:matrix:channel:#mixedroomalias:example.org"; const store: Record = { [lowerAliasKey]: { + sessionId: "matrix-alias", updatedAt: 50, - deliveryContext: { - channel: "matrix", - to: "room:#MixedRoomAlias:example.org", - }, - } as unknown as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { channel: "matrix", to: "room:#MixedRoomAlias:example.org" }, + }), + }, }; const r = resolveSessionStoreEntry({ store, sessionKey: mixedAliasKey }); @@ -388,13 +399,16 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code it("does not collapse Matrix thread artifacts when the stored thread id differs by case", () => { const store: Record = { [ROOM_LOWER_THREAD_KEY]: { + sessionId: "matrix-thread-mismatch", updatedAt: 50, - deliveryContext: { - channel: "matrix", - to: "room:!MixedRoomAbCdEf:example.org", - threadId: "$threadrootabc", - }, - } as unknown as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "matrix", + to: "room:!MixedRoomAbCdEf:example.org", + threadId: "$threadrootabc", + }, + }), + }, }; const r = resolveSessionStoreEntry({ store, sessionKey: ROOM_MIXED_THREAD_KEY }); @@ -406,13 +420,16 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code it("collapses Matrix thread artifacts when room and thread metadata both match", () => { const store: Record = { [ROOM_LOWER_THREAD_KEY]: { + sessionId: "matrix-thread-match", updatedAt: 50, - deliveryContext: { - channel: "matrix", - to: "room:!MixedRoomAbCdEf:example.org", - threadId: "$ThreadRootAbC", - }, - } as unknown as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "matrix", + to: "room:!MixedRoomAbCdEf:example.org", + threadId: "$ThreadRootAbC", + }, + }), + }, }; const r = resolveSessionStoreEntry({ store, sessionKey: ROOM_MIXED_THREAD_KEY }); @@ -424,13 +441,16 @@ describe("resolveSessionStoreEntry — case-distinct Matrix session safety (code it("collapses Matrix thread artifacts with legacy lowercased room and preserved event id", () => { const store: Record = { [ROOM_LOWER_ROOM_PRESERVED_THREAD_KEY]: { + sessionId: "matrix-thread-preserved", updatedAt: 50, - deliveryContext: { - channel: "matrix", - to: "room:!MixedRoomAbCdEf:example.org", - threadId: "$ThreadRootAbC", - }, - } as unknown as SessionEntry, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "matrix", + to: "room:!MixedRoomAbCdEf:example.org", + threadId: "$ThreadRootAbC", + }, + }), + }, }; const r = resolveSessionStoreEntry({ store, sessionKey: ROOM_MIXED_THREAD_KEY }); diff --git a/src/state/openclaw-agent-db-schema.ts b/src/state/openclaw-agent-db-schema.ts index a2275b068f16..3b5c9a6a2efc 100644 --- a/src/state/openclaw-agent-db-schema.ts +++ b/src/state/openclaw-agent-db-schema.ts @@ -374,8 +374,11 @@ function migratedSessionScope( } function migratedEntryChannel(entry: MigratedSessionEntry): string | null { - const deliveryContext = migratedObjectField(entry, "deliveryContext"); - const origin = migratedObjectField(entry, "origin"); + const delivery = migratedObjectField(entry, "delivery"); + const deliveryContext = + migratedObjectField(delivery ?? {}, "context") ?? migratedObjectField(entry, "deliveryContext"); + const origin = + migratedObjectField(delivery ?? {}, "origin") ?? migratedObjectField(entry, "origin"); return ( migratedText(entry.channel) ?? migratedText(deliveryContext?.channel) ?? @@ -385,8 +388,11 @@ function migratedEntryChannel(entry: MigratedSessionEntry): string | null { } function migratedEntryAccountId(entry: MigratedSessionEntry): string | null { - const deliveryContext = migratedObjectField(entry, "deliveryContext"); - const origin = migratedObjectField(entry, "origin"); + const delivery = migratedObjectField(entry, "delivery"); + const deliveryContext = + migratedObjectField(delivery ?? {}, "context") ?? migratedObjectField(entry, "deliveryContext"); + const origin = + migratedObjectField(delivery ?? {}, "origin") ?? migratedObjectField(entry, "origin"); return ( migratedText(deliveryContext?.accountId) ?? migratedText(entry.lastAccountId) ?? diff --git a/src/state/openclaw-agent-db-session-migrations.ts b/src/state/openclaw-agent-db-session-migrations.ts index 42c261129fea..ec47b650f8b1 100644 --- a/src/state/openclaw-agent-db-session-migrations.ts +++ b/src/state/openclaw-agent-db-session-migrations.ts @@ -65,8 +65,11 @@ function migratedConversation( persistedChatType?: string, sessionKey?: string, ) { - const delivery = migratedObject(entry, "deliveryContext"); - const origin = migratedObject(entry, "origin"); + const canonicalDelivery = migratedObject(entry, "delivery"); + const delivery = + migratedObject(canonicalDelivery ?? {}, "context") ?? migratedObject(entry, "deliveryContext"); + const origin = + migratedObject(canonicalDelivery ?? {}, "origin") ?? migratedObject(entry, "origin"); const deliveryRouteTarget = migratedText(delivery?.to); const kind = inferMigratedChatType({ entry, diff --git a/src/status/status-message.ts b/src/status/status-message.ts index cda42d92d588..2f5a15dd6e70 100644 --- a/src/status/status-message.ts +++ b/src/status/status-message.ts @@ -62,6 +62,7 @@ import type { MediaUnderstandingDecision } from "../media-understanding/types.js import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import { formatFastModeStatusValue } from "../shared/fast-mode.js"; import { resolveStatusTtsSnapshot } from "../tts/status-config.js"; +import { sessionDeliveryChannel, sessionDeliveryOrigin } from "../utils/delivery-context.shared.js"; import { estimateUsageCost, formatTokenCount, @@ -525,16 +526,16 @@ function resolveChannelModelNote(params: { } const channelOverride = resolveChannelModelOverride({ cfg: params.config, - channel: params.entry.channel ?? params.entry.origin?.provider, + channel: sessionDeliveryChannel(params.entry), groupId: params.entry.groupId, - groupChatType: params.entry.chatType ?? params.entry.origin?.chatType, + groupChatType: params.entry.chatType ?? sessionDeliveryOrigin(params.entry)?.chatType, groupChannel: params.entry.groupChannel, groupSubject: params.entry.subject, parentSessionKey: params.parentSessionKey, directUserIds: [ - params.entry.origin?.nativeDirectUserId, - params.entry.origin?.from, - params.entry.origin?.to, + sessionDeliveryOrigin(params.entry)?.nativeDirectUserId, + sessionDeliveryOrigin(params.entry)?.from, + sessionDeliveryOrigin(params.entry)?.to, ], }); if (!channelOverride) { diff --git a/src/status/status-text.test.ts b/src/status/status-text.test.ts index a292c10dd26f..13bc06b9fd10 100644 --- a/src/status/status-text.test.ts +++ b/src/status/status-text.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { appendSessionCostLine } from "./status-runtime-lines.js"; import { buildStatusText } from "./status-text.js"; @@ -88,7 +89,9 @@ describe("buildStatusText channel features", () => { sessionEntry: { sessionId: "telegram-rich-account", updatedAt: 0, - lastAccountId: "work", + delivery: normalizeSessionDeliveryState({ + context: { channel: "telegram", accountId: "work" }, + }), }, }); diff --git a/src/status/status-text.ts b/src/status/status-text.ts index bdc096a5e421..3ed70526bd9b 100644 --- a/src/status/status-text.ts +++ b/src/status/status-text.ts @@ -52,6 +52,10 @@ import { formatTaskStatusDetail, formatTaskStatusTitle, } from "../tasks/task-status.js"; +import { + deliveryContextFromSession, + sessionDeliveryOrigin, +} from "../utils/delivery-context.shared.js"; // Status text helpers render runtime status summaries for CLI output. import { resolveUsageCredentialType } from "./codex-synthetic-usage.js"; import { @@ -86,8 +90,8 @@ function resolveStatusChannelFeatureLine(params: { const telegramConfig = params.cfg.channels?.telegram; const accountId = normalizeAccountId( params.statusAccountId ?? - params.sessionEntry?.lastAccountId ?? - params.sessionEntry?.origin?.accountId ?? + deliveryContextFromSession(params.sessionEntry)?.accountId ?? + sessionDeliveryOrigin(params.sessionEntry)?.accountId ?? telegramConfig?.defaultAccount, ); const accountConfig = resolveNormalizedAccountEntry( diff --git a/src/talk/agent-consult-runtime.test.ts b/src/talk/agent-consult-runtime.test.ts index 557a832e110e..4b704fd0e789 100644 --- a/src/talk/agent-consult-runtime.test.ts +++ b/src/talk/agent-consult-runtime.test.ts @@ -7,6 +7,7 @@ import type { SessionEntry } from "../config/sessions/types.js"; import { MODEL_SELECTION_LOCKED_MESSAGE } from "../sessions/model-overrides.js"; import { runExclusiveSessionLifecycleMutation } from "../sessions/session-lifecycle-admission.js"; import { closeOpenClawAgentDatabaseByPath } from "../state/openclaw-agent-db.js"; +import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js"; import { consultRealtimeVoiceAgent } from "./agent-consult-runtime.js"; import { REALTIME_VOICE_AGENT_CONSULT_TOOL } from "./agent-consult-tool.js"; import { @@ -59,16 +60,7 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) { modelSelectionLocked?: boolean; forkedFromParent?: boolean; totalTokens?: number; - deliveryContext?: { - channel?: string; - to?: string; - accountId?: string; - threadId?: string | number; - }; - lastChannel?: string; - lastTo?: string; - lastAccountId?: string; - lastThreadId?: string | number; + delivery?: SessionEntry["delivery"]; } > = {}; const runEmbeddedAgent = vi.fn(async () => ({ @@ -642,11 +634,9 @@ describe("realtime voice agent consult runtime", () => { const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); sessionStore["agent:main:discord:channel:123"] = { sessionId: "parent-session", - deliveryContext: { - channel: "discord", - to: "channel:123", - accountId: "default", - }, + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "channel:123", accountId: "default" }, + }), updatedAt: 1, }; @@ -684,15 +674,9 @@ describe("realtime voice agent consult runtime", () => { createdVia: "talk", createdActor: { type: "agent", id: "agent:main:discord:channel:123" }, createdAt: voiceEntry.createdAt, - deliveryContext: { - channel: "discord", - to: "channel:123", - accountId: "default", - }, - lastChannel: "discord", - lastTo: "channel:123", - lastAccountId: "default", - lastThreadId: undefined, + delivery: normalizeSessionDeliveryState({ + context: { channel: "discord", to: "channel:123", accountId: "default" }, + }), updatedAt: voiceEntry.updatedAt, }); expectNonEmptyString(voiceEntry.sessionId); @@ -703,12 +687,14 @@ describe("realtime voice agent consult runtime", () => { const { runtime, runEmbeddedAgent, sessionStore } = createAgentRuntime(); sessionStore["voice:google-meet:meet-1"] = { sessionId: "call-session", - deliveryContext: { - channel: "discord", - to: "channel:123", - accountId: "default", - threadId: "thread-456", - }, + delivery: normalizeSessionDeliveryState({ + context: { + channel: "discord", + to: "channel:123", + accountId: "default", + threadId: "thread-456", + }, + }), updatedAt: 1, }; diff --git a/src/talk/agent-consult-runtime.ts b/src/talk/agent-consult-runtime.ts index f8343fec9bdd..beb20c303ec8 100644 --- a/src/talk/agent-consult-runtime.ts +++ b/src/talk/agent-consult-runtime.ts @@ -15,6 +15,7 @@ import { beginSessionWorkAdmission } from "../sessions/session-lifecycle-admissi import { deliveryContextFromSession, normalizeDeliveryContext, + normalizeSessionDeliveryState, type DeliveryContext, } from "../utils/delivery-context.shared.js"; import { @@ -110,11 +111,7 @@ function resolveDeliverySessionFields(context?: DeliveryContext): Partial ({ sendDurableMessageBatch })); const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempDir: string; -async function seedSession( - sessionKey: string, - route: { lastChannel?: string; lastTo?: string } = {}, -): Promise { +async function seedSession(sessionKey: string, context: DeliveryContext = {}): Promise { await replaceSessionEntry( { agentId: "main", sessionKey }, { sessionId: `session-${sessionKey.replaceAll(":", "-")}`, updatedAt: Date.now(), - ...route, + delivery: normalizeSessionDeliveryState({ context }), }, ); } @@ -382,8 +383,8 @@ describe("client voice session", () => { it("records post-close effects and defers the digest until the last consult completes", async () => { await seedSession("agent:main:main", { - lastChannel: "discord", - lastTo: "channel:voice-updates", + channel: "discord", + to: "channel:voice-updates", }); const voiceSessionId = createOrResumeClientVoiceSession({ agentId: "main", @@ -452,8 +453,8 @@ describe("client voice session", () => { it("retries a deferred digest on the next voice session after a failed delivery", async () => { await seedSession("agent:main:main", { - lastChannel: "discord", - lastTo: "channel:voice-updates", + channel: "discord", + to: "channel:voice-updates", }); const voiceSessionId = createOrResumeClientVoiceSession({ agentId: "main", @@ -504,8 +505,8 @@ describe("client voice session", () => { it("retries the mutation digest after a transient send failure", async () => { await seedSession("agent:main:main", { - lastChannel: "discord", - lastTo: "channel:voice-updates", + channel: "discord", + to: "channel:voice-updates", }); const voiceSessionId = createOrResumeClientVoiceSession({ agentId: "main", @@ -540,8 +541,8 @@ describe("client voice session", () => { it("delivers one mutation digest and skips webchat or missing targets", async () => { await seedSession("agent:main:main", { - lastChannel: "discord", - lastTo: "channel:voice-updates", + channel: "discord", + to: "channel:voice-updates", }); const delivered = createOrResumeClientVoiceSession({ agentId: "main", @@ -572,7 +573,7 @@ describe("client voice session", () => { ); for (const [voiceSessionId, route] of [ - ["voice-webchat", { lastChannel: "webchat", lastTo: "browser" }], + ["voice-webchat", { channel: "webchat", to: "browser" }], ["voice-no-target", {}], ] as const) { const sessionKey = `agent:main:${voiceSessionId}`; diff --git a/src/utils/delivery-context.shared.ts b/src/utils/delivery-context.shared.ts index f31ef125fd4d..76d339660cf8 100644 --- a/src/utils/delivery-context.shared.ts +++ b/src/utils/delivery-context.shared.ts @@ -1,3 +1,8 @@ +import type { + SessionDeliveryState, + SessionEntry, + SessionOrigin, +} from "../config/sessions/types.js"; // Shared delivery context helpers expose route normalization shared by modules. import { channelRouteCompactKey, @@ -8,7 +13,7 @@ import { type ChannelRouteRef, } from "../plugin-sdk/channel-route.js"; import { normalizeAccountId } from "./account-id.js"; -import type { DeliveryContext, DeliveryContextSessionSource } from "./delivery-context.types.js"; +import type { DeliveryContext } from "./delivery-context.types.js"; import { INTERNAL_MESSAGE_CHANNEL, isInternalNonDeliveryChannel, @@ -19,8 +24,8 @@ export type { DeliveryContext } from "./delivery-context.types.js"; /** * Delivery-context normalization and projection helpers. * - * Sessions still carry route metadata plus older `last*` fields; this module - * keeps those shapes converged on the canonical SDK channel-route contract. + * Persisted sessions expose one closed delivery state. Compatibility + * projections are derived from that state at public boundaries. */ /** Normalizes a delivery context into canonical channel route fields, dropping invalid routes. */ @@ -139,92 +144,127 @@ function mergeExternalDeliveryContextOverInternalRoute( }); } -/** Reconciles legacy session delivery fields, route metadata, and explicit delivery context. */ -export function normalizeSessionDeliveryFields(source?: DeliveryContextSessionSource): { +type SessionDeliveryProjection = { route?: ChannelRouteRef; deliveryContext?: DeliveryContext; + origin?: SessionOrigin; + channel?: string; lastChannel?: string; lastTo?: string; lastAccountId?: string; lastThreadId?: string | number; -} { - if (!source) { - return { - route: undefined, - deliveryContext: undefined, - lastChannel: undefined, - lastTo: undefined, - lastAccountId: undefined, - lastThreadId: undefined, - }; +}; + +export function isCanonicalSessionDeliveryState(value: unknown): value is SessionDeliveryState { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return false; + } + const candidate = value as Record; + if (candidate.kind === "none" || candidate.kind === "internal") { + return true; + } + return ( + candidate.kind === "external" && + Boolean(candidate.route && typeof candidate.route === "object") && + Boolean(candidate.context && typeof candidate.context === "object") && + Boolean(candidate.origin && typeof candidate.origin === "object") + ); +} + +/** Builds one canonical delivery state from current turn routing facts. */ +export function normalizeSessionDeliveryState(params?: { + route?: ChannelRouteRef; + context?: DeliveryContext; + origin?: SessionOrigin; +}): SessionDeliveryState { + if (!params) { + return { kind: "none" }; } - const normalizedRoute = normalizeDeliveryChannelRoute(source.route); + const normalizedRoute = normalizeDeliveryChannelRoute(params.route); const routeContext = deliveryContextFromChannelRoute(normalizedRoute); - const legacyContext = normalizeDeliveryContext({ - channel: source.lastChannel ?? source.channel, - to: source.lastTo, - accountId: source.lastAccountId, - threadId: source.lastThreadId, + const originContext = normalizeDeliveryContext({ + channel: params.origin?.provider, + to: params.origin?.to, + accountId: params.origin?.accountId, + threadId: params.origin?.threadId, }); - const deliveryContext = normalizeDeliveryContext(source.deliveryContext); - // Legacy webchat `last*` fields can outlive the external channel that should - // receive replies. Prefer an explicit deliverable context when it exists. - const sessionContext = - isInternalRouteContext(legacyContext) && hasExternalDeliveryTarget(deliveryContext) - ? mergeExternalDeliveryContextOverInternalRoute(deliveryContext, legacyContext) - : mergeDeliveryContext(legacyContext, deliveryContext); - const routeInternalContext = mergeDeliveryContext(routeContext, legacyContext); - // Route metadata normally wins, except for internal fallback routes paired - // with an explicit external delivery target from newer session state. + const context = normalizeDeliveryContext(params.context); + const fallbackContext = mergeDeliveryContext(context, originContext); const routeIsInternalFallback = - isInternalRouteContext(routeContext) && hasExternalDeliveryTarget(deliveryContext); + isInternalRouteContext(routeContext) && hasExternalDeliveryTarget(context); const merged = routeIsInternalFallback - ? mergeExternalDeliveryContextOverInternalRoute(deliveryContext, routeInternalContext) - : mergeDeliveryContext(routeContext, sessionContext); + ? mergeExternalDeliveryContextOverInternalRoute( + context, + mergeDeliveryContext(routeContext, originContext), + ) + : mergeDeliveryContext(routeContext, fallbackContext); if (!merged) { - return { - route: undefined, - deliveryContext: undefined, - lastChannel: undefined, - lastTo: undefined, - lastAccountId: undefined, - lastThreadId: undefined, - }; + return { kind: "none" }; } + if (isInternalRouteContext(merged)) { + return { kind: "internal" }; + } + const route = mergeRouteMetadataWithDeliveryContext( + routeIsInternalFallback ? undefined : normalizedRoute, + merged, + ); + if (!route) { + return { kind: "none" }; + } + const origin: SessionOrigin = { ...params.origin }; + origin.provider ??= merged.channel; + origin.to ??= merged.to; + origin.accountId ??= merged.accountId; + origin.threadId ??= merged.threadId; + origin.chatType ??= route.target?.chatType; + return { kind: "external", route, context: merged, origin }; +} +/** Projects compatibility fields without persisting duplicate delivery state. */ +export function projectSessionDeliveryFields( + delivery?: SessionDeliveryState, +): SessionDeliveryProjection { + if (delivery?.kind !== "external") { + return {}; + } return { - route: mergeRouteMetadataWithDeliveryContext( - routeIsInternalFallback ? undefined : normalizedRoute, - merged, - ), - deliveryContext: merged, - lastChannel: merged.channel, - lastTo: merged.to, - lastAccountId: merged.accountId, - lastThreadId: merged.threadId, + route: delivery.route, + deliveryContext: delivery.context, + origin: delivery.origin, + channel: delivery.context.channel ?? delivery.origin.provider, + lastChannel: delivery.context.channel, + lastTo: delivery.context.to, + lastAccountId: delivery.context.accountId, + lastThreadId: delivery.context.threadId, }; } -/** Derives the best delivery context from current and legacy session fields. */ +/** Reads only the canonical persisted delivery record. */ export function deliveryContextFromSession( - entry?: DeliveryContextSessionSource, + entry?: Pick, ): DeliveryContext | undefined { - if (!entry) { - return undefined; - } - const source: DeliveryContextSessionSource = { - route: entry.route, - channel: entry.channel ?? entry.origin?.provider, - lastChannel: entry.lastChannel, - lastTo: entry.lastTo, - lastAccountId: entry.lastAccountId ?? entry.origin?.accountId, - lastThreadId: entry.lastThreadId ?? entry.deliveryContext?.threadId ?? entry.origin?.threadId, - origin: entry.origin, - deliveryContext: entry.deliveryContext, - }; - return normalizeSessionDeliveryFields(source).deliveryContext; + return entry?.delivery?.kind === "external" ? entry.delivery.context : undefined; +} + +export function sessionDeliveryRoute( + entry?: Pick, +): ChannelRouteRef | undefined { + return entry?.delivery?.kind === "external" ? entry.delivery.route : undefined; +} + +export function sessionDeliveryOrigin( + entry?: Pick, +): SessionOrigin | undefined { + return entry?.delivery?.kind === "external" ? entry.delivery.origin : undefined; +} + +export function sessionDeliveryChannel(entry?: Pick): string | undefined { + const delivery = entry?.delivery; + return delivery?.kind === "external" + ? (delivery.context.channel ?? delivery.origin.provider) + : undefined; } /** Merges delivery contexts without mixing target/account/thread fields across route owners. */ diff --git a/src/utils/delivery-context.test.ts b/src/utils/delivery-context.test.ts index 7707589f67da..378e47498eb2 100644 --- a/src/utils/delivery-context.test.ts +++ b/src/utils/delivery-context.test.ts @@ -6,7 +6,10 @@ import { mergeDeliveryContext, normalizeDeliveryContext, } from "./delivery-context.js"; -import { normalizeSessionDeliveryFields } from "./delivery-context.shared.js"; +import { + normalizeSessionDeliveryState, + projectSessionDeliveryFields, +} from "./delivery-context.shared.js"; describe("delivery context helpers", () => { it("normalizes channel/to/accountId and drops empty contexts", () => { @@ -109,259 +112,110 @@ describe("delivery context helpers", () => { }); it("derives delivery context from a session entry", () => { + const delivery = normalizeSessionDeliveryState({ + route: { + channel: "slack", + accountId: "work", + target: { to: "channel:C123" }, + thread: { id: "177000.123" }, + }, + }); + expect(deliveryContextFromSession({ delivery })).toEqual({ + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }); + }); + + it("does not reconstruct delivery from retired session fields at runtime", () => { expect( deliveryContextFromSession({ route: { channel: "slack", - accountId: "work", target: { to: "channel:C123" }, - thread: { id: "177000.123" }, }, - channel: "webchat", - lastChannel: "webchat", - lastTo: "user:old", - }), - ).toEqual({ - channel: "slack", - to: "channel:C123", - accountId: "work", - threadId: "177000.123", - }); + lastChannel: "slack", + lastTo: "channel:C123", + } as unknown as { delivery?: never }), + ).toBeUndefined(); + }); + + it("normalizes the closed none, internal, and external states", () => { + expect(normalizeSessionDeliveryState()).toEqual({ kind: "none" }); + expect( + normalizeSessionDeliveryState({ context: { channel: "webchat", to: "dashboard" } }), + ).toEqual({ kind: "internal" }); expect( - deliveryContextFromSession({ - channel: "webchat", - lastChannel: " demo-channel ", - lastTo: " +1777 ", - lastAccountId: " acct-9 ", + normalizeSessionDeliveryState({ + route: { + channel: "Slack", + accountId: " work ", + target: { to: " channel:C123 ", rawTo: " slack://C123 ", chatType: "channel" }, + thread: { id: " 177000.123 ", kind: "thread", source: "target" }, + }, + context: { channel: "discord", to: "channel:old" }, + origin: { label: "Support" }, }), ).toEqual({ - channel: "demo-channel", - to: "+1777", - accountId: "acct-9", - }); - - expect( - deliveryContextFromSession({ - channel: "demo-channel", - lastTo: " 123 ", - lastThreadId: " 999 ", - }), - ).toEqual({ - channel: "demo-channel", - to: "123", - accountId: undefined, - threadId: "999", - }); - - expect( - deliveryContextFromSession({ - channel: "demo-channel", - lastTo: " -1001 ", - origin: { threadId: 42 }, - }), - ).toEqual({ - channel: "demo-channel", - to: "-1001", - accountId: undefined, - threadId: 42, - }); - - expect( - deliveryContextFromSession({ - channel: "demo-channel", - lastTo: " -1001 ", - deliveryContext: { threadId: " 777 " }, - origin: { threadId: 42 }, - }), - ).toEqual({ - channel: "demo-channel", - to: "-1001", - accountId: undefined, - threadId: "777", + kind: "external", + route: { + channel: "slack", + accountId: "work", + target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" }, + thread: { id: "177000.123", kind: "thread", source: "target" }, + }, + context: { + channel: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + }, + origin: { + label: "Support", + provider: "slack", + to: "channel:C123", + accountId: "work", + threadId: "177000.123", + chatType: "channel", + }, }); }); - it("prefers explicit external delivery context over stale webchat legacy fields", () => { - expect( - deliveryContextFromSession({ - channel: "webchat", - deliveryContext: { - channel: "room-chat", - to: " peer-1 ", - accountId: " acct-1 ", - threadId: " thread-1 ", - }, - }), - ).toEqual({ - channel: "room-chat", - to: "peer-1", - accountId: "acct-1", - threadId: "thread-1", + it("projects compatibility fields without duplicating them in the session row", () => { + const delivery = normalizeSessionDeliveryState({ + context: { channel: "telegram", to: "-1001", accountId: "bot", threadId: 42 }, + origin: { label: "Ops" }, }); - - expect( - deliveryContextFromSession({ - channel: "webchat", - lastChannel: "webchat", - lastTo: "session:dashboard", - lastAccountId: "work", - lastThreadId: "thread-2", - deliveryContext: { - channel: "room-chat", - to: "peer-2", - }, - }), - ).toEqual({ - channel: "room-chat", - to: "peer-2", - accountId: "work", - threadId: "thread-2", - }); - - expect( - deliveryContextFromSession({ - lastChannel: "heartbeat", - lastTo: "heartbeat", - deliveryContext: { - channel: "telegram", - to: "-100123", - }, - }), - ).toEqual({ + const expectedProjection = { + route: { + channel: "telegram", + accountId: "bot", + target: { to: "-1001" }, + thread: { id: 42 }, + }, + deliveryContext: { + channel: "telegram", + to: "-1001", + accountId: "bot", + threadId: 42, + }, + origin: { + label: "Ops", + provider: "telegram", + to: "-1001", + accountId: "bot", + threadId: 42, + }, channel: "telegram", - to: "-100123", - accountId: undefined, - }); - - const routeNormalized = normalizeSessionDeliveryFields({ - route: { - channel: "webchat", - accountId: "work", - target: { to: "session:dashboard" }, - thread: { id: "thread-route" }, - }, - deliveryContext: { - channel: "room-chat", - to: "peer-route", - }, - }); - expect(routeNormalized.deliveryContext).toEqual({ - channel: "room-chat", - to: "peer-route", - accountId: "work", - threadId: "thread-route", - }); - expect(routeNormalized.route).toEqual({ - channel: "room-chat", - accountId: "work", - target: { to: "peer-route" }, - thread: { id: "thread-route" }, - }); - }); - - it("does not promote tool-only context over internal session delivery", () => { - const normalized = normalizeSessionDeliveryFields({ - route: { - channel: "webchat", - accountId: "work", - target: { to: "session:dashboard" }, - }, - deliveryContext: { - channel: "sessions_send", - to: "session:handoff", - }, - }); - - expect(normalized.deliveryContext).toEqual({ - channel: "webchat", - to: "session:dashboard", - accountId: "work", - }); - expect(normalized.route).toEqual({ - channel: "webchat", - accountId: "work", - target: { to: "session:dashboard" }, - }); - - const staleLegacyExternal = normalizeSessionDeliveryFields({ - route: { - channel: "webchat", - accountId: "work", - target: { to: "session:dashboard" }, - }, - lastChannel: "room-chat", - lastTo: "peer-old", - lastAccountId: "old-workspace", - }); - - expect(staleLegacyExternal.deliveryContext).toEqual({ - channel: "webchat", - to: "session:dashboard", - accountId: "work", - }); - expect(staleLegacyExternal.route).toEqual({ - channel: "webchat", - accountId: "work", - target: { to: "session:dashboard" }, - }); - }); - - it("normalizes delivery fields, mirrors session fields, and avoids cross-channel carryover", () => { - const normalized = normalizeSessionDeliveryFields({ - deliveryContext: { - channel: " demo-fallback ", - to: " channel:1 ", - accountId: " acct-2 ", - threadId: " 444 ", - }, - lastChannel: " demo-primary ", - lastTo: " +1555 ", - }); - - expect(normalized.deliveryContext).toEqual({ - channel: "demo-primary", - to: "+1555", - accountId: undefined, - }); - expect(normalized.lastChannel).toBe("demo-primary"); - expect(normalized.lastTo).toBe("+1555"); - expect(normalized.lastAccountId).toBeUndefined(); - expect(normalized.lastThreadId).toBeUndefined(); - }); - - it("normalizes route-first delivery fields and mirrors legacy fields", () => { - const normalized = normalizeSessionDeliveryFields({ - route: { - channel: "Slack", - accountId: " work ", - target: { to: " channel:C123 ", rawTo: " slack://C123 ", chatType: "channel" }, - thread: { id: " 177000.123 ", kind: "thread", source: "target" }, - }, - deliveryContext: { - channel: "discord", - to: "channel:old", - threadId: "old-thread", - }, - lastChannel: "discord", - lastTo: "channel:older", - }); - - expect(normalized.route).toEqual({ - channel: "slack", - accountId: "work", - target: { to: "channel:C123", rawTo: "slack://C123", chatType: "channel" }, - thread: { id: "177000.123", kind: "thread", source: "target" }, - }); - expect(normalized.deliveryContext).toEqual({ - channel: "slack", - to: "channel:C123", - accountId: "work", - threadId: "177000.123", - }); - expect(normalized.lastChannel).toBe("slack"); - expect(normalized.lastTo).toBe("channel:C123"); - expect(normalized.lastAccountId).toBe("work"); - expect(normalized.lastThreadId).toBe("177000.123"); + lastChannel: "telegram", + lastTo: "-1001", + lastAccountId: "bot", + lastThreadId: 42, + }; + const projection = projectSessionDeliveryFields(delivery); + expect(projection).toEqual(expectedProjection); + expect(JSON.stringify(projection)).toBe(JSON.stringify(expectedProjection)); }); }); diff --git a/src/utils/delivery-context.ts b/src/utils/delivery-context.ts index 92ba3c28e04b..c769fcda9312 100644 --- a/src/utils/delivery-context.ts +++ b/src/utils/delivery-context.ts @@ -4,5 +4,6 @@ export { deliveryContextKey, mergeDeliveryContext, normalizeDeliveryContext, + sessionDeliveryChannel, } from "./delivery-context.shared.js"; export type { DeliveryContext } from "./delivery-context.types.js"; diff --git a/src/utils/delivery-context.types.ts b/src/utils/delivery-context.types.ts index 6a2ff0962a8e..7ce6ea4ebbc7 100644 --- a/src/utils/delivery-context.types.ts +++ b/src/utils/delivery-context.types.ts @@ -1,5 +1,5 @@ // Delivery context types describe normalized channel route delivery inputs. -import type { ChannelRouteRef, ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; +import type { ChannelRouteTargetInput } from "../plugin-sdk/channel-route.js"; /** Deferred outbound delivery intent attached to a session or task. */ type DeliveryIntentRef = { @@ -27,27 +27,3 @@ export type DeliveryContext = Pick< /** Optional queued-delivery intent associated with this context. */ deliveryIntent?: DeliveryIntentRef; }; - -/** Mixed legacy and modern session fields used to reconstruct a delivery context. */ -export type DeliveryContextSessionSource = { - /** Modern SDK route metadata, preferred when present and routable. */ - route?: ChannelRouteRef; - /** Original/current session channel; may be an internal channel such as webchat. */ - channel?: string; - /** Legacy mirrored delivery channel. */ - lastChannel?: string; - /** Legacy mirrored delivery target. */ - lastTo?: string; - /** Legacy mirrored account/workspace id. */ - lastAccountId?: string; - /** Legacy mirrored thread/topic id. */ - lastThreadId?: string | number; - /** Older origin fields emitted before delivery context became canonical. */ - origin?: { - provider?: string; - accountId?: string; - threadId?: string | number; - }; - /** Canonical delivery context stored on newer session records. */ - deliveryContext?: DeliveryContext; -}; diff --git a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts index 2d9f68d0cdfe..01df1eed079d 100644 --- a/test/helpers/sqlite-sessions-transcripts-flip-proof.ts +++ b/test/helpers/sqlite-sessions-transcripts-flip-proof.ts @@ -36,6 +36,7 @@ import { resolveSessionTranscriptIdentity, } from "../../src/plugin-sdk/session-transcript-runtime.js"; import { sleep } from "../../src/utils.js"; +import { normalizeSessionDeliveryState } from "../../src/utils/delivery-context.shared.js"; import { createOpenClawTestInstance } from "./openclaw-test-instance.js"; type DoctorMode = "import" | "inspect" | "validate" | "restore"; @@ -1388,7 +1389,7 @@ async function runGatewayCleanupPruningProof( await importSqliteSessionRows({ agentId: context.agentId, entry: { - channel: "cli", + delivery: normalizeSessionDeliveryState({ context: { channel: "cli" } }), chatType: "direct", sessionFile: formatSqliteSessionFileMarker({ agentId: context.agentId,