mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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 head2c76dd339f. - Required merge gates passed before the squash merge. Prepared head SHA:2c76dd339fReview: 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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)}`,
|
||||
);
|
||||
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user