diff --git a/open-sse/config/kiroConstants.js b/open-sse/config/kiroConstants.js index 3ff6acbb..93b8f456 100644 --- a/open-sse/config/kiroConstants.js +++ b/open-sse/config/kiroConstants.js @@ -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). diff --git a/open-sse/translator/index.js b/open-sse/translator/index.js index e9c84971..37e5bde6 100644 --- a/open-sse/translator/index.js +++ b/open-sse/translator/index.js @@ -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) diff --git a/open-sse/translator/request/claude-to-kiro.js b/open-sse/translator/request/claude-to-kiro.js index c42823b6..bcb8b97f 100644 --- a/open-sse/translator/request/claude-to-kiro.js +++ b/open-sse/translator/request/claude-to-kiro.js @@ -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 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 = `\n${systemText}\n\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 = {}; diff --git a/open-sse/translator/request/openai-to-kiro.js b/open-sse/translator/request/openai-to-kiro.js index 5c8cc00f..a2681b5a 100644 --- a/open-sse/translator/request/openai-to-kiro.js +++ b/open-sse/translator/request/openai-to-kiro.js @@ -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 = {}; diff --git a/open-sse/utils/kiroSessionReplay.js b/open-sse/utils/kiroSessionReplay.js new file mode 100644 index 00000000..11cae9cd --- /dev/null +++ b/open-sse/utils/kiroSessionReplay.js @@ -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(); diff --git a/open-sse/utils/sessionManager.js b/open-sse/utils/sessionManager.js index 05f90896..b6f16f1a 100644 --- a/open-sse/utils/sessionManager.js +++ b/open-sse/utils/sessionManager.js @@ -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(); diff --git a/tests/translator/claude-kiro-direct.test.js b/tests/translator/claude-kiro-direct.test.js index 1b189ec6..01e21709 100644 --- a/tests/translator/claude-kiro-direct.test.js +++ b/tests/translator/claude-kiro-direct.test.js @@ -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( "enabled" ); + 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( - "24576" - ); + expect(out.additionalModelRequestFields).toBeUndefined(); + expect(out.thinking).toBeUndefined(); + expect(out.systemPrompt).toContain("24576"); + }); + + 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("24576"); + }); + + 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"); }); }); diff --git a/tests/unit/kiro-thinking-strip.test.js b/tests/unit/kiro-thinking-strip.test.js index 91b5009c..1fff6e9e 100644 --- a/tests/unit/kiro-thinking-strip.test.js +++ b/tests/unit/kiro-thinking-strip.test.js @@ -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 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"); + }); }); diff --git a/tests/unit/openai-to-kiro.test.js b/tests/unit/openai-to-kiro.test.js index ddda87ab..8b1c97e8 100644 --- a/tests/unit/openai-to-kiro.test.js +++ b/tests/unit/openai-to-kiro.test.js @@ -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("1024"); + expect(systemPromptOf(result)).toContain("1024"); + 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("24576"); + expect(systemPromptOf(result)).toContain("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("24576"); + 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("32000"); + expect(systemPromptOf(result)).toContain("32000"); + 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("32000"); + expect(systemPromptOf(result)).toContain("32000"); + 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("4096"); + expect(systemPromptOf(result)).toContain("4096"); }); 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("16000"); + expect(systemPromptOf(result)).toContain("16000"); + }); + + 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("enabled"); - expect(contentOf(result)).not.toContain(""); + expect(systemPromptOf(result)).not.toContain("enabled"); + expect(systemPromptOf(result)).not.toContain(""); + expect(result.additionalModelRequestFields).toBeUndefined(); }); }); }); diff --git a/tests/unit/session-manager.test.js b/tests/unit/session-manager.test.js index 37474813..a7cecb59 100644 --- a/tests/unit/session-manager.test.js +++ b/tests/unit/session-manager.test.js @@ -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); + }); });