mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 15:04:09 +00:00
fix(agents): expose session status route context
Expose session status route context so agents can distinguish session origin, active live route, and persisted delivery route. Add maintainer fixup to keep active route metadata on the real live run key when policy and run keys differ. Thanks @nxmxbbd. Closes #84544
This commit is contained in:
@@ -120,6 +120,19 @@ sparse token/cache counters from the latest transcript usage entry, and
|
||||
the caller's current session; visible client labels such as `openclaw-tui` are
|
||||
not session keys.
|
||||
|
||||
When route metadata is available, `session_status` also includes a visible
|
||||
`Route context` JSON block and matching structured `details` fields. These
|
||||
fields disambiguate the session key from the route that is currently handling
|
||||
the live run:
|
||||
|
||||
- `origin` is where the session was created, or the provider inferred from a
|
||||
deliverable session-key prefix when older state lacks stored origin metadata.
|
||||
- `active` is the current live-run route. It is only reported for the live or
|
||||
current session being handled now.
|
||||
- `deliveryContext` is the persisted delivery route stored on the session,
|
||||
which OpenClaw can reuse for later delivery even when the active surface
|
||||
differs.
|
||||
|
||||
`sessions_yield` intentionally ends the current turn so the next message can be
|
||||
the follow-up event you are waiting for. Use it after spawning sub-agents when
|
||||
you want completion results to arrive as the next message instead of building
|
||||
|
||||
@@ -617,6 +617,165 @@ describe("session_status tool", () => {
|
||||
expect(details.statusText).toContain("OpenClaw");
|
||||
});
|
||||
|
||||
it("reports origin, active, and persisted delivery route metadata for semantic current", async () => {
|
||||
const sessionKey = "agent:main:discord:channel:1489550370136129537";
|
||||
resetSessionStore({
|
||||
[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",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createSessionStatusTool({
|
||||
agentSessionKey: sessionKey,
|
||||
runSessionKey: sessionKey,
|
||||
activeDeliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "control-ui-conversation",
|
||||
accountId: "browser",
|
||||
threadId: "webchat-thread",
|
||||
},
|
||||
config: mockConfig as never,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-current-route-context", { sessionKey: "current" });
|
||||
const details = result.details as {
|
||||
ok?: boolean;
|
||||
sessionKey?: string;
|
||||
statusText?: string;
|
||||
origin?: { provider?: string; accountId?: string };
|
||||
active?: { channel?: string; to?: string; accountId?: string; threadId?: string };
|
||||
deliveryContext?: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string;
|
||||
};
|
||||
};
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.sessionKey).toBe(sessionKey);
|
||||
expect(details.origin).toEqual({ provider: "discord", accountId: "bot-primary" });
|
||||
expect(details.active).toEqual({
|
||||
channel: "webchat",
|
||||
to: "control-ui-conversation",
|
||||
accountId: "browser",
|
||||
threadId: "webchat-thread",
|
||||
});
|
||||
expect(details.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "channel:1489550370136129537",
|
||||
accountId: "bot-primary",
|
||||
threadId: "thread-origin",
|
||||
});
|
||||
const text =
|
||||
result.content.find((item): item is { type: "text"; text: string } => item.type === "text")
|
||||
?.text ?? "";
|
||||
expect(text).toContain("Route context:");
|
||||
expect(text).toContain('"origin"');
|
||||
expect(text).toContain('"active"');
|
||||
expect(text).toContain('"deliveryContext"');
|
||||
expect(details.statusText).toContain('"active"');
|
||||
});
|
||||
|
||||
it("does not report an active route for explicit non-live session lookups", async () => {
|
||||
const currentKey = "agent:main:main";
|
||||
const targetKey = "agent:main:discord:channel:1489550370136129537";
|
||||
resetSessionStore({
|
||||
[currentKey]: {
|
||||
sessionId: "s-main",
|
||||
updatedAt: 5,
|
||||
},
|
||||
[targetKey]: {
|
||||
sessionId: "s-target",
|
||||
updatedAt: 10,
|
||||
deliveryContext: {
|
||||
channel: "discord",
|
||||
to: "channel:1489550370136129537",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockConfig = {
|
||||
...mockConfig,
|
||||
tools: { sessions: { visibility: "all" }, agentToAgent: { enabled: true, allow: ["*"] } },
|
||||
};
|
||||
|
||||
const tool = createSessionStatusTool({
|
||||
agentSessionKey: currentKey,
|
||||
runSessionKey: currentKey,
|
||||
activeDeliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "control-ui-conversation",
|
||||
},
|
||||
config: mockConfig as never,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-explicit-non-live-route-context", {
|
||||
sessionKey: targetKey,
|
||||
});
|
||||
const details = result.details as {
|
||||
origin?: { provider?: string };
|
||||
active?: { channel?: string };
|
||||
deliveryContext?: { channel?: string; to?: string };
|
||||
};
|
||||
expect(details.origin).toEqual({ provider: "discord" });
|
||||
expect(details.active).toBeUndefined();
|
||||
expect(details.deliveryContext).toEqual({
|
||||
channel: "discord",
|
||||
to: "channel:1489550370136129537",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not report an active route for an explicit stale policy-key lookup", async () => {
|
||||
const policyKey = "agent:main:telegram:default:direct:1234";
|
||||
const runKey = "agent:main:main";
|
||||
resetSessionStore({
|
||||
[policyKey]: {
|
||||
sessionId: "s-policy",
|
||||
updatedAt: 5,
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
to: "telegram:direct:1234",
|
||||
},
|
||||
},
|
||||
[runKey]: {
|
||||
sessionId: "s-run",
|
||||
updatedAt: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = createSessionStatusTool({
|
||||
agentSessionKey: policyKey,
|
||||
runSessionKey: runKey,
|
||||
activeDeliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "control-ui-conversation",
|
||||
},
|
||||
config: mockConfig as never,
|
||||
});
|
||||
|
||||
const result = await tool.execute("call-explicit-stale-policy-key-route-context", {
|
||||
sessionKey: policyKey,
|
||||
});
|
||||
const details = result.details as {
|
||||
sessionKey?: string;
|
||||
active?: { channel?: string };
|
||||
deliveryContext?: { channel?: string; to?: string };
|
||||
};
|
||||
expect(details.sessionKey).toBe(policyKey);
|
||||
expect(details.active).toBeUndefined();
|
||||
expect(details.deliveryContext).toEqual({
|
||||
channel: "telegram",
|
||||
to: "telegram:direct:1234",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects explicit cross-session key under tree visibility even when it equals runSessionKey (#76708)", async () => {
|
||||
resetSessionStore({
|
||||
"agent:main:telegram:default:direct:1234": {
|
||||
|
||||
@@ -516,6 +516,12 @@ export function createOpenClawTools(
|
||||
sandboxed: options?.sandboxed,
|
||||
activeModelProvider: options?.modelProvider,
|
||||
activeModelId: options?.modelId,
|
||||
activeDeliveryContext: {
|
||||
channel: options?.agentChannel,
|
||||
to: options?.currentChannelId ?? options?.agentTo,
|
||||
accountId: options?.agentAccountId,
|
||||
threadId: options?.currentThreadTs ?? options?.agentThreadId,
|
||||
},
|
||||
}),
|
||||
...collectPresentOpenClawTools([webSearchTool, webFetchTool, imageTool, pdfTool]),
|
||||
];
|
||||
|
||||
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => {
|
||||
return {
|
||||
stubTool,
|
||||
createCronToolOptions: vi.fn(),
|
||||
createSessionStatusToolOptions: vi.fn(),
|
||||
createImageGenerateToolOptions: vi.fn(),
|
||||
createMusicGenerateToolOptions: vi.fn(),
|
||||
createVideoGenerateToolOptions: vi.fn(),
|
||||
@@ -83,7 +84,10 @@ vi.mock("./tools/pdf-tool.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./tools/session-status-tool.js", () => ({
|
||||
createSessionStatusTool: () => mocks.stubTool("session_status"),
|
||||
createSessionStatusTool: (options: unknown) => {
|
||||
mocks.createSessionStatusToolOptions(options);
|
||||
return mocks.stubTool("session_status");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./tools/sessions-history-tool.js", () => ({
|
||||
@@ -351,6 +355,40 @@ describe("createOpenClawTools media generation session wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOpenClawTools session status route context wiring", () => {
|
||||
beforeEach(() => {
|
||||
mocks.createSessionStatusToolOptions.mockClear();
|
||||
});
|
||||
|
||||
it("passes the active live-run route into the session_status tool", () => {
|
||||
createOpenClawTools({
|
||||
agentSessionKey: "agent:main:discord:channel:1489550370136129537",
|
||||
runSessionKey: "agent:main:discord:channel:1489550370136129537",
|
||||
agentChannel: "webchat",
|
||||
agentAccountId: "browser",
|
||||
agentTo: "channel:1489550370136129537",
|
||||
agentThreadId: "origin-thread",
|
||||
currentChannelId: "webchat:control-ui",
|
||||
currentThreadTs: "webchat-thread-1",
|
||||
disableMessageTool: true,
|
||||
disablePluginTools: true,
|
||||
});
|
||||
|
||||
expect(mocks.createSessionStatusToolOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentSessionKey: "agent:main:discord:channel:1489550370136129537",
|
||||
runSessionKey: "agent:main:discord:channel:1489550370136129537",
|
||||
activeDeliveryContext: {
|
||||
channel: "webchat",
|
||||
to: "webchat:control-ui",
|
||||
accountId: "browser",
|
||||
threadId: "webchat-thread-1",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOpenClawTools cron context wiring", () => {
|
||||
beforeEach(() => {
|
||||
mocks.createCronToolOptions.mockClear();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { readStringValue } from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import { Type } from "typebox";
|
||||
import type {
|
||||
@@ -29,6 +30,15 @@ import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import type { BuildStatusTextParams } from "../../status/status-text.types.js";
|
||||
import { buildTaskStatusSnapshotForRelatedSessionKeyForOwner } from "../../tasks/task-owner-access.js";
|
||||
import { formatTaskStatusDetail, formatTaskStatusTitle } from "../../tasks/task-status.js";
|
||||
import {
|
||||
deliveryContextFromSession,
|
||||
normalizeDeliveryContext,
|
||||
type DeliveryContext,
|
||||
} from "../../utils/delivery-context.shared.js";
|
||||
import {
|
||||
isDeliverableMessageChannel,
|
||||
normalizeMessageChannel,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { loadModelCatalog } from "../model-catalog.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
@@ -190,6 +200,140 @@ function listImplicitDefaultDirectFallbackKeys(params: {
|
||||
|
||||
type ActiveStatusModelIdentity = { provider?: string; model: string };
|
||||
|
||||
type SessionStatusOriginDetails = {
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
type SessionStatusDeliveryContextDetails = {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
};
|
||||
|
||||
type SessionStatusRouteDetails = {
|
||||
origin?: SessionStatusOriginDetails;
|
||||
active?: SessionStatusDeliveryContextDetails;
|
||||
deliveryContext?: SessionStatusDeliveryContextDetails;
|
||||
};
|
||||
|
||||
const INTERNAL_SESSION_KEY_ORIGIN_PREFIXES = new Set(["main", "cron", "subagent", "acp"]);
|
||||
|
||||
function readRouteThreadId(value: unknown): string | number | undefined {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return value.trim();
|
||||
}
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function compactOriginDetails(params: {
|
||||
provider?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
}): SessionStatusOriginDetails | undefined {
|
||||
const threadId = readRouteThreadId(params.threadId);
|
||||
const details: SessionStatusOriginDetails = {
|
||||
...(params.provider ? { provider: params.provider } : {}),
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
return Object.keys(details).length ? details : undefined;
|
||||
}
|
||||
|
||||
function compactDeliveryContextDetails(params: {
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
threadId?: string | number;
|
||||
}): SessionStatusDeliveryContextDetails | undefined {
|
||||
const threadId = readRouteThreadId(params.threadId);
|
||||
const details: SessionStatusDeliveryContextDetails = {
|
||||
...(params.channel ? { channel: params.channel } : {}),
|
||||
...(params.to ? { to: params.to } : {}),
|
||||
...(params.accountId ? { accountId: params.accountId } : {}),
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
return Object.keys(details).length ? details : undefined;
|
||||
}
|
||||
|
||||
function normalizeStatusDeliveryContext(
|
||||
context?: DeliveryContext,
|
||||
): SessionStatusDeliveryContextDetails | undefined {
|
||||
return compactDeliveryContextDetails({
|
||||
channel: readStringValue(context?.channel),
|
||||
to: readStringValue(context?.to),
|
||||
accountId: readStringValue(context?.accountId),
|
||||
threadId: context?.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeActiveDeliveryContext(
|
||||
context?: DeliveryContext,
|
||||
): SessionStatusDeliveryContextDetails | undefined {
|
||||
if (!context) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = normalizeDeliveryContext(context);
|
||||
const rawChannel = readStringValue(normalized?.channel) ?? readStringValue(context.channel);
|
||||
const channel = rawChannel ? (normalizeMessageChannel(rawChannel) ?? rawChannel) : undefined;
|
||||
return compactDeliveryContextDetails({
|
||||
channel,
|
||||
to: readStringValue(normalized?.to) ?? readStringValue(context.to),
|
||||
accountId: readStringValue(normalized?.accountId) ?? readStringValue(context.accountId),
|
||||
threadId: normalized?.threadId ?? context.threadId,
|
||||
});
|
||||
}
|
||||
|
||||
function inferOriginProviderFromSessionKey(sessionKey: string): string | undefined {
|
||||
const parsed = parseAgentSessionKey(sessionKey);
|
||||
const head = readStringValue(parsed?.rest.split(":")[0]);
|
||||
if (!head || INTERNAL_SESSION_KEY_ORIGIN_PREFIXES.has(head.toLowerCase())) {
|
||||
return undefined;
|
||||
}
|
||||
const channel = normalizeMessageChannel(head);
|
||||
return channel && isDeliverableMessageChannel(channel) ? channel : undefined;
|
||||
}
|
||||
|
||||
function buildSessionStatusRouteDetails(params: {
|
||||
entry: SessionEntry;
|
||||
sessionKey: string;
|
||||
activeDeliveryContext?: DeliveryContext;
|
||||
isLiveRunSession?: boolean;
|
||||
}): SessionStatusRouteDetails {
|
||||
const origin = compactOriginDetails({
|
||||
provider:
|
||||
readStringValue(params.entry.origin?.provider) ??
|
||||
inferOriginProviderFromSessionKey(params.sessionKey),
|
||||
accountId: readStringValue(params.entry.origin?.accountId),
|
||||
threadId: params.entry.origin?.threadId,
|
||||
});
|
||||
const deliveryContext = normalizeStatusDeliveryContext(deliveryContextFromSession(params.entry));
|
||||
const active = params.isLiveRunSession
|
||||
? normalizeActiveDeliveryContext(params.activeDeliveryContext)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
...(origin ? { origin } : {}),
|
||||
...(active ? { active } : {}),
|
||||
...(deliveryContext ? { deliveryContext } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatSessionStatusRouteContext(details: SessionStatusRouteDetails): string | undefined {
|
||||
if (Object.keys(details).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return `Route context:
|
||||
\`\`\`json
|
||||
${JSON.stringify(details, null, 2)}
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
function resolveActiveStatusModelIdentity(params: {
|
||||
activeModelId?: string;
|
||||
activeModelProvider?: string;
|
||||
@@ -348,6 +492,8 @@ export function createSessionStatusTool(opts?: {
|
||||
sandboxed?: boolean;
|
||||
activeModelProvider?: string;
|
||||
activeModelId?: string;
|
||||
/** Active live-run route, kept separate from the persisted/origin delivery route. */
|
||||
activeDeliveryContext?: DeliveryContext;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
label: "Session Status",
|
||||
@@ -673,17 +819,18 @@ export function createSessionStatusTool(opts?: {
|
||||
const activeModelId = opts?.activeModelId?.trim();
|
||||
const activeModelProvider = opts?.activeModelProvider?.trim();
|
||||
const isImplicitCurrentRequest = requestedKeyParam === undefined;
|
||||
const liveSessionKeys = [
|
||||
opts?.runSessionKey,
|
||||
storeScopedRequesterKey,
|
||||
effectiveRequesterKey,
|
||||
visibilityRequesterKey,
|
||||
];
|
||||
const activeModelIdentity = resolveActiveStatusModelIdentity({
|
||||
activeModelId,
|
||||
activeModelProvider,
|
||||
isImplicitCurrentRequest,
|
||||
isSemanticCurrentRequest,
|
||||
liveSessionKeys: [
|
||||
opts?.runSessionKey,
|
||||
storeScopedRequesterKey,
|
||||
effectiveRequesterKey,
|
||||
visibilityRequesterKey,
|
||||
],
|
||||
liveSessionKeys,
|
||||
modelRaw,
|
||||
resolvedKey: resolved.key,
|
||||
});
|
||||
@@ -767,6 +914,27 @@ export function createSessionStatusTool(opts?: {
|
||||
taskLine && !statusText.includes(taskLine) ? `${statusText}\n${taskLine}` : statusText;
|
||||
const resultOverrideProvider = statusSessionEntry.providerOverride?.trim();
|
||||
const resultOverrideModel = statusSessionEntry.modelOverride?.trim();
|
||||
const liveSessionKeySet = new Set(
|
||||
liveSessionKeys
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
const activeRouteRunSessionKey = opts?.runSessionKey?.trim();
|
||||
const isLiveRouteSession = activeRouteRunSessionKey
|
||||
? resolved.key.trim() === activeRouteRunSessionKey
|
||||
: liveSessionKeySet.has(resolved.key.trim());
|
||||
const routeDetails = buildSessionStatusRouteDetails({
|
||||
entry: statusSessionEntry,
|
||||
sessionKey: resolved.key,
|
||||
activeDeliveryContext: opts?.activeDeliveryContext,
|
||||
isLiveRunSession: isLiveRouteSession,
|
||||
});
|
||||
const routeContextText = formatSessionStatusRouteContext(routeDetails);
|
||||
const visibleStatusText = routeContextText
|
||||
? `${fullStatusText}
|
||||
|
||||
${routeContextText}`
|
||||
: fullStatusText;
|
||||
const modelOverrideForResult =
|
||||
modelRaw === undefined
|
||||
? undefined
|
||||
@@ -777,7 +945,7 @@ export function createSessionStatusTool(opts?: {
|
||||
: null;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: fullStatusText }],
|
||||
content: [{ type: "text", text: visibleStatusText }],
|
||||
details: {
|
||||
ok: true,
|
||||
sessionKey: resolved.key,
|
||||
@@ -791,7 +959,8 @@ export function createSessionStatusTool(opts?: {
|
||||
modelOverride: modelOverrideForResult,
|
||||
}
|
||||
: {}),
|
||||
statusText: fullStatusText,
|
||||
statusText: visibleStatusText,
|
||||
...routeDetails,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user