From 62f4e67141dd4fa1616243c433b04d0bdbfb2c42 Mon Sep 17 00:00:00 2001 From: cloudsigma Date: Tue, 28 Jul 2026 15:50:23 +0000 Subject: [PATCH] fix: derive agent-scoped session identity when host omits sessionId --- index.ts | 42 ++++++++++++++++++-- package-lock.json | 4 +- package.json | 2 +- test/agent-scoped-session.test.ts | 66 +++++++++++++++++++++++++++++++ test/smoke.mjs | 4 +- 5 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 test/agent-scoped-session.test.ts diff --git a/index.ts b/index.ts index 0b73292..30944a7 100644 --- a/index.ts +++ b/index.ts @@ -29,7 +29,7 @@ const REQUESTER_RUNTIME_SCHEMA_VERSION = "2026-06-04" const REQUESTER_RUNTIME_SOURCE = "openclaw-taas-affinity" const GIT_PROBE_TIMEOUT_MS = 250 const LAST_ROUTE_LIMIT = 256 -const PLUGIN_VERSION = "0.7.0" +const PLUGIN_VERSION = "0.8.0" // OpenClaw stores active registry state (including workspaceDir) on globalThis // under this well-known symbol key. @@ -77,7 +77,7 @@ type ResolvedSessionIdentity = { source: string sourceHint: string localSessionScoped: boolean - identityMode: "native" | "legacy_env" + identityMode: "native" | "legacy_env" | "agent_scoped" } /** @@ -92,6 +92,7 @@ type ResolvedSessionIdentity = { function resolveSessionIdentity( workspaceDirFromCtx?: string, sessionIdFromCtx?: unknown, + agentIdFromCtx?: unknown, ): ResolvedSessionIdentity | null { const nativeSessionId = safeString(sessionIdFromCtx) const sourceHint = workspaceDirFromCtx @@ -119,6 +120,30 @@ function resolveSessionIdentity( } } + // Agent-scoped fallback. + // + // OpenClaw does not include `sessionId` in the wrapStreamFn context for the + // openai-completions transport, so neither branch above fires and the plugin + // previously injected nothing at all. That lane carries all agent traffic, so + // TaaS minted a fresh session id per request: affinity never engaged and + // continuity read 0% while prompt-cache reads showed ~47% real continuation. + // + // `agentId` IS supplied and is stable for the agent conversation, which is the + // granularity affinity needs. Scope it per workspace so two agents sharing a + // name in different workspaces do not collide, and label the mode distinctly + // so it is never mistaken for a native session id. + const agentId = safeString(agentIdFromCtx) + if (agentId) { + const scope = workspaceDirFromCtx ? `:${workspaceDirFromCtx}` : "" + return { + sessionId: deriveFallbackSessionId(`agent-scope:${agentId}${scope}`), + source: "openclaw:ctx.agentId", + sourceHint, + localSessionScoped: true, + identityMode: "agent_scoped", + } + } + return null } @@ -392,7 +417,11 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { const { streamFn } = ctx if (!streamFn) return undefined - const identity = resolveSessionIdentity(ctx.workspaceDir, (ctx as { sessionId?: unknown }).sessionId) + const identity = resolveSessionIdentity( + ctx.workspaceDir, + (ctx as { sessionId?: unknown }).sessionId, + (ctx as { agentId?: unknown }).agentId, + ) // Resolve agent identity from the session key when no dir/env hint exists so // TaaS never falls back to minting its own per-request session id. const agentIdForCapture = resolveAgentIdentity( @@ -430,7 +459,11 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { } function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): ProviderTransportTurnState | null { - const identity = resolveSessionIdentity(undefined, (ctx as { sessionId?: unknown }).sessionId) + const identity = resolveSessionIdentity( + undefined, + (ctx as { sessionId?: unknown }).sessionId, + (ctx as { agentId?: unknown }).agentId, + ) if (!identity) { if (isDev) console.debug(`[taas-affinity] no native or legacy session identity; skipping affinity headers turnId=${ctx.turnId}`) return null @@ -452,6 +485,7 @@ export default { // Internal helpers exposed strictly for unit tests. Not part of the plugin // contract and not used at runtime. __test__: { + resolveSessionIdentity, resolveAgentIdentity, deriveAgentIdForCapture, buildCorrelationMetadata, diff --git a/package-lock.json b/package-lock.json index fc4eaad..b726b3f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "openclaw-taas-affinity", - "version": "0.6.0", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openclaw-taas-affinity", - "version": "0.6.0", + "version": "0.8.0", "license": "MIT", "devDependencies": { "@types/node": "^22.0.0", diff --git a/package.json b/package.json index 5b0d773..972394a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openclaw-taas-affinity", - "version": "0.7.0", + "version": "0.8.0", "description": "OpenClaw provider plugin \u2014 CloudSigma TaaS session affinity. Injects a stable X-Session-Id header per conversation so TaaS can pin the session to the same OAuth token / Bedrock region / Claude Code node, maximising prompt-cache hit rates.", "type": "module", "main": "dist/index.js", diff --git a/test/agent-scoped-session.test.ts b/test/agent-scoped-session.test.ts new file mode 100644 index 0000000..be81c44 --- /dev/null +++ b/test/agent-scoped-session.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import plugin from "../index.ts" + +/** + * OpenClaw does not include `sessionId` in the wrapStreamFn context for the + * openai-completions transport (see extra-params: it passes config, agentDir, + * workspaceDir, agentId, provider, modelId, extraParams, thinkingLevel, model, + * streamFn). That lane carries all agent traffic, so the plugin previously + * injected nothing and TaaS minted a fresh session id per request: + * + * 2248 OpenAI session records -> 2247 minted, 1 caller-supplied + * 272 requests -> 272 distinct session ids -> continuity 0% + * + * `agentId` is present and stable for the conversation, so it is a valid + * affinity key when no native session id exists. + */ + +const internals = (plugin as unknown as { __test__?: Record }).__test__ + +type Resolve = ( + workspaceDir?: string, + sessionId?: unknown, + agentId?: unknown +) => { sessionId: string; identityMode: string; source: string } | null + +test("agent id yields a stable session identity when no native session id exists", () => { + assert.ok(internals) + const resolve = internals!.resolveSessionIdentity as Resolve + const ws = "/home/x/.openclaw/workspace" + + const a = resolve(ws, undefined, "main") + const b = resolve(ws, undefined, "main") + + assert.ok(a, "must resolve an identity from agentId alone") + assert.equal(a!.sessionId, b!.sessionId, "identity must be stable across requests") + assert.equal(a!.identityMode, "agent_scoped") + assert.equal(a!.source, "openclaw:ctx.agentId") +}) + +test("different agents get different identities", () => { + const resolve = internals!.resolveSessionIdentity as Resolve + const ws = "/home/x/.openclaw/workspace" + assert.notEqual(resolve(ws, undefined, "main")!.sessionId, resolve(ws, undefined, "other")!.sessionId) +}) + +test("identity is workspace scoped so same-named agents do not collide", () => { + const resolve = internals!.resolveSessionIdentity as Resolve + assert.notEqual( + resolve("/ws/a", undefined, "main")!.sessionId, + resolve("/ws/b", undefined, "main")!.sessionId + ) +}) + +test("a native session id always takes precedence", () => { + const resolve = internals!.resolveSessionIdentity as Resolve + const r = resolve("/home/x/workspace", "agent:main:main", "main") + assert.equal(r!.identityMode, "native") + assert.equal(r!.sessionId, "agent:main:main") +}) + +test("no session and no agent still yields no fabricated identity", () => { + const resolve = internals!.resolveSessionIdentity as Resolve + assert.equal(resolve("/home/x/workspace", undefined, undefined), null) +}) diff --git a/test/smoke.mjs b/test/smoke.mjs index 804a4b2..2faeb65 100644 --- a/test/smoke.mjs +++ b/test/smoke.mjs @@ -68,7 +68,7 @@ const transportState = provider.resolveTransportTurnState({ sessionId: "smoke-lo assert.equal(transportState.headers["X-Session-Id"], "smoke-local-session") assert.equal(transportState.headers["X-OpenClaw-Session-Id"], transportState.headers["X-Session-Id"]) -assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.7.0") +assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.8.0") assert.equal(transportState.headers["X-OpenClaw-Turn-Id"], "turn-smoke") assert.equal(transportState.headers["X-OpenClaw-Attempt"], "1") @@ -93,7 +93,7 @@ assert.equal(capturedPayload.metadata.requester_runtime.tool_execution, "directi assert.equal("available_bridges" in capturedPayload.metadata.requester_runtime, false) assert.equal(capturedPayload.metadata.openclaw_correlation.schema_version, "2026-06-05") assert.equal(capturedPayload.metadata.openclaw_correlation.source, "openclaw-taas-affinity") -assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.7.0") +assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.8.0") assert.equal(capturedPayload.metadata.openclaw_correlation.session_id, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.sticky_key, localSessionA) assert.equal(capturedPayload.metadata.openclaw_correlation.provider, "cloudsigma")