resolve merge conflicts

This commit is contained in:
Johnny Eric Amancio
2026-08-24 15:08:20 +02:00
43 changed files with 832 additions and 153 deletions
@@ -0,0 +1,6 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Adopt OpenCode v1.18.16 through v1.18.18 improvements, including conversation-aware compaction, expanded reasoning effort support, provider compatibility fixes, and retry handling.
+1 -1
View File
@@ -1 +1 @@
v1.18.15
v1.18.18
+2 -1
View File
@@ -21,5 +21,6 @@
"@types/react-dom": "^19.2.3",
"typescript": "^5.8.2"
},
"version": "7.4.23"
"version": "7.4.23",
"peerDependencies": {}
}
+1
View File
@@ -1018,6 +1018,7 @@
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"solid-js@1.9.12": "patches/solid-js@1.9.12.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
+4 -3
View File
@@ -18,12 +18,12 @@
"random": "echo 'Random script'",
"sso": "aws sso login --sso-session=opencode --no-browser",
"test": "echo 'do not run tests from root' && exit 1",
"test:script:ci": "mkdir -p .artifacts/unit && bun test ./script --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"extension": "bun --cwd packages/kilo-vscode script/launch.ts",
"extension:isolated": "bun --cwd packages/kilo-vscode script/launch.ts --isolated",
"extension:isolated:clean": "bun --cwd packages/kilo-vscode script/launch.ts --isolated --clean",
"dev-setup": "bun run --cwd packages/opencode --conditions=browser src/index.ts dev-setup",
"dev:local": "bun run packages/opencode/script/dev-local.ts"
"dev:local": "bun run packages/opencode/script/dev-local.ts",
"test:script:ci": "mkdir -p .artifacts/unit && bun test ./script --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"workspaces": {
"packages": [
@@ -170,12 +170,13 @@
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
"@ai-sdk/groq@3.0.31": "patches/@ai-sdk%2Fgroq@3.0.31.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"solid-js@1.9.12": "patches/solid-js@1.9.12.patch",
"@ai-sdk/openai-compatible@2.0.48": "patches/@ai-sdk%2Fopenai-compatible@2.0.48.patch"
},
+2 -6
View File
@@ -30,15 +30,11 @@ Guidelines:
Complete the user's search request efficiently and report your findings clearly.`
const PROMPT_COMPACTION = `You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
const PROMPT_COMPACTION = `You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.`
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.`
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
+25 -19
View File
@@ -44,6 +44,15 @@ Rules:
- Use terse bullets, not prose paragraphs.
- Preserve exact file paths, symbols, commands, error strings, URLs, and identifiers when known.
- Do not mention the summary process or that context was compacted.`
const SUMMARY_UPDATE_INSTRUCTIONS = `The <prior-summary> summarizes everything that happened before the <conversation>. Construct a new summary that combines both. The <prior-summary> is discarded after this: anything you do not carry into the new summary is lost.
When combining:
- Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary> even when the <conversation> does not mention them. Drop only what is finished and no longer needed.
- The <conversation> is more recent than the <prior-summary>. Where they conflict, the conversation wins: state the corrected fact and drop the old claim.
- Add new progress, decisions, constraints, and context from the conversation.
- Move completed work from "Active" to "Completed".
- If a blocker has been resolved, update the summary to reflect that while keeping any details still needed to continue the work.
- Update "Objective" and "Next Move" to reflect the current work state.`
type Entry = {
readonly seq: number
@@ -136,36 +145,33 @@ const select = (
if (conversation.length === 0) return
let total = 0
let split = conversation.length
let splitPrefix = ""
let splitSuffix = ""
for (let index = conversation.length - 1; index >= 0; index--) {
const next = total + Token.estimate(conversation[index])
if (next > tokens) {
const remaining = Math.max(0, tokens - total) * 4
if (remaining > 0) {
splitPrefix = conversation[index].slice(0, -remaining)
splitSuffix = conversation[index].slice(-remaining)
split = index + 1
}
break
}
if (next > tokens) break
total = next
split = index
}
return {
head: [...conversation.slice(0, split), splitPrefix].filter(Boolean).join("\n\n"),
recent: [splitSuffix, ...conversation.slice(split)].filter(Boolean).join("\n\n"),
head: conversation.slice(0, split).join("\n\n"),
recent: conversation.slice(split).join("\n\n"),
}
}
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) =>
[
input.previousSummary
? `Update the anchored summary below using the conversation history above.\nPreserve still-true details, remove stale details, and merge in the new facts.\n<previous-summary>\n${input.previousSummary}\n</previous-summary>`
: "Create a new anchored summary from the conversation history.",
export const buildPrompt = (input: { readonly previousSummary?: string; readonly context: readonly string[] }) => {
const conversation = `Here is the conversation so far:\n\n<conversation>\n${input.context.join("\n\n")}\n</conversation>`
if (!input.previousSummary)
return [
conversation,
"Create a new anchored summary from the conversation history in the <conversation> tags above so another coding agent can continue the work.",
SUMMARY_TEMPLATE,
].join("\n\n")
return [
conversation,
`Here is the summary of the conversation before the <conversation> above:\n\n<prior-summary>\n${input.previousSummary}\n</prior-summary>`,
SUMMARY_UPDATE_INSTRUCTIONS,
SUMMARY_TEMPLATE,
...input.context,
].join("\n\n")
}
export const make = (dependencies: Dependencies) => {
const config = settings(dependencies.config)
+1 -1
View File
@@ -282,7 +282,7 @@ export const Info = Schema.Struct({
}),
tail_turns: Schema.optional(NonNegativeInt).annotate({
description:
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
"Maximum number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction. By default retention is limited only by the preserved token budget.",
}),
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
+28
View File
@@ -0,0 +1,28 @@
import { createGroq } from "@ai-sdk/groq"
import { expect, test } from "bun:test"
test("Groq passes through unknown reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "openai/gpt-oss-120b",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createGroq({ apiKey: "test", fetch: mockFetch })("openai/gpt-oss-120b")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { groq: { reasoningEffort: "custom" } },
})
expect(body?.reasoning_effort).toBe("custom")
})
@@ -27,6 +27,32 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => {
expect(body?.prompt_cache_key).toBe("session-123")
})
test("Mistral passes through unknown reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created: 0,
model: "mistral-large-latest",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { mistral: { reasoningEffort: "custom" } },
})
expect(body?.reasoning_effort).toBe("custom")
})
test("Mistral round-trips native reasoning in assistant history", async () => {
let body: { messages?: unknown[] } | undefined
const mockFetch = Object.assign(
@@ -30,3 +30,56 @@ test("xAI Responses sends promptCacheKey as prompt_cache_key", async () => {
expect(body?.prompt_cache_key).toBe("session-123")
})
test("xAI Responses passes through xhigh reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "response-1",
created_at: 0,
model: "grok-4",
object: "response",
output: [],
usage: { input_tokens: 1, output_tokens: 0 },
status: "completed",
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({ apiKey: "test", fetch: mockFetch }).responses("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { reasoningEffort: "xhigh" } },
})
expect(body?.reasoning).toEqual({ effort: "xhigh" })
})
test("xAI Chat passes through xhigh reasoning effort", async () => {
let body: Record<string, unknown> | undefined
const mockFetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], init?: RequestInit) => {
body = JSON.parse(String(init?.body))
return Response.json({
id: "chat-1",
created: 0,
model: "grok-4",
object: "chat.completion",
choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
},
{ preconnect: fetch.preconnect },
)
const model = createXai({ apiKey: "test", fetch: mockFetch }).chat("grok-4")
await model.doGenerate({
prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
providerOptions: { xai: { reasoningEffort: "xhigh" } },
})
expect(body?.reasoning_effort).toBe("xhigh")
})
@@ -4,12 +4,32 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
test("compaction prompt preserves detailed work state and relevant files", () => {
const prompt = SessionCompaction.buildPrompt({ context: ["conversation history"] })
expect(prompt).toStartWith(
"Here is the conversation so far:\n\n<conversation>\nconversation history\n</conversation>",
)
expect(prompt.indexOf("</conversation>")).toBeLessThan(prompt.indexOf("Create a new anchored summary"))
expect(prompt).toContain("conversation history in the <conversation> tags above")
expect(prompt).toContain("## Work State\n### Completed")
expect(prompt).toContain("### Active")
expect(prompt).toContain("### Blocked")
expect(prompt).toContain("## Relevant Files")
})
test("compaction prompt gives update instructions for a prior summary", () => {
const prompt = SessionCompaction.buildPrompt({
context: ["new conversation"],
previousSummary: "existing summary",
})
expect(prompt.indexOf("<conversation>")).toBeLessThan(prompt.indexOf("<prior-summary>"))
expect(prompt.indexOf("</prior-summary>")).toBeLessThan(prompt.indexOf("The <prior-summary> summarizes"))
expect(prompt).toContain(
"Carry forward objectives, constraints, user directives, decisions, and parallel workstreams from the <prior-summary>",
)
expect(prompt).toContain('Move completed work from "Active" to "Completed".')
expect(prompt).toContain('Update "Objective" and "Next Move" to reflect the current work state.')
})
test("compaction describes tool media without embedding base64", () => {
const base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB"
const serialized = SessionCompaction.serializeToolContent([
+62 -1
View File
@@ -1170,7 +1170,7 @@ describe("SessionRunnerLLM", () => {
expect(requests).toHaveLength(2)
expect(userTexts(requests[0])[0]).toContain(
"<previous-summary>\n## Objective\n- Preserve the task\n</previous-summary>",
"<prior-summary>\n## Objective\n- Preserve the task\n</prior-summary>",
)
expect(userTexts(requests[0])[0]).toContain("Recent exact request")
expect((yield* (yield* SessionStore.Service).context(sessionID))[0]).toMatchObject({
@@ -1180,6 +1180,67 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("retains only complete serialized messages during compaction", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const earlier = `EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`
const recent = `RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: earlier }), resume: false })
yield* session.resume(sessionID)
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: recent }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/EARLIER_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(`EARLIER_BOUNDARY ${"a".repeat(3_000)} EARLIER_END`)
expect(summary).not.toContain("RECENT_BOUNDARY")
expect(continuation).not.toContain("EARLIER_BOUNDARY")
expect(continuation).not.toContain("EARLIER_END")
expect(continuation).toContain("<recent-context>\n[Assistant]: Earlier answer")
expect(continuation).toContain(`RECENT_BOUNDARY ${"b".repeat(3_000)} RECENT_END`)
}),
)
it.effect("summarizes an oversized newest message without retaining a fragment", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
response = fragmentFixture("text", "text-earlier", ["Earlier answer"]).completeEvents
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Earlier question" }), resume: false })
yield* session.resume(sessionID)
const oversized = `OVERSIZED_BOUNDARY ${"x".repeat(4_500)} OVERSIZED_END`
currentModel = compactModel
requests.length = 0
responses = [
fragmentFixture("text", "text-summary", ["## Objective\n- Preserve the task"]).completeEvents,
fragmentFixture("text", "text-final", ["Continued"]).completeEvents,
]
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: oversized }), resume: false })
yield* session.resume(sessionID)
expect(requests).toHaveLength(2)
const summary = userTexts(requests[0])[0]
const continuation = userTexts(requests[1])[0]
expect(summary.match(/OVERSIZED_BOUNDARY/g)).toHaveLength(1)
expect(summary).toContain(oversized)
expect(continuation).not.toContain("OVERSIZED_BOUNDARY")
expect(continuation).not.toContain("OVERSIZED_END")
expect(continuation).toContain("<recent-context>\n\n</recent-context>")
}),
)
it.effect("forces one compaction and retries after provider context overflow", () =>
Effect.gen(function* () {
const session = yield* setupOverflowRecovery
+2 -1
View File
@@ -20,5 +20,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"version": "7.4.23"
"version": "7.4.23",
"peerDependencies": {}
}
@@ -1,9 +1,5 @@
You are an anchored context summarization assistant for coding sessions.
Summarize only the conversation history you are given. The newest turns may be kept verbatim outside your summary, so focus on the older context that still matters for continuing the work.
If the prompt includes a <previous-summary> block, treat it as the current anchored summary. Update it with the new history by preserving still-true details, removing stale details, and merging in new facts.
You are a context summarization agent. You are given a conversation between a user and an agent. Your goal is to produce a structured summary matching the format specified so another coding agent can continue the work.
Always follow the exact output structure requested by the user prompt. Keep every section, preserve exact file paths and identifiers when known, and prefer terse bullets over paragraphs.
Do not answer the conversation itself. Do not mention that you are summarizing, compacting, or merging context. Respond in the same language as the conversation.
Do not continue the conversation. Do not respond to any questions in the conversation. Only output the structured summary in the exact format requested by the user prompt. Respond in the same language as the conversation.
+5 -23
View File
@@ -37,22 +37,11 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
data: unknown,
source: string,
): DeepMutable<S["Type"]> {
const extra = topLevelExtraKeys(schema, data)
if (extra.length) {
throw new InvalidError({
path: source,
issues: [
{
code: "unrecognized_keys",
keys: extra,
path: [],
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
},
],
})
}
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
const decoded = EffectSchema.decodeUnknownExit(schema)(data, {
errors: "all",
onExcessProperty: "ignore",
propertyOrder: "original",
})
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
const error = Cause.squash(decoded.cause)
@@ -70,10 +59,3 @@ export function schema<S extends EffectSchema.Decoder<unknown, never>>(
{ cause: error },
)
}
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return []
const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name)))
return Object.keys(data).filter((key) => !known.has(key))
}
@@ -48,7 +48,7 @@ export namespace KiloCompactionChunks {
model: Provider.Model
cfg: Config.Info
messages: MessageV2.WithParts[]
prompt: string
prompt: (context: string[]) => string
target: MessageV2.Assistant
outputTokenMax?: number
updateMessage: UpdateMessage
@@ -220,16 +220,10 @@ export namespace KiloCompactionChunks {
].join("\n")
}
function messages(input: { summaries: string[] }) {
return input.summaries.map((summary, index) => ({
role: "user" as const,
content: [
{
type: "text" as const,
text: [`<partial-summary index=\"${index + 1}\">`, summary, "</partial-summary>"].join("\n"),
},
],
}))
function context(input: { summaries: string[] }) {
return input.summaries.map((summary, index) =>
[`<partial-summary index=\"${index + 1}\">`, summary, "</partial-summary>"].join("\n"),
)
}
function assistant(input: { base: MessageV2.Assistant; sessionID: SessionID }) {
@@ -346,7 +340,7 @@ export namespace KiloCompactionChunks {
input: Input & { summaries: string[]; depth: number },
): Effect.Effect<Output, never, Database.Service> {
return Effect.gen(function* () {
const result = yield* run({ ...input, data: messages({ summaries: input.summaries }), text: input.prompt })
const result = yield* run({ ...input, data: [], text: input.prompt(context({ summaries: input.summaries })) })
if (result.result === "continue") return result
if (input.depth >= DEPTH || input.summaries.length <= 1) return result
@@ -56,18 +56,16 @@ export namespace KiloCompactionPayloadRecovery {
agent: Agent.Info
sessionID: SessionID
model: Provider.Model
prompt: string
prompt: (context: string[]) => string
messages: MessageV2.WithParts[]
serialize: (message: MessageV2.WithParts) => string
recovery: MessageV2.WithParts[]
updateMessage: UpdateMessage
updatePart: Update
}) {
const buildPrompt = (promptText: string, items: MessageV2.WithParts[]) => {
const build = (items: MessageV2.WithParts[]) => {
const conversation = items.map(input.serialize).filter(Boolean).join("\n\n")
return [promptText, conversation ? "The following is the conversation history:" : undefined, conversation]
.filter(Boolean)
.join("\n\n")
return input.prompt(conversation ? [conversation] : [])
}
const run = Effect.fn("KiloCompactionPayloadRecovery.process")(function* (text: string) {
@@ -87,7 +85,7 @@ export namespace KiloCompactionPayloadRecovery {
})
})
return run(buildPrompt(input.prompt, input.messages)).pipe(
return run(build(input.messages)).pipe(
Effect.flatMap((result) => {
if (result !== "compact" && (result !== "stop" || !matches(input.processor.message.error))) {
return Effect.succeed(result)
@@ -100,7 +98,7 @@ export namespace KiloCompactionPayloadRecovery {
input.processor.message.finish = undefined
yield* input.updateMessage(input.processor.message)
yield* strip({ messages: input.recovery, update: input.updatePart })
return yield* run(prompt(buildPrompt(input.prompt, input.recovery)))
return yield* run(prompt(build(input.recovery)))
})
}),
)
@@ -88,6 +88,9 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
const pdf =
(remote.capabilities.supports.vision ?? false) &&
(remote.capabilities.limits.vision?.supported_media_types?.includes("application/pdf") ?? false)
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
const endpoint: CopilotEndpoint | undefined = isMsgApi
@@ -127,7 +130,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
audio: false,
image,
video: false,
pdf: false,
pdf,
},
output: {
text: true,
@@ -89,6 +89,8 @@ function sdkKey(npm: string): string | undefined {
return "openrouter"
case "@kilocode/kilo-gateway": // kilocode_change
return "openrouter"
case "merge-gateway-ai-sdk-provider":
return "mergeGateway"
case "ai-gateway-provider":
// ai-gateway-provider/unified wraps createOpenAICompatible({ name: "Unified" }),
// and @ai-sdk/openai-compatible parses compatibleOptions from one of
@@ -615,6 +617,12 @@ export function topP(model: Provider.Model) {
return 0.95
}
if (isLing(model.api.id)) return 0.95 // kilocode_change
if (
["deepseek-v4-flash-0731", "deepseek-v4-flash:0731"].some((name) => id.includes(name)) ||
(id.includes("deepseek-v4-flash") && (model.providerID === "deepseek" || model.providerID.startsWith("opencode")))
) {
return 0.95
}
return undefined
}
@@ -1352,6 +1360,7 @@ function reasoningEffort(model: Provider.Model, effort: string) {
case "@ai-sdk/togetherai":
case "venice-ai-sdk-provider":
case "ai-gateway-provider":
case "merge-gateway-ai-sdk-provider":
return { reasoningEffort: effort }
case "@kilocode/kilo-gateway": // kilocode_change - OpenRouter-shaped reasoning effort
return { reasoning: { effort } } // kilocode_change
+27 -19
View File
@@ -41,9 +41,8 @@ export const PRUNE_MINIMUM = 20_000
export const PRUNE_PROTECT = 40_000
const TOOL_OUTPUT_MAX_CHARS = 2_000
const PRUNE_PROTECTED_TOOLS = ["skill"]
const DEFAULT_TAIL_TURNS = 2
const MIN_PRESERVE_RECENT_TOKENS = 2_000
const MAX_PRESERVE_RECENT_TOKENS = 8_000
const MAX_PRESERVE_RECENT_TOKENS = 15_000
type Turn = {
start: number
end: number
@@ -245,8 +244,8 @@ const layer = Layer.effect(
cfg: ConfigV1.Info
model: Provider.Model
}) {
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
const limit = input.cfg.compaction?.tail_turns
if (limit !== undefined && limit <= 0) return { head: input.messages, tail_start_id: undefined }
// kilocode_change start
const budget = preserveRecentBudget({
cfg: input.cfg,
@@ -256,22 +255,17 @@ const layer = Layer.effect(
// kilocode_change end
const all = turns(input.messages)
if (!all.length) return { head: input.messages, tail_start_id: undefined }
const recent = all.slice(-limit)
const sizes = yield* Effect.forEach(
recent,
(turn) =>
estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
}),
{ concurrency: 1 },
)
const recent = limit === undefined ? all : all.slice(-limit)
let total = 0
let keep: Tail | undefined
for (let i = recent.length - 1; i >= 0; i--) {
const turn = recent[i]!
const size = sizes[i]
// estimate lazily so cost stays proportional to the retained tail, not the whole session
const size = yield* estimate({
messages: input.messages.slice(turn.start, turn.end),
model: input.model,
})
if (total + size <= budget) {
total += size
keep = { start: turn.start, id: turn.id }
@@ -415,10 +409,23 @@ const layer = Layer.effect(
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
const msgs = structuredClone(selected.head)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const conversation = msgs.map(serialize).filter(Boolean).join("\n\n")
// kilocode_change start - rerender upstream prompt after payload stripping or chunk reduction
const render = (context: string[]) => {
if (compacting.prompt)
return [
compacting.prompt,
...(context.length ? ["The following is the conversation history:", ...context] : []),
]
.filter(Boolean)
.join("\n\n")
return [buildPrompt({ previousSummary, context }), ...compacting.context].filter(Boolean).join("\n\n")
}
const nextPrompt = render(conversation ? [conversation] : [])
// kilocode_change end
// kilocode_change start
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
stripMedia: true,
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
@@ -436,6 +443,7 @@ const layer = Layer.effect(
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
}),
)
// kilocode_change end
const ctx = yield* InstanceState.context
const msg: SessionV1.Assistant = {
id: MessageID.ascending(),
@@ -478,7 +486,7 @@ const layer = Layer.effect(
agent,
sessionID: input.sessionID,
model,
prompt: nextPrompt,
prompt: render,
messages: msgs,
serialize,
recovery: selected.head,
@@ -500,7 +508,7 @@ const layer = Layer.effect(
cfg,
outputTokenMax: flags.outputTokenMax,
messages: selected.head,
prompt: nextPrompt,
prompt: render,
target: processor.message,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
@@ -540,7 +548,7 @@ const layer = Layer.effect(
cfg,
outputTokenMax: flags.outputTokenMax,
messages: selected.head,
prompt: nextPrompt,
prompt: render,
target: processor.message,
updateMessage: session.updateMessage,
updatePart: session.updatePart,
@@ -1,4 +1,4 @@
You are Kilo, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL.
You are Kilo, a coding agent that helps users with software engineering tasks. You are powered by {{MODEL_NAME}}, a large language model trained by Meta MSL.
Use the instructions below and the tools available to assist the user.
@@ -61,5 +61,5 @@ Use the instructions below and the tools available to assist the user.
- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise.
# User Help & Feedback
- Users can give feedback or report issues at https://github.com/Kilo-Org/kilocode and mention that they are using Meta Muse Spark.
- Users can give feedback or report issues at https://github.com/Kilo-Org/kilocode and mention that they are using Meta {{MODEL_NAME}}.
- When users ask directly about Kilo (eg. "can Kilo do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from the Kilo docs at https://kilo.ai/docs.
+11 -3
View File
@@ -25,8 +25,10 @@ export type Retryable = {
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_JITTER_FACTOR = 0.25
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export const RETRY_MAX_RETRIES = 5
const RETRYABLE_MESSAGE_PATTERNS = [
/\b(?:429|500|502|503|504|524)\b/i, // kilocode_change
@@ -41,7 +43,7 @@ function cap(ms: number) {
return Math.min(ms, RETRY_MAX_DELAY)
}
export function delay(attempt: number, error?: SessionV1.APIError) {
export function delay(attempt: number, error?: SessionV1.APIError, random = Math.random()) {
if (error) {
const headers = error.data.responseHeaders
if (headers) {
@@ -67,11 +69,16 @@ export function delay(attempt: number, error?: SessionV1.APIError) {
}
}
return cap(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1))
return cap(exponential(attempt, random))
}
}
return cap(Math.min(RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1), RETRY_MAX_DELAY_NO_HEADERS))
return cap(Math.min(exponential(attempt, random), RETRY_MAX_DELAY_NO_HEADERS))
}
function exponential(attempt: number, random: number) {
const base = RETRY_INITIAL_DELAY * Math.pow(RETRY_BACKOFF_FACTOR, attempt - 1)
return Math.ceil(base + base * RETRY_JITTER_FACTOR * random)
}
// kilocode_change - Kilo does not emit OpenCode Go actions
@@ -147,6 +154,7 @@ export function policy(opts: {
const error = opts.parse(meta.input)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
if (meta.attempt > RETRY_MAX_RETRIES) return Cause.done(meta.attempt)
return Effect.gen(function* () {
// kilocode_change start — handle network disconnect via offline handler
if (opts.offline && SessionNetwork.disconnected(meta.input)) {
+9 -2
View File
@@ -73,7 +73,10 @@ export function provider(model: Provider.Model) {
const kilo = prompt()
if (kilo) return kilo
// kilocode_change end
if (model.api.id.includes("muse-spark")) return [PROMPT_META]
if (model.api.id.includes("muse")) {
const name = model.api.id.includes("muse-glimmer") ? "Muse Glimmer" : "Muse Spark"
return [PROMPT_META.replaceAll("{{MODEL_NAME}}", name)]
}
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
@@ -85,7 +88,11 @@ export function provider(model: Provider.Model) {
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
if (
model.api.id.toLowerCase().includes("kimi") ||
["kimi-for-coding", "moonshotai", "moonshotai-cn"].includes(model.providerID)
)
return [PROMPT_KIMI]
if (isLing(model.api.id)) return [PROMPT_LING] // kilocode_change
return [PROMPT_DEFAULT]
}
+5 -10
View File
@@ -761,12 +761,12 @@ accountTokenIt.instance("resolves env templates in account config with account t
)
// kilocode_change start
it.instance("validates config schema and reports warning on invalid fields", () =>
it.instance("validates config schema and reports warning on invalid values", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* writeConfigEffect(test.directory, {
$schema: "https://app.kilo.ai/config.json",
invalid_field: "should cause error",
model: 42,
})
// invalid schema surfaces as warnings, not a throw
yield* Config.use.get()
@@ -1563,7 +1563,7 @@ it.instance("permission config preserves user key order", () =>
}),
)
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
test("config parser preserves permission order while ignoring unknown top-level keys", () => {
const config = ConfigParse.schema(
ConfigV1.Info,
{
@@ -1572,18 +1572,13 @@ test("config parser preserves permission order while rejecting unknown top-level
"*": "deny",
edit: "ask",
},
plugins: ["example"],
},
"test",
)
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
try {
ConfigParse.schema(ConfigV1.Info, { invalid_field: true }, "test")
throw new Error("expected config parse to fail")
} catch (err) {
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
}
expect(config).not.toHaveProperty("plugins")
})
// kilocode_change start - preserve legacy agent requirement declarations without forwarding them
@@ -368,9 +368,14 @@ describe("KiloCompactionPayloadRecovery", () => {
expect(result).toBe("continue")
expect(captures).toHaveLength(2)
expect(captures[0]).toContain("<conversation>")
expect(captures[0].match(/Here is the conversation so far:/g)).toHaveLength(1)
expect(captures[0]).toContain("Attached image/png: old.png")
expect(captures[0]).toContain("old output")
expect(captures[0]).not.toContain("keep output")
expect(captures[1]).toContain("<conversation>")
expect(captures[1].match(/Here is the conversation so far:/g)).toHaveLength(1)
expect(captures[1]).toContain("previous compaction request exceeded")
expect(captures[1]).not.toContain("data:image/png;base64")
expect(captures[1]).not.toContain("old output")
expect(captures[1]).not.toContain("keep output")
@@ -459,6 +459,8 @@ describe("KiloCompactionChunks", () => {
expect(result).toBe("continue")
expect(calls.length).toBeGreaterThanOrEqual(1)
expect(calls.at(-1)).toContain("Create a new anchored summary")
expect(calls.at(-1)).toContain("<partial-summary")
expect(calls.at(-1)?.match(/Here is the conversation so far:/g)).toHaveLength(1)
expect(summaries).toHaveLength(1)
expect(parts.map((part) => part.text)).toEqual(["final summary"])
} finally {
@@ -187,6 +187,74 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("detects PDF input support when vision and media type are advertised", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "pdf-model",
name: "PDF Model",
version: "pdf-model-2026-06-01",
capabilities: {
family: "pdf-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["application/pdf"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
{
model_picker_enabled: true,
id: "vision-only-model",
name: "Vision Only Model",
version: "vision-only-model-2026-06-01",
capabilities: {
family: "vision-only-model",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
vision: {
max_prompt_image_size: 10000000,
max_prompt_images: 10,
supported_media_types: ["image/png"],
},
},
supports: {
streaming: true,
vision: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const models = (await CopilotModels.get("https://api.githubcopilot.com")).models
const model = models["pdf-model"]
expect(model.capabilities.input.pdf).toBe(true)
expect(models["vision-only-model"].capabilities.input.pdf).toBe(false)
})
test("uses zero cost when Copilot reports a zero billing batch size", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@@ -1550,6 +1550,33 @@ test("models.dev reasoning options replace generated variants and unsupported to
expect(models["gemini-3-pro-fast"].variants).toEqual(models.override.variants)
})
test("MERGE Gateway exposes declared effort variants without model-specific handling", () => {
const provider = {
id: "merge-gateway",
name: "MERGE Gateway",
env: ["MERGE_GATEWAY_API_KEY"],
npm: "merge-gateway-ai-sdk-provider",
models: {
"openai/gpt-5.6-sol": {
id: "openai/gpt-5.6-sol",
name: "GPT-5.6 Sol",
reasoning: true,
reasoning_options: [{ type: "effort", values: ["none", "low", "medium", "high", "xhigh", "max"] }],
limit: { context: 128_000, output: 64_000 },
},
},
} as unknown as ModelsDev.Provider
expect(Provider.fromModelsDevProvider(provider).models["openai/gpt-5.6-sol"].variants).toEqual({
none: { reasoningEffort: "none" },
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high" },
xhigh: { reasoningEffort: "xhigh" },
max: { reasoningEffort: "max" },
})
})
test("public provider info omits invalid models", () => {
const provider = Provider.fromModelsDevProvider({
id: "test",
@@ -3537,6 +3537,37 @@ describe("ProviderTransform sampling defaults - Gemini", () => {
})
})
describe("ProviderTransform sampling defaults - DeepSeek", () => {
const model = (providerID: string, id: string) =>
({
id: `${providerID}/${id}`,
providerID,
api: { id },
}) as any
test.each([
["deepseek", "deepseek-v4-flash"],
["opencode", "deepseek-v4-flash"],
["opencode-go", "deepseek-v4-flash"],
["openrouter", "deepseek/deepseek-v4-flash-0731"],
["ollama-cloud", "deepseek-v4-flash:0731"],
])("defaults top_p for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBe(0.95)
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})
test.each([
["openrouter", "deepseek/deepseek-v4-flash"],
["vercel", "deepseek/deepseek-v4-flash"],
["custom", "deepseek-ai/DeepSeek-V4-Flash"],
])("preserves legacy defaults for %s/%s", (providerID, id) => {
expect(ProviderTransform.temperature(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topP(model(providerID, id))).toBeUndefined()
expect(ProviderTransform.topK(model(providerID, id))).toBeUndefined()
})
})
describe("ProviderTransform.reasoningVariants", () => {
const model = (reasoning_options: ModelsDev.Model["reasoning_options"]) => ({ reasoning_options }) as ModelsDev.Model
const target = (npm: string, id = "test-model") =>
@@ -3604,6 +3635,7 @@ describe("ProviderTransform.reasoningVariants", () => {
["@ai-sdk/togetherai", { reasoningEffort: "high" }],
["venice-ai-sdk-provider", { reasoningEffort: "high" }],
["ai-gateway-provider", { reasoningEffort: "high" }],
["merge-gateway-ai-sdk-provider", { reasoningEffort: "high" }],
["@ai-sdk/amazon-bedrock", { reasoningConfig: { type: "enabled", maxReasoningEffort: "high" } }],
])("converts effort for %s", (npm, expected, ...args) => {
const id = args[0] as string | undefined
@@ -5992,6 +6024,25 @@ describe("ProviderTransform.options - OpenAI Responses API params guard", () =>
})
})
// kilocode_change end
describe("ProviderTransform.providerOptions - merge-gateway-ai-sdk-provider", () => {
const model = {
id: "merge-gateway/openai/gpt-5.6-sol",
providerID: "merge-gateway",
api: {
id: "openai/gpt-5.6-sol",
url: "https://api-gateway.merge.dev/v1/ai-sdk",
npm: "merge-gateway-ai-sdk-provider",
},
capabilities: { reasoning: true },
} as any
test("routes normalized effort under the adapter's mergeGateway key", () => {
expect(ProviderTransform.providerOptions(model, { reasoningEffort: "high" })).toEqual({
mergeGateway: { reasoningEffort: "high" },
})
})
})
describe("ProviderTransform.options - kimi family adaptive thinking", () => {
const createModel = (overrides: Record<string, any> = {}) =>
({
@@ -161,6 +161,21 @@ describe("HttpApi instance context middleware", () => {
}),
)
it.live("persists the routed project while loading instance context", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true })
const project = yield* Project.Service
yield* serveProbe()
const response = yield* HttpClient.get(`/probe?directory=${encodeURIComponent(dir)}`)
expect(response.status).toBe(200)
const saved = (yield* project.list()).find((item) => item.worktree === dir)
expect(saved).toBeDefined()
expect(saved?.id).not.toBe("global")
}),
)
it.live("falls back to the raw directory when URI decoding fails", () =>
Effect.gen(function* () {
yield* serveProbe()
@@ -387,6 +387,20 @@ function autocontinue(enabled: boolean) {
})
}
function compactionContext(context: string) {
return Layer.mock(Plugin.Service)({
trigger: <Name extends string, Input, Output>(name: Name, _input: Input, output: Output) => {
if (name !== "experimental.session.compacting") return Effect.succeed(output)
return Effect.sync(() => {
;(output as { context: string[] }).context.push(context)
return output
})
},
list: () => Effect.succeed([]),
init: () => Effect.void,
})
}
describe("session.compaction.isOverflow", () => {
it.live(
"returns true when token count exceeds usable context",
@@ -1450,11 +1464,21 @@ describe("session.compaction.process", () => {
const captured = JSON.stringify(messages)
expect(messages).toHaveLength(1)
expect(messages[0]?.role).toBe("user")
expect(captured).toContain("Here is the conversation so far:")
expect(captured).toContain("<conversation>")
expect(captured.indexOf("[User]: older context")).toBeLessThan(
captured.indexOf("Create a new anchored summary"),
)
expect(captured).toContain("[User]: older context")
expect(captured).not.toContain("keep this turn")
expect(captured).not.toContain("and this one too")
expect(captured).not.toContain("What did we do so far?")
}).pipe(withCompaction({ llm: stub.llmLayer }))
}).pipe(
withCompaction({
llm: stub.llmLayer,
config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }),
}),
)
},
{ git: true },
)
@@ -1491,9 +1515,11 @@ describe("session.compaction.process", () => {
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false })
expect(captured).toContain("<previous-summary>")
expect(captured).toContain("<prior-summary>")
expect(captured).toContain("summary one")
expect(captured.match(/summary one/g)?.length).toBe(1)
expect(captured.indexOf("latest turn")).toBeLessThan(captured.indexOf("<prior-summary>"))
expect(captured).toContain("summary of the conversation before the <conversation> above")
expect(captured).toContain("## Important Details")
expect(captured).toContain("## Work State")
}).pipe(withCompaction({ llm: stub.llmLayer }))
@@ -1501,6 +1527,49 @@ describe("session.compaction.process", () => {
{ git: true },
)
itCompaction.instance(
"keeps plugin context outside the serialized conversation",
() => {
const stub = llm()
let captured = ""
stub.push(
reply("summary", (input) => {
captured = JSON.stringify(input.messages)
}),
)
return Effect.gen(function* () {
const ssn = yield* SessionNs.Service
const session = yield* ssn.create({})
yield* createUserMessage(session.id, "older context")
yield* createUserMessage(session.id, "keep this turn")
yield* createUserMessage(session.id, "and this one too")
yield* createCompactionMarker(session.id)
const msgs = yield* ssn.messages({ sessionID: session.id })
const parent = msgs.at(-1)?.info.id
expect(parent).toBeTruthy()
yield* SessionCompaction.use.process({
parentID: parent!,
messages: msgs,
sessionID: session.id,
auto: false,
})
expect(captured).toContain("Prioritize unresolved migration details")
expect(captured.indexOf("</conversation>")).toBeLessThan(
captured.indexOf("Prioritize unresolved migration details"),
)
}).pipe(
withCompaction({
llm: stub.llmLayer,
plugin: compactionContext("Prioritize unresolved migration details"),
}),
)
},
{ git: true },
)
itCompaction.instance(
"serializes repeated compaction history as one user message",
() => {
+35 -4
View File
@@ -35,10 +35,18 @@ function wrap(message: unknown): ReturnType<NamedError["toObject"]> {
describe("session.retry.delay", () => {
test("caps delay at 30 seconds when headers missing", () => {
const error = apiError()
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error))
const delays = Array.from({ length: 10 }, (_, index) => SessionRetry.delay(index + 1, error, 0))
expect(delays).toStrictEqual([2000, 4000, 8000, 16000, 30000, 30000, 30000, 30000, 30000, 30000])
})
test("adds jitter to exponential delays", () => {
const error = apiError()
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
expect(SessionRetry.delay(1, error, 1)).toBe(2500)
expect(SessionRetry.delay(4, error, 1)).toBe(20000)
expect(SessionRetry.delay(5, error, 1)).toBe(30000)
})
test("prefers retry-after-ms when shorter than exponential", () => {
const error = apiError({ "retry-after-ms": "1500" })
expect(SessionRetry.delay(4, error)).toBe(1500)
@@ -59,18 +67,18 @@ describe("session.retry.delay", () => {
test("ignores invalid retry hints", () => {
const error = apiError({ "retry-after": "not-a-number" })
expect(SessionRetry.delay(1, error)).toBe(2000)
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
})
test("ignores malformed date retry hints", () => {
const error = apiError({ "retry-after": "Invalid Date String" })
expect(SessionRetry.delay(1, error)).toBe(2000)
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
})
test("ignores past date retry hints", () => {
const pastDate = new Date(Date.now() - 5000).toUTCString()
const error = apiError({ "retry-after": pastDate })
expect(SessionRetry.delay(1, error)).toBe(2000)
expect(SessionRetry.delay(1, error, 0)).toBe(2000)
})
test("uses retry-after values even when exceeding 10 minutes with headers", () => {
@@ -115,6 +123,29 @@ describe("session.retry.delay", () => {
})
}),
)
it.instance("policy stops after five retries", () =>
Effect.gen(function* () {
const attempts: number[] = []
const error = apiError({ "retry-after-ms": "0" })
const step = yield* Schedule.toStepWithMetadata(
SessionRetry.policy({
provider: "test",
parse: Schema.decodeUnknownSync(SessionV1.APIError.Schema),
set: (info) =>
Effect.sync(() => {
attempts.push(info.attempt)
}),
}),
)
yield* Effect.forEach(Array.from({ length: SessionRetry.RETRY_MAX_RETRIES + 1 }), () =>
Effect.ignore(step(error)),
)
expect(attempts).toStrictEqual([1, 2, 3, 4, 5])
}),
)
})
describe("session.retry.retryable", () => {
+22 -3
View File
@@ -68,9 +68,28 @@ const it = testEffect(
describe("session.system", () => {
test("selects the Meta prompt for Muse Spark model IDs", () => {
expect(SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0]).toContain(
"Meta Muse Spark",
)
for (const id of ["meta/muse-spark-preview", "muse-spark-1.1", "muse-spark-1.2"]) {
const prompt = SystemPrompt.provider({ api: { id } } as Provider.Model)[0]
expect(prompt).toContain("powered by Muse Spark,")
expect(prompt).toContain("using Meta Muse Spark.")
expect(prompt).not.toContain("{{MODEL_NAME}}")
}
})
test("selects the Meta prompt for Muse Glimmer model IDs", () => {
for (const id of ["meta/muse-glimmer", "meta/muse-glimmer-30b", "muse-glimmer-30b"]) {
const prompt = SystemPrompt.provider({ api: { id } } as Provider.Model)[0]
expect(prompt).toContain("powered by Muse Glimmer,")
expect(prompt).toContain("using Meta Muse Glimmer.")
expect(prompt).not.toContain("{{MODEL_NAME}}")
}
})
test("selects the Kimi prompt for official provider model IDs", () => {
for (const providerID of ["kimi-for-coding", "moonshotai", "moonshotai-cn"]) {
const prompt = SystemPrompt.provider({ providerID, api: { id: "k3" } } as Provider.Model)[0]
expect(prompt).toContain("# Prompt and Tool Use")
}
})
it.effect("skills output is sorted by name and stable across calls", () =>
+2 -1
View File
@@ -19,5 +19,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"version": "7.4.23"
"version": "7.4.23",
"peerDependencies": {}
}
+2 -1
View File
@@ -19,5 +19,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"version": "7.4.23"
"version": "7.4.23",
"peerDependencies": {}
}
+4 -3
View File
@@ -9,8 +9,8 @@
},
"scripts": {
"test": "bun test --timeout 5000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
"typecheck": "tsgo --noEmit",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml"
},
"dependencies": {
"@opencode-ai/client": "workspace:*",
@@ -23,5 +23,6 @@
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"version": "7.4.23"
"version": "7.4.23",
"peerDependencies": {}
}
+4
View File
@@ -5,6 +5,10 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
export type NormalizedProviderListResponse = {
all: Map<string, Provider>
defaultModel?: {
providerID: string
modelID: string
} | null
default: {
[key: string]: string
}
+1 -1
View File
@@ -25,7 +25,7 @@
"border-weak-base": "#DBDBDB",
"border-weaker-base": "#E8E8E8",
"icon-base": "#8F8F8F",
"icon-weak-base": "C7C7C7",
"icon-weak-base": "#C7C7C7",
"surface-raised-base": "#F3F3F3",
"surface-raised-base-hover": "#EDEDED",
"surface-base": "#F8F8F8",
+2 -2
View File
@@ -195,9 +195,9 @@
/* Loading */
[data-component="button-v2"][data-variant="loading"] {
background: #f2f2f2;
background: var(--v2-background-bg-layer-02);
color: var(--v2-text-text-base);
box-shadow: inset 0 0 0 0.5px rgba(0, 0, 0, 0.08);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
cursor: default;
pointer-events: none;
}
+76
View File
@@ -0,0 +1,76 @@
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 8b23996dcce6c1ad5b17ef59f92196fb97312d79..80be2e52a347042b89da8e502834afd92120877a 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -10,13 +10,7 @@ declare const groqLanguageModelOptions: z.ZodObject<{
raw: "raw";
hidden: "hidden";
}>>;
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- default: "default";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
parallelToolCalls: z.ZodOptional<z.ZodBoolean>;
user: z.ZodOptional<z.ZodString>;
structuredOutputs: z.ZodOptional<z.ZodBoolean>;
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 8b23996dcce6c1ad5b17ef59f92196fb97312d79..80be2e52a347042b89da8e502834afd92120877a 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -10,13 +10,7 @@ declare const groqLanguageModelOptions: z.ZodObject<{
raw: "raw";
hidden: "hidden";
}>>;
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- default: "default";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
parallelToolCalls: z.ZodOptional<z.ZodBoolean>;
user: z.ZodOptional<z.ZodString>;
structuredOutputs: z.ZodOptional<z.ZodBoolean>;
diff --git a/dist/index.js b/dist/index.js
index 45a104f2e0775761858eac2a82ced64bceba1f5e..f60ac36f4a064d527e8f8881b1d6c58ff69286a3 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -214,7 +214,7 @@ var groqLanguageModelOptions = import_v4.z.object({
* Specifies the reasoning effort level for model inference.
* @see https://console.groq.com/docs/reasoning#reasoning-effort
*/
- reasoningEffort: import_v4.z.enum(["none", "default", "low", "medium", "high"]).optional(),
+ reasoningEffort: import_v4.z.string().optional(),
/**
* Whether to enable parallel function calling during tool use. Default to true.
*/
diff --git a/dist/index.mjs b/dist/index.mjs
index c644c32235d8fa88c51c0fc6958feb1da4877c96..2c2f81869673eb4633e843d93ff5376abf1e67d0 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -203,7 +203,7 @@ var groqLanguageModelOptions = z.object({
* Specifies the reasoning effort level for model inference.
* @see https://console.groq.com/docs/reasoning#reasoning-effort
*/
- reasoningEffort: z.enum(["none", "default", "low", "medium", "high"]).optional(),
+ reasoningEffort: z.string().optional(),
/**
* Whether to enable parallel function calling during tool use. Default to true.
*/
diff --git a/src/groq-chat-options.ts b/src/groq-chat-options.ts
index 3812cdf53308709f166f05c58c5d46a5d8189c8b..af520c5459bd752b3cce03c3b4afbeed31157d90 100644
--- a/src/groq-chat-options.ts
+++ b/src/groq-chat-options.ts
@@ -33,6 +33,4 @@ export const groqLanguageModelOptions = z.object({
* Specifies the reasoning effort level for model inference.
* @see https://console.groq.com/docs/reasoning#reasoning-effort
*/
- reasoningEffort: z
- .enum(['none', 'default', 'low', 'medium', 'high'])
- .optional(),
+ reasoningEffort: z.string().optional(),
+16 -11
View File
@@ -2,10 +2,12 @@ diff --git a/dist/index.d.mts b/dist/index.d.mts
index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
none: "none";
high: "high";
}>>;
@@ -13,7 +13,5 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
@@ -14,10 +16,12 @@ diff --git a/dist/index.d.ts b/dist/index.d.ts
index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
none: "none";
high: "high";
}>>;
@@ -13,7 +13,5 @@ declare const mistralLanguageModelOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
+ promptCacheKey: z.ZodOptional<z.ZodString>;
}, z.core.$strip>;
type MistralLanguageModelOptions = z.infer<typeof mistralLanguageModelOptions>;
@@ -69,7 +73,7 @@ index d3f904c12a1d582cc7b9e9a2d30273e1a8505b28..267f34e20ea392b7a85ad5259d72d506
* - `'none'`: Disable reasoning
*/
- reasoningEffort: import_v4.z.enum(["high", "none"]).optional()
+ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(),
+ reasoningEffort: import_v4.z.string().optional(),
+ promptCacheKey: import_v4.z.string().optional()
});
@@ -268,7 +272,7 @@ index d2eff622c1b84a96bdeb4012cb0206a33012a04d..3bff11ddd6136ada45809568828cbc8f
* - `'none'`: Disable reasoning
*/
- reasoningEffort: z.enum(["high", "none"]).optional()
+ reasoningEffort: z.enum(["high", "none"]).optional(),
+ reasoningEffort: z.string().optional(),
+ promptCacheKey: z.string().optional()
});
@@ -655,7 +659,8 @@ index 54b29c08517d348995b6ca093b11160e453d5c8b..de30c3e7d924889339e38b1067cb26e9
@@ -64,6 +64,11 @@ export const mistralLanguageModelOptions = z.object({
* - `'none'`: Disable reasoning
*/
reasoningEffort: z.enum(['high', 'none']).optional(),
- reasoningEffort: z.enum(['high', 'none']).optional(),
+ reasoningEffort: z.string().optional(),
+
+ /**
+ * A stable identifier used to route requests with shared prompt prefixes.
+111 -7
View File
@@ -1,8 +1,32 @@
diff --git a/dist/index.d.mts b/dist/index.d.mts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..6ac6e2873b7681ac632c903694aa38c7b09773fa 100644
--- a/dist/index.d.mts
+++ b/dist/index.d.mts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
@@ -6,11 +6,6 @@ import { FetchFunction } from '@ai-sdk/provider-utils';
type XaiChatModelId = 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'grok-4.20-0309-non-reasoning' | 'grok-4.20-multi-agent-0309' | 'grok-build-0.1' | (string & {});
declare const xaiLanguageModelChatOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
logprobs: z.ZodOptional<z.ZodBoolean>;
topLogprobs: z.ZodOptional<z.ZodNumber>;
parallel_function_calling: z.ZodOptional<z.ZodBoolean>;
@@ -68,16 +63,12 @@ type XaiResponsesModelId = 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'grok-4.20-
* @see https://docs.x.ai/docs/api-reference#create-new-response
*/
declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
logprobs: z.ZodOptional<z.ZodBoolean>;
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
@@ -11,10 +35,34 @@ index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf517
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.d.ts b/dist/index.d.ts
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf51710390f9a 100644
index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..6ac6e2873b7681ac632c903694aa38c7b09773fa 100644
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -78,6 +78,7 @@ declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
@@ -6,11 +6,6 @@ import { FetchFunction } from '@ai-sdk/provider-utils';
type XaiChatModelId = 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'grok-4.20-0309-non-reasoning' | 'grok-4.20-multi-agent-0309' | 'grok-build-0.1' | (string & {});
declare const xaiLanguageModelChatOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
logprobs: z.ZodOptional<z.ZodBoolean>;
topLogprobs: z.ZodOptional<z.ZodNumber>;
parallel_function_calling: z.ZodOptional<z.ZodBoolean>;
@@ -68,16 +63,12 @@ type XaiResponsesModelId = 'grok-4.3' | 'grok-4.20-0309-reasoning' | 'grok-4.20-
* @see https://docs.x.ai/docs/api-reference#create-new-response
*/
declare const xaiLanguageModelResponsesOptions: z.ZodObject<{
- reasoningEffort: z.ZodOptional<z.ZodEnum<{
- none: "none";
- low: "low";
- medium: "medium";
- high: "high";
- }>>;
+ reasoningEffort: z.ZodOptional<z.ZodString>;
logprobs: z.ZodOptional<z.ZodBoolean>;
topLogprobs: z.ZodOptional<z.ZodNumber>;
store: z.ZodOptional<z.ZodBoolean>;
previousResponseId: z.ZodOptional<z.ZodString>;
@@ -23,9 +71,18 @@ index 266c5ffdd9ee74ff95908ce90858ee4369d4e4ae..990ef4195bc67b6d25f249e1c81cf517
"file_search_call.results": "file_search_call.results";
}>>>>;
diff --git a/dist/index.js b/dist/index.js
index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..dd7dbeb3bc307e0d355f4bb4939d06cc8eae7528 100644
index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..0fd8f0d1cae951cd24401034a9c1dba762d9fd84 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -246,7 +246,7 @@ var searchSourceSchema = import_v4.z.discriminatedUnion("type", [
rssSourceSchema
]);
var xaiLanguageModelChatOptions = import_v4.z.object({
- reasoningEffort: import_v4.z.enum(["none", "low", "medium", "high"]).optional(),
+ reasoningEffort: import_v4.z.string().optional(),
logprobs: import_v4.z.boolean().optional(),
topLogprobs: import_v4.z.number().int().min(0).max(8).optional(),
/**
@@ -1119,6 +1119,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
@@ -41,6 +98,15 @@ index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..dd7dbeb3bc307e0d355f4bb4939d06cc
} else {
throw new import_provider4.UnsupportedFunctionalityError({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1746,7 +1754,7 @@ var xaiLanguageModelResponsesOptions = import_v47.z.object({
* tokens), `medium` and `high` (uses more reasoning tokens). Not all models
* support reasoning effort; see xAI's docs for the values each model accepts.
*/
- reasoningEffort: import_v47.z.enum(["none", "low", "medium", "high"]).optional(),
+ reasoningEffort: import_v47.z.string().optional(),
logprobs: import_v47.z.boolean().optional(),
topLogprobs: import_v47.z.number().int().min(0).max(8).optional(),
/**
@@ -1760,6 +1768,10 @@ var xaiLanguageModelResponsesOptions = import_v47.z.object({
* The ID of the previous response from the model.
*/
@@ -63,9 +129,18 @@ index 717b74538f5c8f0d6ab1475ebb2a84a47ccd3950..dd7dbeb3bc307e0d355f4bb4939d06cc
};
if (xaiTools2 && xaiTools2.length > 0) {
diff --git a/dist/index.mjs b/dist/index.mjs
index a26af109585fc2bd3053b320142aa869c06d36f4..774adaf971b648544317a4fc65d0c56e488d4fc7 100644
index a26af109585fc2bd3053b320142aa869c06d36f4..5faca56477b4e55a87f6f57850731c7d3e1721a5 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -230,7 +230,7 @@ var searchSourceSchema = z.discriminatedUnion("type", [
rssSourceSchema
]);
var xaiLanguageModelChatOptions = z.object({
- reasoningEffort: z.enum(["none", "low", "medium", "high"]).optional(),
+ reasoningEffort: z.string().optional(),
logprobs: z.boolean().optional(),
topLogprobs: z.number().int().min(0).max(8).optional(),
/**
@@ -1122,6 +1122,14 @@ async function convertToXaiResponsesInput({
type: "input_file",
file_url: block.data.toString()
@@ -81,6 +156,15 @@ index a26af109585fc2bd3053b320142aa869c06d36f4..774adaf971b648544317a4fc65d0c56e
} else {
throw new UnsupportedFunctionalityError3({
functionality: `file part media type ${block.mediaType} as inline data (xAI Responses requires a URL or a Files API reference for non-image files)`
@@ -1749,7 +1757,7 @@ var xaiLanguageModelResponsesOptions = z7.object({
* tokens), `medium` and `high` (uses more reasoning tokens). Not all models
* support reasoning effort; see xAI's docs for the values each model accepts.
*/
- reasoningEffort: z7.enum(["none", "low", "medium", "high"]).optional(),
+ reasoningEffort: z7.string().optional(),
logprobs: z7.boolean().optional(),
topLogprobs: z7.number().int().min(0).max(8).optional(),
/**
@@ -1763,6 +1771,10 @@ var xaiLanguageModelResponsesOptions = z7.object({
* The ID of the previous response from the model.
*/
@@ -158,9 +242,18 @@ index f90df62eb9a30154388b1390e9f3acc3ccc022bf..00e61cba6cf048ae0045be692f33cb7e
if (xaiTools && xaiTools.length > 0) {
diff --git a/src/responses/xai-responses-options.ts b/src/responses/xai-responses-options.ts
index f8e96c061bf8793a402ababb8cad65bb2ad6aead..15c168892c1e8755453c61d3061e958cfd51ac71 100644
index f8e96c061bf8793a402ababb8cad65bb2ad6aead..2a39a36221ab23ea0000bff1d7854c5bce3f9d74 100644
--- a/src/responses/xai-responses-options.ts
+++ b/src/responses/xai-responses-options.ts
@@ -18,7 +18,7 @@ export const xaiLanguageModelResponsesOptions = z.object({
* tokens), `medium` and `high` (uses more reasoning tokens). Not all models
* support reasoning effort; see xAI's docs for the values each model accepts.
*/
- reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
+ reasoningEffort: z.string().optional(),
logprobs: z.boolean().optional(),
topLogprobs: z.number().int().min(0).max(8).optional(),
/**
@@ -32,6 +32,10 @@ export const xaiLanguageModelResponsesOptions = z.object({
* The ID of the previous response from the model.
*/
@@ -172,3 +265,14 @@ index f8e96c061bf8793a402ababb8cad65bb2ad6aead..15c168892c1e8755453c61d3061e958c
/**
* Specify additional output data to include in the model response.
* Example values: 'file_search_call.results'.
diff --git a/src/xai-chat-options.ts b/src/xai-chat-options.ts
index d70a72a9fa01da2c711c291da5ce949efbde60b5..fd6b1ae025388b614f08b620244be553199479ca 100644
--- a/src/xai-chat-options.ts
+++ b/src/xai-chat-options.ts
@@ -52,5 +52,5 @@ const searchSourceSchema = z.discriminatedUnion('type', [
// xai-specific provider options
export const xaiLanguageModelChatOptions = z.object({
- reasoningEffort: z.enum(['none', 'low', 'medium', 'high']).optional(),
+ reasoningEffort: z.string().optional(),
logprobs: z.boolean().optional(),
topLogprobs: z.number().int().min(0).max(8).optional(),