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
@@ -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