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
This commit is contained in:
Peter Steinberger
2026-07-24 01:01:19 -07:00
committed by GitHub
parent d29b11300f
commit 73bba03e4c
185 changed files with 2961 additions and 1922 deletions
+2
View File
@@ -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
+4
View File
@@ -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
+2
View File
@@ -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
+2
View File
@@ -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:
@@ -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
+8 -4
View File
@@ -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
+7 -2
View File
@@ -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,
},
});
+17 -15
View File
@@ -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 });
@@ -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<string, unknown>)
: {};
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");
@@ -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<SessionEntry, "chatType" | "delivery">;
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;
+29 -30
View File
@@ -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",
},
}),
};
}
@@ -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,
},
@@ -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<typeof normalizeSessionDeliveryState>;
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 = {
@@ -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<typeof chatType> => chatType !== undefined,
);
if (
+7 -3
View File
@@ -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" },
}),
},
});
+20 -13
View File
@@ -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",
},
}),
},
});
@@ -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",
+6 -2
View File
@@ -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,
},
});
@@ -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 });
@@ -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" } }),
},
});
@@ -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");
+2 -1
View File
@@ -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,
+4 -2
View File
@@ -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(
@@ -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),
+17 -12
View File
@@ -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<SessionBindingAdapter["capabilities"]>;
@@ -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",
@@ -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> = {}): SessionEntry {
return {
export type CommandSessionEntryFixture = Partial<SessionEntry> & {
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<SessionEntry> = {},
overrides: CommandSessionEntryFixture = {},
sessionKey = "agent:main:main",
): { entry: SessionEntry; store: Record<string, SessionEntry> } {
const entry = createCommandSessionEntry({
@@ -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<SessionEntry> = {},
overrides: CommandSessionEntryFixture = {},
storePath = "/tmp/openclaw-sessions.json",
sessionKey = "agent:main:main",
): { entry: SessionEntry; store: Record<string, SessionEntry> } {
@@ -794,7 +799,7 @@ function setupStoredSession(
}
function setupBareStoredSession(
overrides: Partial<SessionEntry> = {},
overrides: CommandSessionEntryFixture = {},
storePath = "/tmp/openclaw-sessions.json",
sessionKey = "agent:main:main",
): { entry: SessionEntry; store: Record<string, SessionEntry> } {
@@ -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,
}),
+2 -2
View File
@@ -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") {
+15 -10
View File
@@ -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({
+9 -9
View File
@@ -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;
+1
View File
@@ -2425,6 +2425,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
const replacementEntry: SessionEntry = {
sessionId: "replacement-session-id",
updatedAt: 2,
delivery: { kind: "none" },
modelProvider: "openai",
model: "gpt-5.5",
};
+2 -1
View File
@@ -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,
@@ -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(),
@@ -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(),
},
@@ -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([]);
+1
View File
@@ -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 }
@@ -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<string> {
async function writeStorePath(
storePath: string,
store: Record<string, SessionEntry>,
store: Record<string, SessionEntryFixture>,
): Promise<void> {
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<string, SessionEntry>): Promise<void> {
async function writeStore(
sessionsDir: string,
store: Record<string, SessionEntryFixture>,
): Promise<void> {
await writeStorePath(path.join(sessionsDir, "sessions.json"), store);
}
function mainSessionEntry(overrides: Partial<SessionEntry> = {}): 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> = {}): SessionEntry {
});
}
function runningSessionEntry(
sessionId: string,
overrides: Partial<SessionEntry> = {},
): 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<SessionEntry> = {},
overrides: SessionEntryFixture = {},
sessionKey = "agent:main:main",
): Record<string, SessionEntry> {
return createSessionStore(mainSessionEntry(overrides), sessionKey);
@@ -242,7 +244,7 @@ async function writeMainSession({
sessionsDir,
sessionKey = "agent:main:main",
...entry
}: Partial<SessionEntry> & { sessionsDir: string; sessionKey?: string }): Promise<void> {
}: SessionEntryFixture & { sessionsDir: string; sessionKey?: string }): Promise<void> {
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", [
@@ -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<string, SessionEntry>) {
function resetSessionStore(inputStore: Record<string, SessionEntry>) {
const store = Object.fromEntries(
Object.entries(inputStore).map(([key, entry]) => [
key,
normalizeLegacySessionEntryDelivery(entry),
]),
) as Record<string, SessionEntry>;
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<string, unknown>;
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 () => {
+2 -2
View File
@@ -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,
});
@@ -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);
});
});
+7 -5
View File
@@ -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,
+3 -5
View File
@@ -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<SessionEntry, "delivery">,
requesterOrigin?: DeliveryContext,
): DeliveryContext | undefined {
const normalizedRequester = normalizeDeliveryContext(requesterOrigin);
@@ -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<SessionEntry, "updatedAt"> & { updatedAt?: number };
type SessionEntryFixture = Partial<Omit<SessionEntry, "updatedAt">> & {
updatedAt?: number;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
lastThreadId?: string | number;
};
type SessionStoreFixture = Record<string, SessionEntryFixture | undefined>;
function visibleAgentResponse(runId = "run-main") {
@@ -292,18 +299,15 @@ function setMessageToolGroupReplyConfig(): void {
});
}
function toSessionEntry(
sessionKey: string,
entry?: Partial<SessionEntry>,
): 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<string, SessionEntry> {
+12 -6
View File
@@ -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",
},
}),
},
}));
@@ -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<TRequest extends GatewayRequest, TResult>(
mock.mockImplementation(createGatewayMethodMock(responses, fallback));
}
export type SessionEntryFixture = Partial<InternalSessionEntry> & {
channel?: string;
deliveryContext?: DeliveryContext;
origin?: SessionOrigin;
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
lastThreadId?: string | number;
};
export function createSessionStore(
overrides: Partial<InternalSessionEntry> = {},
overrides: SessionEntryFixture = {},
sessionKey = "agent:main:subagent:child",
): Record<string, InternalSessionEntry> {
return {
@@ -51,14 +64,12 @@ export function createSessionStore(
};
}
export function createSessionEntry(
overrides: Partial<InternalSessionEntry> = {},
): InternalSessionEntry {
return {
export function createSessionEntry(overrides: SessionEntryFixture = {}): InternalSessionEntry {
return normalizeLegacySessionEntryDelivery({
sessionId: "sess-child",
updatedAt: 1,
...overrides,
};
} as InternalSessionEntry);
}
export function createAssistantToolCallMessage(content: unknown[]) {
@@ -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<SessionEntry> => ({
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();
+6 -8
View File
@@ -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,
+1 -2
View File
@@ -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 };
+3 -7
View File
@@ -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<string, unknown>)
: undefined;
const entryChannel = readStringValue(entry.channel);
const entryOrigin = entry.origin as Record<string, unknown> | 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({
+11 -27
View File
@@ -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({
+6 -16
View File
@@ -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",
},
+2 -1
View File
@@ -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({
@@ -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,
});
}
@@ -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" } }),
},
},
}),
+11 -11
View File
@@ -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)
);
}
+23 -11
View File
@@ -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<MsgContext>
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 () => {
+13 -4
View File
@@ -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 {
@@ -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);
@@ -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<SessionEntry, "chatType" | "delivery">;
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;
@@ -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({
@@ -410,6 +410,7 @@ function createSessionEntry(overrides?: Partial<SessionEntry>): 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);
@@ -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();
@@ -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 });
@@ -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 ??
@@ -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: {
@@ -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<typeof resolveEffectiveReplyRoute>[0];
type EffectiveReplyRouteContext = EffectiveReplyRouteParams["ctx"];
type EffectiveReplyRouteEntry = NonNullable<EffectiveReplyRouteParams["entry"]>;
type LegacyDeliveryFixture = Partial<SessionEntry> & {
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",
+16 -16
View File
@@ -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<SessionEntry, "delivery" | "chatType">;
/** 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 } : {}),
};
}
+11 -3
View File
@@ -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 =
@@ -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",
},
}),
},
}),
);
+10 -18
View File
@@ -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;
}
+8 -5
View File
@@ -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,
@@ -752,6 +752,7 @@ describe("resolveContextTokens", () => {
const makeEntry = (overrides: Partial<SessionEntry> = {}): SessionEntry => ({
sessionId: "session-id",
updatedAt: Date.now(),
delivery: { kind: "none" },
...overrides,
});
@@ -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)
);
}
+2 -14
View File
@@ -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" },
},
};
}
@@ -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);
@@ -29,6 +29,7 @@ function createResetFixture(entry: Partial<SessionEntry> = {}) {
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",
+11 -5
View File
@@ -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;
}
+3 -4
View File
@@ -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)",
+34 -40
View File
@@ -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) {
@@ -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<typeof projectSessionDeliveryFields>;
function projectSessionEntry(entry: SessionEntry): ProjectedSessionEntry {
return { ...entry, ...projectSessionDeliveryFields(entry.delivery) };
}
export const initSessionState = async (
params: Omit<Parameters<typeof initSessionStateRaw>[0], "ctx"> & {
ctx: Record<string, unknown>;
},
) => 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<SessionEntry>;
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<string, SessionEntry> {
export function readSessionStore(storePath: string): Record<string, ProjectedSessionEntry> {
const entries = Object.fromEntries(
listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [sessionKey, entry]),
) as Record<string, SessionEntry>;
listSessionEntries({ storePath }).map(({ sessionKey, entry }) => [
sessionKey,
projectSessionEntry(entry),
]),
) as Record<string, ProjectedSessionEntry>;
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<string, SessionEntry
}
const entry = loadSessionEntry({ storePath, sessionKey: prop, readConsistency: "latest" });
if (entry) {
target[prop] = entry;
target[prop] = projectSessionEntry(entry);
}
return Reflect.get(target, prop, receiver);
},
+3 -2
View File
@@ -15,6 +15,7 @@ import {
buildStatusMessage as buildStatusMessageRaw,
type buildStatusMessage as BuildStatusMessage,
} from "../status/status-message.js";
import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.js";
import { createSuccessfulImageMediaDecision } from "./media-understanding.test-fixtures.js";
import { buildCommandsMessage, buildCommandsMessagePaginated, buildHelpMessage } from "./status.js";
@@ -834,7 +835,7 @@ describe("buildStatusMessage", () => {
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,
+2 -2
View File
@@ -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;
+18 -32
View File
@@ -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();
+5 -1
View File
@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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);
+9 -4
View File
@@ -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<SessionEntry, "delivery"> }
| undefined;
expect(deliveryCall?.opts?.to).toBeUndefined();
expect(deliveryCall?.sessionEntry?.lastTo).toBe("+1555");
expect(deliveryContextFromSession(deliveryCall?.sessionEntry)?.to).toBe("+1555");
});
});
+12 -5
View File
@@ -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<Record<string, unknown>> {
@@ -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<string, unknown>,
): 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<string, unknown> | 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);
});
});
@@ -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<string>();
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<OpenClawAgentKyselyDatabase>(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<OpenClawAgentKyselyDatabase>(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;
}
+2 -1
View File
@@ -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",
@@ -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<typeof import("./doctor-sqlite-maintenance-lock.js")>();
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()
@@ -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<ReturnType<typeof runSessionSqlite>>;
@@ -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 &&
+1 -2
View File
@@ -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();
});
+3 -13
View File
@@ -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,
@@ -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",
},
}),
};
}
+9 -6
View File
@@ -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",
+9 -11
View File
@@ -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,
@@ -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<SessionEntry> & {
deliveryContext?: DeliveryContext;
origin?: SessionOrigin;
};
const upsertSessionEntry = (
scope: Parameters<typeof upsertCanonicalSessionEntry>[0],
entry: LegacyDeliveryFixture,
) => upsertCanonicalSessionEntry(scope, normalizeLegacySessionEntryDelivery(entry as SessionEntry));
async function withConversationStore(
run: (params: {
@@ -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",
+10 -15
View File
@@ -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,
});
}
@@ -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<SessionEntry> & {
deliveryContext?: DeliveryContext;
origin?: SessionOrigin;
};
const upsertSessionEntry = (
scope: Parameters<typeof upsertCanonicalSessionEntry>[0],
entry: LegacyDeliveryFixture,
) => upsertCanonicalSessionEntry(scope, normalizeLegacySessionEntryDelivery(entry as SessionEntry));
describe("conversation registry", () => {
let tempDir: string;

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