mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
d29b11300f
commit
73bba03e4c
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2425,6 +2425,7 @@ describe("updateSessionStoreAfterAgentRun", () => {
|
||||
const replacementEntry: SessionEntry = {
|
||||
sessionId: "replacement-session-id",
|
||||
updatedAt: 2,
|
||||
delivery: { kind: "none" },
|
||||
modelProvider: "openai",
|
||||
model: "gpt-5.5",
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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" } }),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
Reference in New Issue
Block a user