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:
parent
70e8dc4974
commit
9c58ba645e
10 changed files with 786 additions and 90 deletions
|
|
@ -131,6 +131,50 @@ export function resolveKiroThinkingBudget(body, headers, model) {
|
||||||
return null;
|
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.
|
* Detect whether an inbound request is asking for reasoning / thinking output.
|
||||||
* Thin wrapper over resolveKiroThinkingBudget (single source of truth).
|
* 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)
|
// Normalize thinking to the target provider-native format (config-driven, capability-aware).
|
||||||
applyThinking(targetFormat, model, result, provider, thinkingIntent);
|
// 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
|
// Always normalize to clean OpenAI format when target is OpenAI
|
||||||
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
// This handles hybrid requests (e.g., OpenAI messages + Claude tools)
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,15 @@
|
||||||
*/
|
*/
|
||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.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 {
|
import {
|
||||||
resolveKiroModel,
|
resolveKiroModel,
|
||||||
resolveKiroThinkingBudget,
|
resolveKiroThinkingBudget,
|
||||||
buildThinkingSystemPrefix,
|
buildThinkingSystemPrefix,
|
||||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||||
resolveDefaultProfileArn,
|
resolveDefaultProfileArn,
|
||||||
|
buildKiroAdditionalModelRequestFieldsForModel,
|
||||||
} from "../../config/kiroConstants.js";
|
} from "../../config/kiroConstants.js";
|
||||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
import { DEFAULT_IMAGE_MIME } from "../schema/index.js";
|
||||||
import { ROLE, CLAUDE_BLOCK } 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.
|
* 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 || "")
|
||||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||||
|
|
||||||
let finalContent = currentMessage?.userInputMessage?.content || "";
|
// Kiro CLI/KAS sends system prompt as top-level `systemPrompt`. Keep a
|
||||||
|
// content fallback too because the CodeWhisperer surface does not always
|
||||||
// System prompt: pass via native systemInstruction field (Kiro/Q API supports it)
|
// enforce top-level systemPrompt for direct calls.
|
||||||
// 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.
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
const prefixParts = [];
|
const systemPromptParts = [];
|
||||||
if (thinkingBudget !== null) prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
if (thinkingBudget !== null) systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||||
prefixParts.push(`[Context: Current time is ${timestamp}]`);
|
if (agentic) systemPromptParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
||||||
if (agentic) prefixParts.push(KIRO_AGENTIC_SYSTEM_PROMPT);
|
const systemInstruction = extractClaudeSystemText(body.system);
|
||||||
finalContent = `${prefixParts.join("\n\n")}\n\n${finalContent}`;
|
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 = {
|
const userInputMessage = {
|
||||||
content: finalContent,
|
content: replayCurrent.content || "",
|
||||||
modelId: upstreamModel,
|
modelId: upstreamModel,
|
||||||
origin: "AI_EDITOR",
|
origin: "AI_EDITOR",
|
||||||
...(currentMessage?.userInputMessage?.userInputMessageContext && {
|
...(replayCurrent.userInputMessageContext && {
|
||||||
userInputMessageContext:
|
userInputMessageContext: replayCurrent.userInputMessageContext,
|
||||||
currentMessage.userInputMessage.userInputMessageContext,
|
|
||||||
}),
|
}),
|
||||||
...(currentMessage?.userInputMessage?.images && {
|
...(replayCurrent.images && {
|
||||||
images: currentMessage.userInputMessage.images,
|
images: replayCurrent.images,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (systemInstruction) {
|
|
||||||
userInputMessage.systemInstruction = systemInstruction;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
conversationState: {
|
conversationState: {
|
||||||
chatTriggerType: "MANUAL",
|
chatTriggerType: "MANUAL",
|
||||||
conversationId: uuidv4(),
|
conversationId,
|
||||||
|
agentContinuationId: continuationId,
|
||||||
|
agentTaskType: "vibe",
|
||||||
currentMessage: {
|
currentMessage: {
|
||||||
userInputMessage,
|
userInputMessage,
|
||||||
},
|
},
|
||||||
history,
|
history: replay.history,
|
||||||
},
|
},
|
||||||
|
agentMode: "vibe",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (profileArn) payload.profileArn = profileArn;
|
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) {
|
if (maxTokens || temperature !== undefined || topP !== undefined) {
|
||||||
payload.inferenceConfig = {};
|
payload.inferenceConfig = {};
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,15 @@
|
||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
import { v4 as uuidv4 } from "uuid";
|
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 {
|
import {
|
||||||
resolveKiroModel,
|
resolveKiroModel,
|
||||||
resolveKiroThinkingBudget,
|
resolveKiroThinkingBudget,
|
||||||
buildThinkingSystemPrefix,
|
buildThinkingSystemPrefix,
|
||||||
KIRO_AGENTIC_SYSTEM_PROMPT,
|
KIRO_AGENTIC_SYSTEM_PROMPT,
|
||||||
resolveDefaultProfileArn
|
resolveDefaultProfileArn,
|
||||||
|
buildKiroAdditionalModelRequestFieldsForModel
|
||||||
} from "../../config/kiroConstants.js";
|
} from "../../config/kiroConstants.js";
|
||||||
import { parseDataUri } from "../concerns/image.js";
|
import { parseDataUri } from "../concerns/image.js";
|
||||||
import { DEFAULT_IMAGE_MIME } from "../schema/index.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 || "")
|
||||||
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
: (credentials?.providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod));
|
||||||
|
|
||||||
let finalContent = currentMessage?.userInputMessage?.content || "";
|
|
||||||
|
|
||||||
const timestamp = new Date().toISOString();
|
const timestamp = new Date().toISOString();
|
||||||
|
|
||||||
// Build the system-prompt prefix that goes ABOVE the user message body.
|
// Kiro CLI/KAS sends these as top-level systemPrompt. Keep a content fallback
|
||||||
// Order: thinking_mode tag first (so Kiro sees it before any user text),
|
// too because the CodeWhisperer surface does not always enforce top-level
|
||||||
// then context/timestamp marker, then optional agentic chunked-write prompt.
|
// systemPrompt for direct calls.
|
||||||
const prefixParts = [];
|
const systemPromptParts = [];
|
||||||
if (thinkingBudget !== null) {
|
if (thinkingBudget !== null) {
|
||||||
prefixParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
systemPromptParts.push(buildThinkingSystemPrefix(thinkingBudget));
|
||||||
}
|
}
|
||||||
prefixParts.push(`[Context: Current time is ${timestamp}]`);
|
|
||||||
if (agentic) {
|
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 = {
|
const payload = {
|
||||||
conversationState: {
|
conversationState: {
|
||||||
chatTriggerType: "MANUAL",
|
chatTriggerType: "MANUAL",
|
||||||
conversationId: resolveSessionId({ headers: credentials?.rawHeaders, body, connectionId: credentials?.connectionId, scope: "kiro" }),
|
conversationId,
|
||||||
|
agentContinuationId: continuationId,
|
||||||
|
agentTaskType: "vibe",
|
||||||
currentMessage: {
|
currentMessage: {
|
||||||
userInputMessage: {
|
userInputMessage: {
|
||||||
content: finalContent,
|
content: replayCurrent.content || "",
|
||||||
modelId: upstreamModel,
|
modelId: upstreamModel,
|
||||||
origin: "AI_EDITOR",
|
origin: "AI_EDITOR",
|
||||||
...(currentMessage?.userInputMessage?.images?.length > 0 && {
|
...(replayCurrent.images?.length > 0 && {
|
||||||
images: currentMessage.userInputMessage.images
|
images: replayCurrent.images
|
||||||
}),
|
}),
|
||||||
...(currentMessage?.userInputMessage?.userInputMessageContext && {
|
...(replayCurrent.userInputMessageContext && {
|
||||||
userInputMessageContext: currentMessage.userInputMessage.userInputMessageContext
|
userInputMessageContext: replayCurrent.userInputMessageContext
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
history: history
|
history: replay.history
|
||||||
}
|
},
|
||||||
|
agentMode: "vibe",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (profileArn) {
|
if (profileArn) {
|
||||||
payload.profileArn = 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) {
|
if (maxTokens || temperature !== undefined || topP !== undefined) {
|
||||||
payload.inferenceConfig = {};
|
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 }
|
// Runtime storage: Key = connectionId, Value = { sessionId, lastUsed }
|
||||||
const runtimeSessionStore = new Map();
|
const runtimeSessionStore = new Map();
|
||||||
|
const continuationStore = new Map();
|
||||||
|
|
||||||
// Periodically evict entries that haven't been used within TTL
|
// Periodically evict entries that haven't been used within TTL
|
||||||
const cleanupInterval = setInterval(() => {
|
const cleanupInterval = setInterval(() => {
|
||||||
|
|
@ -80,6 +81,7 @@ export function generateBinaryStyleId() {
|
||||||
export function clearSessionStore() {
|
export function clearSessionStore() {
|
||||||
runtimeSessionStore.clear();
|
runtimeSessionStore.clear();
|
||||||
assistantSessionStore.clear();
|
assistantSessionStore.clear();
|
||||||
|
continuationStore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversation-stable session store: Key = hash(scope+assistant text), Value = { sessionId, lastUsed }
|
// 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_MIN_LEN = 50;
|
||||||
const ASSISTANT_CAP_LEN = 50;
|
const ASSISTANT_CAP_LEN = 50;
|
||||||
const MAX_ASSISTANT_SESSIONS = 5000;
|
const MAX_ASSISTANT_SESSIONS = 5000;
|
||||||
|
const MAX_CONTINUATION_SESSIONS = 5000;
|
||||||
|
|
||||||
// Client headers/body fields that carry an upstream session id (priority order)
|
// 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-]+)$/;
|
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
|
||||||
|
|
||||||
function sha16(text) {
|
function sha16(text) {
|
||||||
|
|
@ -131,7 +134,7 @@ function extractAntigravitySession(body) {
|
||||||
return m ? normalizeSessionId(m[1]) : null;
|
return m ? normalizeSessionId(m[1]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractClientSessionId(headers, body) {
|
function extractClientSessionId(headers, body, scope = "") {
|
||||||
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
|
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
|
||||||
if (claude) return `claude:${claude}`;
|
if (claude) return `claude:${claude}`;
|
||||||
const antigravity = extractAntigravitySession(body);
|
const antigravity = extractAntigravitySession(body);
|
||||||
|
|
@ -140,18 +143,25 @@ function extractClientSessionId(headers, body) {
|
||||||
const v = headerValue(headers, key);
|
const v = headerValue(headers, key);
|
||||||
if (v) return v;
|
if (v) return v;
|
||||||
}
|
}
|
||||||
|
const requestId = scope === "kiro" ? null : headerValue(headers, "x-client-request-id");
|
||||||
|
if (requestId) return requestId;
|
||||||
const fromBody =
|
const fromBody =
|
||||||
normalizeSessionId(body?.prompt_cache_key) ||
|
normalizeSessionId(body?.prompt_cache_key) ||
|
||||||
normalizeSessionId(body?.session_id) ||
|
normalizeSessionId(body?.session_id) ||
|
||||||
normalizeSessionId(body?.conversation_id) ||
|
normalizeSessionId(body?.conversation_id) ||
|
||||||
normalizeSessionId(body?.metadata?.user_id);
|
(scope === "kiro" ? null : normalizeSessionId(body?.metadata?.user_id));
|
||||||
return fromBody || null;
|
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)
|
// Accumulate assistant text from OpenAI/Responses-style input/messages (cap-limited)
|
||||||
function accumulateAssistantText(body) {
|
function accumulateAssistantText(body) {
|
||||||
const items = Array.isArray(body?.input) ? body.input
|
const items = requestMessages(body);
|
||||||
: Array.isArray(body?.messages) ? body.messages : null;
|
|
||||||
if (!items) return "";
|
if (!items) return "";
|
||||||
let text = "";
|
let text = "";
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|
@ -193,16 +203,39 @@ function assistantTextSessionId(scope, body) {
|
||||||
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
|
* @param {string} [opts.connectionId] - Connection identifier (fallback scope)
|
||||||
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
|
* @param {string} [opts.workspaceId] - Provider workspace id (account-wide fallback)
|
||||||
* @param {string} [opts.scope] - Provider scope to isolate cache keys across providers
|
* @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 = "" } = {}) {
|
export function resolveSessionIdentity({ headers, body, connectionId, workspaceId, scope = "" } = {}) {
|
||||||
const client = extractClientSessionId(headers, body);
|
const client = extractClientSessionId(headers, body, scope);
|
||||||
if (client) return client;
|
if (client) return { sessionId: client, ephemeral: false };
|
||||||
const fromAssistant = assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
const fromAssistant = scope === "kiro" ? null : assistantTextSessionId(`${scope}:${connectionId || ""}`, body);
|
||||||
if (fromAssistant) return fromAssistant;
|
if (fromAssistant) return { sessionId: fromAssistant, ephemeral: false };
|
||||||
const ws = normalizeSessionId(workspaceId);
|
const ws = normalizeSessionId(workspaceId);
|
||||||
if (ws) return ws;
|
if (ws) return { sessionId: ws, ephemeral: false };
|
||||||
return deriveSessionId(connectionId);
|
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)
|
// Capture session id from request body + credentials (envelope still intact here)
|
||||||
|
|
@ -227,5 +260,8 @@ const assistantCleanup = setInterval(() => {
|
||||||
for (const [key, entry] of assistantSessionStore) {
|
for (const [key, entry] of assistantSessionStore) {
|
||||||
if (now - entry.lastUsed > MEMORY_CONFIG.sessionTtlMs) assistantSessionStore.delete(key);
|
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);
|
}, MEMORY_CONFIG.sessionCleanupIntervalMs);
|
||||||
if (assistantCleanup.unref) assistantCleanup.unref();
|
if (assistantCleanup.unref) assistantCleanup.unref();
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import "./registerAll.js";
|
||||||
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
|
import { translateRequest, translateResponse } from "../../open-sse/translator/index.js";
|
||||||
import { FORMATS } from "../../open-sse/translator/formats.js";
|
import { FORMATS } from "../../open-sse/translator/formats.js";
|
||||||
|
|
||||||
const C2K = (body) =>
|
const C2K = (body, credentials = null, model = "claude-sonnet-4.5") =>
|
||||||
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, "claude-sonnet-4.5", body, true, null, "kiro");
|
translateRequest(FORMATS.CLAUDE, FORMATS.KIRO, model, body, true, credentials, "kiro");
|
||||||
|
|
||||||
describe("Claude → Kiro (direct route)", () => {
|
describe("Claude → Kiro (direct route)", () => {
|
||||||
it("produces a Kiro conversationState payload", () => {
|
it("produces a Kiro conversationState payload", () => {
|
||||||
|
|
@ -16,6 +16,27 @@ describe("Claude → Kiro (direct route)", () => {
|
||||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain("hello");
|
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)", () => {
|
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.
|
// Client omitted `tools` but kept a tool_result after compaction.
|
||||||
const out = C2K({
|
const out = C2K({
|
||||||
|
|
@ -60,20 +81,60 @@ describe("Claude → Kiro (direct route)", () => {
|
||||||
null,
|
null,
|
||||||
"kiro"
|
"kiro"
|
||||||
);
|
);
|
||||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
expect(out.systemPrompt).toContain(
|
||||||
"<thinking_mode>enabled</thinking_mode>"
|
"<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({
|
const out = C2K({
|
||||||
output_config: { effort: "high" },
|
output_config: { effort: "high" },
|
||||||
messages: [{ role: "user", content: "think with adaptive effort" }],
|
messages: [{ role: "user", content: "think with adaptive effort" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(out.conversationState.currentMessage.userInputMessage.content).toContain(
|
expect(out.additionalModelRequestFields).toBeUndefined();
|
||||||
"<max_thinking_length>24576</max_thinking_length>"
|
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 { describe, it, expect } from "vitest";
|
||||||
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
import { KiroExecutor } from "../../open-sse/executors/kiro.js";
|
||||||
|
import "../translator/registerAll.js";
|
||||||
|
|
||||||
function createMockFrame(eventType, payloadObj) {
|
function createMockFrame(eventType, payloadObj) {
|
||||||
const payloadStr = JSON.stringify(payloadObj);
|
const payloadStr = JSON.stringify(payloadObj);
|
||||||
|
|
@ -47,6 +48,13 @@ async function readAllSSE(stream) {
|
||||||
return result;
|
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", () => {
|
describe("KiroExecutor thinking tag stripping", () => {
|
||||||
it("strips <thinking> tags from assistantResponseEvent", async () => {
|
it("strips <thinking> tags from assistantResponseEvent", async () => {
|
||||||
const executor = new KiroExecutor();
|
const executor = new KiroExecutor();
|
||||||
|
|
@ -121,4 +129,53 @@ describe("KiroExecutor thinking tag stripping", () => {
|
||||||
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
|
const contentChunks = objects.filter(obj => obj.choices[0].delta.content !== undefined);
|
||||||
expect(contentChunks.length).toBe(0);
|
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) =>
|
const contentOf = (result) =>
|
||||||
result.conversationState.currentMessage.userInputMessage.content;
|
result.conversationState.currentMessage.userInputMessage.content;
|
||||||
|
const systemPromptOf = (result) => result.systemPrompt || "";
|
||||||
|
|
||||||
describe("openaiToKiroRequest", () => {
|
describe("openaiToKiroRequest", () => {
|
||||||
describe("basic message conversion", () => {
|
describe("basic message conversion", () => {
|
||||||
|
|
@ -293,7 +294,11 @@ describe("openaiToKiroRequest", () => {
|
||||||
|
|
||||||
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
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", () => {
|
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, {});
|
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", () => {
|
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, {});
|
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", () => {
|
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, {});
|
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", () => {
|
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, {});
|
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", () => {
|
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, {});
|
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", () => {
|
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, {});
|
const result = openaiToKiroRequest("claude-sonnet-4.6", body, true, {});
|
||||||
|
|
||||||
expect(contentOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
expect(systemPromptOf(result)).not.toContain("<thinking_mode>enabled</thinking_mode>");
|
||||||
expect(contentOf(result)).not.toContain("<max_thinking_length>");
|
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).
|
// A2: locks resolveSessionId priority/stickiness (codex/kiro/antigravity centralization).
|
||||||
import { describe, it, expect, beforeEach } from "vitest";
|
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.
|
// Assistant text must reach ASSISTANT_MIN_LEN (80) to use assistant anchor; else first user message.
|
||||||
const longAssistant = "x".repeat(80);
|
const longAssistant = "x".repeat(80);
|
||||||
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
|
const bodyWithAssistant = { messages: [{ role: "assistant", content: longAssistant }] };
|
||||||
const bodyWithUserOnly = { messages: [{ role: "user", content: "hello from first user message anchor" }] };
|
const bodyWithUserOnly = { messages: [{ role: "user", content: "hello from first user message anchor" }] };
|
||||||
|
|
||||||
beforeEach(() => clearSessionStore());
|
beforeEach(() => {
|
||||||
|
clearSessionStore();
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveSessionId", () => {
|
describe("resolveSessionId", () => {
|
||||||
it("stickiness: same body+connectionId+scope -> same id", () => {
|
it("stickiness: same body+connectionId+scope -> same id", () => {
|
||||||
|
|
@ -55,8 +57,170 @@ describe("resolveSessionId", () => {
|
||||||
expect(got).toBe("client-sess-123");
|
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", () => {
|
it("workspaceId path: empty body + workspaceId set -> normalized workspaceId", () => {
|
||||||
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
|
const got = resolveSessionId({ body: {}, connectionId: "conn1", workspaceId: "ws-abc" });
|
||||||
expect(got).toBe("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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue