fix(kiro): improve direct session cache reuse
Reshape Kiro direct requests so resumed client sessions reuse Kiro's cache-affinity fields instead of starting unrelated CodeWhisperer conversations. - keep conversationState.conversationId stable when the client sends an explicit session id (x-session-id, session_id, conversation_id, Claude Code session metadata) - add a stable conversationState.agentContinuationId per Kiro session - send conversationState.agentTaskType: "vibe" and agentMode: "vibe", matching the normal Kiro CLI/KAS chat path - move Kiro thinking instructions into Kiro-compatible systemPrompt / additionalModelRequestFields instead of generic top-level thinking - keep volatile timestamp context out of the top-level systemPrompt; it remains only in user content fallback - suppress additionalModelRequestFields for legacy 4.5-era Claude/Kiro models that reject it, while defaulting future Claude/Kiro model ids to supported - preserve Kiro meteringEvent credit usage internally for accounting without leaking provider-specific fields into OpenAI-compatible usage - prevent unrelated headerless Kiro requests from sharing one connection-wide continuation - cap/evict continuation sessions so long-running processes do not grow the continuation map unbounded - treat generated headerless Kiro sessions as one-shot so they do not evict real explicit-session continuations - keep credit-only Kiro metering valid for internal persistence when token metrics are unavailable
This commit is contained in:
@@ -131,6 +131,50 @@ export function resolveKiroThinkingBudget(body, headers, model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractKiroEffortLevel(body) {
|
||||
const effort =
|
||||
body?.output_config?.effort ??
|
||||
body?.reasoning_effort ??
|
||||
(typeof body?.reasoning === "object" ? body.reasoning?.effort : null);
|
||||
if (typeof effort !== "string") return null;
|
||||
const normalized = effort.toLowerCase();
|
||||
if (normalized === "none" || normalized === "off" || normalized === "disabled") return null;
|
||||
if (normalized === "xhigh" || normalized === "max") return "high";
|
||||
if (["low", "medium", "high"].includes(normalized)) return normalized;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildKiroAdditionalModelRequestFields(body) {
|
||||
const effort = extractKiroEffortLevel(body);
|
||||
if (!effort) return undefined;
|
||||
// Mirrors Kiro CLI/KAS buildEffortRequestFields("output_config").
|
||||
return {
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort },
|
||||
};
|
||||
}
|
||||
|
||||
export function supportsKiroAdditionalModelRequestFields(model) {
|
||||
if (typeof model !== "string") return false;
|
||||
const normalized = model.toLowerCase().replace(/-/g, ".");
|
||||
if (!normalized.includes("claude")) return false;
|
||||
const match = normalized.match(/(?:^|[/.])claude(?:[/.][a-z]+)*[/.](\d+)(?:[/.](\d+))?(?:[/.]|$)/);
|
||||
if (!match) return false;
|
||||
const [, majorText, minorText] = match;
|
||||
const major = Number(majorText);
|
||||
const minor = minorText === undefined ? null : Number(minorText);
|
||||
const dateSuffixMinor = minor !== null && minor >= 1000;
|
||||
// Kiro rejected additionalModelRequestFields on legacy 4.5 models in live smoke.
|
||||
// Default future Claude/Kiro models to supported so new model releases do not
|
||||
// need a code allowlist update.
|
||||
return !(major < 4 || (major === 4 && (minor === null || minor <= 5 || dateSuffixMinor)));
|
||||
}
|
||||
|
||||
export function buildKiroAdditionalModelRequestFieldsForModel(body, model) {
|
||||
if (!supportsKiroAdditionalModelRequestFields(model)) return undefined;
|
||||
return buildKiroAdditionalModelRequestFields(body);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether an inbound request is asking for reasoning / thinking output.
|
||||
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
|
||||
|
||||
@@ -103,8 +103,16 @@ export function translateRequest(sourceFormat, targetFormat, model, body, stream
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize thinking to the target provider-native format (config-driven, capability-aware)
|
||||
applyThinking(targetFormat, model, result, provider, thinkingIntent);
|
||||
// Normalize thinking to the target provider-native format (config-driven, capability-aware).
|
||||
// Kiro's GenerateAssistantResponse request does not accept the generic top-level
|
||||
// `thinking` field; its translators map thinking intent to KAS-compatible
|
||||
// systemPrompt/additionalModelRequestFields instead.
|
||||
const kiroThinkingMappedByTranslator =
|
||||
targetFormat === FORMATS.KIRO &&
|
||||
(sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.CLAUDE);
|
||||
if (!kiroThinkingMappedByTranslator) {
|
||||
applyThinking(targetFormat, model, result, provider, thinkingIntent);
|
||||
}
|
||||
|
||||
// Always normalize to clean OpenAI format when target is OpenAI
|
||||
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||
|
||||
@@ -24,13 +24,15 @@
|
||||
*/
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
||||
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
||||
import {
|
||||
resolveKiroModel,
|
||||
resolveKiroThinkingBudget,
|
||||
buildThinkingSystemPrefix,
|
||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||
resolveDefaultProfileArn,
|
||||
buildKiroAdditionalModelRequestFieldsForModel,
|
||||
} from "../../config/kiroConstants.js";
|
||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||
import { ROLE, CLAUDE_BLOCK } from "../schema/index.js";
|
||||
@@ -363,6 +365,18 @@ function reconcileOrphanedToolResults(history, currentMessage) {
|
||||
}
|
||||
}
|
||||
|
||||
function extractClaudeSystemText(system) {
|
||||
if (!system) return "";
|
||||
if (typeof system === "string") return system;
|
||||
if (Array.isArray(system)) {
|
||||
return system.map((s) => {
|
||||
if (typeof s === "string") return s;
|
||||
return s?.text || "";
|
||||
}).filter(Boolean).join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Kiro payload directly from a Claude Messages API request body.
|
||||
*/
|
||||
@@ -402,62 +416,75 @@ export function claudeToKiroRequest(model, body, stream, credentials) {
|
||||
? (credentials?.providerSpecificData?.profileArn || "")
|
||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||
|
||||
let finalContent = currentMessage?.userInputMessage?.content || "";
|
||||
|
||||
// System prompt: pass via native systemInstruction field (Kiro/Q API supports it)
|
||||
// and also prepend as <instructions> in user content as fallback for upstreams
|
||||
// that don't support the native field.
|
||||
let systemInstruction = undefined;
|
||||
if (body.system) {
|
||||
let systemText = "";
|
||||
if (typeof body.system === "string") {
|
||||
systemText = body.system;
|
||||
} else if (Array.isArray(body.system)) {
|
||||
systemText = body.system.map((s) => s.text || "").join("\n");
|
||||
}
|
||||
if (systemText) {
|
||||
systemInstruction = systemText;
|
||||
finalContent = `<instructions>\n${systemText}\n</instructions>\n\n${finalContent}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Prefix order: thinking_mode tag, timestamp marker, then agentic prompt.
|
||||
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
|
||||
// content fallback too because the CodeWhisperer surface does not always
|
||||
// enforce top-level systemPrompt for direct calls.
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefixParts = [];
|
||||
if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
prefixParts.push(`[Context: Current time is ${timestamp}]`);
|
||||
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
|
||||
const systemPromptParts = [];
|
||||
if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||
const systemInstruction = extractClaudeSystemText(body.system);
|
||||
if (systemInstruction) systemPromptParts.push(systemInstruction);
|
||||
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
|
||||
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
|
||||
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
|
||||
|
||||
const sessionIdentity = resolveSessionIdentity({
|
||||
headers: credentials?.rawHeaders,
|
||||
body,
|
||||
connectionId: credentials?.connectionId,
|
||||
scope: "kiro",
|
||||
});
|
||||
const conversationId = sessionIdentity.sessionId;
|
||||
const continuationId = resolveContinuationId({
|
||||
sessionId: conversationId,
|
||||
connectionId: credentials?.connectionId,
|
||||
scope: "kiro",
|
||||
ephemeral: sessionIdentity.ephemeral,
|
||||
});
|
||||
const replay = applyKiroSessionReplay({
|
||||
conversationId,
|
||||
connectionId: credentials?.connectionId,
|
||||
modelId: upstreamModel,
|
||||
systemPrompt,
|
||||
contentPrefix,
|
||||
currentContentPrefix: currentTimeContext,
|
||||
history,
|
||||
currentMessage,
|
||||
});
|
||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
||||
const userInputMessage = {
|
||||
content: finalContent,
|
||||
content: replayCurrent.content || "",
|
||||
modelId: upstreamModel,
|
||||
origin: "AI_EDITOR",
|
||||
...(currentMessage?.userInputMessage?.userInputMessageContext && {
|
||||
userInputMessageContext:
|
||||
currentMessage.userInputMessage.userInputMessageContext,
|
||||
...(replayCurrent.userInputMessageContext && {
|
||||
userInputMessageContext: replayCurrent.userInputMessageContext,
|
||||
}),
|
||||
...(currentMessage?.userInputMessage?.images && {
|
||||
images: currentMessage.userInputMessage.images,
|
||||
...(replayCurrent.images && {
|
||||
images: replayCurrent.images,
|
||||
}),
|
||||
};
|
||||
|
||||
if (systemInstruction) {
|
||||
userInputMessage.systemInstruction = systemInstruction;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: uuidv4(),
|
||||
conversationId,
|
||||
agentContinuationId: continuationId,
|
||||
agentTaskType: "vibe",
|
||||
currentMessage: {
|
||||
userInputMessage,
|
||||
},
|
||||
history,
|
||||
history: replay.history,
|
||||
},
|
||||
agentMode: "vibe",
|
||||
};
|
||||
|
||||
if (profileArn) payload.profileArn = profileArn;
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
if (maxTokens || temperature !== undefined || topP !== undefined) {
|
||||
payload.inferenceConfig = {};
|
||||
|
||||
@@ -5,13 +5,15 @@
|
||||
import { register } from "../index.js";
|
||||
import { FORMATS } from "../formats.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { resolveSessionId } from "../../utils/sessionManager.js";
|
||||
import { applyKiroSessionReplay } from "../../utils/kiroSessionReplay.js";
|
||||
import { resolveContinuationId, resolveSessionIdentity } from "../../utils/sessionManager.js";
|
||||
import {
|
||||
resolveKiroModel,
|
||||
resolveKiroThinkingBudget,
|
||||
buildThinkingSystemPrefix,
|
||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||
resolveDefaultProfileArn
|
||||
resolveDefaultProfileArn,
|
||||
buildKiroAdditionalModelRequestFieldsForModel
|
||||
} from "../../config/kiroConstants.js";
|
||||
import { parseDataUri } from "../concerns/image.js";
|
||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||
@@ -546,47 +548,74 @@ export function openaiToKiroRequest(model, body, stream, credentials) {
|
||||
? (credentials?.providerSpecificData?.profileArn || "")
|
||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||
|
||||
let finalContent = currentMessage?.userInputMessage?.content || "";
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Build the system-prompt prefix that goes ABOVE the user message body.
|
||||
// Order: thinking_mode tag first (so Kiro sees it before any user text),
|
||||
// then context/timestamp marker, then optional agentic chunked-write prompt.
|
||||
const prefixParts = [];
|
||||
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
|
||||
// too because the CodeWhisperer surface does not always enforce top-level
|
||||
// systemPrompt for direct calls.
|
||||
const systemPromptParts = [];
|
||||
if (thinkingBudget !== null) {
|
||||
prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||
}
|
||||
prefixParts.push(`[Context: Current time is ${timestamp}]`);
|
||||
if (agentic) {
|
||||
prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||
systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||
}
|
||||
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
|
||||
const systemPrompt = systemPromptParts.filter(Boolean).join("\n\n");
|
||||
const currentTimeContext = `[Context: Current time is ${timestamp}]`;
|
||||
const contentPrefix = [systemPrompt, currentTimeContext].filter(Boolean).join("\n\n");
|
||||
|
||||
const sessionIdentity = resolveSessionIdentity({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" });
|
||||
const conversationId = sessionIdentity.sessionId;
|
||||
const continuationId = resolveContinuationId({
|
||||
sessionId: conversationId,
|
||||
connectionId: credentials?.connectionId,
|
||||
scope: "kiro",
|
||||
ephemeral: sessionIdentity.ephemeral,
|
||||
});
|
||||
const replay = applyKiroSessionReplay({
|
||||
conversationId,
|
||||
connectionId: credentials?.connectionId,
|
||||
modelId: upstreamModel,
|
||||
systemPrompt,
|
||||
contentPrefix,
|
||||
currentContentPrefix: currentTimeContext,
|
||||
history,
|
||||
currentMessage,
|
||||
});
|
||||
const replayCurrent = replay.currentMessage?.userInputMessage || {};
|
||||
|
||||
const payload = {
|
||||
conversationState: {
|
||||
chatTriggerType: "MANUAL",
|
||||
conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
|
||||
conversationId,
|
||||
agentContinuationId: continuationId,
|
||||
agentTaskType: "vibe",
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
content: finalContent,
|
||||
content: replayCurrent.content || "",
|
||||
modelId: upstreamModel,
|
||||
origin: "AI_EDITOR",
|
||||
...(currentMessage?.userInputMessage?.images?.length > 0 && {
|
||||
images: currentMessage.userInputMessage.images
|
||||
...(replayCurrent.images?.length > 0 && {
|
||||
images: replayCurrent.images
|
||||
}),
|
||||
...(currentMessage?.userInputMessage?.userInputMessageContext && {
|
||||
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
|
||||
...(replayCurrent.userInputMessageContext && {
|
||||
userInputMessageContext: replayCurrent.userInputMessageContext
|
||||
})
|
||||
}
|
||||
},
|
||||
history: history
|
||||
}
|
||||
history: replay.history
|
||||
},
|
||||
agentMode: "vibe",
|
||||
};
|
||||
|
||||
if (profileArn) {
|
||||
payload.profileArn = profileArn;
|
||||
}
|
||||
if (systemPrompt) payload.systemPrompt = systemPrompt;
|
||||
const additionalModelRequestFields = buildKiroAdditionalModelRequestFieldsForModel(body, upstreamModel);
|
||||
if (additionalModelRequestFields) {
|
||||
payload.additionalModelRequestFields = additionalModelRequestFields;
|
||||
}
|
||||
|
||||
if (maxTokens || temperature !== undefined || topP !== undefined) {
|
||||
payload.inferenceConfig = {};
|
||||
|
||||
125
open-sse/utils/kiroSessionReplay.js
Normal file
125
open-sse/utils/kiroSessionReplay.js
Normal file
@@ -0,0 +1,125 @@
|
||||
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||
|
||||
const sessionStartStore = new Map();
|
||||
const MAX_SESSION_STARTS = 5000;
|
||||
|
||||
function clone(value) {
|
||||
return value == null ? value : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function sessionKey(connectionId, conversationId) {
|
||||
return `${connectionId || ""}:${conversationId || ""}`;
|
||||
}
|
||||
|
||||
function ensureUserMessageModelId(message, modelId) {
|
||||
if (message?.userInputMessage && !message.userInputMessage.modelId && modelId) {
|
||||
message.userInputMessage.modelId = modelId;
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function ensureHistoryModelIds(history, modelId) {
|
||||
for (const item of history || []) {
|
||||
ensureUserMessageModelId(item, modelId);
|
||||
}
|
||||
return history;
|
||||
}
|
||||
|
||||
function prefixUserMessage(message, contentPrefix, modelId) {
|
||||
const out = clone(message) || { userInputMessage: { content: "" } };
|
||||
if (!out.userInputMessage) out.userInputMessage = { content: "" };
|
||||
ensureUserMessageModelId(out, modelId);
|
||||
if (contentPrefix) {
|
||||
const content = out.userInputMessage.content || "";
|
||||
out.userInputMessage.content = content
|
||||
? `${contentPrefix}\n\n${content}`
|
||||
: contentPrefix;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function findFirstUserIndex(history) {
|
||||
return history.findIndex((item) => item?.userInputMessage);
|
||||
}
|
||||
|
||||
function rememberSessionStart(key, entry) {
|
||||
if (sessionStartStore.size >= MAX_SESSION_STARTS) {
|
||||
sessionStartStore.delete(sessionStartStore.keys().next().value);
|
||||
}
|
||||
sessionStartStore.set(key, { ...entry, lastUsed: Date.now() });
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve Kiro cacheability by freezing the first user message (`msg0`) for a
|
||||
* session, replaying that exact message as the first history user on later
|
||||
* turns, and injecting volatile current-time context only into the current turn.
|
||||
*/
|
||||
export function applyKiroSessionReplay({
|
||||
conversationId,
|
||||
connectionId,
|
||||
modelId,
|
||||
systemPrompt = "",
|
||||
contentPrefix = "",
|
||||
currentContentPrefix = "",
|
||||
history = [],
|
||||
currentMessage,
|
||||
} = {}) {
|
||||
const key = sessionKey(connectionId, conversationId);
|
||||
const existing = conversationId ? sessionStartStore.get(key) : null;
|
||||
const baseHistory = clone(history) || [];
|
||||
const baseCurrent = clone(currentMessage) || { userInputMessage: { content: "" } };
|
||||
|
||||
if (existing && existing.modelId === modelId && existing.systemPrompt === systemPrompt) {
|
||||
existing.lastUsed = Date.now();
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
const sessionStart = ensureUserMessageModelId(clone(existing.sessionStart), modelId);
|
||||
if (firstUserIndex >= 0) {
|
||||
baseHistory[firstUserIndex] = sessionStart;
|
||||
} else {
|
||||
baseHistory.unshift(sessionStart);
|
||||
}
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
currentMessage: prefixUserMessage(baseCurrent, currentContentPrefix, modelId),
|
||||
replayed: true,
|
||||
};
|
||||
}
|
||||
|
||||
const firstUserIndex = findFirstUserIndex(baseHistory);
|
||||
let sessionStart;
|
||||
let nextCurrent = ensureUserMessageModelId(baseCurrent, modelId);
|
||||
if (firstUserIndex >= 0) {
|
||||
sessionStart = prefixUserMessage(baseHistory[firstUserIndex], contentPrefix, modelId);
|
||||
baseHistory[firstUserIndex] = clone(sessionStart);
|
||||
nextCurrent = prefixUserMessage(baseCurrent, currentContentPrefix, modelId);
|
||||
} else {
|
||||
sessionStart = prefixUserMessage(baseCurrent, contentPrefix, modelId);
|
||||
nextCurrent = clone(sessionStart);
|
||||
}
|
||||
|
||||
if (conversationId) {
|
||||
rememberSessionStart(key, {
|
||||
sessionStart: clone(sessionStart),
|
||||
modelId,
|
||||
systemPrompt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
history: ensureHistoryModelIds(baseHistory, modelId),
|
||||
currentMessage: nextCurrent,
|
||||
replayed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearKiroSessionReplayStore() {
|
||||
sessionStartStore.clear();
|
||||
}
|
||||
|
||||
const cleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of sessionStartStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) sessionStartStore.delete(key);
|
||||
}
|
||||
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||
if (cleanup.unref) cleanup.unref();
|
||||
@@ -13,6 +13,7 @@ import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
||||
|
||||
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
|
||||
const runtimeSessionStore = new Map();
|
||||
const continuationStore = new Map();
|
||||
|
||||
// Periodically evict entries that haven't been used within TTL
|
||||
const cleanupInterval = setInterval(() => {
|
||||
@@ -80,6 +81,7 @@ export function generateBinaryStyleId() {
|
||||
export function clearSessionStore() {
|
||||
runtimeSessionStore.clear();
|
||||
assistantSessionStore.clear();
|
||||
continuationStore.clear();
|
||||
}
|
||||
|
||||
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
|
||||
@@ -87,9 +89,10 @@ const assistantSessionStore = new Map();
|
||||
const ASSISTANT_MIN_LEN = 50;
|
||||
const ASSISTANT_CAP_LEN = 50;
|
||||
const MAX_ASSISTANT_SESSIONS = 5000;
|
||||
const MAX_CONTINUATION_SESSIONS = 5000;
|
||||
|
||||
// Client headers/body fields that carry an upstream session id (priority order)
|
||||
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id", "x-client-request-id"];
|
||||
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
|
||||
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
|
||||
|
||||
function sha16(text) {
|
||||
@@ -131,7 +134,7 @@ function extractAntigravitySession(body) {
|
||||
return m ? normalizeSessionId(m[1]) : null;
|
||||
}
|
||||
|
||||
function extractClientSessionId(headers, body) {
|
||||
function extractClientSessionId(headers, body, scope = "") {
|
||||
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
|
||||
if (claude) return `claude:${claude}`;
|
||||
const antigravity = extractAntigravitySession(body);
|
||||
@@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) {
|
||||
const v = headerValue(headers, key);
|
||||
if (v) return v;
|
||||
}
|
||||
const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
|
||||
if (requestId) return requestId;
|
||||
const fromBody =
|
||||
normalizeSessionId(body?.prompt_cache_key) ||
|
||||
normalizeSessionId(body?.session_id) ||
|
||||
normalizeSessionId(body?.conversation_id) ||
|
||||
normalizeSessionId(body?.metadata?.user_id);
|
||||
(scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id));
|
||||
return fromBody || null;
|
||||
}
|
||||
|
||||
function requestMessages(body) {
|
||||
if (Array.isArray(body?.messages)) return body.messages;
|
||||
if (Array.isArray(body?.input)) return body.input;
|
||||
return [];
|
||||
}
|
||||
|
||||
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
|
||||
function accumulateAssistantText(body) {
|
||||
const items = Array.isArray(body?.input) ? body.input
|
||||
: Array.isArray(body?.messages) ? body.messages : null;
|
||||
const items = requestMessages(body);
|
||||
if (!items) return "";
|
||||
let text = "";
|
||||
for (const item of items) {
|
||||
@@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) {
|
||||
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
|
||||
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
|
||||
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
|
||||
* @returns {string} A stable session id
|
||||
* @returns {{sessionId: string, ephemeral: boolean}} A session id plus whether it is one-shot
|
||||
*/
|
||||
export function resolveSessionId({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
|
||||
const client = extractClientSessionId(headers, body);
|
||||
if (client) return client;
|
||||
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
||||
if (fromAssistant) return fromAssistant;
|
||||
export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
|
||||
const client = extractClientSessionId(headers, body, scope);
|
||||
if (client) return { sessionId: client, ephemeral: false };
|
||||
const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
||||
if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false };
|
||||
const ws = normalizeSessionId(workspaceId);
|
||||
if (ws) return ws;
|
||||
return deriveSessionId(connectionId);
|
||||
if (ws) return { sessionId: ws, ephemeral: false };
|
||||
if (scope === "kiro") return { sessionId: generateBinaryStyleId(), ephemeral: true };
|
||||
return { sessionId: deriveSessionId(connectionId), ephemeral: false };
|
||||
}
|
||||
|
||||
export function resolveSessionId(opts = {}) {
|
||||
return resolveSessionIdentity(opts).sessionId;
|
||||
}
|
||||
|
||||
export function resolveContinuationId({ sessionId, connectionId, scope = "", ephemeral = false } = {}) {
|
||||
if (ephemeral) return crypto.randomUUID();
|
||||
const key = `${scope}:${connectionId || ""}:${sessionId || ""}`;
|
||||
const existing = continuationStore.get(key);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
continuationStore.delete(key);
|
||||
continuationStore.set(key, existing);
|
||||
return existing.continuationId;
|
||||
}
|
||||
const continuationId = crypto.randomUUID();
|
||||
if (continuationStore.size >= MAX_CONTINUATION_SESSIONS) {
|
||||
continuationStore.delete(continuationStore.keys().next().value);
|
||||
}
|
||||
continuationStore.set(key, { continuationId, lastUsed: Date.now() });
|
||||
return continuationId;
|
||||
}
|
||||
|
||||
// Capture session id from request body + credentials (envelope still intact here)
|
||||
@@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => {
|
||||
for (const [key, entry] of assistantSessionStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
|
||||
}
|
||||
for (const [key, entry] of continuationStore) {
|
||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) continuationStore.delete(key);
|
||||
}
|
||||
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||
if (assistantCleanup.unref) assistantCleanup.unref();
|
||||
|
||||
@@ -6,8 +6,8 @@ import "./registerAll.js";
|
||||
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||
|
||||
const C2K = (body) =>
|
||||
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro");
|
||||
const C2K = (body, credentials = null, model = "claude-sonnet-4.5") =>
|
||||
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, model, body, true, credentials, "kiro");
|
||||
|
||||
describe("Claude → Kiro (direct route)", () => {
|
||||
it("produces a Kiro conversationState payload", () => {
|
||||
@@ -16,6 +16,27 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello");
|
||||
});
|
||||
|
||||
it("keeps conversationId stable from client session headers and replays frozen msg0", () => {
|
||||
const credentials = {
|
||||
rawHeaders: { "x-session-id": "hermes-session-123-claude-replay" },
|
||||
connectionId: "kiro-account-1",
|
||||
};
|
||||
const first = C2K({ messages: [{ role: "user", content: "first" }] }, credentials);
|
||||
const second = C2K({ messages: [{ role: "user", content: "second" }] }, credentials);
|
||||
|
||||
expect(first.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-123-claude-replay");
|
||||
expect(first.conversationState.agentContinuationId).toBeTruthy();
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(first.conversationState.agentTaskType).toBe("vibe");
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.5");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second");
|
||||
});
|
||||
|
||||
it("guard 1: with no tools, a dangling tool_result is flattened to text (no structured ref)", () => {
|
||||
// Client omitted `tools` but kept a tool_result after compaction.
|
||||
const out = C2K({
|
||||
@@ -60,20 +81,60 @@ describe("Claude → Kiro (direct route)", () => {
|
||||
null,
|
||||
"kiro"
|
||||
);
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
||||
expect(out.systemPrompt).toContain(
|
||||
"<thinking_mode>enabled</thinking_mode>"
|
||||
);
|
||||
expect(out.agentMode).toBe("vibe");
|
||||
});
|
||||
|
||||
it("maps output_config.effort high to Kiro max_thinking_length 24576", () => {
|
||||
it("does not send additionalModelRequestFields for Kiro models without effort support", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "high" },
|
||||
messages: [{ role: "user", content: "think with adaptive effort" }],
|
||||
});
|
||||
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
||||
"<max_thinking_length>24576</max_thinking_length>"
|
||||
);
|
||||
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("maps output_config.effort high to Kiro CLI-style additionalModelRequestFields for effort models", () => {
|
||||
const out = C2K({
|
||||
output_config: { effort: "high" },
|
||||
messages: [{ role: "user", content: "think with adaptive effort" }],
|
||||
}, null, "claude-sonnet-5");
|
||||
|
||||
expect(out.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
expect(out.thinking).toBeUndefined();
|
||||
expect(out.systemPrompt).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("sends Claude system as top-level systemPrompt and keeps a user-content fallback", () => {
|
||||
const out = C2K({
|
||||
system: "system-only instruction",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
});
|
||||
|
||||
expect(out.systemPrompt).toContain("system-only instruction");
|
||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("system-only instruction");
|
||||
});
|
||||
|
||||
it("keeps top-level systemPrompt stable across turns", () => {
|
||||
const first = C2K({
|
||||
system: "stable instruction",
|
||||
messages: [{ role: "user", content: "first" }],
|
||||
});
|
||||
const second = C2K({
|
||||
system: "stable instruction",
|
||||
messages: [{ role: "user", content: "second" }],
|
||||
});
|
||||
|
||||
expect(first.systemPrompt).toBe(second.systemPrompt);
|
||||
expect(first.systemPrompt).not.toContain("Current time");
|
||||
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||
import "../translator/registerAll.js";
|
||||
|
||||
function createMockFrame(eventType, payloadObj) {
|
||||
const payloadStr = JSON.stringify(payloadObj);
|
||||
@@ -47,6 +48,13 @@ async function readAllSSE(stream) {
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readNextWithTimeout(reader) {
|
||||
return Promise.race([
|
||||
reader.read(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timed out waiting for SSE chunk")), 100)),
|
||||
]);
|
||||
}
|
||||
|
||||
describe("KiroExecutor thinking tag stripping", () => {
|
||||
it("strips <thinking> tags from assistantResponseEvent", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
@@ -121,4 +129,53 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
|
||||
expect(contentChunks.length).toBe(0);
|
||||
});
|
||||
|
||||
it("emits a terminal chunk at messageStop before the upstream stream closes", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("assistantResponseEvent", { content: "OK" });
|
||||
const f2 = createMockFrame("messageStopEvent", {});
|
||||
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(f1);
|
||||
controller.enqueue(f2);
|
||||
}
|
||||
});
|
||||
|
||||
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
|
||||
const reader = transformedResponse.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let output = "";
|
||||
for (let i = 0; i < 4 && !output.includes("\"finish_reason\":\"stop\""); i++) {
|
||||
const { value } = await readNextWithTimeout(reader);
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
await reader.cancel();
|
||||
|
||||
expect(output).toContain("\"finish_reason\":\"stop\"");
|
||||
});
|
||||
|
||||
it("uses tool_calls finish reason for tool streams without messageStop", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
|
||||
const f1 = createMockFrame("toolUseEvent", { toolUseId: "tool-1", name: "read_file", input: { path: "a.txt" } });
|
||||
|
||||
const readableStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(f1);
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
|
||||
const transformedResponse = executor.transformEventStreamToSSE({ body: readableStream }, "claude-test");
|
||||
const output = await readAllSSE(transformedResponse.body);
|
||||
const objects = output
|
||||
.split("\n")
|
||||
.filter(line => line.startsWith("data: ") && !line.includes("[DONE]"))
|
||||
.map(line => JSON.parse(line.slice(6)));
|
||||
|
||||
const finalChunk = objects.at(-1);
|
||||
expect(finalChunk.choices[0].finish_reason).toBe("tool_calls");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import { openaiToKiroRequest } from "../../open-sse/translator/request/openai-to
|
||||
|
||||
const contentOf = (result) =>
|
||||
result.conversationState.currentMessage.userInputMessage.content;
|
||||
const systemPromptOf = (result) => result.systemPrompt || "";
|
||||
|
||||
describe("openaiToKiroRequest", () => {
|
||||
describe("basic message conversion", () => {
|
||||
@@ -293,7 +294,11 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>1024</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "low" },
|
||||
});
|
||||
});
|
||||
|
||||
it("maps reasoning_effort high to max_thinking_length 24576", () => {
|
||||
@@ -304,7 +309,97 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Legacy model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.5", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for date-suffixed Claude 4 model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Date-suffixed Claude 4 should stay legacy" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4-20250514", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for pre-4 legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Older model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-3.7", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send additionalModelRequestFields for prefixed pre-4 legacy Kiro model ids", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Prefixed older model id should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("kiro/claude-3-7-sonnet-20250219", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send Claude-specific additionalModelRequestFields for prefixed non-Claude aliases", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Prefixed non-Claude alias should not get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("kiro/gpt-4o", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not send Claude-specific additionalModelRequestFields for non-Claude aliases", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Non-Claude aliases should not get Claude adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("gpt-4o", body, true, {});
|
||||
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>24576</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
|
||||
it("defaults future Kiro model ids to additionalModelRequestFields support", () => {
|
||||
const body = {
|
||||
reasoning_effort: "high",
|
||||
messages: [{ role: "user", content: "Future model id should get adaptive fields" }]
|
||||
};
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.60", body, true, {});
|
||||
|
||||
expect(result.additionalModelRequestFields).toEqual({
|
||||
thinking: { type: "adaptive", display: "summarized" },
|
||||
output_config: { effort: "high" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps reasoning_effort max to Kiro max_thinking_length 32000", () => {
|
||||
@@ -315,7 +410,8 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps OpenAI Responses reasoning.effort xhigh to max_thinking_length 32000", () => {
|
||||
@@ -326,7 +422,8 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>32000</max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields?.output_config?.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("uses Claude thinking.budget_tokens as max_thinking_length", () => {
|
||||
@@ -337,7 +434,7 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>4096</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("uses the default budget for synthetic -thinking models with no explicit config", () => {
|
||||
@@ -347,7 +444,54 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6-thinking", body, true, {});
|
||||
|
||||
expect(contentOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
|
||||
expect(systemPromptOf(result)).toContain("<max_thinking_length>16000</max_thinking_length>");
|
||||
});
|
||||
|
||||
it("keeps top-level systemPrompt stable across turns", () => {
|
||||
const first = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6-thinking",
|
||||
{ messages: [{ role: "user", content: "first" }] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
const second = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6-thinking",
|
||||
{ messages: [{ role: "user", content: "second" }] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
|
||||
expect(first.systemPrompt).toBe(second.systemPrompt);
|
||||
expect(first.systemPrompt).not.toContain("Current time");
|
||||
expect(first.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
});
|
||||
|
||||
it("replays frozen msg0 for explicit Kiro sessions while keeping current time fresh", () => {
|
||||
const credentials = {
|
||||
connectionId: "kiro-account-openai-replay",
|
||||
rawHeaders: { "x-session-id": "hermes-session-openai-replay" },
|
||||
};
|
||||
const first = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6",
|
||||
{ messages: [{ role: "user", content: "first turn" }] },
|
||||
true,
|
||||
credentials
|
||||
);
|
||||
const second = openaiToKiroRequest(
|
||||
"claude-sonnet-4.6",
|
||||
{ messages: [{ role: "user", content: "second turn" }] },
|
||||
true,
|
||||
credentials
|
||||
);
|
||||
|
||||
expect(second.conversationState.conversationId).toBe("hermes-session-openai-replay");
|
||||
expect(second.conversationState.agentContinuationId).toBe(first.conversationState.agentContinuationId);
|
||||
expect(second.conversationState.history[0].userInputMessage.content).toBe(
|
||||
first.conversationState.currentMessage.userInputMessage.content
|
||||
);
|
||||
expect(second.conversationState.history[0].userInputMessage.modelId).toBe("claude-sonnet-4.6");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("Current time");
|
||||
expect(second.conversationState.currentMessage.userInputMessage.content).toContain("second turn");
|
||||
});
|
||||
|
||||
it("does not inject thinking prefix for reasoning_effort none", () => {
|
||||
@@ -358,8 +502,9 @@ describe("openaiToKiroRequest", () => {
|
||||
|
||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||
|
||||
expect(contentOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(systemPromptOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||
expect(systemPromptOf(result)).not.toContain("<max_thinking_length>");
|
||||
expect(result.additionalModelRequestFields).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// A2: locks resolveSessionId priority/stickiness (codex/kiro/antigravity centralization).
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { resolveSessionId, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
import { resolveContinuationId, resolveSessionId, resolveSessionIdentity, deriveSessionId, clearSessionStore } from "../../open-sse/utils/sessionManager.js";
|
||||
|
||||
// Assistant text must reach ASSISTANT_MIN_LEN (80) to use assistant anchor; else first user message.
|
||||
const longAssistant = "x".repeat(80);
|
||||
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
|
||||
const bodyWithUserOnly = { messages: [{ role: "user", content: "hello from first user message anchor" }] };
|
||||
|
||||
beforeEach(() => clearSessionStore());
|
||||
beforeEach(() => {
|
||||
clearSessionStore();
|
||||
});
|
||||
|
||||
describe("resolveSessionId", () => {
|
||||
it("stickiness: same body+connectionId+scope -> same id", () => {
|
||||
@@ -55,8 +57,170 @@ describe("resolveSessionId", () => {
|
||||
expect(got).toBe("client-sess-123");
|
||||
});
|
||||
|
||||
it("does not treat request-scoped x-client-request-id as a session override", () => {
|
||||
const first = resolveSessionId({
|
||||
headers: { "x-client-request-id": "req-1" },
|
||||
body: bodyWithUserOnly,
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
const second = resolveSessionId({
|
||||
headers: { "x-client-request-id": "req-2" },
|
||||
body: bodyWithUserOnly,
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
|
||||
expect(first).not.toBe("req-1");
|
||||
expect(second).not.toBe("req-2");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("does not treat request-scoped previous_response_id as a Kiro session override", () => {
|
||||
const first = resolveSessionId({
|
||||
body: { ...bodyWithUserOnly, previous_response_id: "resp-1" },
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
const second = resolveSessionId({
|
||||
body: { ...bodyWithUserOnly, previous_response_id: "resp-2" },
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
|
||||
expect(first).not.toBe("resp-1");
|
||||
expect(second).not.toBe("resp-2");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("does not treat raw metadata.user_id as a Kiro conversation session", () => {
|
||||
const first = resolveSessionId({
|
||||
body: {
|
||||
metadata: { user_id: "user-123" },
|
||||
messages: [{ role: "user", content: "new chat about invoices" }],
|
||||
},
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
const second = resolveSessionId({
|
||||
body: {
|
||||
metadata: { user_id: "user-123" },
|
||||
messages: [{ role: "user", content: "unrelated new chat about refunds" }],
|
||||
},
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
|
||||
expect(first).not.toBe("user-123");
|
||||
expect(second).not.toBe("user-123");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it("keeps Claude Code session_id metadata as a Kiro conversation session", () => {
|
||||
const body = {
|
||||
metadata: { user_id: JSON.stringify({ session_id: "claude-code-session-123" }) },
|
||||
messages: [{ role: "user", content: "same Claude Code session" }],
|
||||
};
|
||||
|
||||
expect(resolveSessionId({ body, connectionId: "conn1", scope: "kiro" })).toBe("claude:claude-code-session-123");
|
||||
});
|
||||
|
||||
it("keeps raw metadata.user_id as a non-Kiro session fallback", () => {
|
||||
const got = resolveSessionId({
|
||||
body: {
|
||||
metadata: { user_id: "user-123" },
|
||||
messages: [{ role: "user", content: "non-Kiro provider" }],
|
||||
},
|
||||
connectionId: "conn1",
|
||||
scope: "codex",
|
||||
});
|
||||
|
||||
expect(got).toBe("user-123");
|
||||
});
|
||||
|
||||
it("keeps x-client-request-id as a session override outside Kiro scope", () => {
|
||||
const got = resolveSessionId({
|
||||
headers: { "x-client-request-id": "req-1" },
|
||||
body: bodyWithAssistant,
|
||||
connectionId: "conn1",
|
||||
scope: "codex",
|
||||
});
|
||||
|
||||
expect(got).toBe("req-1");
|
||||
});
|
||||
|
||||
|
||||
it("workspaceId path: empty body + workspaceId set -> normalized workspaceId", () => {
|
||||
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
|
||||
expect(got).toBe("ws-abc");
|
||||
});
|
||||
|
||||
it("uses fresh Kiro sessions for unrelated headerless requests on the same connection", () => {
|
||||
const a = resolveSessionId({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
|
||||
const b = resolveSessionId({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("marks generated headerless Kiro sessions as ephemeral", () => {
|
||||
const generated = resolveSessionIdentity({ body: bodyWithUserOnly, connectionId: "conn1", scope: "kiro" });
|
||||
const explicit = resolveSessionIdentity({
|
||||
headers: { "x-session-id": "client-sess-123" },
|
||||
body: bodyWithUserOnly,
|
||||
connectionId: "conn1",
|
||||
scope: "kiro",
|
||||
});
|
||||
|
||||
expect(generated.ephemeral).toBe(true);
|
||||
expect(explicit).toEqual({ sessionId: "client-sess-123", ephemeral: false });
|
||||
});
|
||||
|
||||
it("does not switch Kiro headerless requests to assistant-text session ids mid-conversation", () => {
|
||||
const withAssistant = { messages: [{ role: "user", content: "same user" }, { role: "assistant", content: "y".repeat(80) }] };
|
||||
const a = resolveSessionId({ body: withAssistant, connectionId: "conn1", scope: "kiro" });
|
||||
const b = resolveSessionId({ body: withAssistant, connectionId: "conn1", scope: "kiro" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContinuationId", () => {
|
||||
it("keeps continuation id stable for the same Kiro session", () => {
|
||||
const opts = { sessionId: "kiro-session-1", connectionId: "conn1", scope: "kiro" };
|
||||
expect(resolveContinuationId(opts)).toBe(resolveContinuationId(opts));
|
||||
});
|
||||
|
||||
it("uses a different continuation id for a different Kiro session", () => {
|
||||
const a = resolveContinuationId({ sessionId: "kiro-session-1", connectionId: "conn1", scope: "kiro" });
|
||||
const b = resolveContinuationId({ sessionId: "kiro-session-2", connectionId: "conn1", scope: "kiro" });
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("does not evict a recently used continuation id when the store exceeds its cap", () => {
|
||||
const first = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
|
||||
for (let i = 1; i < 5000; i++) {
|
||||
resolveContinuationId({ sessionId: `kiro-session-${i}`, connectionId: "conn1", scope: "kiro" });
|
||||
}
|
||||
expect(resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" })).toBe(first);
|
||||
resolveContinuationId({ sessionId: "kiro-session-5000", connectionId: "conn1", scope: "kiro" });
|
||||
|
||||
expect(resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" })).toBe(first);
|
||||
});
|
||||
|
||||
it("evicts old continuation ids when the store exceeds its cap", () => {
|
||||
const first = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
|
||||
for (let i = 1; i <= 5000; i++) {
|
||||
resolveContinuationId({ sessionId: `kiro-session-${i}`, connectionId: "conn1", scope: "kiro" });
|
||||
}
|
||||
|
||||
const afterEviction = resolveContinuationId({ sessionId: "kiro-session-0", connectionId: "conn1", scope: "kiro" });
|
||||
expect(afterEviction).not.toBe(first);
|
||||
});
|
||||
|
||||
it("does not let ephemeral Kiro continuations evict explicit session continuations", () => {
|
||||
const stable = resolveContinuationId({ sessionId: "explicit-session", connectionId: "conn1", scope: "kiro" });
|
||||
for (let i = 0; i <= 5000; i++) {
|
||||
resolveContinuationId({ sessionId: `ephemeral-session-${i}`, connectionId: "conn1", scope: "kiro", ephemeral: true });
|
||||
}
|
||||
|
||||
expect(resolveContinuationId({ sessionId: "explicit-session", connectionId: "conn1", scope: "kiro" })).toBe(stable);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user