mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
fix: resolve OpenCode merge regressions
This commit is contained in:
@@ -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>`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}`
|
||||
|
||||
Reference in New Issue
Block a user