Files
Johnny Amancio 76d06fd8ba fix: name the auto-approve commands by scope and repair CI failures
Restore packages/ui/src/components/markdown.css. Upstream moved markdown
styling into the new session-ui package and deleted this file, but Kilo still
ships packages/ui's markdown component to the VS Code webview, so the rules
went missing while their consumer stayed. The copy button lost
position: absolute and opacity: 0, so it rendered below every code block
instead of in its corner, and showed the copy and check icons at once because
nothing hid the check state.

The palette showed "Enable auto-approve permissions" next to "Enable
auto-approve mode", which reads as the same command twice. They are not: one
lasts for the TUI run and is what --auto and --yolo seed, the other saves a
rule to global config. Both now say which they are and carry a desc, which
the palette already renders. The saved one is enforced server side, so its
desc says it covers every client. No behaviour change, and the command names
are untouched so the slash aliases still work.

Tests no longer depend on the developer's credential store. Credential
imports Global.data/auth.json on startup, and the catalog, plugin and
models-dev layers built Credential.node without overriding Global, so results
changed depending on whether the machine was logged in. They now use a temp
data directory, as the integration suite already did. That suite in turn set
KILO_AUTH_CONTENT at module scope; bun shares one process across test files,
so it leaked into credential.test.ts, whose auth.json cases then read "{}"
and found no credentials. Scoped with beforeAll/afterAll.

macOS-only failures, all merge gaps in Kilo tests:

- sandbox/state.test.ts runs a script in a subprocess; the LayerNode
  migration put AppNodeBuilder.build in it without adding the import
- sandbox/session-tools.test.ts mocked MCP without clients(), which
  upstream's resource tools now call
- sandbox/session.test.ts built SessionV2 without binding SessionExecution

Generated files: schema.gen.ts was formatted by an older prettier than the
one main's dependency bump resolves, so the generator no longer reproduced it
and the migration check called the schema stale. packages/client was missing
Pty.sessionID and the compaction include field. Both regenerated.

Also corrected the listener comment: it claimed application services still
come from AppRuntime, but the SessionV2 and MoveSession builds sit inside
KiloListener's Layer.fresh boundary and self-provide their subtrees, which is
deliberate since both bind this listener's own LocationServiceMap.
2026-07-30 22:01:05 +02:00

375 lines
14 KiB
TypeScript

