fix(agent): support explicit CLI session keys (#85121)

Summary:
- The PR adds `openclaw agent --session-key`, normalizes explicit session keys through Gateway and embedded agent execution, and updates docs, tests, and changelog.
- Reproducibility: yes. Current main's `openclaw agent` registration and gateway CLI option type lack `--sessi ... Gateway agent protocol already accepts `sessionKey`; this is source-reproducible without executing the CLI.

Automerge notes:
- PR branch already contained follow-up commit before automerge: fix(agent): support explicit CLI session keys

Validation:
- ClawSweeper review passed for head 2c76dd339f.
- Required merge gates passed before the squash merge.

Prepared head SHA: 2c76dd339f
Review: https://github.com/openclaw/openclaw/pull/85121#issuecomment-4513508932

Co-authored-by: Kaspre <kaspre@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Kaspre
2026-05-21 23:08:25 -04:00
committed by GitHub
parent a4c81c6f35
commit eb7f3b7b50
12 changed files with 627 additions and 19 deletions
+1
View File
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
+5
View File
@@ -13,6 +13,7 @@ Use `--agent <id>` to target a configured agent directly.
Pass at least one session selector:
- `--to <dest>`
- `--session-key <key>`
- `--session-id <id>`
- `--agent <id>`
@@ -24,6 +25,7 @@ Related:
- `-m, --message <text>`: required message body
- `-t, --to <dest>`: recipient used to derive the session key
- `--session-key <key>`: explicit session key to use for routing
- `--session-id <id>`: explicit session id
- `--agent <id>`: agent id; overrides routing bindings
- `--model <id>`: model override for this run (`provider/model` or model id)
@@ -44,6 +46,8 @@ Related:
openclaw agent --to +15555550123 --message "status update" --deliver
openclaw agent --agent ops --message "Summarize logs"
openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs"
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
@@ -57,6 +61,7 @@ openclaw agent --agent ops --message "Run locally" --local
- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive.
- Gateway-backed runs leave Gateway-owned MCP loopback resources under the running Gateway process; older clients may still send the historical cleanup flag, but the Gateway accepts it as a compatibility no-op.
- `--channel`, `--reply-channel`, and `--reply-account` affect reply delivery, not session routing.
- `--session-key` selects an explicit session key. Agent-prefixed keys must use `agent:<agent-id>:<session-key>`, and `--agent` must match the key's agent id when both are provided. Bare non-sentinel keys are scoped to `--agent` when supplied, or to the configured default agent otherwise; for example, `--agent ops --session-key incident-42` routes to `agent:ops:incident-42`. Literal `global` and `unknown` remain unscoped only when no `--agent` is supplied; in that case, embedded fallback and store ownership use the configured default agent.
- `--json` keeps stdout reserved for the JSON response. Gateway, plugin, and embedded-fallback diagnostics are routed to stderr so scripts can parse stdout directly.
- Embedded fallback JSON includes `meta.transport: "embedded"` and `meta.fallbackFrom: "gateway"` so scripts can distinguish fallback runs from Gateway runs.
- If the Gateway accepts an agent run but the CLI times out waiting for the final reply, embedded fallback uses a fresh explicit `gateway-fallback-*` session/run id and reports `meta.fallbackReason: "gateway_timeout"` plus the fallback session fields. This avoids racing the Gateway-owned transcript lock or silently replacing the original routed conversation session.
+19 -1
View File
@@ -15,7 +15,7 @@ programmatic delivery.
<Steps>
<Step title="Run a simple agent turn">
```bash
openclaw agent --message "What is the weather today?"
openclaw agent --agent main --message "What is the weather today?"
```
This sends the message through the Gateway and prints the reply.
@@ -32,6 +32,9 @@ programmatic delivery.
# Reuse an existing session
openclaw agent --session-id abc123 --message "Continue the task"
# Target an exact session key
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
```
</Step>
@@ -55,6 +58,7 @@ programmatic delivery.
| ----------------------------- | ----------------------------------------------------------- |
| `--message \<text\>` | Message to send (required) |
| `--to \<dest\>` | Derive session key from a target (phone, chat id) |
| `--session-key \<key\>` | Use an explicit session key |
| `--agent \<id\>` | Target a configured agent (uses its `main` session) |
| `--session-id \<id\>` | Reuse an existing session by id |
| `--local` | Force local embedded runtime (skip Gateway) |
@@ -75,6 +79,14 @@ programmatic delivery.
- If the Gateway is unreachable, the CLI **falls back** to the local embedded run.
- Session selection: `--to` derives the session key (group/channel targets
preserve isolation; direct chats collapse to `main`).
- `--session-key` selects an explicit key. Agent-prefixed keys must use
`agent:<agent-id>:<session-key>`, and `--agent` must match that agent id when
both are supplied. Bare non-sentinel keys are scoped to `--agent` when
supplied; for example, `--agent ops --session-key incident-42` routes to
`agent:ops:incident-42`. Without `--agent`, bare non-sentinel keys are scoped
to the configured default agent. Literal `global` and `unknown` remain
unscoped only when no `--agent` is supplied; in that case, embedded fallback
and store ownership use the configured default agent.
- Thinking and verbose flags persist into the session store.
- Output: plain text by default, or `--json` for structured payload + metadata.
- With `--json --deliver`, the JSON includes delivery status for sent,
@@ -90,6 +102,12 @@ openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
# Turn with thinking level
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
# Exact session key
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
# Legacy key scoped to an agent
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
# Deliver to a different channel than the session
openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin"
```
+33 -6
View File
@@ -22,9 +22,12 @@ import { buildOutboundSessionContext } from "../infra/outbound/session-context.j
import { createSubsystemLogger } from "../logging/subsystem.js";
import { loadManifestMetadataSnapshot } from "../plugins/manifest-contract-eligibility.js";
import {
classifySessionKeyShape,
isUnscopedSessionKeySentinel,
isSubagentSessionKey,
normalizeAgentId,
resolveAgentIdFromSessionKey,
scopeLegacySessionKeyToAgent,
} from "../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
import { applyVerboseOverride } from "../sessions/level-overrides.js";
@@ -48,6 +51,7 @@ import {
markAutoFallbackPrimaryProbe,
resolveAutoFallbackPrimaryProbe,
resolveAgentDir,
resolveDefaultAgentId,
resolveEffectiveModelFallbacks,
resolveSessionAgentId,
resolveAgentSkillsFilter,
@@ -322,8 +326,11 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run
if (!message.trim()) {
throw new Error("Message (--message) is required");
}
if (!opts.to && !opts.sessionId && !opts.sessionKey && !opts.agentId) {
throw new Error("Pass --to <E.164>, --session-id, or --agent to choose a session");
const rawExplicitSessionKey = opts.sessionKey?.trim();
if (!opts.to && !opts.sessionId && !rawExplicitSessionKey && !opts.agentId) {
throw new Error(
"Pass --to <E.164>, --session-key, --session-id, or --agent to choose a session",
);
}
const { cfg } = await resolveAgentRuntimeConfig(runtime, {
@@ -346,8 +353,28 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run
);
}
}
if (agentIdOverride && opts.sessionKey) {
const sessionAgentId = resolveAgentIdFromSessionKey(opts.sessionKey);
const shouldScopeDefaultAgentKey =
rawExplicitSessionKey &&
!agentIdOverride &&
classifySessionKeyShape(rawExplicitSessionKey) === "legacy_or_alias" &&
!isUnscopedSessionKeySentinel(rawExplicitSessionKey);
const explicitSessionKey = scopeLegacySessionKeyToAgent({
agentId:
agentIdOverride ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg) : undefined),
sessionKey: rawExplicitSessionKey,
mainKey: cfg.session?.mainKey,
});
if (explicitSessionKey && classifySessionKeyShape(explicitSessionKey) === "malformed_agent") {
throw new Error(
`Invalid --session-key "${explicitSessionKey}". Agent-prefixed session keys must use agent:<agent-id>:<session-key>.`,
);
}
if (
agentIdOverride &&
explicitSessionKey &&
classifySessionKeyShape(explicitSessionKey) === "agent"
) {
const sessionAgentId = resolveAgentIdFromSessionKey(explicitSessionKey);
if (sessionAgentId !== agentIdOverride) {
throw new Error(
`Agent id "${agentIdOverrideRaw}" does not match session key agent "${sessionAgentId}".`,
@@ -381,7 +408,7 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run
cfg,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: opts.sessionKey,
sessionKey: explicitSessionKey,
agentId: agentIdOverride,
});
@@ -398,7 +425,7 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run
const sessionAgentId =
agentIdOverride ??
resolveSessionAgentId({
sessionKey: sessionKey ?? opts.sessionKey?.trim(),
sessionKey: sessionKey ?? explicitSessionKey,
config: cfg,
});
const outboundSession = buildOutboundSessionContext({
+4 -1
View File
@@ -24,6 +24,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
buildAgentMainSessionKey,
DEFAULT_AGENT_ID,
isUnscopedSessionKeySentinel,
normalizeAgentId,
normalizeMainKey,
} from "../../routing/session-key.js";
@@ -218,7 +219,9 @@ export function resolveSessionKeyForRequest(opts: {
})
: undefined);
const storeAgentId = explicitSessionKey
? resolveAgentIdFromSessionKey(explicitSessionKey)
? isUnscopedSessionKeySentinel(explicitSessionKey)
? (requestedAgentId ?? defaultAgentId)
: resolveAgentIdFromSessionKey(explicitSessionKey)
: (requestedAgentId ?? defaultAgentId);
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: storeAgentId,
+10
View File
@@ -134,6 +134,16 @@ describe("registerAgentCommands", () => {
expect(deps).toEqual({ deps: true });
});
it("forwards an explicit session key to the agent command", async () => {
await runCli(["agent", "--message", "hi", "--session-key", "agent:ops:incident-42"]);
const [options, callRuntime, deps] = commandCall(agentCliCommandMock);
expect((options as { message?: string }).message).toBe("hi");
expect((options as { sessionKey?: string }).sessionKey).toBe("agent:ops:incident-42");
expect(callRuntime).toBe(runtime);
expect(deps).toEqual({ deps: true });
});
it("runs agents add and computes hasFlags based on explicit options", async () => {
await runCli(["agents", "add", "alpha"]);
const [alphaOptions, alphaRuntime, alphaFlags] = commandCall(agentsAddCommandMock, 0);
+5
View File
@@ -68,6 +68,7 @@ export function registerAgentCommands(
.description("Run an agent turn via the Gateway (use --local for embedded)")
.requiredOption("-m, --message <text>", "Message body for the agent")
.option("-t, --to <number>", "Recipient number in E.164 used to derive the session key")
.option("--session-key <key>", "Explicit session key (agent:<id>:<key>, or scoped to --agent)")
.option("--session-id <id>", "Use an explicit session id")
.option("--agent <id>", "Agent id (overrides routing bindings)")
.option("--model <id>", "Model override for this run (provider/model or model id)")
@@ -102,6 +103,10 @@ ${theme.heading("Examples:")}
${formatHelpExamples([
['openclaw agent --to +15555550123 --message "status update"', "Start a new session."],
['openclaw agent --agent ops --message "Summarize logs"', "Use a specific agent."],
[
'openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"',
"Target an exact session key.",
],
[
'openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium',
"Target a session with explicit thinking level.",
+326
View File
@@ -42,6 +42,7 @@ function mockConfig(storePath: string, overrides?: Partial<OpenClawConfig>) {
timeoutSeconds: 600,
...overrides?.agents?.defaults,
},
...(overrides?.agents?.list ? { list: overrides.agents.list } : {}),
},
session: {
store: storePath,
@@ -188,6 +189,178 @@ describe("agentCliCommand", () => {
});
});
it("uses an explicit session key as the gateway session selector", async () => {
await withTempStore(async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", sessionKey: "agent:main:incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(requireFirstCallArg(callGateway, "gateway"), "gateway request");
const params = requireRecord(request.params, "gateway request params");
expect(params.sessionKey).toBe("agent:main:incident-42");
expect(params.sessionId).toBeUndefined();
expect(params.to).toBeUndefined();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("scopes legacy explicit session keys to the requested agent", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", agent: "ops", sessionKey: "incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBe("ops");
expect(params.sessionKey).toBe("agent:ops:incident-42");
},
{ agents: { list: [{ id: "main" }, { id: "ops" }] } },
);
});
it("accepts agent-prefixed session keys when only casing differs from --agent", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand(
{ message: "hi", agent: "OPS", sessionKey: "agent:OPS:incident-42" },
runtime,
);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBe("ops");
expect(params.sessionKey).toBe("agent:OPS:incident-42");
},
{ agents: { list: [{ id: "main" }, { id: "ops" }] } },
);
});
it("scopes legacy explicit session keys to the default agent when no agent is requested", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", sessionKey: "incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBeUndefined();
expect(params.sessionKey).toBe("agent:ops:incident-42");
},
{ agents: { list: [{ id: "ops", default: true }, { id: "main" }] } },
);
});
it("prefers explicit session keys when a session id is also supplied", async () => {
await withTempStore(
async ({ store }) => {
fs.writeFileSync(
store,
JSON.stringify({
"agent:main:main": { sessionId: "existing-main-session", updatedAt: 1 },
}),
);
mockGatewaySuccessReply();
await agentCliCommand(
{
message: "hi",
sessionId: "existing-main-session",
sessionKey: "agent:ops:incident-42",
},
runtime,
);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.sessionId).toBe("existing-main-session");
expect(params.sessionKey).toBe("agent:ops:incident-42");
},
{ agents: { list: [{ id: "main" }, { id: "ops" }] } },
);
});
it("scopes legacy global session keys to the requested agent before gateway dispatch", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", agent: "ops", sessionKey: "global" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBe("ops");
expect(params.sessionKey).toBe("agent:ops:global");
},
{ agents: { list: [{ id: "main" }, { id: "ops" }] } },
);
});
it("preserves unscoped global session keys when no agent is requested", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", sessionKey: "global" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBeUndefined();
expect(params.sessionKey).toBe("global");
},
{ agents: { list: [{ id: "ops", default: true }, { id: "main" }] } },
);
});
it("preserves unscoped unknown session keys when no agent is requested", async () => {
await withTempStore(
async () => {
mockGatewaySuccessReply();
await agentCliCommand({ message: "hi", sessionKey: "unknown" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
const request = requireRecord(
requireFirstCallArg(callGateway, "gateway"),
"gateway request",
);
const params = requireRecord(request.params, "gateway request params");
expect(params.agentId).toBeUndefined();
expect(params.sessionKey).toBe("unknown");
},
{ agents: { list: [{ id: "ops", default: true }, { id: "main" }] } },
);
});
it("stays silent when the gateway returns an intentional empty reply", async () => {
await withTempStore(async () => {
callGateway.mockResolvedValue({
@@ -323,6 +496,27 @@ describe("agentCliCommand", () => {
});
});
it("preserves explicit session keys for embedded fallback when the gateway closes", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayClosedError());
mockLocalAgentReply();
await agentCliCommand({ message: "hi", sessionKey: "agent:main:incident-42" }, runtime);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(agentCommand).toHaveBeenCalledTimes(1);
const fallbackOpts = requireRecord(
requireFirstCallArg(agentCommand, "embedded agent"),
"embedded agent options",
);
expect(fallbackOpts.sessionKey).toBe("agent:main:incident-42");
expect(fallbackOpts.resultMetaOverrides).toMatchObject({
transport: "embedded",
fallbackFrom: "gateway",
});
});
});
it("does not fall back to embedded agent for gateway request errors", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(
@@ -388,6 +582,63 @@ describe("agentCliCommand", () => {
});
});
it("uses the explicit session key agent for timeout fallback sessions", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
mockLocalAgentReply();
await agentCliCommand({ message: "hi", sessionKey: "agent:ops:incident-42" }, runtime);
expect(agentCommand).toHaveBeenCalledTimes(1);
const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as {
sessionId?: string;
sessionKey?: string;
};
expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/);
expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`);
});
});
it("uses the default-scoped legacy session key agent for timeout fallback sessions", async () => {
await withTempStore(
async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
mockLocalAgentReply();
await agentCliCommand({ message: "hi", sessionKey: "incident-42" }, runtime);
expect(agentCommand).toHaveBeenCalledTimes(1);
const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as {
sessionId?: string;
sessionKey?: string;
};
expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/);
expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`);
},
{ agents: { list: [{ id: "ops", default: true }, { id: "main" }] } },
);
});
it("uses the default agent for timeout fallback with unscoped global session keys", async () => {
await withTempStore(
async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
mockLocalAgentReply();
await agentCliCommand({ message: "hi", sessionKey: "global" }, runtime);
expect(agentCommand).toHaveBeenCalledTimes(1);
const fallbackOpts = requireFirstCallArg(agentCommand, "embedded agent") as {
sessionId?: string;
sessionKey?: string;
};
expect(fallbackOpts.sessionId).toMatch(/^gateway-fallback-/);
expect(fallbackOpts.sessionKey).toBe(`agent:ops:explicit:${fallbackOpts.sessionId}`);
},
{ agents: { list: [{ id: "ops", default: true }, { id: "main" }] } },
);
});
it("keeps timeout fallback from replacing the routed conversation session key", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
@@ -505,6 +756,81 @@ describe("agentCliCommand", () => {
});
});
it("passes explicit session keys to local embedded runs", async () => {
await withTempStore(async () => {
mockLocalAgentReply();
await agentCliCommand(
{
message: "hi",
sessionKey: "agent:main:incident-42",
local: true,
},
runtime,
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).toHaveBeenCalledTimes(1);
const localOpts = requireRecord(
requireFirstCallArg(agentCommand, "embedded agent"),
"embedded agent options",
);
expect(localOpts.sessionKey).toBe("agent:main:incident-42");
});
});
it("scopes legacy explicit session keys before local embedded runs", async () => {
await withTempStore(async () => {
mockLocalAgentReply();
await agentCliCommand(
{
message: "hi",
agent: "ops",
sessionKey: "incident-42",
local: true,
},
runtime,
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).toHaveBeenCalledTimes(1);
const localOpts = requireRecord(
requireFirstCallArg(agentCommand, "embedded agent"),
"embedded agent options",
);
expect(localOpts.agentId).toBe("ops");
expect(localOpts.sessionKey).toBe("agent:ops:incident-42");
});
});
it("rejects malformed agent-prefixed session keys before gateway or local fallback", async () => {
await withTempStore(async () => {
await expect(
agentCliCommand({ message: "hi", sessionKey: "agent:main" }, runtime),
).rejects.toThrow(
'Invalid --session-key "agent:main". Agent-prefixed session keys must use agent:<agent-id>:<session-key>.',
);
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("rejects explicit session keys whose agent does not match --agent", async () => {
await withTempStore(async () => {
await expect(
agentCliCommand(
{ message: "hi", agent: "ops", sessionKey: "agent:main:incident-42" },
runtime,
),
).rejects.toThrow('Agent id "ops" does not match session key agent "main".');
expect(callGateway).not.toHaveBeenCalled();
expect(agentCommand).not.toHaveBeenCalled();
});
});
it("forces bundle MCP cleanup on embedded fallback", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayClosedError());
+103 -11
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { listAgentIds } from "../agents/agent-scope.js";
import { listAgentIds, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { formatCliCommand } from "../cli/command-format.js";
import type { CliDeps } from "../cli/deps.types.js";
import { withProgress } from "../cli/progress.js";
@@ -10,7 +10,13 @@ import { callGateway, isGatewayTransportError, randomIdempotencyKey } from "../g
import { ADMIN_SCOPE } from "../gateway/operator-scopes.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
import { routeLogsToStderr } from "../logging/console.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
classifySessionKeyShape,
isUnscopedSessionKeySentinel,
normalizeAgentId,
resolveAgentIdFromSessionKey,
scopeLegacySessionKeyToAgent,
} from "../routing/session-key.js";
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import { normalizeMessageChannel } from "../utils/message-channel.js";
@@ -48,6 +54,7 @@ type AgentCliOpts = {
model?: string;
to?: string;
sessionId?: string;
sessionKey?: string;
thinking?: string;
verbose?: string;
json?: boolean;
@@ -114,6 +121,58 @@ function isGatewayAgentEmbeddedFallbackError(err: unknown): boolean {
return isGatewayTransportError(err);
}
function validateExplicitSessionKeyForDispatch(
opts: Pick<AgentCliOpts, "agent" | "sessionKey">,
): void {
const sessionKey = opts.sessionKey?.trim();
if (!sessionKey) {
return;
}
if (classifySessionKeyShape(sessionKey) === "malformed_agent") {
throw new Error(
`Invalid --session-key "${sessionKey}". Agent-prefixed session keys must use agent:<agent-id>:<session-key>.`,
);
}
const agentIdRaw = opts.agent?.trim() || undefined;
if (!agentIdRaw || classifySessionKeyShape(sessionKey) !== "agent") {
return;
}
const agentId = normalizeAgentId(agentIdRaw);
const sessionAgentId = resolveAgentIdFromSessionKey(sessionKey);
if (sessionAgentId !== agentId) {
throw new Error(
`Agent id "${agentIdRaw}" does not match session key agent "${sessionAgentId}".`,
);
}
}
function normalizeSessionKeyOptsForDispatch(opts: AgentCliOpts): AgentCliOpts {
const rawSessionKey = opts.sessionKey?.trim();
const isLegacySessionKey =
rawSessionKey && classifySessionKeyShape(rawSessionKey) === "legacy_or_alias";
const agentIdRaw = opts.agent?.trim();
const shouldScopeDefaultAgentKey =
isLegacySessionKey && !agentIdRaw && !isUnscopedSessionKeySentinel(rawSessionKey);
const cfg =
isLegacySessionKey && (agentIdRaw || shouldScopeDefaultAgentKey)
? getRuntimeConfig()
: undefined;
const sessionKey = scopeLegacySessionKeyToAgent({
agentId: agentIdRaw ?? (shouldScopeDefaultAgentKey ? resolveDefaultAgentId(cfg!) : undefined),
sessionKey: opts.sessionKey,
mainKey: cfg?.session?.mainKey,
});
if (sessionKey === opts.sessionKey) {
return opts;
}
return {
...opts,
sessionKey,
};
}
function createGatewayTimeoutFallbackSessionId(): string {
return `${GATEWAY_TIMEOUT_FALLBACK_SESSION_PREFIX}${randomUUID()}`;
}
@@ -129,6 +188,34 @@ function createGatewayTimeoutFallbackSession(agentId?: string): {
};
}
function resolveAgentIdForGatewayTimeoutFallback(opts: AgentCliOpts): string | undefined {
const explicitSessionKey = opts.sessionKey?.trim();
if (classifySessionKeyShape(explicitSessionKey) === "agent") {
return resolveAgentIdFromSessionKey(explicitSessionKey);
}
if (isUnscopedSessionKeySentinel(explicitSessionKey)) {
return resolveDefaultAgentId(getRuntimeConfig());
}
const agentIdRaw = opts.agent?.trim();
if (agentIdRaw) {
return normalizeAgentId(agentIdRaw);
}
if (!opts.to && !opts.sessionId) {
return undefined;
}
const cfg = getRuntimeConfig();
const resolvedSessionKey = resolveSessionKeyForRequest({
cfg,
to: opts.to,
sessionId: opts.sessionId,
}).sessionKey;
return classifySessionKeyShape(resolvedSessionKey) === "agent"
? resolveAgentIdFromSessionKey(resolvedSessionKey)
: undefined;
}
function buildGatewayJsonResponse(response: GatewayAgentResponse): GatewayAgentResponse {
const deliveryStatus = response.result?.deliveryStatus;
if (deliveryStatus === undefined) {
@@ -143,14 +230,15 @@ function buildGatewayJsonResponse(response: GatewayAgentResponse): GatewayAgentR
async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
protectJsonStdout(opts);
const body = (opts.message ?? "").trim();
const explicitSessionKey = opts.sessionKey?.trim();
if (!body) {
throw new Error(
`Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or pass --to/--session-id for an existing conversation.`,
`Missing message. Use ${formatCliCommand('openclaw agent --message "..." --agent <id>')} or pass --to/--session-key/--session-id for an existing conversation.`,
);
}
if (!opts.to && !opts.sessionId && !opts.agent) {
if (!opts.to && !opts.sessionId && !opts.agent && !explicitSessionKey) {
throw new Error(
`No target session selected. Use --agent <id>, --session-id <id>, or --to <E.164>. Run ${formatCliCommand("openclaw agents list")} to see agents.`,
`No target session selected. Use --agent <id>, --session-key <key>, --session-id <id>, or --to <E.164>. Run ${formatCliCommand("openclaw agents list")} to see agents.`,
);
}
@@ -176,6 +264,7 @@ async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
agentId,
to: opts.to,
sessionId: opts.sessionId,
sessionKey: explicitSessionKey,
}).sessionKey;
const channel = normalizeMessageChannel(opts.channel);
@@ -248,22 +337,25 @@ async function agentViaGatewayCommand(opts: AgentCliOpts, runtime: RuntimeEnv) {
export async function agentCliCommand(opts: AgentCliOpts, runtime: RuntimeEnv, deps?: CliDeps) {
protectJsonStdout(opts);
const dispatchOpts = normalizeSessionKeyOptsForDispatch(opts);
validateExplicitSessionKeyForDispatch(dispatchOpts);
const localOpts = {
...opts,
agentId: opts.agent,
replyAccountId: opts.replyAccount,
...dispatchOpts,
agentId: dispatchOpts.agent,
replyAccountId: dispatchOpts.replyAccount,
cleanupBundleMcpOnRunEnd: true,
cleanupCliLiveSessionOnRunEnd: true,
};
if (opts.local === true) {
if (dispatchOpts.local === true) {
return await agentCommand(localOpts, runtime, deps);
}
try {
return await agentViaGatewayCommand(opts, runtime);
return await agentViaGatewayCommand(dispatchOpts, runtime);
} catch (err) {
if (isGatewayAgentTimeoutError(err)) {
const fallbackSession = createGatewayTimeoutFallbackSession(opts.agent);
const fallbackAgentId = resolveAgentIdForGatewayTimeoutFallback(dispatchOpts);
const fallbackSession = createGatewayTimeoutFallbackSession(fallbackAgentId);
runtime.error?.(
`EMBEDDED FALLBACK: Gateway agent timed out; running embedded agent with fresh session ${fallbackSession.sessionId}: ${String(err)}`,
);
+53
View File
@@ -1125,4 +1125,57 @@ describe("agentCommand", () => {
);
});
});
it("uses explicit session keys for embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "main" }, { id: "ops" }]);
await agentCommand({ message: "hi", sessionKey: "agent:ops:incident-42" }, runtime);
let callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("agent:ops:incident-42");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
await agentCommand({ message: "hi", agentId: "ops", sessionKey: "incident-42" }, runtime);
callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("agent:ops:incident-42");
await agentCommand({ message: "hi", agentId: "ops", sessionKey: "global" }, runtime);
callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("agent:ops:global");
});
});
it("scopes bare explicit session keys to the default agent for embedded runs", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
mockConfig(home, store, undefined, undefined, [{ id: "ops", default: true }, { id: "main" }]);
await agentCommand({ message: "hi", sessionKey: "incident-42" }, runtime);
let callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("agent:ops:incident-42");
await agentCommand({ message: "hi", sessionKey: "global" }, runtime);
callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("global");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
await agentCommand({ message: "hi", sessionKey: "unknown" }, runtime);
callArgs = getLastEmbeddedCall();
expect(callArgs?.agentId).toBe("ops");
expect(callArgs?.sessionKey).toBe("unknown");
expect(callArgs?.sessionFile).toContain(`${path.sep}agents${path.sep}ops${path.sep}sessions`);
});
});
});
+43
View File
@@ -14,6 +14,8 @@ import {
parseAgentSessionKey,
resolveEventSessionKey,
scopedHeartbeatWakeOptions,
isUnscopedSessionKeySentinel,
scopeLegacySessionKeyToAgent,
toAgentStoreSessionKey,
} from "./session-key.js";
@@ -33,6 +35,47 @@ describe("classifySessionKeyShape", () => {
});
});
describe("scopeLegacySessionKeyToAgent", () => {
it("scopes legacy aliases to the requested agent", () => {
expect(scopeLegacySessionKeyToAgent({ agentId: "Ops", sessionKey: "Incident-42" })).toBe(
"agent:ops:incident-42",
);
});
it("honors configured main-key aliases when scoping legacy keys", () => {
expect(
scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "main", mainKey: "work" }),
).toBe("agent:ops:work");
});
it("preserves already agent-prefixed keys", () => {
expect(
scopeLegacySessionKeyToAgent({
agentId: "ops",
sessionKey: "agent:main:incident-42",
}),
).toBe("agent:main:incident-42");
});
it("scopes global and unknown legacy aliases to the requested agent", () => {
expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "global" })).toBe(
"agent:ops:global",
);
expect(scopeLegacySessionKeyToAgent({ agentId: "ops", sessionKey: "UNKNOWN" })).toBe(
"agent:ops:unknown",
);
});
});
describe("isUnscopedSessionKeySentinel", () => {
it("recognizes literal global and unknown sentinels", () => {
expect(isUnscopedSessionKeySentinel("global")).toBe(true);
expect(isUnscopedSessionKeySentinel("UNKNOWN")).toBe(true);
expect(isUnscopedSessionKeySentinel("agent:ops:global")).toBe(false);
expect(isUnscopedSessionKeySentinel("incident-42")).toBe(false);
});
});
describe("session key backward compatibility", () => {
function expectBackwardCompatibleDirectSessionKey(key: string) {
expect(classifySessionKeyShape(key)).toBe("agent");
+25
View File
@@ -131,6 +131,31 @@ export function classifySessionKeyShape(sessionKey: string | undefined | null):
: "legacy_or_alias";
}
export function isUnscopedSessionKeySentinel(sessionKey: string | undefined | null): boolean {
const lowered = normalizeLowercaseStringOrEmpty(sessionKey);
return lowered === "global" || lowered === "unknown";
}
export function scopeLegacySessionKeyToAgent(params: {
agentId?: string | undefined;
sessionKey?: string | undefined;
mainKey?: string | undefined;
}): string | undefined {
const raw = (params.sessionKey ?? "").trim();
if (!raw) {
return undefined;
}
const agentId = params.agentId?.trim();
if (!agentId || classifySessionKeyShape(raw) !== "legacy_or_alias") {
return raw;
}
return toAgentStoreSessionKey({
agentId,
requestKey: raw,
mainKey: params.mainKey,
});
}
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {