fix: resolve OpenCode merge regressions

This commit is contained in:
marius-kilocode
2026-07-24 12:56:27 +02:00
parent 51d8031c99
commit 1b60773cae
19 changed files with 413 additions and 49 deletions
@@ -17,6 +17,8 @@ Changes from opencode v1.17.5 to v1.17.9 upstream:
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
- Core Improvements: MCP servers can now receive the current workspace as a client root.
@@ -25,3 +27,4 @@ Changes from opencode v1.17.5 to v1.17.9 upstream:
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
@@ -17,7 +17,7 @@ export const LLMGatewayPlugin = PluginV2.define({
if (item.provider.api.url !== "https://api.llmgateway.io/v1") continue
if (item.provider.id !== ProviderV2.ID.make("llmgateway")) continue // kilocode_change
evt.provider.update(item.provider.id, (provider) => {
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/" // kilocode_change
provider.request.headers["HTTP-Referer"] = "https://kilo.ai/"
// kilocode_change start
provider.request.headers["X-Title"] = "Kilo Code"
provider.request.headers["X-Source"] = "kilo"
@@ -258,6 +258,6 @@ function claim(token: string) {
}
const successPage =
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>" // kilocode_change
"<!doctype html><title>Kilo</title><h1>Authorization successful</h1><p>You can close this window.</p>"
const errorPage = (message: string) =>
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>` // kilocode_change
`<!doctype html><title>Kilo</title><h1>Authorization failed</h1><p>${message.replace(/[&<>"']/g, "")}</p>`
+4 -3
View File
@@ -216,9 +216,10 @@ export const layer = Layer.effect(
KILO_TERMINAL: "1",
KILO_PTY_ID: id, // kilocode_change - let nested Kilo processes identify their parent terminal
} as Record<string, string>
// kilocode_change start - do not expose the local server credential to processes spawned by user terminals
delete env.KILO_SERVER_PASSWORD
delete env.KILO_SERVER_USERNAME
// kilocode_change start - do not expose local server credentials to user terminals.
// node-pty inherits parent values for omitted keys, so empty tombstones are required.
env.KILO_SERVER_PASSWORD = ""
env.KILO_SERVER_USERNAME = ""
// kilocode_change end
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
@@ -50,7 +50,7 @@ describe("LLMGatewayPlugin", () => {
})
expect((yield* catalog.provider.get(ProviderV2.ID.make("llmgateway"))).request.headers).toEqual({
Existing: "value",
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
"X-Source": "kilo", // kilocode_change
})
@@ -0,0 +1,15 @@
import { expect, test } from "@playwright/test"
test("renders Mermaid from delimiter-free Markdown source", async ({ page }) => {
await page.goto("/iframe.html?id=shared--markdown-mermaid&viewMode=story")
const markdown = page.locator('[data-component="markdown"]')
await expect(markdown.getByRole("heading", { name: "Flow" })).toBeVisible()
const diagram = markdown.locator('[data-mermaid-state="rendered"]')
await expect(diagram.locator('svg[aria-roledescription="flowchart-v2"]')).toBeVisible()
const source = diagram.locator('code[data-lang="mermaid"]')
await expect(source).toContainText("flowchart TD")
await expect(source).not.toContainText("```mermaid")
await expect(markdown.getByText("Rendered after the diagram.")).toBeVisible()
})
@@ -10,6 +10,7 @@ import { ModelSelectorBase } from "../components/shared/ModelSelector"
import { SessionContext } from "../context/session"
import type { EnrichedModel } from "../context/provider"
import type { ModelSelection } from "../types/messages"
import { Markdown } from "@kilocode/kilo-ui/markdown"
const meta: Meta = {
title: "Shared",
@@ -18,6 +19,27 @@ const meta: Meta = {
export default meta
type Story = StoryObj
export const MarkdownMermaid: Story = {
name: "Markdown - Mermaid diagram",
render: () => (
<StoryProviders>
<Markdown
text={`# Flow
\`\`\`mermaid
flowchart TD
A[Prompt] --> B{Needs tools?}
B -->|Yes| C[Run tool]
B -->|No| D[Respond]
C --> D
\`\`\`
Rendered after the diagram.`}
/>
</StoryProviders>
),
}
// ---------------------------------------------------------------------------
// ModelSelector
// ---------------------------------------------------------------------------
@@ -123,6 +123,7 @@ export namespace KiloSessionPromptQueue {
target: MessageID,
work: Effect.Effect<A, E>,
cancelled: Effect.Effect<A, E>,
reserved: Effect.Effect<void> = Effect.void,
): Effect.Effect<A, E> {
return Effect.acquireUseRelease(
Effect.sync(() => {
@@ -136,7 +137,8 @@ export namespace KiloSessionPromptQueue {
return { seq: mine, version: version(sessionID), previous, done, tail } satisfies Slot
}),
(slot) =>
Effect.promise(() => settle(slot.previous)).pipe(
reserved.pipe(
Effect.andThen(Effect.promise(() => settle(slot.previous))),
Effect.flatMap(() => {
if (slot.version !== version(sessionID)) return cancelled
// Snapshot the latest seq at the moment this slot actually starts
+8 -8
View File
@@ -473,7 +473,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
"X-Source": "kilo", // kilocode_change
},
@@ -484,7 +484,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
},
},
@@ -494,7 +494,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: provider.source === "config",
options: {
headers: {
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
"X-BILLING-INVOKE-ORIGIN": "KiloCode", // kilocode_change
},
@@ -505,7 +505,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"http-referer": "https://kilo.ai/", // kilocode_change
"http-referer": "https://kilo.ai/",
"x-title": "Kilo Code", // kilocode_change
},
},
@@ -611,7 +611,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
},
},
@@ -811,7 +811,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
if (!apiToken) {
throw new Error(
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
"Set it via environment variable or run `kilo auth cloudflare-ai-gateway`.", // kilocode_change
"Set it via environment variable or run `kilo auth cloudflare-ai-gateway`.",
)
}
@@ -869,7 +869,7 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://kilo.ai/", // kilocode_change
"HTTP-Referer": "https://kilo.ai/",
"X-Title": "Kilo Code", // kilocode_change
},
},
@@ -1077,7 +1077,7 @@ export const Info = Schema.Struct({
description: optionalOmitUndefined(Schema.String), // kilocode_change
source: Schema.Literals(["env", "config", "custom", "api"]),
env: Schema.Array(Schema.String),
key: optionalOmitUndefined(Schema.String), // kilocode_change
key: optionalOmitUndefined(Schema.String),
metadata: optionalOmitUndefined(ProviderMetadata), // kilocode_change
options: Schema.Record(Schema.String, Schema.Any),
models: Schema.Record(Schema.String, Model),
+1 -1
View File
@@ -1275,7 +1275,7 @@ export function options(input: {
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot" || // kilocode_change
input.model.api.npm === "@ai-sdk/github-copilot" ||
input.model.api.npm === "@openrouter/ai-sdk-provider" || // kilocode_change
input.model.api.npm === "@kilocode/kilo-gateway" || // kilocode_change
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
@@ -171,7 +171,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
OpenApi.annotations({
identifier: "experimental.console.switchOrg",
summary: "Switch active Console org",
description: "Persist a new active Console account/org selection for the current local Kilo state.", // kilocode_change
description: "Persist a new active Console account/org selection for the current local Kilo state.",
}),
),
HttpApiEndpoint.get("tool", ExperimentalPaths.tool, {
@@ -289,7 +289,7 @@ export const ExperimentalApi = HttpApi.make("experimental")
identifier: "experimental.session.list",
summary: "List sessions",
description:
"Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.", // kilocode_change
"Get a list of all Kilo sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
}),
),
HttpApiEndpoint.post("sessionBackground", ExperimentalPaths.sessionBackground, {
+15 -16
View File
@@ -754,7 +754,7 @@ export const layer = Layer.effect(
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
const agentName = input.agent
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() // kilocode_change
const ag = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!ag) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -1086,7 +1086,7 @@ export const layer = Layer.effect(
if (mime === "application/x-directory") {
const args = { filePath: filepath }
const exit = yield* execRead(args).pipe(Effect.exit) // kilocode_change - list only; child bytes need separate reads
const exit = yield* execRead(args).pipe(Effect.exit)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
yield* Effect.logError("failed to read directory", { error, filepath })
@@ -1403,17 +1403,17 @@ export const layer = Layer.effect(
// kilocode_change end
}
// kilocode_change start — unblock tools waiting on user input so any in-flight
// handle.process can return. Adding a new user message is the signal that any
// pending tool prompt is superseded, so we dismiss even on the noReply path.
// Critically we never cancel the in-flight fiber here — that would abort the
// streamText call mid-tokens and cut off the assistant reply. The enqueue call
// below serializes this prompt after the current turn's current LLM step, and
// runLoop checks hasFollowup between steps to break out once it has been
// enqueued during the turn.
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID))
yield* question.dismissAll(input.sessionID)
if (input.noReply === true) return message
// kilocode_change start — register the queued follow-up before dismissing blockers.
// Otherwise the old turn can resume from a dismissed question and start another
// LLM step before hasFollowup observes the replacement prompt.
const dismiss = Effect.gen(function* () {
yield* Effect.promise(() => Suggestion.dismissAll(input.sessionID)).pipe(Effect.orDie)
yield* question.dismissAll(input.sessionID)
})
if (input.noReply === true) {
yield* dismiss
return message
}
// Queue tails and runner fibers can resume outside the HTTP request's
// ambient instance context; bridge both Effect refs and legacy ALS.
const bridge = yield* EffectBridge.make()
@@ -1426,6 +1426,7 @@ export const layer = Layer.effect(
),
), // kilocode_change
bridge.run(lastAssistant(input.sessionID)),
dismiss,
)
// kilocode_change end
},
@@ -2049,7 +2050,7 @@ export const layer = Layer.effect(
yield* getModel(taskModel.providerID, taskModel.modelID, input.sessionID)
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo() // kilocode_change
const agent = agentName ? yield* agents.get(agentName) : yield* agents.defaultInfo()
if (!agent) {
const available = (yield* agents.list()).filter((a) => !a.hidden).map((a) => a.name)
const hint = available.length ? ` Available agents: ${available.join(", ")}` : ""
@@ -2179,11 +2180,9 @@ export const PromptInput = Schema.Struct({
description:
"@deprecated tools and permissions have been merged, you can set permissions on the session itself now",
}),
// kilocode_change start - keep internal ephemeral tool controls out of the public prompt schema
format: Schema.optional(SessionV1.Format),
system: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String),
// kilocode_change end
// kilocode_change start - managed product slow-snapshot policy
snapshotInitialization: Schema.optional(Schema.Literal("wait")).annotate({
description: "Wait silently if snapshot initialization is slow instead of asking the user.",
@@ -0,0 +1,78 @@
import { expect } from "bun:test"
import { Effect } from "effect"
import { ProviderV2 } from "@opencode-ai/core/provider"
import { Provider } from "../../src/provider/provider"
import { testEffect } from "../lib/effect"
const it = testEffect(Provider.defaultLayer)
const auth = <A, E, R>(value: Record<string, unknown>, effect: Effect.Effect<A, E, R>) =>
Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.KILO_AUTH_CONTENT
process.env.KILO_AUTH_CONTENT = JSON.stringify(value)
return previous
}),
() => effect,
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.KILO_AUTH_CONTENT
else process.env.KILO_AUTH_CONTENT = previous
}),
)
it.instance(
"uses saved Azure resource metadata",
() =>
auth(
{ azure: { type: "api", key: "azure-key", metadata: { resourceName: "saved-resource" } } },
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("azure")]
expect(item.key).toBe("azure-key")
expect(item.options.resourceName).toBe("saved-resource")
}),
),
{ config: {} },
)
it.instance(
"uses saved GitLab OAuth access",
() =>
auth(
{ gitlab: { type: "oauth", refresh: "refresh", access: "oauth-access", expires: Date.now() + 60_000 } },
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("gitlab")]
expect(item.options.apiKey).toBe("oauth-access")
}),
),
{ config: {} },
)
it.instance(
"uses saved Cloudflare Workers AI account metadata",
() =>
auth(
{
"cloudflare-workers-ai": {
type: "api",
key: "cloudflare-key",
metadata: { accountId: "saved-account" },
},
},
Effect.gen(function* () {
const provider = yield* Provider.Service
const item = (yield* provider.list())[ProviderV2.ID.make("cloudflare-workers-ai")]
expect(item.key).toBe("cloudflare-key")
expect(item.options.apiKey).toBe("cloudflare-key")
const model = Object.values(item.models)[0]
const language = yield* provider.getLanguage(model)
const url = (
language as unknown as { config: { url: (input: { path: string; modelId: string }) => string } }
).config.url({ path: "/chat/completions", modelId: model.id })
expect(url).toBe("https://api.cloudflare.com/client/v4/accounts/saved-account/ai/v1/chat/completions")
}),
),
{ config: {} },
)
@@ -0,0 +1,207 @@
import path from "path"
import { afterAll, beforeAll, expect, test } from "bun:test"
import fs from "fs/promises"
import os from "os"
import { Effect } from "effect"
import { Flag } from "@opencode-ai/core/flag/flag"
import { MessageV2 } from "../../src/session/message-v2"
import { Session } from "../../src/session/session"
import { SessionPrompt } from "../../src/session/prompt"
import { SessionID } from "../../src/session/schema"
import {
provideTestInstance,
disposeTestRuntime,
provideInstance,
testInstanceStoreLayer,
tmpdir,
} from "../fixture/fixture"
import { remove as cleanup } from "./cleanup"
const previous = Flag.KILO_DB
const dbfile = path.join(os.tmpdir(), `kilo-prompt-steering-${process.pid}-${crypto.randomUUID()}.db`)
beforeAll(async () => {
await fs.rm(dbfile, { force: true })
Flag.KILO_DB = dbfile
})
afterAll(async () => {
await disposeTestRuntime()
Flag.KILO_DB = previous
await Promise.all([dbfile, `${dbfile}-wal`, `${dbfile}-shm`].map(cleanup))
})
function line(input: unknown) {
return `data: ${JSON.stringify(input)}\n\n`
}
function chunk(input: { delta?: Record<string, unknown>; finish?: string }) {
return {
id: "chatcmpl-steering-test",
object: "chat.completion.chunk",
choices: [{ delta: input.delta ?? {}, ...(input.finish ? { finish_reason: input.finish } : {}) }],
}
}
function response(input: string) {
return new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(
new TextEncoder().encode(
[
line(chunk({ delta: { role: "assistant" } })),
line(chunk({ delta: { content: input } })),
line(chunk({ finish: "stop" })),
"data: [DONE]\n\n",
].join(""),
),
)
ctrl.close()
},
})
}
function question() {
const args = JSON.stringify({
questions: [
{
header: "Redirect",
question: "Continue the old task?",
options: [{ label: "Yes", description: "Continue" }],
},
],
})
return new ReadableStream<Uint8Array>({
start(ctrl) {
ctrl.enqueue(
new TextEncoder().encode(
[
line(
chunk({
delta: {
role: "assistant",
tool_calls: [
{
index: 0,
id: "call-question",
type: "function",
function: { name: "question", arguments: args },
},
],
},
}),
),
line(chunk({ finish: "tool_calls" })),
"data: [DONE]\n\n",
].join(""),
),
)
ctrl.close()
},
})
}
const sessions = {
create: (input: Parameters<Session.Interface["create"]>[0]) =>
Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))),
messages: (sessionID: SessionID) =>
Effect.runPromise(
Session.Service.use((svc) => svc.messages({ sessionID })).pipe(Effect.provide(Session.defaultLayer)),
),
}
async function wait(sessionID: SessionID) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
const msgs = await sessions.messages(sessionID)
if (
msgs.some((msg) =>
msg.parts.some((part) => part.type === "tool" && part.tool === "question" && part.state.status === "running"),
)
)
return
await Bun.sleep(20)
}
throw new Error("question tool did not become pending")
}
function scoped<T>(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise<T>) {
return Effect.runPromise(
SessionPrompt.Service.use((prompt) => Effect.promise(() => fn(prompt))).pipe(
Effect.provide(SessionPrompt.defaultLayer),
provideInstance(dir),
Effect.provide(testInstanceStoreLayer),
Effect.scoped,
),
)
}
function tail(body: Record<string, unknown>): { role: string; content: unknown } | undefined {
const msgs = Array.isArray(body.messages) ? (body.messages as Array<Record<string, unknown>>) : []
const item = msgs.findLast((msg) => msg.role !== "system")
if (!item || typeof item.role !== "string") return
return { role: item.role, content: item.content }
}
test("runs queued steering before resuming a dismissed question turn", async () => {
const calls: Array<Record<string, unknown>> = []
const server = Bun.serve({
port: 0,
async fetch(req) {
if (!new URL(req.url).pathname.endsWith("/chat/completions")) return new Response("not found", { status: 404 })
calls.push((await req.json()) as Record<string, unknown>)
return new Response(calls.length === 1 ? question() : response("steering acknowledged"), {
status: 200,
headers: { "Content-Type": "text/event-stream" },
})
},
})
try {
await using tmp = await tmpdir({
git: true,
init: async (dir) =>
Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
enabled_providers: ["alibaba"],
provider: { alibaba: { options: { apiKey: "test-key", baseURL: `${server.url.origin}/v1` } } },
agent: { code: { model: "alibaba/qwen-plus" } },
}),
),
})
await provideTestInstance({
directory: tmp.path,
fn: () =>
scoped(tmp.path, async (prompt) => {
const session = await sessions.create({ title: "Queued steering regression" })
const first = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "code",
parts: [{ type: "text", text: "perform the old task" }],
}),
)
await wait(session.id)
const second = Effect.runPromise(
prompt.prompt({
sessionID: session.id,
agent: "code",
parts: [{ type: "text", text: "stop the old task and inspect the failing test" }],
}),
)
await first
const result = await second
expect(result.parts.some((part) => part.type === "text" && part.text.includes("steering acknowledged"))).toBe(
true,
)
expect(calls).toHaveLength(2)
expect(tail(calls[1]!)?.role).toBe("user")
expect(JSON.stringify(tail(calls[1]!)?.content)).toContain("stop the old task and inspect the failing test")
expect(JSON.stringify(tail(calls[1]!)?.content)).not.toContain("<system-reminder>")
}),
})
} finally {
server.stop(true)
}
}, 60_000)
@@ -179,6 +179,24 @@ describe("v2 pty HttpApi", () => {
() =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped({ git: true, config: { formatter: false, lsp: false } })
// kilocode_change start - verify child env precedence and credential stripping through the canonical PTY route
const previous = {
password: process.env.KILO_SERVER_PASSWORD,
username: process.env.KILO_SERVER_USERNAME,
}
yield* Effect.acquireRelease(
Effect.sync(() => {
process.env.KILO_SERVER_PASSWORD = "host-password"
process.env.KILO_SERVER_USERNAME = "host-username"
}),
() =>
Effect.sync(() => {
if (previous.password === undefined) delete process.env.KILO_SERVER_PASSWORD
else process.env.KILO_SERVER_PASSWORD = previous.password
if (previous.username === undefined) delete process.env.KILO_SERVER_USERNAME
else process.env.KILO_SERVER_USERNAME = previous.username
}),
)
const plugin = path.join(dir, "plugin.ts")
const cwd = path.join(dir, "child")
yield* Effect.promise(() => mkdir(cwd))
@@ -191,6 +209,10 @@ describe("v2 pty HttpApi", () => {
' output.env.SHARED = "plugin"',
' output.env.PLUGIN = "plugin"',
' output.env.TERM = "plugin"',
' output.env.KILO_TERMINAL = "plugin"',
' output.env.KILO_PTY_ID = "plugin"',
' output.env.KILO_SERVER_PASSWORD = "plugin-password"',
' output.env.KILO_SERVER_USERNAME = "plugin-username"',
" output.env.HOOK_CWD = input.cwd",
" },",
"})",
@@ -209,9 +231,20 @@ describe("v2 pty HttpApi", () => {
directoryHeader(dir),
HttpClientRequest.bodyJson({
command: "/bin/sh",
args: ["-c", 'printf "%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$HOOK_CWD"; sleep 5'],
args: [
"-c",
'printf "%s|%s|%s|%s|%s|%s|%s|%s|%s\\n" "$CALLER" "$SHARED" "$PLUGIN" "$TERM" "$KILO_TERMINAL" "$KILO_PTY_ID" "${KILO_SERVER_PASSWORD-unset}" "${KILO_SERVER_USERNAME-unset}" "$HOOK_CWD"; sleep 5',
],
cwd,
env: { CALLER: "caller", SHARED: "caller", TERM: "caller" },
env: {
CALLER: "caller",
SHARED: "caller",
TERM: "caller",
KILO_TERMINAL: "caller",
KILO_PTY_ID: "caller",
KILO_SERVER_PASSWORD: "caller-password",
KILO_SERVER_USERNAME: "caller-username",
},
}),
Effect.flatMap(HttpClient.execute),
)
@@ -240,9 +273,9 @@ describe("v2 pty HttpApi", () => {
return yield* takeUntil(expected, next)
})
expect(yield* takeUntil(`caller|plugin|plugin|xterm-256color|${cwd}`)).toContain(
`caller|plugin|plugin|xterm-256color|${cwd}`,
)
const output = yield* takeUntil("caller|plugin|plugin|xterm-256color")
expect(output).toContain(`caller|plugin|plugin|xterm-256color|1|${info.id}|||${cwd}`)
// kilocode_change end
yield* write(new Socket.CloseEvent(1000, "done")).pipe(Effect.catch(() => Effect.void))
yield* HttpClientRequest.delete(`/api/pty/${info.id}`).pipe(directoryHeader(dir), HttpClient.execute)
}),
@@ -17,7 +17,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { provideTmpdirInstance } from "../fixture/fixture"
import { resetDatabase } from "../fixture/db"
import { pollWithTimeout, testEffect } from "../lib/effect" // kilocode_change
import { pollWithTimeout, testEffect } from "../lib/effect"
const env = LayerNode.buildLayer(CrossSpawnSpawner.node)
const it = testEffect(env)
@@ -304,13 +304,13 @@ describe("ShareNext", () => {
deletions: 0,
status: "modified",
},
], // kilocode_change
],
})
const sync = yield* pollWithTimeout(
Effect.sync(() => seen[0]),
"share sync was not sent",
"3 seconds",
) // kilocode_change
)
expect(seen).toHaveLength(1)
expect(sync.url).toBe("https://legacy-share.example.com/api/share/shr_abc/sync") // kilocode_change
+2 -2
View File
@@ -143,7 +143,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
void Promise.all([
result.location.model.refresh(eventLocation(metadata)),
result.location.provider.refresh(eventLocation(metadata)),
]) // kilocode_change
])
break
case "session.next.agent.switched":
message.update(event.properties.sessionID, (draft) => {
@@ -455,7 +455,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
result.location.integration.refresh(eventLocation(metadata)),
result.location.model.refresh(eventLocation(metadata)),
result.location.provider.refresh(eventLocation(metadata)),
]) // kilocode_change
])
break
}
} // kilocode_change
+2 -2
View File
@@ -1747,7 +1747,7 @@ function AssistantMessage(props: {
</Switch>
</>
)
} // kilocode_change
}
// kilocode_change start - register rendered step-finish parts
const PART_MAPPING = {
@@ -1758,7 +1758,7 @@ const PART_MAPPING = {
}
// kilocode_change end
const INLINE_TOOL_ICON_WIDTH = 2 // kilocode_change
const INLINE_TOOL_ICON_WIDTH = 2
// kilocode_change start - show concrete routed models reported by gateway/provider responses
function StepFinishPart(props: { last: boolean; part: StepFinishPart; message: AssistantMessage }) {
+6 -2
View File
@@ -43,6 +43,7 @@ type RenderedBlock =
key: string
mode: "code"
raw: string
src: string // kilocode_change - Mermaid consumes delimiter-free source while raw preserves stream identity
hash: string
language: string
complete: boolean
@@ -385,6 +386,7 @@ export function Markdown(
key: blockKey,
mode: block.mode,
raw: block.raw,
src: block.src, // kilocode_change
hash: String(block.raw.length),
complete: !!block.complete,
language: "mermaid",
@@ -401,6 +403,7 @@ export function Markdown(
key: blockKey,
mode: block.mode,
raw: block.raw,
src: block.src, // kilocode_change
hash: String(block.raw.length),
complete: !!block.complete,
...result,
@@ -603,6 +606,7 @@ function pendingBlocks(
key,
mode: block.mode,
raw: block.raw,
src: block.src, // kilocode_change
hash: String(block.raw.length),
language: block.language ?? "text",
complete: !!block.complete,
@@ -716,7 +720,7 @@ function updateCodeBlock(
pre.setAttribute("dir", "auto")
const codeElement = document.createElement("code")
codeElement.setAttribute("data-lang", "mermaid")
codeElement.textContent = block.raw
codeElement.textContent = block.src // kilocode_change - Mermaid rejects fenced Markdown as diagram source
pre.appendChild(codeElement)
wrapper.appendChild(pre)
wrapper.appendChild(createCopyButton(labels))
@@ -760,7 +764,7 @@ function updateCodeBlock(
const wrapper = document.createElement("div")
wrapper.setAttribute("data-component", "markdown-code")
const pre = document.createElement("pre")
pre.className = "shiki Kilo" // kilocode_change
pre.className = "shiki Kilo"
pre.setAttribute("dir", "auto") // kilocode_change
const codeElement = document.createElement("code")
codeElement.className = `language-${block.language}`