mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
fix: resolve OpenCode merge regressions
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user