import { afterAll, beforeAll, describe, expect } from "bun:test"
import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Credential } from "@opencode-ai/core/credential"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Integration } from "@opencode-ai/core/integration"
import { Global } from "@opencode-ai/core/global"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { testEffect } from "./lib/effect"
// kilocode_change start - Kilo dual-writes stored credentials into Global.data/auth.json and re-imports
// them on every startup. testEffect rebuilds the layer per test, so without isolation one test's
// credentials reappear in the next as "Imported" (and the real developer store gets written to).
// KILO_AUTH_CONTENT is Kilo's process-local credential mode: it skips the auth.json dual-write.
// Set it around this file only. bun shares one process across test files, so setting it at module
// scope leaks into credential.test.ts, whose auth.json cases then read "{}" and see no credentials.
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "kilo-integration-test-"))
let previous: string | undefined
beforeAll(() => {
previous = process.env.KILO_AUTH_CONTENT
process.env.KILO_AUTH_CONTENT ??= "{}"
})
afterAll(() => {
if (previous === undefined) delete process.env.KILO_AUTH_CONTENT
else process.env.KILO_AUTH_CONTENT = previous
})
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]), [
[Global.node, Global.layerWith({ data: directory })],
]),
)
// kilocode_change end
describe("Integration", () => {
it.effect("registers integrations through the editor", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const scope = yield* Scope.fork(yield* Scope.Scope)
const openai = Integration.ID.make("openai")
yield* integrations
.transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(scope))
expect(yield* integrations.get(openai)).toEqual(
new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
)
yield* Scope.close(scope, Exit.void)
expect(yield* integrations.get(openai)).toBeUndefined()
}),
)
it.effect("reveals the previous registration when an override closes", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const id = Integration.ID.make("openai")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
yield* integrations
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
.pipe(Scope.provide(second))
expect((yield* integrations.get(id))?.name).toBe("OpenAI Override")
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(id))?.name).toBe("OpenAI")
expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
}),
)
it.effect("registers and overrides methods independently", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
const first = yield* Scope.fork(yield* Scope.Scope)
const second = yield* Scope.fork(yield* Scope.Scope)
const authorize = () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
})
yield* integrations
.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize,
}),
)
.pipe(Scope.provide(first))
yield* integrations
.transform((editor) => {
expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" })
expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }])
expect(editor.method.list(integrationID)).toEqual([
expect.objectContaining({ id: methodID, label: "ChatGPT" }),
])
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
authorize,
})
})
.pipe(Scope.provide(second))
expect((yield* integrations.get(integrationID))?.name).toBe("openai")
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT Override" })
yield* Scope.close(second, Exit.void)
expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
}),
)
it.effect("connects with a key and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const events = yield* EventV2.Service
const integrationID = Integration.ID.make("openai")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { type: "key", label: "API key" },
}),
)
const updated = yield* events
.subscribe(Integration.Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.yieldNow
yield* integrations.connection.key({
integrationID,
key: "secret",
label: "Work",
})
expect(yield* credentials.list(integrationID)).toEqual([
expect.objectContaining({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "secret" }),
}),
])
expect((yield* Fiber.join(updated)).length).toBe(1)
}),
)
it.effect("completes code OAuth once and stores the credential", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.succeed({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: (code: string) =>
Effect.succeed(
Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code },
}),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({
integrationID,
methodID,
inputs: {},
label: "Personal",
})
expect(attempt.mode).toBe("code")
yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
expect((yield* credentials.list(integrationID))[0]).toEqual(
expect.objectContaining({
integrationID,
label: "Personal",
value: Credential.OAuth.make({
type: "oauth",
methodID,
access: "access",
refresh: "refresh",
expires: 1,
metadata: { code: "1234" },
}),
}),
)
}),
)
it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("chatgpt")
let closed = false
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "ChatGPT" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "code" as const,
url: "https://example.com/authorize",
instructions: "Paste the code",
callback: () => Effect.die("unexpected callback"),
}),
),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
Integration.CodeRequiredError,
)
expect(closed).toBe(false)
yield* integrations.attempt.cancel(attempt.attemptID)
expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("completes auto OAuth in the background", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.succeed({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.succeed(
Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
),
}),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "complete",
time: attempt.time,
})
expect(yield* credentials.list(integrationID)).toHaveLength(1)
}),
)
it.effect("expires abandoned OAuth attempts", () =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("openai")
const methodID = Integration.MethodID.make("browser")
let closed = false
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: { id: methodID, type: "oauth", label: "Browser" },
authorize: () =>
Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
Effect.as({
mode: "auto" as const,
url: "https://example.com/authorize",
instructions: "Sign in",
callback: Effect.never,
}),
),
}),
)
const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
yield* TestClock.adjust(Duration.minutes(10))
yield* Effect.yieldNow
expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
status: "expired",
time: attempt.time,
})
expect(closed).toBe(true)
expect(yield* credentials.list(integrationID)).toEqual([])
}),
)
it.effect("projects credential and env connections", () => {
const integrationID = Integration.ID.make("acme")
return Effect.acquireUseRelease(
Effect.sync(() => {
const previous = process.env.INTEGRATION_TEST_ACME_KEY
process.env.INTEGRATION_TEST_ACME_KEY = "secret"
delete process.env.INTEGRATION_TEST_ACME_MISSING
return previous
}),
() =>
Effect.gen(function* () {
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
method: {
type: "env",
names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
},
}),
)
const work = yield* credentials.create({
integrationID,
label: "Work",
value: Credential.Key.make({ type: "key", key: "a" }),
})
const personal = yield* credentials.create({
integrationID,
label: "Personal",
value: Credential.Key.make({ type: "key", key: "b" }),
})
// Stored credentials and detected env vars appear as connections.
expect((yield* integrations.get(integrationID))?.connections).toEqual([
{
type: "credential",
id: personal.id,
label: "Personal",
},
{ type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
])
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
id: personal.id,
label: "Personal",
})
expect(work.id).not.toBe(personal.id)
}),
(previous) =>
Effect.sync(() => {
if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
else process.env.INTEGRATION_TEST_ACME_KEY = previous
}),
)
})
})