diff --git a/index.ts b/index.ts index 10f8650..0b73292 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.6.0" +const PLUGIN_VERSION = "0.7.0" // OpenClaw stores active registry state (including workspaceDir) on globalThis // under this well-known symbol key. @@ -180,6 +180,34 @@ function deriveAgentIdForCapture(ctx: { agentDir?: string; workspaceDir?: string return seg } +/** + * Agent identity for the correlation envelope. + * + * TaaS requires BOTH an agent id and a session id before it will trust a + * caller-supplied identity (`identity_present = session_present && agent_present`). + * When neither is usable it discards the identity and mints a fresh session per + * request, which silently destroys affinity and prompt-cache reuse. + * + * Directory/env derivation is best-effort and frequently unavailable, so fall + * back to the agent segment encoded in OpenClaw session keys + * (`agent::`) and finally to a stable literal. The value only + * needs to be stable for the conversation, never globally unique. + */ +function resolveAgentIdentity( + ctx: { agentDir?: string; workspaceDir?: string }, + sessionId: string | null | undefined +): string | null { + const derived = deriveAgentIdForCapture(ctx) + if (derived) return derived + const sid = safeString(sessionId) + if (sid) { + const match = /^agent:([^:]+):/.exec(sid) + if (match?.[1]) return match[1] + return "main" + } + return null +} + function buildCorrelationMetadata( sessionId: string, source: string, @@ -365,7 +393,12 @@ function buildWrapper(ctx: ProviderWrapStreamFnContext) { if (!streamFn) return undefined const identity = resolveSessionIdentity(ctx.workspaceDir, (ctx as { sessionId?: unknown }).sessionId) - const agentIdForCapture = deriveAgentIdForCapture(ctx as { agentDir?: string; workspaceDir?: string }) + // 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( + ctx as { agentDir?: string; workspaceDir?: string }, + identity?.sessionId + ) const requesterRuntime = identity ? buildRequesterRuntime(ctx, identity.sessionId, identity.source, identity.sourceHint) : undefined const correlation = identity ? buildCorrelationMetadata(identity.sessionId, identity.source, identity.sourceHint, ctx, agentIdForCapture) : undefined @@ -402,7 +435,10 @@ function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): if (isDev) console.debug(`[taas-affinity] no native or legacy session identity; skipping affinity headers turnId=${ctx.turnId}`) return null } - const agentId = deriveAgentIdForCapture(ctx as unknown as { agentDir?: string; workspaceDir?: string }) + const agentId = resolveAgentIdentity( + ctx as unknown as { agentDir?: string; workspaceDir?: string }, + identity.sessionId + ) if (isDev) { console.debug( `[taas-affinity] resolveTransportTurnState sessionId=${identity.sessionId} ` + @@ -413,6 +449,14 @@ function buildTransportTurnState(ctx: ProviderResolveTransportTurnStateContext): } export default { + // Internal helpers exposed strictly for unit tests. Not part of the plugin + // contract and not used at runtime. + __test__: { + resolveAgentIdentity, + deriveAgentIdForCapture, + buildCorrelationMetadata, + buildCorrelationHeaders, + }, id: "openclaw-taas-affinity", name: "CloudSigma TaaS Token Cache Optimizer", description: diff --git a/package.json b/package.json index 2450d8c..5b0d773 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "openclaw-taas-affinity", - "version": "0.6.0", - "description": "OpenClaw provider plugin — 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.", + "version": "0.7.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", "openclaw": { diff --git a/test/agent-identity.test.ts b/test/agent-identity.test.ts new file mode 100644 index 0000000..cf31a20 --- /dev/null +++ b/test/agent-identity.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict" +import test from "node:test" + +import plugin from "../index.ts" + +/** + * TaaS requires BOTH an agent id and a session id before it trusts a + * caller-supplied identity: + * + * identity_present = session_present && agent_present + * + * In production every request logged + * `fallback_reason=missing_agent_and_session`, so TaaS discarded the supplied + * identity and minted a fresh session per request. That destroyed affinity and + * pinned continuity at 0%. + */ + +const internals = (plugin as unknown as { __test__?: Record }).__test__ + +test("agent identity is derived from an OpenClaw session key when no dir hint exists", () => { + assert.ok(internals, "plugin must expose test internals") + const resolveAgentIdentity = internals!.resolveAgentIdentity as ( + ctx: { agentDir?: string; workspaceDir?: string }, + sessionId: string | null | undefined + ) => string | null + + // No agentDir/workspaceDir, which is the production case for the + // openai-completions transport. + assert.equal(resolveAgentIdentity({}, "agent:main:main"), "main") + assert.equal(resolveAgentIdentity({}, "agent:new-agent-3:main"), "new-agent-3") + assert.equal( + resolveAgentIdentity({}, "agent:main:subagent:1ab99fc0-55fb-4b22-a5b3-c4223ef63d6d"), + "main" + ) +}) + +test("agent identity still prefers an explicit directory hint", () => { + const resolveAgentIdentity = internals!.resolveAgentIdentity as ( + ctx: { agentDir?: string; workspaceDir?: string }, + sessionId: string | null | undefined + ) => string | null + + assert.equal(resolveAgentIdentity({ workspaceDir: "/home/x/workspace" }, "agent:zzz:main"), "main") + assert.equal( + resolveAgentIdentity({ workspaceDir: "/home/x/workspace-billing" }, "agent:zzz:main"), + "billing" + ) +}) + +test("no session identity yields no fabricated agent identity", () => { + const resolveAgentIdentity = internals!.resolveAgentIdentity as ( + ctx: { agentDir?: string; workspaceDir?: string }, + sessionId: string | null | undefined + ) => string | null + + assert.equal(resolveAgentIdentity({}, null), null) + assert.equal(resolveAgentIdentity({}, undefined), null) + assert.equal(resolveAgentIdentity({}, ""), null) +}) + +test("correlation envelope carries agent_id so TaaS sees a complete identity", () => { + const buildCorrelationMetadata = internals!.buildCorrelationMetadata as ( + sessionId: string, + source: string, + sourceHint: string, + ctx: unknown, + agentId: string | null + ) => Record + + const meta = buildCorrelationMetadata( + "agent:main:main", + "openclaw:ctx.sessionId", + "stateDir:/home/x/.openclaw", + { provider: "cloudsigma", modelId: "gpt-5.6-sol" }, + "main" + ) + + assert.equal(meta.session_id, "agent:main:main") + assert.equal(meta.agent_id, "main") + // TaaS classifies declared_plugin from a source starting with "openclaw-". + assert.ok(String(meta.source).startsWith("openclaw-")) + assert.ok(meta.plugin_version) +}) diff --git a/test/smoke.mjs b/test/smoke.mjs index ae9aad3..804a4b2 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.6.0") +assert.equal(transportState.headers["X-OpenClaw-Plugin-Version"], "0.7.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.6.0") +assert.equal(capturedPayload.metadata.openclaw_correlation.plugin_version, "0.7.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")