Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -77,7 +77,7 @@ type ResolvedSessionIdentity = {
source: string
sourceHint: string
localSessionScoped: boolean
identityMode: "native" | "legacy_env"
identityMode: "native" | "legacy_env" | "agent_scoped"
}

/**
Expand All @@ -92,6 +92,7 @@ type ResolvedSessionIdentity = {
function resolveSessionIdentity(
workspaceDirFromCtx?: string,
sessionIdFromCtx?: unknown,
agentIdFromCtx?: unknown,
): ResolvedSessionIdentity | null {
const nativeSessionId = safeString(sessionIdFromCtx)
const sourceHint = workspaceDirFromCtx
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
66 changes: 66 additions & 0 deletions test/agent-scoped-session.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }).__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)
})
4 changes: 2 additions & 2 deletions test/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")
Expand Down
Loading