From 1b60773caefdcc7fdd96351b3f1990e2a1afb808 Mon Sep 17 00:00:00 2001 From: marius-kilocode Date: Fri, 24 Jul 2026 12:56:27 +0200 Subject: [PATCH] fix: resolve OpenCode merge regressions --- .changeset/opencode-v1-17-5-to-v1-17-9.md | 3 + .../core/src/plugin/provider/llmgateway.ts | 2 +- .../core/src/plugin/provider/openai-auth.ts | 4 +- packages/core/src/pty.ts | 7 +- .../test/plugin/provider-llmgateway.test.ts | 2 +- .../tests/markdown-mermaid.spec.ts | 15 ++ .../webview-ui/src/stories/shared.stories.tsx | 22 ++ .../src/kilocode/session/prompt-queue.ts | 4 +- packages/opencode/src/provider/provider.ts | 16 +- packages/opencode/src/provider/transform.ts | 2 +- .../instance/httpapi/groups/experimental.ts | 4 +- packages/opencode/src/session/prompt.ts | 31 ++- .../test/kilocode/provider-saved-auth.test.ts | 78 +++++++ .../kilocode/session-prompt-steering.test.ts | 207 ++++++++++++++++++ .../test/server/httpapi-v2-pty.test.ts | 43 +++- .../opencode/test/share/share-next.test.ts | 6 +- packages/tui/src/context/data.tsx | 4 +- packages/tui/src/routes/session/index.tsx | 4 +- packages/ui/src/components/markdown.tsx | 8 +- 19 files changed, 413 insertions(+), 49 deletions(-) create mode 100644 packages/kilo-vscode/tests/markdown-mermaid.spec.ts create mode 100644 packages/opencode/test/kilocode/provider-saved-auth.test.ts create mode 100644 packages/opencode/test/kilocode/session-prompt-steering.test.ts diff --git a/.changeset/opencode-v1-17-5-to-v1-17-9.md b/.changeset/opencode-v1-17-5-to-v1-17-9.md index b645959b70..5a019637c3 100644 --- a/.changeset/opencode-v1-17-5-to-v1-17-9.md +++ b/.changeset/opencode-v1-17-5-to-v1-17-9.md @@ -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. diff --git a/packages/core/src/plugin/provider/llmgateway.ts b/packages/core/src/plugin/provider/llmgateway.ts index aa97b7f8db..b416abd284 100644 --- a/packages/core/src/plugin/provider/llmgateway.ts +++ b/packages/core/src/plugin/provider/llmgateway.ts @@ -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" diff --git a/packages/core/src/plugin/provider/openai-auth.ts b/packages/core/src/plugin/provider/openai-auth.ts index 181247e9f1..fb9e8d300a 100644 --- a/packages/core/src/plugin/provider/openai-auth.ts +++ b/packages/core/src/plugin/provider/openai-auth.ts @@ -258,6 +258,6 @@ function claim(token: string) { } const successPage = - "Kilo

Authorization successful

You can close this window.

" // kilocode_change + "Kilo

Authorization successful

You can close this window.

" const errorPage = (message: string) => - `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` // kilocode_change + `Kilo

Authorization failed

${message.replace(/[&<>"']/g, "")}

` diff --git a/packages/core/src/pty.ts b/packages/core/src/pty.ts index 8d68e34296..28e48f6b05 100644 --- a/packages/core/src/pty.ts +++ b/packages/core/src/pty.ts @@ -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 - // 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" diff --git a/packages/core/test/plugin/provider-llmgateway.test.ts b/packages/core/test/plugin/provider-llmgateway.test.ts index c8192714d8..f92d5fc0bb 100644 --- a/packages/core/test/plugin/provider-llmgateway.test.ts +++ b/packages/core/test/plugin/provider-llmgateway.test.ts @@ -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 }) diff --git a/packages/kilo-vscode/tests/markdown-mermaid.spec.ts b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts new file mode 100644 index 0000000000..e18acc7e33 --- /dev/null +++ b/packages/kilo-vscode/tests/markdown-mermaid.spec.ts @@ -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() +}) diff --git a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx index bf3a16f7dc..81b27047db 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/shared.stories.tsx @@ -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: () => ( + + B{Needs tools?} + B -->|Yes| C[Run tool] + B -->|No| D[Respond] + C --> D +\`\`\` + +Rendered after the diagram.`} + /> + + ), +} + // --------------------------------------------------------------------------- // ModelSelector // --------------------------------------------------------------------------- diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 419c7e1379..e56d8c5765 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -123,6 +123,7 @@ export namespace KiloSessionPromptQueue { target: MessageID, work: Effect.Effect, cancelled: Effect.Effect, + reserved: Effect.Effect = Effect.void, ): Effect.Effect { 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 diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 5269adf502..802f35a0be 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -473,7 +473,7 @@ function custom(dep: CustomDep): Record { 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 { 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 { 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 { 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 { 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 { 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 { 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), diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 5ede81851f..318289d119 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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" diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts index d5aedadadc..77bf29ba44 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts @@ -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, { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 992453b581..11f0dd0ab7 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -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.", diff --git a/packages/opencode/test/kilocode/provider-saved-auth.test.ts b/packages/opencode/test/kilocode/provider-saved-auth.test.ts new file mode 100644 index 0000000000..6391d69cc2 --- /dev/null +++ b/packages/opencode/test/kilocode/provider-saved-auth.test.ts @@ -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 = (value: Record, effect: Effect.Effect) => + 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: {} }, +) diff --git a/packages/opencode/test/kilocode/session-prompt-steering.test.ts b/packages/opencode/test/kilocode/session-prompt-steering.test.ts new file mode 100644 index 0000000000..1f38272779 --- /dev/null +++ b/packages/opencode/test/kilocode/session-prompt-steering.test.ts @@ -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; 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({ + 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({ + 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[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(dir: string, fn: (prompt: SessionPrompt.Interface) => Promise) { + 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): { role: string; content: unknown } | undefined { + const msgs = Array.isArray(body.messages) ? (body.messages as Array>) : [] + 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> = [] + 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) + 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("") + }), + }) + } finally { + server.stop(true) + } +}, 60_000) diff --git a/packages/opencode/test/server/httpapi-v2-pty.test.ts b/packages/opencode/test/server/httpapi-v2-pty.test.ts index a8fd2232d9..d2eb68f77a 100644 --- a/packages/opencode/test/server/httpapi-v2-pty.test.ts +++ b/packages/opencode/test/server/httpapi-v2-pty.test.ts @@ -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) }), diff --git a/packages/opencode/test/share/share-next.test.ts b/packages/opencode/test/share/share-next.test.ts index 0171dfa189..ccc21f2ee2 100644 --- a/packages/opencode/test/share/share-next.test.ts +++ b/packages/opencode/test/share/share-next.test.ts @@ -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 diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index e159276b73..d416108a5e 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -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 diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 1a2dd894aa..fc1d28b8d7 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -1747,7 +1747,7 @@ function AssistantMessage(props: { ) -} // 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 }) { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index 8003142ec0..8cef102376 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -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}`