diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index c527ffa5a6..024a3d4df7 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -8,6 +8,7 @@ import { Flag } from "../flag/flag" import { isAbsolute, join } from "path" import { existsSync } from "fs" // kilocode_change import { DbPreflight } from "../kilocode/db-preflight" // kilocode_change +import { ensure as compat } from "../kilocode/database-compat" // kilocode_change import { DatabaseMigration } from "./migration" import { InstallationChannel } from "../installation/version" import { makeGlobalNode } from "../effect/app-node" @@ -33,6 +34,7 @@ const layer = Layer.effect( yield* db.run("PRAGMA foreign_keys = ON") yield* db.run("PRAGMA wal_checkpoint(PASSIVE)") yield* DatabaseMigration.apply(db) + yield* compat(db) // kilocode_change - keep the shared database usable by released CLIs return { db } }).pipe(Effect.orDie), diff --git a/packages/core/src/kilocode/database-compat.ts b/packages/core/src/kilocode/database-compat.ts new file mode 100644 index 0000000000..9982d3d728 --- /dev/null +++ b/packages/core/src/kilocode/database-compat.ts @@ -0,0 +1,22 @@ +import { Effect } from "effect" +import type { Database } from "../database/database" + +type Db = Database.Interface["db"] + +export function ensure(db: Db) { + return db.transaction( + (tx) => + Effect.gen(function* () { + const rows = yield* tx.all<{ name: string }>("PRAGMA table_info('session_context_epoch')") + const names = new Set(rows.map((row) => row.name)) + + if (!names.has("agent")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `agent` text DEFAULT 'build' NOT NULL") + if (!names.has("replacement_seq")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `replacement_seq` integer") + if (!names.has("revision")) + yield* tx.run("ALTER TABLE `session_context_epoch` ADD `revision` integer DEFAULT 0 NOT NULL") + }), + { behavior: "immediate" }, + ) +} diff --git a/packages/core/src/tool/apply-patch.ts b/packages/core/src/tool/apply-patch.ts index 3d7105a31a..6074740815 100644 --- a/packages/core/src/tool/apply-patch.ts +++ b/packages/core/src/tool/apply-patch.ts @@ -10,6 +10,7 @@ import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { Patch } from "../patch" import { PermissionV2 } from "../permission" +import { ToolOutputStore } from "../tool-output-store" // kilocode_change import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -34,6 +35,21 @@ export const Output = Schema.Struct({ }) export type Output = typeof Output.Type +// kilocode_change start - keep durable/SSE tool records bounded without hiding normal-sized diff previews +const compact = (output: Output): Output => { + if (Buffer.byteLength(JSON.stringify(output), "utf-8") <= ToolOutputStore.MAX_BYTES) return output + return { + ...output, + files: output.files.map((file) => ({ + additions: file.additions, + deletions: file.deletions, + ...(file.file === undefined ? {} : { file: file.file }), + ...(file.status === undefined ? {} : { status: file.status }), + })), + } +} +// kilocode_change end + export const toModelOutput = (output: Output) => [ "Applied patch sequentially:", @@ -72,6 +88,8 @@ const layer = Layer.effectDiscard( "Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet.", input: Input, output: Output, + structured: Output, // kilocode_change + toStructuredOutput: ({ output }) => compact(output), // kilocode_change toModelOutput: ({ output }) => [{ type: "text", text: toModelOutput(output) }], execute: (input, context) => { const applied: Array = [] diff --git a/packages/core/src/tool/edit.ts b/packages/core/src/tool/edit.ts index 77842f0ed6..a92b0836e8 100644 --- a/packages/core/src/tool/edit.ts +++ b/packages/core/src/tool/edit.ts @@ -17,6 +17,7 @@ import { FileMutation } from "../file-mutation" import { FSUtil } from "../fs-util" import { LocationMutation } from "../location-mutation" import { PermissionV2 } from "../permission" +import { ToolOutputStore } from "../tool-output-store" // kilocode_change import { ToolRegistry } from "./registry" import { Tool } from "./tool" import { Tools } from "./tools" @@ -41,6 +42,21 @@ export const Output = Schema.Struct({ }) export type Output = typeof Output.Type +// kilocode_change start - keep durable/SSE tool records bounded without hiding normal-sized diff previews +const compact = (output: Output): Output => { + if (Buffer.byteLength(JSON.stringify(output), "utf-8") <= ToolOutputStore.MAX_BYTES) return output + return { + ...output, + files: output.files.map((file) => ({ + additions: file.additions, + deletions: file.deletions, + ...(file.file === undefined ? {} : { file: file.file }), + ...(file.status === undefined ? {} : { status: file.status }), + })), + } +} +// kilocode_change end + const normalizeLineEndings = (text: string) => text.replaceAll("\r\n", "\n") const detectLineEnding = (text: string): "\n" | "\r\n" => (text.includes("\r\n") ? "\r\n" : "\n") const convertToLineEnding = (text: string, ending: "\n" | "\r\n") => @@ -105,6 +121,8 @@ const layer = Layer.effectDiscard( "Replace exact text in one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.", input: Input, output: Output, + structured: Output, // kilocode_change + toStructuredOutput: ({ output }) => compact(output), // kilocode_change toModelOutput: ({ input, output }) => [ { type: "text", text: toModelOutput(output, input.oldString, input.newString) }, ], diff --git a/packages/core/test/kilocode/database-migration-compat.test.ts b/packages/core/test/kilocode/database-migration-compat.test.ts index ebabb5e0ff..8546df16b3 100644 --- a/packages/core/test/kilocode/database-migration-compat.test.ts +++ b/packages/core/test/kilocode/database-migration-compat.test.ts @@ -11,6 +11,8 @@ import path from "path" import { tmpdir } from "../fixture/tmpdir" import { SessionHistory } from "@opencode-ai/core/session/history" import { SessionV2 } from "@opencode-ai/core/session" +import { ensure } from "@opencode-ai/core/kilocode/database-compat" +import { Database } from "@opencode-ai/core/database/database" const make = EffectDrizzleSqlite.makeWithDefaults() const run = (effect: Effect.Effect) => @@ -43,18 +45,30 @@ describe("database migration compatibility", () => { ) yield* DatabaseMigration.applyOnly(db, migrations.slice(split)) + yield* ensure(db) // This is the projection shape written by the CLI bundled with VS Code v7.4.7. yield* db.run( sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('message', 'ses_session', 'user', 1, 1, '{}')`, ) yield* db.run(sql`UPDATE session_message SET data = '{"text":"updated"}' WHERE id = 'message'`) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, agent, snapshot, baseline_seq, revision) VALUES ('ses_session', 'baseline', 'build', '{}', 0, 0)`, + ) + yield* db.run( + sql`UPDATE session_context_epoch SET replacement_seq = 4, revision = revision + 1 WHERE session_id = 'ses_session'`, + ) expect(yield* db.get(sql`SELECT id, seq, data FROM session_message WHERE id = 'message'`)).toEqual({ id: "message", seq: null, data: '{"text":"updated"}', }) + expect( + yield* db.get( + sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'ses_session'`, + ), + ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) yield* db.run( sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_sequenced', 'ses_session', 'user', 1, 2, 2, '{"text":"current","files":[],"agents":[],"time":{"created":2}}')`, ) @@ -119,6 +133,37 @@ describe("database migration compatibility", () => { ) }) + test("keeps released context epoch writes compatible on fresh current databases", async () => { + await using tmp = await tmpdir() + const filename = path.join(tmp.path, "kilo.db") + await Effect.runPromise( + Database.Service.use((service) => + Effect.gen(function* () { + const db = service.db + yield* ensure(db) + yield* db.run( + sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('project', '/repo', 1, 1, '[]')`, + ) + yield* db.run( + sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'project', 'session', '/repo', 'Session', '7.4.7', 1, 1)`, + ) + yield* db.run( + sql`INSERT INTO session_context_epoch (session_id, baseline, agent, snapshot, baseline_seq, revision) VALUES ('session', 'baseline', 'build', '{}', 0, 0)`, + ) + yield* db.run( + sql`UPDATE session_context_epoch SET replacement_seq = 4, revision = revision + 1 WHERE session_id = 'session'`, + ) + + expect( + yield* db.get( + sql`SELECT agent, replacement_seq AS replacementSeq, revision FROM session_context_epoch WHERE session_id = 'session'`, + ), + ).toEqual({ agent: "build", replacementSeq: 4, revision: 1 }) + }), + ).pipe(Effect.provide(Database.layerFromPath(filename)), Effect.scoped), + ) + }) + test("preserves sequenced projections when repairing an already-migrated database", async () => { await run( Effect.gen(function* () { diff --git a/packages/core/test/tool-apply-patch.test.ts b/packages/core/test/tool-apply-patch.test.ts index b986d4972a..9a14025da7 100644 --- a/packages/core/test/tool-apply-patch.test.ts +++ b/packages/core/test/tool-apply-patch.test.ts @@ -202,6 +202,33 @@ describe("ApplyPatchTool", () => { ), ) + // kilocode_change start + it.live("omits oversized patches from durable structured output", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const content = "x".repeat(ToolOutputStore.MAX_BYTES) + const patch = `*** Begin Patch\n*** Add File: large.txt\n+${content}\n*** End Patch` + return withTool(tmp.path, (registry) => settleTool(registry, call(patch))).pipe( + Effect.tap((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toEqual({ + applied: [{ type: "add", resource: "large.txt", target: path.join(tmp.path, "large.txt") }], + files: [{ file: "large.txt", status: "added", additions: 1, deletions: 0 }], + }) + expect(Buffer.byteLength(JSON.stringify(settled.output?.structured), "utf-8")).toBeLessThanOrEqual( + ToolOutputStore.MAX_BYTES, + ) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + // kilocode_change end + it.live("rejects moves before applying any hunk", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/core/test/tool-edit.test.ts b/packages/core/test/tool-edit.test.ts index 2c684523b7..f72324a227 100644 --- a/packages/core/test/tool-edit.test.ts +++ b/packages/core/test/tool-edit.test.ts @@ -159,6 +159,39 @@ describe("EditTool", () => { ), ) + // kilocode_change start + it.live("omits an oversized patch from durable structured output", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + reset() + const target = path.join(tmp.path, "large.txt") + const before = "a".repeat(ToolOutputStore.MAX_BYTES) + const after = "b".repeat(ToolOutputStore.MAX_BYTES) + return Effect.promise(() => fs.writeFile(target, before)).pipe( + Effect.andThen( + withTool(tmp.path, (registry) => + settleTool(registry, call({ path: "large.txt", oldString: before, newString: after })), + ), + ), + Effect.tap((settled) => + Effect.sync(() => { + expect(settled.output?.structured).toEqual({ + replacements: 1, + files: [{ file: "large.txt", status: "modified", additions: 1, deletions: 1 }], + }) + expect(Buffer.byteLength(JSON.stringify(settled.output?.structured), "utf-8")).toBeLessThanOrEqual( + ToolOutputStore.MAX_BYTES, + ) + }), + ), + ) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + // kilocode_change end + it.live("accepts an absolute file path inside the active Location", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png index b72d40dbcf..47c6308f84 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/readable-chat-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:58fef726cab9c37deeb8f3e4fac8f3d6c943acf11c46c0d8d53af93df028a4c2 -size 46120 +oid sha256:cef414cf59da1f632a7d5d9a2e44f792e7e2ea6b4041bcc214cb32c052f851d8 +size 46930 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png index e295a7a45a..0441c44b14 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b21ced6db14cbc68efe2f996542b7810e000a9c8a5e714c0aa434928865046ab -size 32798 +oid sha256:bd700edca458baefb4ce6cea68ed78f8c002bfb6edd842dbea318c2fa443b814 +size 32789 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png index 3b00eed9a1..2ed225e9ec 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-many-prompts-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e417f0d51ea0d9b15ab1dcee4d5b49fe064bfd6d9dad6c8d5cc38e59eea049 -size 27189 +oid sha256:d7fc23fcb7adf483c0b771ef601b23cb365dc7f40033b00fce703b35910aa4fc +size 27159 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index b18d777a11..623df73220 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8ae5166647579b4ebe139206f34ce2227d7fa8c5fde537b7808355fd8a86c70a -size 27309 +oid sha256:d11f4004ed3170647d385c14df077d235f5bb9bd6e5dec07557ee2014d553233 +size 27302 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png index 8676081611..c2410b066a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-wide-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0fc65d066f7ec387cb4f1a8980178281ad961b9b57df2bf1261d6650637414b8 -size 29764 +oid sha256:f8bd56ba87d0c2bbef8a2325ab2e9f0e956c89e2602ff8ca7415d5fb7c9fa1f3 +size 29709 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png index c959f46b63..8e57e390eb 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/chat-busy-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:60de70c3e280fde6486647a2d037bbdb83070b9d1a34e96d4deac8835bb11bcf -size 4238 +oid sha256:222790e6c32346cf6b6cc52a0859ee01d364b52a1ef8b9f385a6e9798adfccfb +size 4256 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png index 5dd9ee6528..da092f54d8 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/diff-summary-collapsed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d86411a4a93770b39e06881761864f24bf7890b8da5faf21d065125cb4662b52 -size 6899 +oid sha256:e854dd761ae03a44210467d2550849b1e49dd9a3ee4efef50d5116962a9a4059 +size 6907 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png index 0ba7543c54..4ac4916fcf 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7020df6386028c96fac00bedf8df1170146bb8ea851d2511944d6cee37365597 -size 34013 +oid sha256:c3ebab71919a7bafe609933ba8909f22e68171d3546473cca7ad5e3d859787ef +size 30655 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png index 6ba300a581..844596d44d 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3e104fbc1ff457317f7715937fb939142374ea20e20cda6aeb1d412867cf66c4 -size 7481 +oid sha256:76b359a64ad4dede420eb4669f973f80f87be63b5d0a1dfe36829f0c19698381 +size 7486 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png index ae5da18746..b403a28540 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5594e89d1abea2e4a0b534fe99d8802fa14edb60479b418bc4b9bee1fa85f7b6 -size 5172 +oid sha256:eecccfc0bbd53bd52261ff8cfc02c444c6d97f6438e6693d8cd998c36909708a +size 5178 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/title-only-reasoning-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/title-only-reasoning-chromium-linux.png index 9566d52792..2a2c7c8d9c 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/title-only-reasoning-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/composite-webview/title-only-reasoning-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:393b2bf1cfeb3ab01e864084f520bdc8ef1cbbeba87e47bb5037e90ca71e109e -size 6757 +oid sha256:43ea789b53b0bd402231f30970e9f8f1648ac1d0bcd971102ed4769bd002d9e6 +size 6764 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png index 02b1d053ba..3f693ae12a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/labs-tool-call-lab/search-previews-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49ecad51e50b6fcdb3c9f5eac8ef516032844de2beb77fee68d206216c1be8fe -size 669184 +oid sha256:b7482c1c495c5f96fe1cda00a956e2e0480f261af043e15441f047e687c4b063 +size 660234 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png index 4e234b87b6..17c6284a46 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/shared/markdown-mermaid-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c09f8d7adef66fd01ec50a13dd8fdad1fe92f789216fd5bd8f83b92dc2e4ac1 -size 18496 +oid sha256:77964c111d0454d7985e3368b821e161e4d6d9fffee5438ee094ab34ff464d52 +size 18623 diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba7162d4f3..279557c078 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -64,6 +64,9 @@ import { Truncate } from "@/tool/truncate" import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" +import * as DateTime from "effect/DateTime" // kilocode_change +import { SessionEvent } from "@opencode-ai/core/session/event" // kilocode_change +import { SessionMessage } from "@opencode-ai/core/session/message" // kilocode_change import { InstanceState } from "@/effect/instance-state" import { InstanceRef } from "@/effect/instance-ref" import { Instance } from "@/kilocode/instance" @@ -628,6 +631,17 @@ export const layer = Layer.effect( }, } yield* sessions.updatePart(part) + // kilocode_change start - dual-write the v2 shell record so the durable timeline correlates with the tool part + if (flags.experimentalEventSystem) { + yield* events.publish(SessionEvent.Shell.Started, { + sessionID: input.sessionID, + messageID: SessionMessage.ID.create(), + timestamp: DateTime.makeUnsafe(started), + callID: part.callID, + command: input.command, + }) + } + // kilocode_change end return { msg, part, cwd: ctx.directory } }).pipe(Effect.ensuring(markReady)) @@ -645,6 +659,16 @@ export const layer = Layer.effect( } if (timeout) output += "\n\n" + ["", timeout, ""].join("\n") // kilocode_change const completed = Date.now() + // kilocode_change start + if (flags.experimentalEventSystem) { + yield* events.publish(SessionEvent.Shell.Ended, { + sessionID: input.sessionID, + timestamp: DateTime.makeUnsafe(completed), + callID: part.callID, + output, + }) + } + // kilocode_change end if (!msg.time.completed) { msg.time.completed = completed yield* sessions.updateMessage(msg) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index d1b0fffd76..2dce9e8cb2 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -157,13 +157,13 @@ describe("opencode run (non-interactive subprocess)", () => { yield* llm.tool("bash", { command: "sed -n 1,5p README.md" }) const result = yield* opencode.run("run sed on readme", { format: "json", - timeoutMs: 45_000, + timeoutMs: 75_000, }) opencode.expectExit(result, 1) expect(result.stderr).toContain("run ended with an auto-rejected permission; pass --auto for autonomous use") expect(opencode.parseJsonEvents(result.stdout).some((event) => event.type === "error")).toBe(true) }), - 60_000, + 90_000, ) // kilocode_change end @@ -302,13 +302,17 @@ describe("opencode run (non-interactive subprocess)", () => { expect(result.exitCode).toBe(0) expect(events.map((event) => event.type)).toEqual([ "step_start", - "text", "tool_use", + "text", // kilocode_change - a pre-denied tool settles before the SDK closes its preceding text part "step_finish", "step_start", "step_finish", ]) - expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) + // kilocode_change start - the pre-denied tool completes before text-end + expect(events.find((event) => event.type === "text")?.part).toEqual( + expect.objectContaining({ type: "text", text: "partial json" }), + ) + // kilocode_change end // kilocode_change - upstream asserts reason "unknown" here. Reaching that requires the bash call // to proceed without permission friction, which a Kilo headless run never does: left alone the ask // is auto-rejected (exit 1, no second step), and settling it up front changes the request sequence @@ -399,12 +403,13 @@ describe("opencode run (non-interactive subprocess)", () => { yield* llm.hang const run = yield* opencode.startRun("wait forever") yield* llm.wait(1) + const interrupted = Date.now() // kilocode_change - assert signal handling, independent of contended CLI startup run.interrupt() const result = yield* run.result expect(result.exitCode).not.toBe(0) - expect(result.durationMs).toBeLessThan(30_000) + expect(Date.now() - interrupted).toBeLessThan(10_000) // kilocode_change }), - 30_000, + 60_000, // kilocode_change ) }) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904b..785ced9c91 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -37,11 +37,11 @@ describe("tui thread", () => { await check(".") }) - test("resolves a relative mini project from PWD when cwd differs", async () => { + test("ignores stale PWD when resolving a relative mini project", async () => { // kilocode_change await using pwd = await tmpdir({ git: true }) await using cwd = await tmpdir({ git: true }) - expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(pwd.path) + expect(resolveThreadDirectory(".", pwd.path, cwd.path)).toBe(cwd.path) // kilocode_change expect(resolveThreadDirectory(undefined, pwd.path, cwd.path)).toBe(cwd.path) }) diff --git a/packages/opencode/test/event-manifest.test.ts b/packages/opencode/test/event-manifest.test.ts index da0f80cee5..be22f5648a 100644 --- a/packages/opencode/test/event-manifest.test.ts +++ b/packages/opencode/test/event-manifest.test.ts @@ -9,12 +9,13 @@ describe("public event manifest", () => { expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions) expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest) expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable) - expect(EventManifest.Latest.size).toBe(88) + expect(EventManifest.Latest.size).toBe(89) // kilocode_change - include global.config.updated expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended) expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated) expect(EventManifest.Latest.has("ide.installed")).toBe(false) expect(EventManifest.Latest.has("server.connected")).toBe(true) expect(EventManifest.Latest.has("global.disposed")).toBe(true) + expect(EventManifest.Latest.has("global.config.updated")).toBe(true) // kilocode_change }) test("contains only the current step settlement versions", () => { diff --git a/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json index 5f966e73ae..f0dec0ba52 100644 --- a/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/kilocode/session/native-openai-oauth-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, @@ -38,7 +38,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, diff --git a/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json b/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json index cf535b73a0..cf369d8c28 100644 --- a/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json +++ b/packages/opencode/test/fixtures/recordings/kilocode/session/native-zen-tool-loop.json @@ -22,7 +22,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, @@ -40,7 +40,7 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"You are Kilo, a highly skilled software engineer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.\\n\\n# Personality\\n\\n- Your goal is to accomplish the user's task, NOT engage in a back and forth conversation.\\n- You accomplish tasks iteratively, breaking them down into clear steps and working through them methodically.\\n- Do not ask for more information than necessary. Use the tools provided to accomplish the user's request efficiently and effectively.\\n- Use the `question` tool only when you need an actual answer from the user.\\n- You are STRICTLY FORBIDDEN from starting your messages with \\\"Great\\\", \\\"Certainly\\\", \\\"Okay\\\", \\\"Sure\\\". You should NOT be conversational in your responses, but rather direct and to the point. For example you should NOT say \\\"Great, I've updated the CSS\\\" but instead something like \\\"I've updated the CSS\\\". It is important you be clear and technical in your messages.\\n- NEVER end your result with a question or request to engage in further conversation. Formulate the end of your result in a way that is final and does not require further input from the user.\\n- The user may provide feedback, which you can use to make improvements and try again. But DO NOT continue in pointless back and forth conversations, i.e. don't end your responses with questions or offers for further assistance.\\n\\n# Code\\n\\n- When making changes to code, always consider the context in which the code is being used. Ensure that your changes are compatible with the existing codebase and that they follow the project's coding standards and best practices.\\nAnswer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_DfI0RwTrlaizfnQ9zkJC8rks\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"prompt_cache_key\":\"session-recorded-opencode-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true,\"include\":[\"reasoning.encrypted_content\"]}" }, "response": { "status": 200, diff --git a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts index d42ae14903..20e67cae66 100644 --- a/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts +++ b/packages/opencode/test/kilocode/compaction-payload-recovery.test.ts @@ -1,34 +1,28 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, mock, test } from "bun:test" import { APICallError } from "ai" import { Effect, Layer, ManagedRuntime, Scope } from "effect" import * as Stream from "effect/Stream" import { LLMEvent, type LLMEvent as Event } from "@opencode-ai/llm" -import { Database } from "@opencode-ai/core/database/database" -import { Agent } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" -import { EventV2Bridge } from "../../src/event-v2-bridge" -import { Image } from "../../src/image/image" import { KiloCompactionPayloadRecovery } from "../../src/kilocode/session/compaction-payload-recovery" import { KiloSessionCompaction } from "../../src/kilocode/session/compaction" -import { Permission } from "../../src/permission" -import { Plugin } from "../../src/plugin" import { provideTestInstance } from "../fixture/fixture" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { Snapshot } from "../../src/snapshot" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" -import * as SessionProcessorModule from "../../src/session/processor" import { Session as SessionNs } from "../../src/session/session" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { SessionCompaction } from "../../src/session/compaction" -import { SessionStatus } from "../../src/session/status" import { SessionSummary } from "../../src/session/summary" import { SyncEvent } from "../../src/sync" import { ProviderTest } from "../fake/provider" +import { Provider } from "../../src/provider/provider" import { tmpdir } from "../fixture/fixture" const sessionID = SessionID.make("ses_payload_recovery") @@ -68,6 +62,7 @@ const summary = Layer.succeed( computeDiff: () => Effect.succeed([]), }), ) +const summaryNode = LayerNode.make({ service: SessionSummary.Service, layer: summary, deps: [] }) function base(id: MessageID) { return { @@ -156,29 +151,26 @@ function reply( const scope = Layer.effect(Scope.Scope, Scope.make()) function runtime(layer: Layer.Layer, config = AppNodeBuilder.build(Config.node)) { - const bus = Bus.layer - const status = AppNodeBuilder.build(SessionStatus.node) - const processor = AppNodeBuilder.build(SessionProcessorModule.SessionProcessor.node, [ - [SessionSummary.node, summary], - ]) const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 100_000, output: 32_000 } }) + const llm = LayerNode.make({ service: LLM.Service, layer, deps: [] }) + const cfg = LayerNode.make({ service: Config.Service, layer: config, deps: [] }) + const provider = LayerNode.make({ + service: Provider.Service, + layer: ProviderTest.fake({ model }).layer, + deps: [], + }) + const flags = LayerNode.make({ service: RuntimeFlags.Service, layer: RuntimeFlags.layer(), deps: [] }) return ManagedRuntime.make( - Layer.mergeAll(AppNodeBuilder.build(SessionCompaction.node, [[SessionProcessorModule.SessionProcessor.node, processor], [SessionSummary.node, summary]]), processor, bus, status).pipe( - Layer.provide(ProviderTest.fake({ model }).layer), - Layer.provideMerge(AppNodeBuilder.build(SessionNs.node)), - Layer.provide(AppNodeBuilder.build(Snapshot.node)), - Layer.provide(layer), - Layer.provide(AppNodeBuilder.build(Permission.node)), - Layer.provide(AppNodeBuilder.build(Agent.node)), - Layer.provide(AppNodeBuilder.build(Plugin.node)), - Layer.provide(status), - Layer.provide(bus), - Layer.provide(config), - Layer.provide(RuntimeFlags.layer()), + LayerNode.compile(LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node]), [ + [LLM.node, llm], + [Config.node, cfg], + [Provider.node, provider], + [RuntimeFlags.node, flags], + [SessionSummary.node, summaryNode], + ]).pipe( + Layer.provide(Bus.layer), Layer.provide(scope), Layer.provide(SyncEvent.defaultLayer), - Layer.provide(AppNodeBuilder.build(EventV2Bridge.node)), - Layer.provide(AppNodeBuilder.build(Database.node)), ), ) } @@ -268,132 +260,136 @@ describe("KiloCompactionPayloadRecovery", () => { } }) - test("retries compaction without media and tool outputs after payload-size failure", async () => { - await using tmp = await tmpdir({ git: true }) - const stub = llm() - const captures: string[] = [] - stub.push((input) => { - captures.push(JSON.stringify(input.messages)) - return Stream.fail( - new APICallError({ - message: "Request Entity Too Large", - url: "https://api.kilo.ai/api/openrouter/responses", - requestBodyValues: {}, - statusCode: 413, - responseHeaders: { "content-type": "text/plain" }, - responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE", - isRetryable: false, - }), - ) - }) - stub.push( - reply("summary", (input) => { + test( + "retries compaction without media and tool outputs after payload-size failure", + async () => { + await using tmp = await tmpdir({ git: true }) + const stub = llm() + const captures: string[] = [] + stub.push((input) => { captures.push(JSON.stringify(input.messages)) - }), - ) - - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const rt = runtime(stub.layer, AppNodeBuilder.build(Config.node)) - const svc = service(rt) - const session = await svc.create({}) - const old = await user(svc, session.id, "old image turn") - await svc.updatePart({ - id: PartID.ascending(), - messageID: old.id, - sessionID: session.id, - type: "file", - mime: "image/png", - filename: "old.png", - url: `data:image/png;base64,${"a".repeat(8_000)}`, - }) - const oldReply = await assistant(svc, session.id, old.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: oldReply.id, - sessionID: session.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "old output".repeat(10_000), - title: "old", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - await user(svc, session.id, "latest turn") - const keep = await user(svc, session.id, "preserved tail turn") - const keepReply = await assistant(svc, session.id, keep.id, tmp.path) - await svc.updatePart({ - id: PartID.ascending(), - messageID: keepReply.id, - sessionID: session.id, - type: "tool", - callID: crypto.randomUUID(), - tool: "bash", - state: { - status: "completed", - input: {}, - output: "keep output", - title: "keep", - metadata: {}, - time: { start: Date.now(), end: Date.now() }, - }, - }) - await Effect.runPromise( - KiloSessionCompaction.create({ - session: { - updateMessage: (msg) => Effect.promise(() => svc.updateMessage(msg)), - updatePart: (part) => Effect.promise(() => svc.updatePart(part)), - }, - sessionID: session.id, - agent: "build", - model: ref, - auto: false, + return Stream.fail( + new APICallError({ + message: "Request Entity Too Large", + url: "https://api.kilo.ai/api/openrouter/responses", + requestBodyValues: {}, + statusCode: 413, + responseHeaders: { "content-type": "text/plain" }, + responseBody: "Request Entity Too Large\n\nFUNCTION_PAYLOAD_TOO_LARGE", + isRetryable: false, }), ) + }) + stub.push( + reply("summary", (input) => { + captures.push(JSON.stringify(input.messages)) + }), + ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const rt = runtime(stub.layer, AppNodeBuilder.build(Config.node)) + const svc = service(rt) + const session = await svc.create({}) + const old = await user(svc, session.id, "old image turn") + await svc.updatePart({ + id: PartID.ascending(), + messageID: old.id, + sessionID: session.id, + type: "file", + mime: "image/png", + filename: "old.png", + url: `data:image/png;base64,${"a".repeat(8_000)}`, + }) + const oldReply = await assistant(svc, session.id, old.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: oldReply.id, + sessionID: session.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "old output".repeat(10_000), + title: "old", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + await user(svc, session.id, "latest turn") + const keep = await user(svc, session.id, "preserved tail turn") + const keepReply = await assistant(svc, session.id, keep.id, tmp.path) + await svc.updatePart({ + id: PartID.ascending(), + messageID: keepReply.id, + sessionID: session.id, + type: "tool", + callID: crypto.randomUUID(), + tool: "bash", + state: { + status: "completed", + input: {}, + output: "keep output", + title: "keep", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: { + updateMessage: (msg) => Effect.promise(() => svc.updateMessage(msg)), + updatePart: (part) => Effect.promise(() => svc.updatePart(part)), + }, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), ) - expect(result).toBe("continue") - expect(captures).toHaveLength(2) - expect(captures[0]).toContain("Attached image/png: old.png") - expect(captures[0]).toContain("old output") - expect(captures[0]).not.toContain("keep output") - expect(captures[1]).not.toContain("data:image/png;base64") - expect(captures[1]).not.toContain("old output") - expect(captures[1]).not.toContain("keep output") - expect(captures[1]).toContain("Attached image/png: old.png") - expect(captures[1]).toContain("Old tool result content cleared") - const tools = (await svc.messages({ sessionID: session.id })) - .flatMap((msg) => msg.parts) - .filter((part): part is MessageV2.ToolPart => part.type === "tool") - expect(tools).toHaveLength(2) - expect(tools[0]?.type).toBe("tool") - if (tools[0]?.state.status === "completed") expect(tools[0].state.time.compacted).toBeNumber() - expect(tools[1]?.type).toBe("tool") - if (tools[1]?.state.status === "completed") expect(tools[1].state.time.compacted).toBeUndefined() - } finally { - await rt.dispose() - } - }, - }) - }) + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), + ) + + expect(result).toBe("continue") + expect(captures).toHaveLength(2) + expect(captures[0]).toContain("Attached image/png: old.png") + expect(captures[0]).toContain("old output") + expect(captures[0]).not.toContain("keep output") + expect(captures[1]).not.toContain("data:image/png;base64") + expect(captures[1]).not.toContain("old output") + expect(captures[1]).not.toContain("keep output") + expect(captures[1]).toContain("Attached image/png: old.png") + expect(captures[1]).toContain("Old tool result content cleared") + const tools = (await svc.messages({ sessionID: session.id })) + .flatMap((msg) => msg.parts) + .filter((part): part is MessageV2.ToolPart => part.type === "tool") + expect(tools).toHaveLength(2) + expect(tools[0]?.type).toBe("tool") + if (tools[0]?.state.status === "completed") expect(tools[0].state.time.compacted).toBeNumber() + expect(tools[1]?.type).toBe("tool") + if (tools[1]?.state.status === "completed") expect(tools[1].state.time.compacted).toBeUndefined() + } finally { + await rt.dispose() + } + }, + }) + }, + 30_000, + ) }) diff --git a/packages/opencode/test/kilocode/cost-propagation.test.ts b/packages/opencode/test/kilocode/cost-propagation.test.ts index 858c6ac280..5fe70b2475 100644 --- a/packages/opencode/test/kilocode/cost-propagation.test.ts +++ b/packages/opencode/test/kilocode/cost-propagation.test.ts @@ -2,15 +2,15 @@ // the same parent assistant message. Without the internal lock, parallel // subagent completions race on read-modify-write and lose deltas (#6321). -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { afterEach, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Bus } from "../../src/bus" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" import { KiloCostPropagation } from "../../src/kilocode/session/cost-propagation" -import { Instance } from "../../src/kilocode/instance" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Session } from "../../src/session/session" import { MessageV2 } from "../../src/session/message-v2" import { MessageID } from "../../src/session/schema" @@ -31,7 +31,16 @@ const ref = { } const it = testEffect( - Layer.mergeAll(AppNodeBuilder.build(Session.node), Bus.layer, AppNodeBuilder.build(Database.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), + LayerNode.compile( + LayerNode.group([ + Session.node, + SessionProjector.node, + MessageV2.node, + Bus.node, + Database.node, + CrossSpawnSpawner.node, + ]), + ), ) const seed = Effect.fn("CostPropagationTest.seed")(function* () { diff --git a/packages/opencode/test/kilocode/fixture/stall-transport.ts b/packages/opencode/test/kilocode/fixture/stall-transport.ts index 8f9c145c16..7e375020df 100644 --- a/packages/opencode/test/kilocode/fixture/stall-transport.ts +++ b/packages/opencode/test/kilocode/fixture/stall-transport.ts @@ -16,6 +16,8 @@ // Progress is mirrored to a JSON file so tests can assert what the provider saw // without sharing module state with the plugin that loads this file. +import { rename } from "node:fs/promises" + export type StallState = { calls: number; stalls: number; recovered: number } const HEAD = { id: "chatcmpl-stall", object: "chat.completion.chunk", created: 0, model: "mock-model" } @@ -84,7 +86,16 @@ function stalling() { export function createStallTransport(input: { state: string; answer?: string; command?: string }) { const state: StallState = { calls: 0, stalls: 0, recovered: 0 } - const save = () => Bun.write(input.state, JSON.stringify(state)) + let pending = Promise.resolve() + const save = () => { + const json = JSON.stringify(state) + const tmp = `${input.state}.${crypto.randomUUID()}.tmp` + pending = pending.then(async () => { + await Bun.write(tmp, json) + await rename(tmp, input.state) + }) + return pending + } return async (_input: unknown, init?: { body?: unknown }) => { const body = typeof init?.body === "string" ? init.body : "" diff --git a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts index 15d05d993d..e3861069e9 100644 --- a/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts +++ b/packages/opencode/test/kilocode/instance-vcs-watcher.test.ts @@ -4,12 +4,17 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Deferred, Effect, Fiber, Layer } from "effect" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { Git } from "../../src/git" +import { InstanceBootstrap } from "../../src/project/bootstrap" import { InstanceStore } from "../../src/project/instance-store" import { KilocodeWatcher } from "../../src/kilocode/watcher" import { tmpdirScoped } from "../fixture/fixture" import { awaitWithTimeout, testEffect } from "../lib/effect" -const layer = Layer.mergeAll(AppNodeBuilder.build(InstanceStore.node), AppNodeBuilder.build(Git.node), AppNodeBuilder.build(CrossSpawnSpawner.node)) +const layer = Layer.mergeAll( + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, InstanceBootstrap.node]]), + AppNodeBuilder.build(Git.node), + AppNodeBuilder.build(CrossSpawnSpawner.node), +) const it = testEffect(layer) // The suite disables the file watcher (see test/preload.ts); this file tests it, so opt back in. diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 201adcb7d3..cb6767a1e7 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -77,6 +77,7 @@ const realToast = await import("@tui/ui/toast") const realEvent = await import("@tui/context/event") const realRoute = await import("@tui/context/route") const realRuntime = await import("@tui/context/runtime") +const realPermission = await import("@tui/context/permission") let capturedInit: (() => any) | undefined @@ -169,6 +170,11 @@ mock.module("@tui/context/route", () => ({ useRoute: () => ({ data: { type: "home" }, navigate: () => {} }), })) +mock.module("@tui/context/permission", () => ({ + ...realPermission, + usePermission: () => ({ mode: "normal", set: () => {}, toggle: () => {} }), +})) + // Import the real Global to get the state path (set by test preload via XDG_STATE_HOME) const { Global } = await import("@opencode-ai/core/global") const modelJsonPath = path.join(Global.Path.state, "model.json") diff --git a/packages/opencode/test/kilocode/plan-exit-detection.test.ts b/packages/opencode/test/kilocode/plan-exit-detection.test.ts index 281556f858..a200147a80 100644 --- a/packages/opencode/test/kilocode/plan-exit-detection.test.ts +++ b/packages/opencode/test/kilocode/plan-exit-detection.test.ts @@ -7,6 +7,8 @@ import { Identifier } from "../../src/id/id" import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { Instance } from "../../src/kilocode/instance" import { provideTestInstance } from "../fixture/fixture" import { PlanFollowup } from "../../src/kilocode/plan-followup" @@ -21,7 +23,10 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) -const session = makeRuntime(Session.Service, Session.defaultLayer) +const session = makeRuntime( + Session.Service, + LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])), +) const sessions = { create: (input?: Parameters[0]) => session.runPromise((svc) => svc.create(input)), diff --git a/packages/opencode/test/kilocode/plan-file.test.ts b/packages/opencode/test/kilocode/plan-file.test.ts index 5c54249726..b0719e9754 100644 --- a/packages/opencode/test/kilocode/plan-file.test.ts +++ b/packages/opencode/test/kilocode/plan-file.test.ts @@ -1,6 +1,6 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect, test } from "bun:test" -import { Effect, Layer, ManagedRuntime } from "effect" +import { Effect, ManagedRuntime } from "effect" import path from "path" import { Agent } from "../../src/agent/agent" import { PlanFile } from "../../src/kilocode/plan-file" @@ -10,12 +10,15 @@ import { Session } from "../../src/session/session" import { MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { PlanExitTool } from "../../src/tool/plan" import { Tool } from "../../src/tool/tool" import { Truncate } from "../../src/tool/truncate" import { tmpdir } from "../fixture/fixture" -const rt = ManagedRuntime.make(Layer.mergeAll(AppNodeBuilder.build(Agent.node), AppNodeBuilder.build(Session.node), AppNodeBuilder.build(Truncate.node))) +const rt = ManagedRuntime.make( + LayerNode.compile(LayerNode.group([Agent.node, Session.node, SessionProjector.node, Truncate.node])), +) async function init() { return rt.runPromise( diff --git a/packages/opencode/test/kilocode/plan-followup.test.ts b/packages/opencode/test/kilocode/plan-followup.test.ts index 61c804b586..154956042e 100644 --- a/packages/opencode/test/kilocode/plan-followup.test.ts +++ b/packages/opencode/test/kilocode/plan-followup.test.ts @@ -11,6 +11,8 @@ import { SessionID, MessageID, PartID } from "../../src/session/schema" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { EventV2 } from "@opencode-ai/core/event" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { formatTodos, generateHandover, PlanFollowup, PlanFollowupRuntime } from "../../src/kilocode/plan-followup" import { Instance } from "../../src/kilocode/instance" import * as KiloInstance from "../../src/kilocode/instance" @@ -67,7 +69,10 @@ const todo = { }, } -const session = makeRuntime(Session.Service, Session.defaultLayer) +const session = makeRuntime( + Session.Service, + LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])), +) const store = { create: (input?: Parameters[0]) => session.runPromise((svc) => svc.create(input)), get: (id: SessionID) => session.runPromise((svc) => svc.get(id)), diff --git a/packages/opencode/test/kilocode/project/instance-store.test.ts b/packages/opencode/test/kilocode/project/instance-store.test.ts index 2c73bca5bf..4528010d40 100644 --- a/packages/opencode/test/kilocode/project/instance-store.test.ts +++ b/packages/opencode/test/kilocode/project/instance-store.test.ts @@ -11,7 +11,10 @@ import { awaitWithTimeout, testEffect } from "../../lib/effect" const bootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) const it = testEffect( - Layer.mergeAll(AppNodeBuilder.build(InstanceStore.node), AppNodeBuilder.build(CrossSpawnSpawner.node)).pipe(Layer.provide(bootstrap)), + Layer.mergeAll( + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, bootstrap]]), + AppNodeBuilder.build(CrossSpawnSpawner.node), + ), ) const register = (disposer: (directory: string) => Promise) => diff --git a/packages/opencode/test/kilocode/question-option-schema.test.ts b/packages/opencode/test/kilocode/question-option-schema.test.ts index 939dec436b..d13ead285b 100644 --- a/packages/opencode/test/kilocode/question-option-schema.test.ts +++ b/packages/opencode/test/kilocode/question-option-schema.test.ts @@ -25,7 +25,7 @@ import fs from "node:fs" import path from "node:path" import { Option } from "../../src/question" -const SOURCE = path.resolve(import.meta.dir, "../../src/question/index.ts") +const SOURCE = path.resolve(import.meta.dir, "../../../schema/src/v1/question.ts") describe("QuestionOption schema — Kilo-specific field contract", () => { test("Option class accepts and round-trips the mode field", () => { @@ -56,14 +56,14 @@ describe("QuestionOption schema — Kilo-specific field contract", () => { // resolution that drops the fields is caught immediately. test("source declares mode as an optional field inside a kilocode_change block", () => { const src = fs.readFileSync(SOURCE, "utf-8") - expect(src).toMatch(/kilocode_change start[^\n]*hint to UI clients/) + expect(src).toMatch(/kilocode_change start[^\n]*localization and mode selection hints/) expect(src).toMatch(/mode:\s*Schema\.optional\(Schema\.String\)/) expect(src).toMatch(/kilocode_change end/) }) test("source declares labelKey and descriptionKey inside a kilocode_change block", () => { const src = fs.readFileSync(SOURCE, "utf-8") - expect(src).toMatch(/kilocode_change start[^\n]*i18n keys/) + expect(src).toMatch(/kilocode_change start[^\n]*localization and mode selection hints/) expect(src).toMatch(/labelKey:\s*Schema\.optional\(Schema\.String\)/) expect(src).toMatch(/descriptionKey:\s*Schema\.optional\(Schema\.String\)/) }) diff --git a/packages/opencode/test/kilocode/recall-search.test.ts b/packages/opencode/test/kilocode/recall-search.test.ts index 65383420fa..843ca468a0 100644 --- a/packages/opencode/test/kilocode/recall-search.test.ts +++ b/packages/opencode/test/kilocode/recall-search.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { RecallSearch } from "../../src/kilocode/session/recall-search" import { Instance } from "../../src/kilocode/instance" import { Session } from "../../src/session/session" @@ -16,7 +17,7 @@ import { testEffect } from "../lib/effect" type Stored = T extends unknown ? Omit : never -const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Session.node), AppNodeBuilder.build(Database.node))) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const add = Effect.fn("RecallSearchTest.add")(function* ( sessionID: SessionID, diff --git a/packages/opencode/test/kilocode/server/cloud-session-import.test.ts b/packages/opencode/test/kilocode/server/cloud-session-import.test.ts index 85de22ee6c..8ee2eab3c5 100644 --- a/packages/opencode/test/kilocode/server/cloud-session-import.test.ts +++ b/packages/opencode/test/kilocode/server/cloud-session-import.test.ts @@ -106,9 +106,19 @@ function data(diff = false) { async function request(directory: string, body: unknown) { const auth = process.env.KILO_AUTH_CONTENT + const fetcher = globalThis.fetch const token = `test-${crypto.randomUUID()}` bodies.set(token, body) process.env.KILO_AUTH_CONTENT = JSON.stringify({ kilo: { type: "api", key: token } }) + globalThis.fetch = Object.assign( + (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => { + const req = new Request(input, init) + const url = new URL(req.url) + if (url.pathname !== "/api/session/ses_cloud/export") return fetcher(req) + return fetcher(new Request(`${ingest.url.origin}${url.pathname}${url.search}`, req)) + }, + { preconnect: fetcher.preconnect }, + ) try { return await HttpApiApp.webHandler().handler( new Request(`http://localhost${KiloGatewayPaths.cloudSessionImport}`, { @@ -120,6 +130,7 @@ async function request(directory: string, body: unknown) { ) } finally { bodies.delete(token) + globalThis.fetch = fetcher if (auth === undefined) delete process.env.KILO_AUTH_CONTENT else process.env.KILO_AUTH_CONTENT = auth } diff --git a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts index f0766ad523..a9ee6d5b48 100644 --- a/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts +++ b/packages/opencode/test/kilocode/server/permission-allow-everything.test.ts @@ -1,28 +1,29 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, test } from "bun:test" import { Flag } from "@opencode-ai/core/flag/flag" -import { Cause, Effect, Exit, Fiber, Layer } from "effect" +import { Cause, Effect, Exit, Fiber } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Bus } from "../../../src/bus" import * as Config from "../../../src/config/config" import { AllowEverythingPermission } from "../../../src/kilocode/permission/allow-everything" import { Permission } from "../../../src/permission" -import { EventV2Bridge } from "../../../src/event-v2-bridge" import { PermissionV1 } from "@opencode-ai/core/v1/permission" -import { Database } from "@opencode-ai/core/database/database" import { provideTestInstance } from "../../fixture/fixture" import { Server } from "../../../src/server/server" import { Session } from "../../../src/session/session" import { provideTmpdirInstance, tmpdir } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const bus = Bus.layer -const env = Layer.mergeAll( - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Config.node), - AppNodeBuilder.build(Session.node), - bus, - AppNodeBuilder.build(CrossSpawnSpawner.node), +const env = LayerNode.compile( + LayerNode.group([ + Permission.node, + Config.node, + Session.node, + SessionProjector.node, + Bus.node, + CrossSpawnSpawner.node, + ]), ) const it = testEffect(env) const original = { diff --git a/packages/opencode/test/kilocode/session-compaction-cap.test.ts b/packages/opencode/test/kilocode/session-compaction-cap.test.ts index 7567e29abc..e8fa8f4014 100644 --- a/packages/opencode/test/kilocode/session-compaction-cap.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-cap.test.ts @@ -3,17 +3,15 @@ // overflows the model context, and that the exhausted turn surfaces as an // error (rather than silently completing). -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" import { Deferred, Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -35,7 +33,6 @@ import { Provider as ProviderSvc } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Question } from "../../src/question" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -52,8 +49,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import * as Log from "@opencode-ai/core/util/log" @@ -133,72 +128,59 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const runState = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + serverNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(BackgroundJob.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(LLM.node), - AppNodeBuilder.build(Env.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Command.node), - AppNodeBuilder.build(Permission.node), - plugin, - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(ProviderSvc.node), - lsp, - mcp, - AppNodeBuilder.build(FSUtil.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = AppNodeBuilder.build(ToolRegistry.node, [[KiloSessions.node, KiloSessions.testLayer]]) - const trunc = AppNodeBuilder.build(Truncate.node) - const proc = AppNodeBuilder.build(SessionProcessor.node, [[SessionSummary.node, summary]]) - const compact = AppNodeBuilder.build(SessionCompaction.node, [ - [SessionProcessor.node, proc], + return LayerNode.compile(root, [ [SessionSummary.node, summary], + [Plugin.node, plugin], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer()], + [KiloSessions.node, KiloSessions.testLayer], ]) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(AppNodeBuilder.build(SessionRevert.node)), - Layer.provide(AppNodeBuilder.build(Image.node)), - Layer.provide(summary), - Layer.provideMerge(runState), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(AppNodeBuilder.build(Instruction.node)), - Layer.provide(AppNodeBuilder.build(SystemPrompt.node)), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(BackgroundJob.node), - Bus.layer, - infra, - AppNodeBuilder.build(Storage.node), - ), - ), - ) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts index d9a7e1b195..ecc1aa5855 100644 --- a/packages/opencode/test/kilocode/session-compaction-chunks.test.ts +++ b/packages/opencode/test/kilocode/session-compaction-chunks.test.ts @@ -1,4 +1,4 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterAll, afterEach, beforeAll, describe, expect, mock, test } from "bun:test" import { Effect, Layer, ManagedRuntime } from "effect" import fs from "fs/promises" @@ -35,6 +35,7 @@ import { ProviderTest } from "../fake/provider" import { tmpdir } from "../fixture/fixture" import { Flag } from "@opencode-ai/core/flag/flag" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { remove as cleanup } from "./cleanup" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Provider } from "../../src/provider/provider" @@ -47,6 +48,8 @@ const agents = Layer.mock(Agent.Service)({ }) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-compaction-chunks-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([SessionNs.node, SessionProjector.node])) +const runtime = makeRuntime(SessionNs.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -54,16 +57,13 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous await Promise.all([dbfile, `${dbfile}-wal`, `${dbfile}-shm`].map(cleanup)) }) -function run(fx: Effect.Effect) { - return Effect.runPromise(fx.pipe(Effect.provide(AppNodeBuilder.build(SessionNs.node)))) -} - const store = { updateMessage: (msg: T) => Effect.promise(() => svc.updateMessage(msg)), updatePart: (part: T) => Effect.promise(() => svc.updatePart(part)), @@ -71,16 +71,16 @@ const store = { const svc = { create(input?: SessionNs.CreateInput) { - return run(SessionNs.Service.use((svc) => svc.create(input))) + return runtime.runPromise((svc) => svc.create(input)) }, messages(input: Parameters[0]) { - return run(SessionNs.Service.use((svc) => svc.messages(input))) + return runtime.runPromise((svc) => svc.messages(input)) }, updateMessage(msg: T) { - return run(SessionNs.Service.use((svc) => svc.updateMessage(msg))) + return runtime.runPromise((svc) => svc.updateMessage(msg)) }, updatePart(part: T) { - return run(SessionNs.Service.use((svc) => svc.updatePart(part))) + return runtime.runPromise((svc) => svc.updatePart(part)) }, } @@ -224,13 +224,18 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error }) }), ) + const processorNode = LayerNode.make({ + service: SessionProcessorModule.SessionProcessor.Service, + layer: processor, + deps: [SessionNs.node], + }) const model = ProviderTest.model({ providerID, id: modelID, limit: { context: 10_000, output: 1_000 } }) return { calls, outputs, rt: ManagedRuntime.make( - AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Bus.node]), [ - [SessionProcessorModule.SessionProcessor.node, processor], + LayerNode.compile(LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node]), [ + [SessionProcessorModule.SessionProcessor.node, processorNode], [Provider.node, ProviderTest.fake({ model }).layer], [Agent.node, agents], [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], @@ -238,6 +243,7 @@ function fakeRuntime(outputTokenMax?: number, error?: MessageV2.Assistant["error Config.node, Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), }), ], ]), @@ -290,8 +296,15 @@ async function failure(error?: MessageV2.Assistant["error"], empty = false) { function liveRuntime(layer: Layer.Layer, context = 10_000) { const model = ProviderTest.model({ providerID, id: modelID, limit: { context, output: 1_000 } }) return ManagedRuntime.make( - AppNodeBuilder.build( - LayerNode.group([SessionCompaction.node, SessionProcessorModule.SessionProcessor.node, Bus.node, SessionStatus.node]), + LayerNode.compile( + LayerNode.group([ + SessionCompaction.node, + SessionProcessorModule.SessionProcessor.node, + SessionNs.node, + SessionProjector.node, + Bus.node, + SessionStatus.node, + ]), [ [SessionSummary.node, summary], [Provider.node, ProviderTest.fake({ model }).layer], @@ -301,6 +314,7 @@ function liveRuntime(layer: Layer.Layer, context = 10_000) { Config.node, Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), }), ], ], @@ -698,82 +712,85 @@ describe("KiloCompactionChunks", () => { }) }) - test( - "compaction must not leak maxOutputTokens into agent options", - async () => { - await using tmp = await tmpdir() - await provideTestInstance({ - directory: tmp.path, - fn: async () => { - const session = await svc.create({}) - const first = await user(session.id, "first " + "a".repeat(20_000)) - await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) - const second = await user(session.id, "second " + "c".repeat(20_000)) - await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(20_000)) - await Effect.runPromise( - KiloSessionCompaction.create({ - session: store, - sessionID: session.id, - agent: "build", - model: ref, - auto: false, - }), - ) + test("compaction must not leak maxOutputTokens into agent options", async () => { + await using tmp = await tmpdir() + await provideTestInstance({ + directory: tmp.path, + fn: async () => { + const session = await svc.create({}) + const first = await user(session.id, "first " + "a".repeat(20_000)) + await assistant(session.id, first.id, tmp.path, "reply " + "b".repeat(20_000)) + const second = await user(session.id, "second " + "c".repeat(20_000)) + await assistant(session.id, second.id, tmp.path, "reply " + "d".repeat(20_000)) + await Effect.runPromise( + KiloSessionCompaction.create({ + session: store, + sessionID: session.id, + agent: "build", + model: ref, + auto: false, + }), + ) - const captured: Array<{ opts: Record; modelLimitOutput: number }> = [] - const bus = Bus.layer - const processor = Layer.effect( - SessionProcessorModule.SessionProcessor.Service, - Effect.gen(function* () { - const sessions = yield* SessionNs.Service - return SessionProcessorModule.SessionProcessor.Service.of({ - create: Effect.fn("TestSessionProcessorLeak.create")((input) => - Effect.succeed({ - get message() { - return input.assistantMessage - }, - updateToolCall: Effect.fn("TestSessionProcessorLeak.updateToolCall")(() => - Effect.succeed(undefined), - ), - metadata: Effect.fn("TestSessionProcessorLeak.metadata")(() => Effect.void), - completeToolCall: Effect.fn("TestSessionProcessorLeak.completeToolCall")(() => Effect.void), - process: Effect.fn("TestSessionProcessorLeak.process")((stream: LLM.StreamInput) => - Effect.gen(function* () { - captured.push({ - opts: stream.agent.options as Record, - modelLimitOutput: stream.model.limit.output, - }) - const text = stream.messages.some((msg) => - JSON.stringify(msg).includes("Create a new anchored summary"), - ) - ? "final summary" - : "chunk summary" - yield* sessions.updatePart({ - id: PartID.ascending(), - messageID: input.assistantMessage.id, - sessionID: input.sessionID, - type: "text", - text, - }) - input.assistantMessage.finish = "stop" - return "continue" as const - }), - ), - } satisfies SessionProcessor.Handle), - ), - }) - }), - ) + const captured: Array<{ opts: Record; modelLimitOutput: number }> = [] + const bus = Bus.layer + const processor = Layer.effect( + SessionProcessorModule.SessionProcessor.Service, + Effect.gen(function* () { + const sessions = yield* SessionNs.Service + return SessionProcessorModule.SessionProcessor.Service.of({ + create: Effect.fn("TestSessionProcessorLeak.create")((input) => + Effect.succeed({ + get message() { + return input.assistantMessage + }, + updateToolCall: Effect.fn("TestSessionProcessorLeak.updateToolCall")(() => Effect.succeed(undefined)), + metadata: Effect.fn("TestSessionProcessorLeak.metadata")(() => Effect.void), + completeToolCall: Effect.fn("TestSessionProcessorLeak.completeToolCall")(() => Effect.void), + process: Effect.fn("TestSessionProcessorLeak.process")((stream: LLM.StreamInput) => + Effect.gen(function* () { + captured.push({ + opts: stream.agent.options as Record, + modelLimitOutput: stream.model.limit.output, + }) + const text = stream.messages.some((msg) => + JSON.stringify(msg).includes("Create a new anchored summary"), + ) + ? "final summary" + : "chunk summary" + yield* sessions.updatePart({ + id: PartID.ascending(), + messageID: input.assistantMessage.id, + sessionID: input.sessionID, + type: "text", + text, + }) + input.assistantMessage.finish = "stop" + return "continue" as const + }), + ), + } satisfies SessionProcessor.Handle), + ), + }) + }), + ) - const model = ProviderTest.model({ - providerID, - id: modelID, - limit: { context: 10_000, output: 1_000 }, - }) - const outputTokenMax = 512 - const rt = ManagedRuntime.make( - AppNodeBuilder.build(LayerNode.group([SessionCompaction.node, Bus.node]), [ - [SessionProcessorModule.SessionProcessor.node, processor], + const model = ProviderTest.model({ + providerID, + id: modelID, + limit: { context: 10_000, output: 1_000 }, + }) + const processorNode = LayerNode.make({ + service: SessionProcessorModule.SessionProcessor.Service, + layer: processor, + deps: [SessionNs.node], + }) + const outputTokenMax = 512 + const rt = ManagedRuntime.make( + LayerNode.compile( + LayerNode.group([SessionCompaction.node, SessionNs.node, SessionProjector.node, Bus.node]), + [ + [SessionProcessorModule.SessionProcessor.node, processorNode], [Provider.node, ProviderTest.fake({ model }).layer], [Agent.node, agents], [RuntimeFlags.node, RuntimeFlags.layer({ outputTokenMax })], @@ -781,49 +798,49 @@ describe("KiloCompactionChunks", () => { Config.node, Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...{}, compaction: { reserved: 1_000 } }), + directories: () => Effect.succeed([]), }), ], - ]), + ], + ), + ) + + try { + const msgs = await svc.messages({ sessionID: session.id }) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: parent!, + messages: msgs, + sessionID: session.id, + auto: false, + }), + ), ) - try { - const msgs = await svc.messages({ sessionID: session.id }) - const parent = msgs.at(-1)?.info.id - expect(parent).toBeTruthy() - const result = await rt.runPromise( - SessionCompaction.Service.use((svc) => - svc.process({ - parentID: parent!, - messages: msgs, - sessionID: session.id, - auto: false, - }), - ), - ) - - expect(result).toBe("continue") - expect(captured.length).toBeGreaterThan(0) - // Negative assertion (the bug surfacing): - // maxOutputTokens must not appear in agent.options that the - // worker hands to the LLM. Today a strict OpenAI-compatible - // upstream rejects that field with - // Unsupported parameter(s): maxOutputTokens`. - for (const c of captured) { - expect(c.opts.maxOutputTokens).toBeUndefined() - } - // Positive assertion (budget preserved through an independent path): - // the constrained model still threads a tightened output limit - // through to every worker. If a future "fix" accidentally severs - // the only budget source along with the leak, this fails. - for (const c of captured) { - expect(c.modelLimitOutput).toBeLessThanOrEqual(outputTokenMax) - } - } finally { - await rt.dispose() + expect(result).toBe("continue") + expect(captured.length).toBeGreaterThan(0) + // Negative assertion (the bug surfacing): + // maxOutputTokens must not appear in agent.options that the + // worker hands to the LLM. Today a strict OpenAI-compatible + // upstream rejects that field with + // Unsupported parameter(s): maxOutputTokens`. + for (const c of captured) { + expect(c.opts.maxOutputTokens).toBeUndefined() } - }, - }) - }, - 30_000, - ) + // Positive assertion (budget preserved through an independent path): + // the constrained model still threads a tightened output limit + // through to every worker. If a future "fix" accidentally severs + // the only budget source along with the leak, this fails. + for (const c of captured) { + expect(c.modelLimitOutput).toBeLessThanOrEqual(outputTokenMax) + } + } finally { + await rt.dispose() + } + }, + }) + }, 30_000) }) diff --git a/packages/opencode/test/kilocode/session-fork-remap.test.ts b/packages/opencode/test/kilocode/session-fork-remap.test.ts index 46f90753e0..5d0586988c 100644 --- a/packages/opencode/test/kilocode/session-fork-remap.test.ts +++ b/packages/opencode/test/kilocode/session-fork-remap.test.ts @@ -1,4 +1,6 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test" import { Effect } from "effect" import { HttpRouter } from "effect/unstable/http" @@ -23,12 +25,15 @@ import path from "path" import os from "os" import fs from "fs/promises" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { remove as cleanup } from "./cleanup" Log.init({ print: false }) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-fork-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -36,6 +41,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -44,14 +50,14 @@ afterAll(async () => { const sessions = { create: (input?: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(AppNodeBuilder.build(Session.node)))), - list: () => Effect.runPromise(Session.Service.use((svc) => svc.list()).pipe(Effect.provide(AppNodeBuilder.build(Session.node)))), + runtime.runPromise((svc) => svc.create(input)), + list: () => runtime.runPromise((svc) => svc.list()), messages: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(AppNodeBuilder.build(Session.node)))), + runtime.runPromise((svc) => svc.messages(input)), updateMessage: (msg: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(AppNodeBuilder.build(Session.node)))), + runtime.runPromise((svc) => svc.updateMessage(msg)), updatePart: (part: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(AppNodeBuilder.build(Session.node)))), + runtime.runPromise((svc) => svc.updatePart(part)), } afterEach(async () => { diff --git a/packages/opencode/test/kilocode/session-list.test.ts b/packages/opencode/test/kilocode/session-list.test.ts index 9067159cbb..53ca4c7510 100644 --- a/packages/opencode/test/kilocode/session-list.test.ts +++ b/packages/opencode/test/kilocode/session-list.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import path from "path" import { seedProject } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -15,7 +16,7 @@ import { AbsolutePath } from "@opencode-ai/core/schema" import * as Log from "@opencode-ai/core/util/log" Log.init({ print: false }) -const layer = Layer.mergeAll(AppNodeBuilder.build(Session.node), AppNodeBuilder.build(Database.node)) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node])) const it = testEffect(layer) describe("Kilo Session.list", () => { diff --git a/packages/opencode/test/kilocode/session-model-usage.test.ts b/packages/opencode/test/kilocode/session-model-usage.test.ts index d8d01c2319..8e606287f3 100644 --- a/packages/opencode/test/kilocode/session-model-usage.test.ts +++ b/packages/opencode/test/kilocode/session-model-usage.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { ModelUsage } from "@/kilocode/session/model-usage" import { ProjectTable } from "@opencode-ai/core/project/sql" import { ProjectV2 } from "@opencode-ai/core/project" @@ -16,7 +17,7 @@ import { eq } from "drizzle-orm" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Session.node), AppNodeBuilder.build(Database.node))) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const ref = (providerID: string, modelID: string) => ({ providerID: ProviderV2.ID.make(providerID), diff --git a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts index d974e1bc66..471cfe776c 100644 --- a/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts +++ b/packages/opencode/test/kilocode/session-processor-empty-tool-calls.test.ts @@ -1,4 +1,5 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect } from "bun:test" import { Context, Effect, Layer } from "effect" @@ -49,6 +50,8 @@ class TestLLM extends Context.Service< } >()("@test/EmptyToolCallsLLM") {} +class State extends Context.Service()("@test/EmptyToolCallsState") {} + function model(selection = ref): Provider.Model { return { id: selection.modelID, @@ -77,48 +80,58 @@ function usage() { } } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - const reply = (...items: Event[]) => push(Stream.make(...items)) - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - const item = queue.shift() ?? Stream.empty - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ reply, script: push })), - ) - }), -) - -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) -const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(SessionSummary.node), - AppNodeBuilder.build(Image.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = AppNodeBuilder.build(SessionProcessor.node).pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [] })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ stream: () => state.queue.shift() ?? Stream.empty }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + const push = (item: Script) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid) + return TestLLM.of({ reply: (...items) => push(Stream.make(...items)), script: push }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts index 9532f77ba9..e6dec024be 100644 --- a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts +++ b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts @@ -1,4 +1,5 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect, spyOn } from "bun:test" import { APICallError } from "ai" @@ -53,6 +54,10 @@ class TestLLM extends Context.Service< } >()("@test/IncompleteResponseRetryLLM") {} +class State extends Context.Service()( + "@test/IncompleteResponseRetryState", +) {} + function model(): Provider.Model { return { id: ref.modelID, @@ -108,63 +113,72 @@ function retryable429() { }) } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - let calls = 0 - const push = (stream: Script) => { - queue.push(stream) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - calls += 1 - return queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) - }, - }), - ), - Layer.succeed( - TestLLM, - TestLLM.of({ - push, - reply: (...events) => push(Stream.make(...events)), - calls: Effect.sync(() => calls), - }), - ), - ) - }), -) - const reference = Layer.mock(Reference.Service, { list: () => Effect.succeed([]), }) -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [], calls: 0 })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ + stream: () => { + state.calls += 1 + return state.queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) + }, + }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + const push = (stream: Script) => Effect.sync(() => state.queue.push(stream)).pipe(Effect.asVoid) + return TestLLM.of({ + push, + reply: (...events) => push(Stream.make(...events)), + calls: Effect.sync(() => state.calls), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) const env = (event = false) => - AppNodeBuilder.build(SessionProcessor.node).pipe( - Layer.provideMerge( - Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer({ experimentalEventSystem: event }), - reference, - AppNodeBuilder.build(SessionSummary.node), - AppNodeBuilder.build(Image.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - llm, - ).pipe(Layer.provideMerge(infra)), - ), - Layer.provide(reference), + LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: event })], + ]).pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer, reference)), ) const it = testEffect(env()) @@ -560,16 +574,23 @@ describe("session processor incomplete response retry", () => { ), ) - eventIt.effect("does not retry when Event V2 mirroring is enabled", () => + eventIt.effect("retries an empty response the same way when the event system flag is on", () => provideTmpdirProject( (dir) => Effect.gen(function* () { const ctx = yield* setup(dir) yield* ctx.test.reply(...empty()) + yield* ctx.test.reply(...success()) + const delay = spyOn(SessionRetry, "delay").mockReturnValue(0) - expect(yield* ctx.handle.process(ctx.input)).toBe("continue") - expect(yield* ctx.test.calls).toBe(1) - expect(ctx.handle.message.finish).toBe("unknown") + try { + expect(yield* ctx.handle.process(ctx.input)).toBe("continue") + } finally { + delay.mockRestore() + } + + expect(yield* ctx.test.calls).toBe(2) + expect(ctx.handle.message.finish).toBe("stop") }), { git: true }, ), diff --git a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts index 7fd0231367..c80849aa7e 100644 --- a/packages/opencode/test/kilocode/session-processor-network-offline.test.ts +++ b/packages/opencode/test/kilocode/session-processor-network-offline.test.ts @@ -1,4 +1,5 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { describe, expect, spyOn } from "bun:test" import { Context, Effect, Layer } from "effect" @@ -48,6 +49,8 @@ class TestLLM extends Context.Service< } >()("@test/OfflineLLM") {} +class State extends Context.Service()("@test/OfflineLLMState") {} + function model(): Provider.Model { return { id: "test-model", @@ -76,47 +79,59 @@ function usage() { } } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - const item = queue.shift() ?? Stream.empty - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ push })), - ) - }), -) - -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) -const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(SessionSummary.node), - AppNodeBuilder.build(Image.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = AppNodeBuilder.build(SessionProcessor.node).pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [] })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ stream: () => state.queue.shift() ?? Stream.empty }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + return TestLLM.of({ + push: (item) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts index abaf7ce590..571633b542 100644 --- a/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts +++ b/packages/opencode/test/kilocode/session-processor-retry-limit.test.ts @@ -4,7 +4,8 @@ // transitively load flag.ts to ensure the env is captured at load time. process.env.KILO_SESSION_RETRY_LIMIT = "2" -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { afterEach, describe, expect, spyOn } from "bun:test" import { APICallError } from "ai" @@ -56,6 +57,8 @@ class TestLLM extends Context.Service< } >()("@test/RetryLimitLLM") {} +class State extends Context.Service()("@test/RetryLimitLLMState") {} + function model(): Provider.Model { return { id: "test-model", @@ -87,49 +90,65 @@ function retryable429() { }) } -const llm = Layer.unwrap( - Effect.gen(function* () { - const queue: Script[] = [] - let calls = 0 - const push = (item: Script) => { - queue.push(item) - return Effect.void - } - return Layer.mergeAll( - Layer.succeed( - LLM.Service, - LLM.Service.of({ - stream: () => { - calls += 1 - const item = queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) - return item - }, - }), - ), - Layer.succeed(TestLLM, TestLLM.of({ push, calls: Effect.sync(() => calls) })), - ) - }), -) - -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) -const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(SessionSummary.node), - AppNodeBuilder.build(Image.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - llm, -).pipe(Layer.provideMerge(infra)) -const env = AppNodeBuilder.build(SessionProcessor.node).pipe(Layer.provideMerge(deps)) +const stateNode = LayerNode.make({ + service: State, + layer: Layer.sync(State, () => State.of({ queue: [], calls: 0 })), + deps: [], +}) +const llmNode = LayerNode.make({ + service: LLM.Service, + layer: Layer.effect( + LLM.Service, + Effect.gen(function* () { + const state = yield* State + return LLM.Service.of({ + stream: () => { + state.calls += 1 + return state.queue.shift() ?? Stream.fail(new Error("unexpected extra llm call")) + }, + }) + }), + ), + deps: [stateNode], +}) +const testNode = LayerNode.make({ + service: TestLLM, + layer: Layer.effect( + TestLLM, + Effect.gen(function* () { + const state = yield* State + return TestLLM.of({ + push: (item) => Effect.sync(() => state.queue.push(item)).pipe(Effect.asVoid), + calls: Effect.sync(() => state.calls), + }) + }), + ), + deps: [stateNode], +}) +const root = LayerNode.group([ + SessionProcessor.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + AgentSvc.node, + Permission.node, + Plugin.node, + Config.node, + SessionSummary.node, + Image.node, + SessionStatus.node, + EventV2Bridge.node, + Database.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + LLM.node, + testNode, +]) +const env = LayerNode.compile(root, [ + [LLM.node, llmNode], + [RuntimeFlags.node, RuntimeFlags.layer()], +]).pipe(Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, Bus.layer, SyncEvent.defaultLayer))) const it = testEffect(env) diff --git a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts index 44e861a308..3ae6024893 100644 --- a/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-compaction-safety.test.ts @@ -2,17 +2,15 @@ // Ensures Kilo's post-filterCompacted trim and post-summary media strip are // applied before messages are serialized for the provider request. -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" -import { NodeFileSystem } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" -import { FetchHttpClient } from "effect/unstable/http" import { Database } from "@opencode-ai/core/database/database" import { Agent as AgentSvc } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" import { Bus } from "../../src/bus" import { Command } from "../../src/command" -import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -31,7 +29,6 @@ import { Provider as ProviderSvc } from "../../src/provider/provider" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Question } from "../../src/question" -import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { Session } from "../../src/session/session" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" @@ -48,8 +45,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" import { KiloSessions } from "../../src/kilo-sessions/kilo-sessions" @@ -126,72 +121,59 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const serverNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) +const root = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + serverNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(BackgroundJob.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(LLM.node), - AppNodeBuilder.build(Env.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Command.node), - AppNodeBuilder.build(Permission.node), - plugin, - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(ProviderSvc.node), - lsp, - mcp, - AppNodeBuilder.build(FSUtil.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = AppNodeBuilder.build(ToolRegistry.node, [[KiloSessions.node, KiloSessions.testLayer]]) - const trunc = AppNodeBuilder.build(Truncate.node) - const proc = AppNodeBuilder.build(SessionProcessor.node, [[SessionSummary.node, summary]]) - const compact = AppNodeBuilder.build(SessionCompaction.node, [ - [SessionProcessor.node, proc], + return LayerNode.compile(root, [ [SessionSummary.node, summary], + [Plugin.node, plugin], + [LSP.node, lsp], + [MCP.node, mcp], + [RuntimeFlags.node, RuntimeFlags.layer()], + [KiloSessions.node, KiloSessions.testLayer], ]) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(AppNodeBuilder.build(SessionRevert.node)), - Layer.provide(AppNodeBuilder.build(Image.node)), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(AppNodeBuilder.build(Instruction.node)), - Layer.provide(AppNodeBuilder.build(SystemPrompt.node)), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(BackgroundJob.node), - Bus.layer, - infra, - AppNodeBuilder.build(Storage.node), - ), - ), - ) } const it = testEffect(makeHttp()) diff --git a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts index b0e5efa303..ce2485be22 100644 --- a/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-permission-refresh.test.ts @@ -1,4 +1,5 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" import { Cause, Effect, Exit, Fiber, Layer } from "effect" @@ -34,6 +35,7 @@ import { RepositoryCache } from "@opencode-ai/core/repository-cache" import { SessionCompaction } from "../../src/session/compaction" import { Instruction } from "../../src/session/instruction" import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" import { SessionProcessor } from "../../src/session/processor" import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" @@ -45,8 +47,6 @@ import { SessionSummary } from "../../src/session/summary" import { Todo } from "../../src/session/todo" import { Skill } from "../../src/skill" import { Snapshot } from "../../src/snapshot" -import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { ToolRegistry } from "../../src/tool/registry" import { Truncate } from "../../src/tool/truncate" @@ -126,74 +126,62 @@ const lsp = Layer.succeed( }), ) -const status = Layer.mergeAll(AppNodeBuilder.build(SessionStatus.node), Bus.layer) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) +// One compiled graph, mirroring test/session/prompt.test.ts. Effect v4 does +// not memoize nested layers, so per-service AppNodeBuilder.build calls each stood up their own +// Database and sessions written by the test were invisible to the prompt loop. +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, + testLLMServerNode, +]) function makeHttp() { - const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(BackgroundJob.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(LLM.node), - AppNodeBuilder.build(Env.node), - AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Command.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(ProviderSvc.node), - lsp, - mcp, - AppNodeBuilder.build(FSUtil.node), - SyncEvent.defaultLayer, - AppNodeBuilder.build(EventV2Bridge.node), - AppNodeBuilder.build(Database.node), - status, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = AppNodeBuilder.build(ToolRegistry.node, [[KiloSessions.node, KiloSessions.testLayer]]) - const trunc = AppNodeBuilder.build(Truncate.node) - const proc = AppNodeBuilder.build(SessionProcessor.node, [[SessionSummary.node, summary]]) - const compact = AppNodeBuilder.build(SessionCompaction.node, [ - [SessionProcessor.node, proc], + return LayerNode.compile(promptRoot, [ [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, mcp], + [KiloSessions.node, KiloSessions.testLayer], ]) - return Layer.mergeAll( - TestLLMServer.layer, - SessionPrompt.layer.pipe( - Layer.provide(AppNodeBuilder.build(SessionRevert.node)), - Layer.provide(AppNodeBuilder.build(Image.node)), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(AppNodeBuilder.build(Instruction.node)), - Layer.provide(AppNodeBuilder.build(SystemPrompt.node)), - Layer.provideMerge(deps), - ), - ).pipe( - Layer.provide( - Layer.mergeAll( - summary, - deps, - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(BackgroundJob.node), - Bus.layer, - infra, - AppNodeBuilder.build(Storage.node), - ), - ), - ) } - const it = testEffect(makeHttp()) const symlinkIt = process.platform === "win32" ? it.live.skip : it.live diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index d6a6178802..a88c83b881 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -1,10 +1,11 @@ import path from "path" -import { afterAll, beforeAll, describe, expect, test } from "bun:test" +import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test" import { Effect } from "effect" import fs from "fs/promises" import os from "os" import { Bus } from "../../src/bus" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { InstanceRef } from "../../src/effect/instance-ref" import { KiloSessionCompaction } from "@/kilocode/session/compaction" import { KiloSessionPromptQueue } from "@/kilocode/session/prompt-queue" @@ -12,6 +13,8 @@ import { KiloSession } from "@/kilocode/session" import { Suggestion } from "../../src/kilocode/suggestion" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { InstanceStore } from "../../src/project/instance-store" import { provideTestInstance } from "../fixture/fixture" import { Session } from "../../src/session/session" @@ -26,9 +29,13 @@ import { remove as cleanup } from "./cleanup" import { pollWithTimeout } from "../lib/effect" Log.init({ print: false }) +setDefaultTimeout(15_000) const previous = Flag.KILO_DB const dbfile = path.join(os.tmpdir(), `kilo-prompt-queue-${process.pid}-${crypto.randomUUID()}.db`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const prompt = LayerNode.compile(LayerNode.group([SessionPrompt.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -36,6 +43,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -49,13 +57,13 @@ const store = { const sessions = { create: (input?: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.create(input)), messages: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.messages(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.messages(input)), updateMessage: (msg: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updateMessage(msg)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updateMessage(msg)), updatePart: (part: T) => - Effect.runPromise(Session.Service.use((svc) => svc.updatePart(part)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.updatePart(part)), } function line(input: unknown) { @@ -121,7 +129,7 @@ function hasText(msg: MessageV2.WithParts, text: string) { 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), + Effect.provide(prompt), provideInstance(dir), Effect.provide(testInstanceStoreLayer), Effect.scoped, diff --git a/packages/opencode/test/kilocode/session-prompt-steering.test.ts b/packages/opencode/test/kilocode/session-prompt-steering.test.ts index dda61d2d02..1da8f07b4d 100644 --- a/packages/opencode/test/kilocode/session-prompt-steering.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-steering.test.ts @@ -4,7 +4,10 @@ import fs from "fs/promises" import os from "os" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { AppRuntime } from "../../src/effect/app-runtime" +import { makeRuntime } from "../../src/effect/run-service" import { MessageV2 } from "../../src/session/message-v2" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" @@ -20,6 +23,9 @@ 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`) +const layer = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node])) +const prompt = LayerNode.compile(LayerNode.group([SessionPrompt.node, SessionProjector.node])) +const runtime = makeRuntime(Session.Service, layer) beforeAll(async () => { await fs.rm(dbfile, { force: true }) @@ -27,6 +33,7 @@ beforeAll(async () => { }) afterAll(async () => { + await runtime.dispose() await AppRuntime.dispose() await disposeTestRuntime() Flag.KILO_DB = previous @@ -105,11 +112,9 @@ function question() { const sessions = { create: (input: Parameters[0]) => - Effect.runPromise(Session.Service.use((svc) => svc.create(input)).pipe(Effect.provide(Session.defaultLayer))), + runtime.runPromise((svc) => svc.create(input)), messages: (sessionID: SessionID) => - Effect.runPromise( - Session.Service.use((svc) => svc.messages({ sessionID })).pipe(Effect.provide(Session.defaultLayer)), - ), + runtime.runPromise((svc) => svc.messages({ sessionID })), } async function wait(sessionID: SessionID) { @@ -130,7 +135,7 @@ async function wait(sessionID: SessionID) { 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), + Effect.provide(prompt), provideInstance(dir), Effect.provide(testInstanceStoreLayer), Effect.scoped, diff --git a/packages/opencode/test/kilocode/session-share.test.ts b/packages/opencode/test/kilocode/session-share.test.ts index 6b52b9e0e0..3ed665b9ff 100644 --- a/packages/opencode/test/kilocode/session-share.test.ts +++ b/packages/opencode/test/kilocode/session-share.test.ts @@ -1,22 +1,27 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { expect, spyOn } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Auth } from "../../src/auth" -import { Config } from "../../src/config/config" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Session } from "../../src/session/session" import { SessionShare } from "../../src/share/session" import { Storage } from "../../src/storage/storage" -import { SyncEvent } from "../../src/sync" import { testEffect } from "../lib/effect" const it = testEffect( - Layer.mergeAll(AppNodeBuilder.build(Auth.node), AppNodeBuilder.build(Storage.node), AppNodeBuilder.build(CrossSpawnSpawner.node), RuntimeFlags.layer()), -) - -const layer = AppNodeBuilder.build(SessionShare.node).pipe( - Layer.provideMerge(AppNodeBuilder.build(Session.node)), + LayerNode.compile( + LayerNode.group([ + SessionShare.node, + Session.node, + SessionProjector.node, + Auth.node, + Storage.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + ]), + ), ) it.instance("shares and unshares sessions through Kilo public URLs", () => { @@ -60,6 +65,5 @@ it.instance("shares and unshares sessions through Kilo public URLs", () => { request.mockRestore() }), ), - Effect.provide(layer), ) }) diff --git a/packages/opencode/test/kilocode/session/revert.test.ts b/packages/opencode/test/kilocode/session/revert.test.ts index 3272d97e4a..a0716e1fd3 100644 --- a/packages/opencode/test/kilocode/session/revert.test.ts +++ b/packages/opencode/test/kilocode/session/revert.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit } from "effect" import fs from "node:fs/promises" import path from "node:path" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -15,11 +16,14 @@ import { Snapshot } from "@/snapshot" import { provideInstance, provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const env = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(SessionRevert.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(CrossSpawnSpawner.node), +const env = LayerNode.compile( + LayerNode.group([ + Session.node, + SessionProjector.node, + SessionRevert.node, + Snapshot.node, + CrossSpawnSpawner.node, + ]), ) const it = testEffect(env) const guarded = process.platform === "win32" ? it.live.skip : it.live diff --git a/packages/opencode/test/kilocode/session/transcript.test.ts b/packages/opencode/test/kilocode/session/transcript.test.ts index 638a5126f9..2dc30786b1 100644 --- a/packages/opencode/test/kilocode/session/transcript.test.ts +++ b/packages/opencode/test/kilocode/session/transcript.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" @@ -10,7 +11,7 @@ import { MessageID, PartID, SessionID } from "@/session/schema" import { provideTmpdirInstance } from "../../fixture/fixture" import { testEffect } from "../../lib/effect" -const env = Layer.mergeAll(AppNodeBuilder.build(Session.node), AppNodeBuilder.build(CrossSpawnSpawner.node)) +const env = LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, CrossSpawnSpawner.node])) const it = testEffect(env) const providerID = ProviderV2.ID.make("test") diff --git a/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts b/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts index 1cd0c74643..19c4e8172a 100644 --- a/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts +++ b/packages/opencode/test/kilocode/snowflake-cortex-provider.test.ts @@ -6,9 +6,8 @@ import { ProviderV2 } from "@opencode-ai/core/provider" import { Provider } from "@/provider/provider" import { Env } from "@/env" import { Plugin } from "@/plugin" -import { provideInstanceEffect, tmpdirScoped } from "../fixture/fixture" +import { provideInstanceEffect, testInstanceStoreLayer, tmpdirScoped } from "../fixture/fixture" import { testEffect } from "../lib/effect" -import { InstanceStore } from "@/project/instance-store" const it = testEffect( Layer.mergeAll(AppNodeBuilder.build(Provider.node), AppNodeBuilder.build(Env.node), AppNodeBuilder.build(Plugin.node), AppNodeBuilder.build(CrossSpawnSpawner.node)), @@ -50,7 +49,7 @@ it.effect("loads Snowflake Cortex from OAuth credentials", () => }) const provider = yield* Provider.use .getProvider(ProviderV2.ID.make("snowflake-cortex")) - .pipe(provideInstanceEffect(directory), Effect.provide(AppNodeBuilder.build(InstanceStore.node))) + .pipe(provideInstanceEffect(directory), Effect.provide(testInstanceStoreLayer)) expect(provider.options.baseURL).toBe("https://test-account.snowflakecomputing.com/api/v2/cortex/v1") expect(provider.options.apiKey).toBe("access-token") diff --git a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts index dc6e06f963..a95d5547ca 100644 --- a/packages/opencode/test/kilocode/stats-subagent-cost.test.ts +++ b/packages/opencode/test/kilocode/stats-subagent-cost.test.ts @@ -3,9 +3,10 @@ // tool propagates each child session's total cost up to the parent's // tool-wrapper assistant message (#6321). -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" import { aggregateSessionStats } from "../../src/cli/cmd/stats" import { MessageV2 } from "../../src/session/message-v2" @@ -18,7 +19,7 @@ import { testEffect } from "../lib/effect" void Log.init({ print: false }) -const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Session.node), AppNodeBuilder.build(Database.node))) +const it = testEffect(LayerNode.compile(LayerNode.group([Session.node, SessionProjector.node, Database.node]))) const ref = { providerID: ProviderV2.ID.make("test"), diff --git a/packages/opencode/test/kilocode/task-nesting.test.ts b/packages/opencode/test/kilocode/task-nesting.test.ts index ee2c915b24..4d56615513 100644 --- a/packages/opencode/test/kilocode/task-nesting.test.ts +++ b/packages/opencode/test/kilocode/task-nesting.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, describe, expect, test } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Effect, Exit } from "effect" import { Database } from "@opencode-ai/core/database/database" import { Agent } from "../../src/agent/agent" import { BackgroundJob } from "../../src/background/job" @@ -35,20 +36,23 @@ const ref = { } const it = testEffect( - Layer.mergeAll( - AppNodeBuilder.build(Agent.node), - AppNodeBuilder.build(BackgroundJob.node), - AppNodeBuilder.build(Bus.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(SessionRunState.node), - AppNodeBuilder.build(SessionStatus.node), - AppNodeBuilder.build(CrossSpawnSpawner.node), - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Truncate.node), - AppNodeBuilder.build(Provider.node), - AppNodeBuilder.build(ToolRegistry.node), - AppNodeBuilder.build(Database.node), + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + Bus.node, + Config.node, + RuntimeFlags.node, + SessionRunState.node, + SessionStatus.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + Truncate.node, + Provider.node, + ToolRegistry.node, + Database.node, + ]), ), ) diff --git a/packages/opencode/test/kilocode/tool-task-model.test.ts b/packages/opencode/test/kilocode/tool-task-model.test.ts index 0a9da7bf8b..62ee105a83 100644 --- a/packages/opencode/test/kilocode/tool-task-model.test.ts +++ b/packages/opencode/test/kilocode/tool-task-model.test.ts @@ -1,6 +1,7 @@ -import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { SessionProjector } from "@opencode-ai/core/session/projector" import { afterEach, beforeAll, describe, expect } from "bun:test" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { Database } from "@opencode-ai/core/database/database" import fs from "fs/promises" import path from "path" @@ -100,20 +101,23 @@ const catalog = { } const it = testEffect( - Layer.mergeAll( - AppNodeBuilder.build(Agent.node), - AppNodeBuilder.build(BackgroundJob.node), - AppNodeBuilder.build(Bus.node), - AppNodeBuilder.build(Config.node), - RuntimeFlags.layer(), - AppNodeBuilder.build(SessionRunState.node), - AppNodeBuilder.build(SessionStatus.node), - AppNodeBuilder.build(CrossSpawnSpawner.node), - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Truncate.node), - AppNodeBuilder.build(Provider.node), - AppNodeBuilder.build(ToolRegistry.node), - AppNodeBuilder.build(Database.node), + LayerNode.compile( + LayerNode.group([ + Agent.node, + BackgroundJob.node, + Bus.node, + Config.node, + RuntimeFlags.node, + SessionRunState.node, + SessionStatus.node, + CrossSpawnSpawner.node, + Session.node, + SessionProjector.node, + Truncate.node, + Provider.node, + ToolRegistry.node, + Database.node, + ]), ), ) diff --git a/packages/opencode/test/kilocode/worktree-remove-lock.test.ts b/packages/opencode/test/kilocode/worktree-remove-lock.test.ts index d9e78cdca6..3ee09b0e59 100644 --- a/packages/opencode/test/kilocode/worktree-remove-lock.test.ts +++ b/packages/opencode/test/kilocode/worktree-remove-lock.test.ts @@ -5,11 +5,19 @@ import * as fs from "fs/promises" import path from "path" import { Effect, Layer } from "effect" import * as CrossSpawnSpawner from "@opencode-ai/core/cross-spawn-spawner" +import { InstanceBootstrap } from "../../src/project/bootstrap-service" +import { InstanceStore } from "../../src/project/instance-store" import { Worktree } from "../../src/worktree" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(Worktree.node), AppNodeBuilder.build(CrossSpawnSpawner.node))) +const bootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void })) +const it = testEffect( + Layer.mergeAll( + AppNodeBuilder.build(Worktree.node, [[InstanceStore.bootstrapNode, bootstrap]]), + AppNodeBuilder.build(CrossSpawnSpawner.node), + ), +) describe("Worktree.remove lock retries", () => { it.live("retries transient git remove lock failures", () => diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 7a2b1cdcd7..cd9b3c3a97 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -212,7 +212,7 @@ export function withCliFixture( const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { const start = Date.now() - const timeoutMs = opts?.timeoutMs ?? 30_000 + const timeoutMs = opts?.timeoutMs ?? 45_000 // kilocode_change - current full CLI startup leaves less than 30s for multi-step runs // stdin: "ignore" so the child doesn't see a piped stdin and block // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is // consumed as the prompt). The old Process.run wrapper defaulted to @@ -537,8 +537,8 @@ export const cliIt = { body: (input: CliFixture) => Effect.Effect, opts?: number | TestOptions, ) => - // kilocode_change start - Windows CI cannot reliably start nested CLI trees concurrently - (process.platform === "win32" ? test : test.concurrent)( + // kilocode_change start - full CLI processes contend heavily during startup after the Effect graph migration + test.serial( name, () => Effect.runPromise(Effect.scoped(withCliFixture(body))), opts, diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 3fbb0fdf3a..ae20488a84 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -21,7 +21,7 @@ const env = Layer.mergeAll( AppNodeBuilder.build(Permission.node), events, AppNodeBuilder.build(CrossSpawnSpawner.node), - AppNodeBuilder.build(InstanceStore.node).pipe(Layer.provide(noopBootstrap)), + AppNodeBuilder.build(InstanceStore.node, [[InstanceStore.bootstrapNode, noopBootstrap]]), // kilocode_change ).pipe(Layer.provide(RuntimeFlags.layer()), Layer.provide(AppNodeBuilder.build(Config.node))) const it = testEffect(Layer.mergeAll(env, RuntimeFlags.layer())) diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts index d01b77e070..6cff029d04 100644 --- a/packages/opencode/test/plugin/auth-override.test.ts +++ b/packages/opencode/test/plugin/auth-override.test.ts @@ -1,49 +1,41 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { describe, expect, test } from "bun:test" import path from "path" import { pathToFileURL } from "url" -import { Effect, Layer } from "effect" +import { Effect } from "effect" import { FSUtil } from "@opencode-ai/core/fs-util" import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture" import { ProviderAuth } from "@/provider/auth" -import { Plugin } from "@/plugin" import { RuntimeFlags } from "@/effect/runtime-flags" -import { Auth } from "@/auth" -import { ModelCache } from "@/provider/model-cache" // kilocode_change -import { EventV2Bridge } from "@/event-v2-bridge" import { TestConfig } from "../fixture/config" import { testEffect } from "../lib/effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { ProviderV2 } from "@opencode-ai/core/provider" +import { Config } from "@/config/config" -const it = testEffect(Layer.mergeAll(AppNodeBuilder.build(CrossSpawnSpawner.node), AppNodeBuilder.build(FSUtil.node))) +const it = testEffect(AppNodeBuilder.build(LayerNode.group([CrossSpawnSpawner.node, FSUtil.node]))) -function layer(directory: string, plugins: string[]) { - return AppNodeBuilder.build(ProviderAuth.node).pipe( - Layer.provide(AppNodeBuilder.build(Auth.node)), - Layer.provide(AppNodeBuilder.build(ModelCache.node)), // kilocode_change - Layer.provide( - AppNodeBuilder.build(Plugin.node).pipe( - Layer.provide(AppNodeBuilder.build(EventV2Bridge.node)), - Layer.provide(RuntimeFlags.layer()), - Layer.provide( - TestConfig.layer({ - get: () => - Effect.succeed({ - plugin: plugins, - plugin_origins: plugins.map((plugin) => ({ - spec: plugin, - source: path.join(directory, "opencode.json"), - scope: "local" as const, - })), - }), - directories: () => Effect.succeed([directory]), +function providerAuthLayer(directory: string, plugins: string[]) { + return AppNodeBuilder.build(ProviderAuth.node, [ + [ + Config.node, + TestConfig.layer({ + get: () => + Effect.succeed({ + plugin: plugins, + plugin_origins: plugins.map((plugin) => ({ + spec: plugin, + source: path.join(directory, "opencode.json"), + scope: "local" as const, + })), }), - ), - ), - ), - ) + directories: () => Effect.succeed([directory]), + }), + ], + [RuntimeFlags.node, RuntimeFlags.layer()], + ]) } describe("plugin.auth-override", () => { @@ -76,10 +68,12 @@ describe("plugin.auth-override", () => { const plain = yield* tmpdirScoped({ git: true }) const plugin = pathToFileURL(path.join(pluginDir, "custom-copilot-auth.ts")).href - const methods = yield* ProviderAuth.use.methods().pipe(Effect.provide(layer(tmp.directory, [plugin]))) + const methods = yield* ProviderAuth.use + .methods() + .pipe(Effect.provide(providerAuthLayer(tmp.directory, [plugin]))) const plainMethods = yield* ProviderAuth.use .methods() - .pipe(Effect.provide(layer(plain, [])), provideInstance(plain)) + .pipe(Effect.provide(providerAuthLayer(plain, [])), provideInstance(plain)) const copilot = methods[ProviderV2.ID.make("github-copilot")] expect(copilot).toBeDefined() diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index c2cd7f1a2a..47051f6ed3 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -727,11 +727,13 @@ it.instance( ) it.instance( - "getSmallModel ignores model IDs without family metadata", + // kilocode_change start - Kilo always has an auto-routed small-model fallback + "getSmallModel falls back to Kilo auto when model IDs lack family metadata", Effect.gen(function* () { const model = yield* Provider.use.getSmallModel(ProviderV2.ID.make("test-provider")) - expect(model).toBeUndefined() + expect(model).toMatchObject({ providerID: "kilo", id: "kilo-auto/small" }) }), + // kilocode_change end { config: { provider: { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index d3725dc8f9..214327e870 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -40,6 +40,7 @@ import { SessionCompaction } from "../../src/session/compaction" import { SessionSummary } from "../../src/session/summary" import { Instruction } from "../../src/session/instruction" import { SessionProcessor } from "../../src/session/processor" +import { SessionProjector } from "@opencode-ai/core/session/projector" // kilocode_change import { SessionPrompt } from "../../src/session/prompt" import { SessionRevert } from "../../src/session/revert" import { SessionRunState } from "../../src/session/run-state" @@ -167,10 +168,10 @@ const lsp = Layer.succeed( }), ) -const status = SessionStatus.layer.pipe(Layer.provideMerge(AppNodeBuilder.build(EventV2Bridge.node))) -const run = SessionRunState.layer.pipe(Layer.provide(status)) -const infra = Layer.mergeAll(NodeFileSystem.layer, AppNodeBuilder.build(CrossSpawnSpawner.node)) - +// kilocode_change start - one compiled graph per env. Effect v4 does not memoize nested layers, so +// LayerNode.compile's cache is the only dedupe; building services with separate AppNodeBuilder.build +// calls gave this file three Database instances and every prompt died with "Session not found". +// Mirrors upstream's harness, with Kilo's KiloSessions/MemoryService/fastAgents deltas. const agent: AgentSvc.Info = { name: "build", mode: "primary", @@ -200,72 +201,93 @@ const blockingProcessor = Layer.succeed( }), ) +const runtimeFlags = RuntimeFlags.layer({ experimentalEventSystem: true }) + +const memoryNode = LayerNode.make({ service: MemoryService.Service, layer: MemoryService.layer, deps: [] }) +const testLLMServerNode = LayerNode.make({ service: TestLLMServer, layer: TestLLMServer.layer, deps: [] }) + +const promptRoot = LayerNode.group([ + SessionPrompt.node, + Session.node, + SessionProjector.node, + MessageV2.node, + Snapshot.node, + LLM.node, + Env.node, + AgentSvc.node, + Command.node, + Permission.node, + Plugin.node, + Config.node, + ProviderSvc.node, + LSP.node, + MCP.node, + FSUtil.node, + BackgroundJob.node, + SessionStatus.node, + SessionRunState.node, + Database.node, + EventV2Bridge.node, + Question.node, + Todo.node, + ToolRegistry.node, + Skill.node, + Git.node, + Ripgrep.node, + Format.node, + Truncate.node, + SessionProcessor.node, + Image.node, + SessionCompaction.node, + SessionRevert.node, + Instruction.node, + SystemPrompt.node, + CrossSpawnSpawner.node, + RuntimeFlags.node, + memoryNode, +]) + function makePrompt(input?: { processor?: "blocking" }) { - const deps = Layer.mergeAll( - AppNodeBuilder.build(Session.node), - AppNodeBuilder.build(Snapshot.node), - AppNodeBuilder.build(LLM.node), - AppNodeBuilder.build(Env.node), - input?.processor === "blocking" ? fastAgents : AppNodeBuilder.build(AgentSvc.node), - AppNodeBuilder.build(Command.node), - AppNodeBuilder.build(Permission.node), - AppNodeBuilder.build(Plugin.node), - AppNodeBuilder.build(Config.node), - AppNodeBuilder.build(ProviderSvc.node), - lsp, - makeMcp(), - AppNodeBuilder.build(FSUtil.node), - AppNodeBuilder.build(BackgroundJob.node), - status, - AppNodeBuilder.build(Database.node), - AppNodeBuilder.build(EventV2Bridge.node), - Bus.layer, - MemoryService.layer, - ).pipe(Layer.provideMerge(infra)) - const question = Question.layer.pipe(Layer.provideMerge(deps)) - const todo = Todo.layer.pipe(Layer.provideMerge(deps)) - const registry = AppNodeBuilder.build(ToolRegistry.node, [ - [KiloSessions.node, KiloSessions.testLayer], - [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], - ]) - const trunc = AppNodeBuilder.build(Truncate.node) - const proc = - input?.processor === "blocking" - ? blockingProcessor - : AppNodeBuilder.build(SessionProcessor.node, [ - [SessionSummary.node, summary], - [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], - ]) - const compact = AppNodeBuilder.build(SessionCompaction.node, [ - [SessionProcessor.node, proc], + const replacements = [ [SessionSummary.node, summary], - [RuntimeFlags.node, RuntimeFlags.layer({ experimentalEventSystem: true })], - ]) - return SessionPrompt.layer.pipe( - Layer.provide(AppNodeBuilder.build(SessionRevert.node)), - Layer.provide(AppNodeBuilder.build(Image.node)), - Layer.provide(summary), - Layer.provideMerge(run), - Layer.provideMerge(compact), - Layer.provideMerge(proc), - Layer.provideMerge(registry), - Layer.provideMerge(trunc), - Layer.provideMerge(question), - Layer.provide(AppNodeBuilder.build(Instruction.node)), - Layer.provide(AppNodeBuilder.build(SystemPrompt.node)), - Layer.provide(RuntimeFlags.layer({ experimentalEventSystem: true })), - Layer.provideMerge(deps), - Layer.provide(summary), - ) + [LSP.node, lsp], + [MCP.node, makeMcp()], + [RuntimeFlags.node, runtimeFlags], + [KiloSessions.node, KiloSessions.testLayer], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(promptRoot, [ + ...replacements, + [SessionProcessor.node, blockingProcessor], + [AgentSvc.node, fastAgents], + ]) + } + return LayerNode.compile(promptRoot, replacements) } function makeHttp(input?: { processor?: "blocking" }) { - return Layer.mergeAll(TestLLMServer.layer, makePrompt(input)) + const root = LayerNode.group([promptRoot, testLLMServerNode]) + const replacements = [ + [SessionSummary.node, summary], + [LSP.node, lsp], + [MCP.node, makeMcp()], + [RuntimeFlags.node, runtimeFlags], + [KiloSessions.node, KiloSessions.testLayer], + ] as const + if (input?.processor === "blocking") { + return LayerNode.compile(root, [ + ...replacements, + [SessionProcessor.node, blockingProcessor], + [AgentSvc.node, fastAgents], + ]) + } + return LayerNode.compile(root, replacements) } function makeHttpNoLLMServer(input?: { processor?: "blocking" }) { return makePrompt(input) } +// kilocode_change end const it = testEffect(makeHttp()) const noLLMServer = testEffect(makeHttpNoLLMServer()) @@ -777,6 +799,7 @@ noLLMServer.instance.skip( ], }) + // kilocode_change start - compile the v2 reader against this test's database graph const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( Effect.provide( LayerNode.compile(SessionV2.node, [ @@ -785,6 +808,7 @@ noLLMServer.instance.skip( ]), ), ) + // kilocode_change end const { db } = yield* Database.Service const row = yield* db .select() @@ -1829,9 +1853,16 @@ unixNoLLMServer( const tool = completedTool(result.parts) if (!tool) return + // kilocode_change start - bind v2 execution and location services in the consolidated test graph const messages = yield* SessionV2.Service.use((session) => session.messages({ sessionID: chat.id })).pipe( - Effect.provide(AppNodeBuilder.build(SessionV2.node)), + Effect.provide( + LayerNode.compile(SessionV2.node, [ + [SessionExecution.node, SessionExecution.noopLayer], + [LocationServiceMap.node, locationServiceMapLayer], + ]), + ), ) + // kilocode_change end const shell = messages.find((message) => message.type === "shell") expect(shell).toMatchObject({