From 7cb46cac566ab275a26d4e4df39a681ad3f1acbb Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Thu, 10 Sep 2026 13:22:15 +0530 Subject: [PATCH 01/12] feat(harness): let Event.log read caller-owned durable types --- packages/harness/src/event/event.ts | 77 ++++++++++++--- packages/harness/test/event.log.test.ts | 93 ++++++++++++++++++- packages/harness/test/settings.loop.test.ts | 7 +- .../harness/test/settings.resolve.test.ts | 5 +- 4 files changed, 166 insertions(+), 16 deletions(-) diff --git a/packages/harness/src/event/event.ts b/packages/harness/src/event/event.ts index e4b9d38..4e804e7 100644 --- a/packages/harness/src/event/event.ts +++ b/packages/harness/src/event/event.ts @@ -92,6 +92,21 @@ export type LogInput = { readonly after?: number; /** Keep the stream open past the marker, appending events as they commit. */ readonly follow?: boolean; + /** + * Which stored types this read understands, keyed by versioned type -- build it + * with `EventSchema.durable([...definitions])`. Rows of any other type are + * skipped, exactly as an unknown type from a newer build is. Defaults to the + * application manifest, so a kernel reader passes nothing. + * + * A caller owning durable definitions outside `EventList` supplies them here, + * including the older versions it still decodes; the default does not know them + * and would filter every row. + * + * Only the definitions are needed, not a decoder: `log` decodes each row's + * `data` with its own definition and builds the envelope itself, so what it + * emits is always a well-formed {@link Payload}. + */ + readonly definitions?: ReadonlyMap; }; export interface PublishOptions { @@ -136,8 +151,10 @@ export interface Interface { readonly readAggregate: (input: ReadAggregateInput) => Effect.Effect>; /** * Reads one aggregate's stored events after the exclusive `after` cursor in - * sequence order, using the application manifest. Unknown types are skipped. - * Emits a {@link Synced} marker after reading through the captured head. + * sequence order, decoded through `input.definitions` -- the application + * manifest unless the caller owns durable types outside `EventList`. Types the + * definitions do not name are skipped. Emits a {@link Synced} marker after reading + * through the captured head. * * Completes after the marker unless `follow` is true. Following rereads storage * when this service instance commits an event; writes through other instances @@ -335,23 +352,50 @@ export const layer = Layer.effect( return { events, hasMore: rows.length > input.limit }; }, Effect.orDie); - // The same read against the process-wide manifest, bounded above. Both - // `log` and `stream` walk an aggregate through this. + /** + * One stored row to a payload, decoding `data` with its own definition and + * building the envelope from the row. Deliberately not a decode of the whole + * envelope through a union: `log` promises {@link Payload}, so `id`, `type` + * and `durable` are the kernel's to construct, never a caller-supplied + * decoder's to reshape or drop. + */ + const decodeLogRow = Effect.fn("Event.decodeLogRow")(function* ( + row: EventRow, + definitions: ReadonlyMap, + ) { + // The SQL filter is built from these same keys, so a row here always matches. + const definition = definitions.get(row.type)!; + const data = yield* Schema.decodeEffect(definition.data as Schema.Codec)(row.data); + return { + id: row.id, + type: definition.type, + durable: { aggregateId: row.aggregateId, seq: row.seq, version: definition.durable?.version ?? 0 }, + data, + } as Payload; + }); + + // The same read as `readAggregate`, bounded above and paged for `log`. The + // manifest is a parameter rather than the process-wide one: a reader owning + // durable types outside `EventList` supplies its own, and the filter would + // otherwise drop every one of its rows. const readPage = Effect.fn("Event.readPage")(function* (input: { readonly aggregateId: string; readonly after: number; readonly through: number; + readonly definitions: ReadonlyMap; }) { const rows = yield* selectAggregateEvents({ aggregateId: input.aggregateId, after: input.after, through: input.through, - types: Array.from(EventManifest.Manifest.definitions.keys()), + types: Array.from(input.definitions.keys()), limit: PAGE_SIZE + 1, }); const page = rows.slice(0, PAGE_SIZE); - const events = (yield* decodeRows(page, EventManifest.Manifest)) as ReadonlyArray; - return { events, hasMore: rows.length > PAGE_SIZE }; + const events = yield* Effect.forEach(page, (row) => decodeLogRow(row, input.definitions)); + // `seq` is the stored row's, not one read back off a decoded value: the + // window advances on what the table actually holds. + return { events, hasMore: rows.length > PAGE_SIZE, seq: page.at(-1)?.seq }; }, Effect.orDie); /** One catch-up pass over (`from`, `through`], paged, oldest first. */ @@ -359,14 +403,18 @@ export const layer = Layer.effect( readonly aggregateId: string; readonly from: number; readonly through: number; + readonly definitions: ReadonlyMap; }): Stream.Stream => Stream.paginate( input.from, Effect.fn("Event.readAggregateStream.page")(function* (after: number) { - const page = yield* readPage({ aggregateId: input.aggregateId, after, through: input.through }); - const last = page.events.at(-1); - const next = - page.hasMore && last?.durable !== undefined ? Option.some(last.durable.seq) : Option.none(); + const page = yield* readPage({ + aggregateId: input.aggregateId, + after, + through: input.through, + definitions: input.definitions, + }); + const next = page.hasMore && page.seq !== undefined ? Option.some(page.seq) : Option.none(); return [page.events, next] as const; }), ); @@ -558,15 +606,20 @@ export const layer = Layer.effect( const log = (input: LogInput): Stream.Stream => Stream.unwrap( Effect.gen(function* () { + const definitions = input.definitions ?? EventManifest.Manifest.definitions; // The cursor outlives a single catch-up: each wake resumes from the // last sequence actually emitted, not from where the pass began. const cursor = yield* Ref.make(input.after ?? -1); const catchUp = (through: number): Stream.Stream => Stream.unwrap( Ref.get(cursor).pipe( - Effect.map((from) => readAggregateStream({ aggregateId: input.aggregateId, from, through })), + Effect.map((from) => + readAggregateStream({ aggregateId: input.aggregateId, from, through, definitions }), + ), ), ).pipe( + // `durable` is built by `decodeLogRow`, never by a caller's decoder, + // so the sequence here is always the stored one. Stream.tap((event) => event.durable === undefined ? Effect.void : Ref.set(cursor, event.durable.seq), ), diff --git a/packages/harness/test/event.log.test.ts b/packages/harness/test/event.log.test.ts index dc22b7f..833cbee 100644 --- a/packages/harness/test/event.log.test.ts +++ b/packages/harness/test/event.log.test.ts @@ -1,8 +1,9 @@ -import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Ref, Stream } from "effect"; +import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Ref, Schema, Stream } from "effect"; import { describe, expect } from "vite-plus/test"; import { Database } from "../src/db/db.ts"; import { Event } from "../src/event/event.ts"; import { EventList } from "../src/event/list.ts"; +import { EventSchema } from "../src/event/schema.ts"; import { SessionMessageSchema } from "../src/session/message/schema.ts"; import { SessionSchema } from "../src/session/schema.ts"; import { testEffect } from "./utils/effect.ts"; @@ -24,6 +25,19 @@ const turns = (events: Event.Interface, sessionId: SessionSchema.ID, names: Read ); }); +/** + * A durable type defined outside `EventList`, standing in for a plugin's own. + * The application manifest cannot name it, which is exactly the case `manifest` + * exists for. + */ +const Foreign = EventSchema.define({ + type: "test.foreign.happened", + durable: { aggregate: "topic", version: 1 }, + schema: { topic: Schema.String, note: Schema.String }, +}); +/** Just the definitions — `log` decodes each row's `data` and builds the envelope. */ +const foreignDefinitions = EventSchema.durable([Foreign]); + const names = (items: ReadonlyArray) => items.flatMap((item) => (Event.isSynced(item) ? [] : [(item.data as { readonly messageId: string }).messageId])); @@ -290,4 +304,81 @@ describe("Event.log", () => { expect(collected).toHaveLength(102); }), ); + + it("reads types the application manifest does not know, when given their manifest", () => + Effect.gen(function* () { + const events = yield* Event.Service; + const topic = "plugin:test.foreign"; + yield* events.publish(Foreign, { topic, note: "one" }); + yield* events.publish(Foreign, { topic, note: "two" }); + + // Without a manifest the default filter excludes the type entirely: the + // rows are stored, and every one of them is skipped. + const withDefault = Array.from(yield* events.log({ aggregateId: topic }).pipe(Stream.runCollect)); + expect(withDefault.filter((item) => !Event.isSynced(item))).toEqual([]); + expect(withDefault.filter(Event.isSynced)).toHaveLength(1); + + // With the owning definitions the same rows decode. + const withDefinitions = Array.from( + yield* events.log({ aggregateId: topic, definitions: foreignDefinitions }).pipe(Stream.runCollect), + ); + const notes = withDefinitions.flatMap((item) => + Event.isSynced(item) ? [] : [(item.data as { readonly note: string }).note], + ); + expect(notes).toEqual(["one", "two"]); + // The envelope is the kernel's, built from the row rather than decoded. + const first = withDefinitions.find((item) => !Event.isSynced(item)) as Event.LogItem & { + readonly durable?: { readonly aggregateId: string; readonly seq: number }; + }; + expect(first.durable?.aggregateId).toBe(topic); + expect(first.durable?.seq).toBe(0); + })); + + it("pages custom definitions across many pages and resumes from a cursor", () => + Effect.gen(function* () { + const events = yield* Event.Service; + const topic = "plugin:test.foreign:paged"; + // Two full pages and a remainder: pagination that stopped after the first + // page would truncate here while still emitting `Synced` at the head. + const total = 300; + yield* Effect.forEach( + Array.from({ length: total }, (_, index) => index), + (index) => events.publish(Foreign, { topic, note: `n${index}` }), + { discard: true }, + ); + + const all = Array.from( + yield* events.log({ aggregateId: topic, definitions: foreignDefinitions }).pipe(Stream.runCollect), + ); + const notes = all.flatMap((item) => + Event.isSynced(item) ? [] : [(item.data as { readonly note: string }).note], + ); + expect(notes).toHaveLength(total); + expect(notes.at(0)).toBe("n0"); + expect(notes.at(-1)).toBe(`n${total - 1}`); + // Sequences are contiguous across the page boundaries, so nothing was + // skipped or re-read where one page hands over to the next. + const seqs = all.flatMap((item) => (Event.isSynced(item) ? [] : [item.durable?.seq])); + expect(seqs).toEqual(Array.from({ length: total }, (_, index) => index)); + + // And a cursor resumes mid-stream rather than replaying from the start. + const resumed = Array.from( + yield* events + .log({ aggregateId: topic, after: 199, definitions: foreignDefinitions }) + .pipe(Stream.runCollect), + ); + const tail = resumed.flatMap((item) => + Event.isSynced(item) ? [] : [(item.data as { readonly note: string }).note], + ); + expect(tail).toHaveLength(total - 200); + expect(tail.at(0)).toBe("n200"); + })); + + it("leaves kernel reads on the application manifest", () => + Effect.gen(function* () { + const events = yield* Event.Service; + yield* turns(events, A, ["first", "second"]); + const items = Array.from(yield* events.log({ aggregateId: A }).pipe(Stream.runCollect)); + expect(names(items)).toEqual(["first", "second"]); + })); }); diff --git a/packages/harness/test/settings.loop.test.ts b/packages/harness/test/settings.loop.test.ts index dee8493..dda844f 100644 --- a/packages/harness/test/settings.loop.test.ts +++ b/packages/harness/test/settings.loop.test.ts @@ -8,6 +8,7 @@ import { Sandbox } from "../src/effect/sandbox.ts"; import { Session } from "../src/effect/session.ts"; import { Event } from "../src/event/event.ts"; import { LLM } from "../src/runner/llm.ts"; +import { defaults } from "../src/settings/schema.ts"; import * as Tool from "../src/tools/tool.ts"; import { assistant } from "./fixtures/llm.ts"; import { withSettings } from "./fixtures/settings.ts"; @@ -419,9 +420,11 @@ describe("settings at exchange boundaries", () => { yield* Effect.promise(() => write(B)); yield* handle.run("with B"); expect(inputs.at(-1)?.thinkingLevel).toBe("high"); - // A's header and retry count are gone, not merged forward. + // A's header and retry count are gone, not merged forward. B names no + // `maxRetries`, so it falls back to the default — read from `defaults` + // rather than restated, which goes stale whenever a default is retuned. expect(inputs.at(-1)?.options?.headers).toEqual({ shared: "b" }); - expect(inputs.at(-1)?.options?.maxRetries).toBe(2); + expect(inputs.at(-1)?.options?.maxRetries).toBe(defaults.model.options?.maxRetries); yield* Effect.promise(() => write(A)); yield* handle.run("back to A"); diff --git a/packages/harness/test/settings.resolve.test.ts b/packages/harness/test/settings.resolve.test.ts index bfe63a3..0b3e1c1 100644 --- a/packages/harness/test/settings.resolve.test.ts +++ b/packages/harness/test/settings.resolve.test.ts @@ -60,7 +60,10 @@ describe("settings resolution", () => { }); const result = collect(merge(base, patch), "openai", "anything"); expect(result).toMatchObject({ - timeoutMs: 90000, + // The patch nulls `timeoutMs` at two levels; both are ignored, so the value + // still comes from `defaults`. Read it from there rather than restating the + // number, which silently goes stale whenever a default is retuned. + timeoutMs: defaults.model.options?.timeoutMs, headers: { a: "inherited", b: "new" }, providerArray: ["one"], extras: { organization: "org" }, From cafb06ee602b2230c9f64766bc729a6d2c6d0449 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 11 Sep 2026 10:39:30 +0530 Subject: [PATCH 02/12] fix(harness): remove unused flag failureMode in tool defn --- packages/harness/src/tools/bash.ts | 1 - packages/harness/src/tools/executor.ts | 17 +++----- packages/harness/src/tools/tool.ts | 16 -------- packages/harness/test/tools.registry.test.ts | 3 +- packages/harness/test/tools.tool.test.ts | 43 +------------------- 5 files changed, 9 insertions(+), 71 deletions(-) diff --git a/packages/harness/src/tools/bash.ts b/packages/harness/src/tools/bash.ts index 4350fbb..3052bbe 100644 --- a/packages/harness/src/tools/bash.ts +++ b/packages/harness/src/tools/bash.ts @@ -74,7 +74,6 @@ export const bashDef = Tool.define({ parameters: BashParams, success: BashSuccess, failure: BashFailure, - failureMode: "return", // The model reads just the command output, not the JSON envelope. encodeContent: (success) => [{ type: "text", text: success.output }], encodeFailureContent: (failure) => [{ type: "text", text: failure.output }], diff --git a/packages/harness/src/tools/executor.ts b/packages/harness/src/tools/executor.ts index bb6edd4..ea35d79 100644 --- a/packages/harness/src/tools/executor.ts +++ b/packages/harness/src/tools/executor.ts @@ -54,8 +54,8 @@ export interface Executor { /** * Atomically transform one complete pending tool-call part into a complete terminal * part. Most failures become a terminal - * `ToolOutcome`; a `failureMode: "error"` tool propagates a {@link ToolExecutionError} - * retaining its declared failure as `cause`, and undeclared failures/defects propagate as defects. + * `ToolOutcome`; declared failures retain their encoded details, while undeclared + * failures/defects propagate as defects. * * Tools enter as {@link RegisteredTool}s (capability `R` already discharged at * registration), so the only requirement left in the result is a progress sink's own @@ -64,7 +64,7 @@ export interface Executor { readonly handle: ( call: Message.ToolCallPendingPart, options?: HandleOptions, - ) => Effect.Effect; + ) => Effect.Effect; } /** Default sliding-queue capacity for best-effort progress. */ @@ -144,7 +144,7 @@ const encodeOutcome = ( call: Message.ToolCallPendingPart, exit: Exit.Exit, latest: Ref.Ref>, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { if (Exit.isSuccess(exit)) { const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.success))(exit.value).pipe(Effect.orDie); @@ -171,12 +171,7 @@ const encodeOutcome = ( if (def.failure === undefined || !Schema.is(asCodec(def.failure))(failure)) { return yield* Effect.die(failure); } - // A declared, expected failure. "error" opts it into the caller's error - // channel as ToolExecutionError; "return" (default) encodes the original - // failure into a model-facing tool error result. - if (def.failureMode === "error") { - return yield* executionError.value; - } + // Encode declared failures as model-facing tool error results. const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.failure))(failure).pipe(Effect.orDie); const content = def.encodeFailureContent ? def.encodeFailureContent(failure) : [yield* jsonText(encoded)]; const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); @@ -209,7 +204,7 @@ export const make = (tools: ReadonlyArray): Executor => { const handle = ( call: Message.ToolCallPendingPart, options?: HandleOptions, - ): Effect.Effect => + ): Effect.Effect => Effect.gen(function* () { const impl = impls.get(call.name); if (impl === undefined) { diff --git a/packages/harness/src/tools/tool.ts b/packages/harness/src/tools/tool.ts index 991aa9c..0c87142 100644 --- a/packages/harness/src/tools/tool.ts +++ b/packages/harness/src/tools/tool.ts @@ -23,19 +23,6 @@ import type { ToolProgress } from "./progress.ts"; /** What the model reads next turn (the `content` side of content/details). */ export type ModelContent = ReadonlyArray; -/** - * Expected failures become a tool-error result fed back to the model ("return"); - * "error" lets them hit the calling effect's error channel. Defaults to - * "return" in {@link define}. - * - * "return" — almost always, for agent tools. - * A bash non-zero exit, a file-not-found, a bad-patch — the model should see these and adapt. - * - * "error" — the rare "this failure is fatal to the run" - * case: e.g. an unrecoverable auth/quota error where letting the model keep looping is pointless. - */ -export type FailureMode = "return" | "error"; - /** Per-call metadata handed to a handler instead of positional args. */ export interface ToolCallContext { readonly callID: string; @@ -68,7 +55,6 @@ export interface ToolDef< readonly success: Success; /** Declared, model-visible failures (typed). Omit for tools that cannot fail expectedly. */ readonly failure?: Failure; - readonly failureMode: FailureMode; /** Render success for the model. Omit → executor falls back to JSON text. */ readonly encodeContent?: (success: Success["Type"]) => ModelContent; /** Render an expected failure for the model. Omit → executor falls back to JSON text. */ @@ -114,7 +100,6 @@ interface DefineInput< readonly parameters: Params; readonly success: Success; readonly failure?: Failure; - readonly failureMode?: FailureMode; readonly encodeContent?: (success: Success["Type"]) => ModelContent; readonly encodeFailureContent?: (failure: Failure["Type"]) => ModelContent; } @@ -129,7 +114,6 @@ export const define = < input: DefineInput, ): ToolDef => ({ ...input, - failureMode: input.failureMode ?? "return", }); /** Attach a handler to an existing definition (the testable def/exec split). */ diff --git a/packages/harness/test/tools.registry.test.ts b/packages/harness/test/tools.registry.test.ts index a2474f2..a372a5a 100644 --- a/packages/harness/test/tools.registry.test.ts +++ b/packages/harness/test/tools.registry.test.ts @@ -231,7 +231,6 @@ const weatherDef = Tool.define({ parameters: WeatherParams, success: WeatherReport, failure: WeatherUnknownCity, - failureMode: "return", encodeContent: (report) => [{ type: "text", text: `${report.city}: ${report.tempC}°C, ${report.sky}` }], encodeFailureContent: (failure) => [{ type: "text", text: `No weather data for "${failure.city}".` }], }); @@ -296,7 +295,7 @@ describe("ToolRegistry — custom weather tool alongside the built-in bash", () expect(elapsed).toBeGreaterThanOrEqual(Duration.toMillis(latency) - 5); // timer jitter tolerance }); - it("returns a declared failure as a model-visible error outcome (failureMode: return)", async () => { + it("returns a declared failure as a model-visible error outcome", async () => { const resolved = Registry.make([registerWeather()]).resolve(); const outcome = await Effect.runPromise(resolved.handle(pendingCall("weather", { city: "atlantis" }))); diff --git a/packages/harness/test/tools.tool.test.ts b/packages/harness/test/tools.tool.test.ts index 4b11a23..b952c1b 100644 --- a/packages/harness/test/tools.tool.test.ts +++ b/packages/harness/test/tools.tool.test.ts @@ -1,7 +1,6 @@ -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { bashDef } from "../src/tools/bash.ts"; -import { ToolExecutionError } from "../src/tools/error.ts"; import * as Executor from "../src/tools/executor.ts"; import * as Tool from "../src/tools/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; @@ -19,23 +18,12 @@ describe("Tool definition", () => { it("is pure, serializable data", () => { expect(bashDef.name).toBe("bash"); expect(bashDef.label).toBe("bash"); - expect(bashDef.failureMode).toBe("return"); expect(typeof bashDef.description).toBe("string"); // schemas are present (not the decoded values) expect(bashDef.parameters).toBeDefined(); expect(bashDef.success).toBeDefined(); expect(bashDef.failure).toBeDefined(); }); - - it('defaults failureMode to "return"', () => { - const def = Tool.define({ - name: "noop", - description: "does nothing", - parameters: Schema.Struct({}), - success: Schema.Struct({}), - }); - expect(def.failureMode).toBe("return"); - }); }); describe("toProviderJsonSchema", () => { @@ -101,40 +89,13 @@ describe("Executor", () => { expect(() => Executor.make([Tool.register(first), Tool.register(second)])).toThrow(/duplicate/i); }); - it('propagates declared failures when failureMode is "error"', async () => { - const tool = Tool.make({ - name: "expectedFailure", - description: "fails through the error channel", - parameters: EmptyParams, - success: EmptySuccess, - failure: ExpectedFailure, - failureMode: "error", - handler: () => Effect.fail(new ExpectedFailure({ message: "boom" })), - }); - const executor = Executor.make([Tool.register(tool)]); - - const exit = await Effect.runPromiseExit(executor.handle(call("expectedFailure"))); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Cause.findErrorOption(exit.cause); - expect(Option.isSome(failure)).toBe(true); - if (Option.isSome(failure)) { - expect(failure.value).toBeInstanceOf(ToolExecutionError); - expect(failure.value.toolName).toBe("expectedFailure"); - expect(failure.value.cause).toBeInstanceOf(ExpectedFailure); - } - } - }); - - it('returns declared failures as tool errors when failureMode is "return"', async () => { + it("returns declared failures as encoded tool errors", async () => { const tool = Tool.make({ name: "returnedFailure", description: "fails as a tool result", parameters: EmptyParams, success: EmptySuccess, failure: ExpectedFailure, - failureMode: "return", handler: () => Effect.fail(new ExpectedFailure({ message: "boom" })), }); const executor = Executor.make([Tool.register(tool)]); From e0a374727cb8eaea2e75d16ec031a248d6d3d480 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 11 Sep 2026 16:18:37 +0530 Subject: [PATCH 03/12] feat(harness): add support for plugins --- packages/harness/README.md | 48 +++ packages/harness/package.json | 4 + packages/harness/src/effect.ts | 3 +- packages/harness/src/effect/harness.ts | 12 +- packages/harness/src/effect/session.ts | 15 +- packages/harness/src/effect/tools.ts | 3 - packages/harness/src/plugin/catalog.ts | 73 ++++ packages/harness/src/plugin/context.ts | 37 ++ packages/harness/src/plugin/host.ts | 57 +++ packages/harness/src/plugin/index.ts | 5 + packages/harness/src/plugin/internal.ts | 7 + .../src/plugin/internal/prompt/default.ts | 17 + .../src/plugin/internal/prompt/guidelines.ts | 10 + .../harness/src/plugin/internal/tool/bash.ts | 15 + packages/harness/src/plugin/loader.ts | 135 +++++++ packages/harness/src/plugin/package.ts | 115 ++++++ packages/harness/src/plugin/plugin.ts | 12 + .../harness/src/plugin/prompt/registry.ts | 20 + packages/harness/src/plugin/prompt/schema.ts | 4 + packages/harness/src/plugin/registry.ts | 28 ++ packages/harness/src/plugin/tool/registry.ts | 67 ++++ packages/harness/src/plugin/tool/schema.ts | 43 ++ packages/harness/src/runner/llm.ts | 16 +- packages/harness/src/runner/loop.ts | 11 +- packages/harness/src/state/prompt.ts | 138 ------- packages/harness/src/state/state.ts | 133 ++----- packages/harness/src/tools/executor.ts | 274 +++++++++---- packages/harness/src/tools/registry.ts | 18 +- packages/harness/src/tools/tool.ts | 11 +- .../test/fixtures/runner.cycle.spec.ts | 1 - packages/harness/test/plugin.catalog.test.ts | 207 ++++++++++ packages/harness/test/plugin.hooks.test.ts | 310 +++++++++++++++ packages/harness/test/plugin.host.test.ts | 369 ++++++++++++++++++ packages/harness/test/runner.llm.live.test.ts | 2 + packages/harness/test/runner.llm.test.ts | 8 +- packages/harness/test/runner.loop.test.ts | 48 ++- packages/harness/test/sdk.test.ts | 14 +- packages/harness/test/settings.live.test.ts | 1 - packages/harness/test/settings.llm.test.ts | 22 +- packages/harness/test/settings.loop.test.ts | 36 +- pnpm-lock.yaml | 68 +++- 41 files changed, 2019 insertions(+), 398 deletions(-) delete mode 100644 packages/harness/src/effect/tools.ts create mode 100644 packages/harness/src/plugin/catalog.ts create mode 100644 packages/harness/src/plugin/context.ts create mode 100644 packages/harness/src/plugin/host.ts create mode 100644 packages/harness/src/plugin/index.ts create mode 100644 packages/harness/src/plugin/internal.ts create mode 100644 packages/harness/src/plugin/internal/prompt/default.ts create mode 100644 packages/harness/src/plugin/internal/prompt/guidelines.ts create mode 100644 packages/harness/src/plugin/internal/tool/bash.ts create mode 100644 packages/harness/src/plugin/loader.ts create mode 100644 packages/harness/src/plugin/package.ts create mode 100644 packages/harness/src/plugin/plugin.ts create mode 100644 packages/harness/src/plugin/prompt/registry.ts create mode 100644 packages/harness/src/plugin/prompt/schema.ts create mode 100644 packages/harness/src/plugin/registry.ts create mode 100644 packages/harness/src/plugin/tool/registry.ts create mode 100644 packages/harness/src/plugin/tool/schema.ts delete mode 100644 packages/harness/src/state/prompt.ts create mode 100644 packages/harness/test/plugin.catalog.test.ts create mode 100644 packages/harness/test/plugin.hooks.test.ts create mode 100644 packages/harness/test/plugin.host.test.ts diff --git a/packages/harness/README.md b/packages/harness/README.md index 97f1297..1375d91 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -15,6 +15,54 @@ The initial public surface is the Effect SDK at `@codeworksh/harness/effect`. - **Model Flexibility:** select any provider and model available in Aikit's generated catalog, including its supported thinking levels. - **Pluggable Sandboxes:** run the same workflow against the host machine, a virtual filesystem, or a remote sandbox. +## Plugins + +Pass an ordered plugin list when constructing the harness. Each plugin has a required ID and contributes tools or a prompt during setup. Setup runs once per exchange, after model resolution; its tools, hooks, and prompt remain pinned through tool continuations. + +```ts +import { Effect, Schema } from "effect"; +import { Harness, Plugin, Tool } from "@codeworksh/harness/effect"; + +const echo = Plugin.define({ + id: "acme.tool.echo", + setup(ctx) { + ctx.plugin.tools.add( + Tool.register( + Tool.make({ + name: "echo", + description: "Echo a message", + parameters: Schema.Struct({ text: Schema.String }), + success: Schema.String, + handler: ({ text }) => Effect.succeed(text), + }), + ), + { + beforeToolCall(call) { + // Arguments have already been decoded. Return { block: true, reason: "..." } + // to skip this handler and its after hook. + }, + afterToolCall({ terminal }) { + // Completed/error results can be patched through content, details, isError. + // Aborted results are observation-only; keep cancellation cleanup short. + }, + }, + ); + }, +}); + +const runtime = Harness.layer({ + plugins: ["codework.tool.bash", echo, "codework.prompt.default"], +}); +``` + +Hooks belong to the tool registration. Sequential or parallel scheduling, selected with `Session.create({ tools: { execution: "parallel" } })`, covers the entire hook/handler pipeline. After runs for a started, interrupted tool if it has not already started, with a one-second cooperative cleanup grace period. The kernel owns result settlement. + +Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle. + +Omitting `plugins` selects Bash, the default prompt, and guidelines. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. + +Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. Plugin discovery from settings and daemon lifecycles are not implemented. + ## Pluggable Sandboxes Harness uses a driver-based sandbox architecture. Drivers share a common lifecycle and I/O surface, keeping provider details out of session and agent-loop code. diff --git a/packages/harness/package.json b/packages/harness/package.json index e9894d2..5784f4e 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -83,7 +83,10 @@ "@effect/sql-sqlite-node": "4.0.0-beta.107", "@platformatic/vfs": "^0.4.0", "effect": "4.0.0-beta.107", + "import-meta-resolve": "^4.2.0", "just-bash": "^2.14.5", + "npm-package-arg": "^14.0.0", + "resolve.exports": "^2.0.3", "tslib": "^2.8.1", "typebox": "^1.3.10", "uuid": "^14.0.1", @@ -93,6 +96,7 @@ "@codeworksh-test/codework-sandbox-vercel": "workspace:*", "@daytona/sdk": "^0.187.0", "@types/node": "^25.9.5", + "@types/npm-package-arg": "^6.1.4", "@vercel/sandbox": "^2.9.2", "bumpp": "^12.2.0", "dedent": "^1.7.2" diff --git a/packages/harness/src/effect.ts b/packages/harness/src/effect.ts index 7374117..d90d84b 100644 --- a/packages/harness/src/effect.ts +++ b/packages/harness/src/effect.ts @@ -1,10 +1,11 @@ export { Harness } from "./effect/harness.ts"; export { Sandbox } from "./effect/sandbox.ts"; export { Session } from "./effect/session.ts"; -export { Tools } from "./effect/tools.ts"; export { EventList } from "./event/list.ts"; export { EventSchema } from "./event/schema.ts"; export { Runner } from "./runner/run.ts"; export * as Tool from "./tools/tool.ts"; export { Settings } from "./settings/settings.ts"; + +export * as Plugin from "./plugin/index.ts"; diff --git a/packages/harness/src/effect/harness.ts b/packages/harness/src/effect/harness.ts index 5b5a70c..2e9fc26 100644 --- a/packages/harness/src/effect/harness.ts +++ b/packages/harness/src/effect/harness.ts @@ -16,9 +16,12 @@ import { SandboxDriverRegistry } from "../sandbox/registry.ts"; import { SessionLive } from "../session/live.ts"; import { SessionRuntime } from "../session/runtime.ts"; import { Settings } from "../settings/settings.ts"; +import { prepare, type PluginRef } from "../plugin/catalog.ts"; +import { builtins, defaultRefs } from "../plugin/internal.ts"; import { State } from "../state/state.ts"; export interface Options { + readonly plugins?: ReadonlyArray; readonly database?: string; readonly home?: string; /** user provided directory containing the highest-priority config. */ @@ -27,10 +30,12 @@ export interface Options { readonly llm?: LLM.Open; } -export const layer = (options: Options = {}) => - Layer.unwrap( +export const layer = (options: Options = {}) => { + const base = process.cwd(); + return Layer.unwrap( Effect.gen(function* () { const paths = yield* Global.resolve(options.home === undefined ? {} : { home: options.home }); + const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, base }); const configuredDatabase = options.database ?? (yield* Database.locationConfig); const global = Global.layerWith(paths); const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data)); @@ -45,7 +50,7 @@ export const layer = (options: Options = {}) => return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))), - Layer.provideMerge(State.layer()), + Layer.provideMerge(State.layer({}, plugins)), Layer.provideMerge( Settings.layer(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }), ), @@ -59,5 +64,6 @@ export const layer = (options: Options = {}) => ); }), ); +}; export * as Harness from "./harness.ts"; diff --git a/packages/harness/src/effect/session.ts b/packages/harness/src/effect/session.ts index 6036960..02906bf 100644 --- a/packages/harness/src/effect/session.ts +++ b/packages/harness/src/effect/session.ts @@ -13,11 +13,8 @@ import { PromptSchema } from "../session/prompt/schema.ts"; import * as SessionRuntime from "../session/runtime.ts"; import { SessionSchema } from "../session/schema.ts"; import { Session as SessionStore } from "../session/session.ts"; -import type { StatePrompt } from "../state/prompt.ts"; import type { State } from "../state/state.ts"; -import type { RegisteredTool } from "../tools/tool.ts"; import type { Info as SandboxInfo } from "./sandbox.ts"; -import type { bash } from "./tools.ts"; export interface ModelConfig { readonly provider: string; @@ -26,15 +23,12 @@ export interface ModelConfig { } export interface ToolsConfig { - readonly builtins?: ReadonlyArray; - readonly extras?: ReadonlyArray; readonly execution?: State.ToolExecutionMode; } export interface SystemPromptConfig { - readonly custom?: string; - readonly append?: string; - readonly override?: StatePrompt.PromptSystemOverride; + readonly custom?: State.Options["promptCustom"]; + readonly append?: State.Options["promptSystemAppend"]; } export interface RuntimeInput { @@ -87,12 +81,9 @@ const runtimeBindings = (input: RuntimeInput): SessionRuntime.Bindings => ({ ...input.model?.options, ...(input.model === undefined ? {} : { provider: input.model.provider, model: input.model.id }), ...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }), - ...(input.tools?.extras === undefined ? {} : { tools: input.tools.extras }), - ...(input.tools?.builtins === undefined ? {} : { builtinTools: input.tools.builtins }), ...(input.tools?.execution === undefined ? {} : { toolExecution: input.tools.execution }), ...(input.systemPrompt?.custom === undefined ? {} : { promptCustom: input.systemPrompt.custom }), ...(input.systemPrompt?.append === undefined ? {} : { promptSystemAppend: input.systemPrompt.append }), - ...(input.systemPrompt?.override === undefined ? {} : { promptSystemOverride: input.systemPrompt.override }), }); const promptInput = (input: PromptInput) => { @@ -190,7 +181,7 @@ export const attach = Effect.fn("Session.attach")(function* (input: AttachInput) /* * Merge, not replace. `runtimeBindings` emits only the keys this call names, so a * bare `attach({ sessionId })` produces `{}` -- and a replace would silently drop the - * tools, prompt overrides, and model a previous attach established. Bindings are now + * tool execution mode, prompt inputs, and model a previous attach established. Bindings are now * the only config layer, so there is nothing behind them to restore what a wipe took. */ yield* runtime.update(input.sessionId, runtimeBindings(input)); diff --git a/packages/harness/src/effect/tools.ts b/packages/harness/src/effect/tools.ts deleted file mode 100644 index 1636889..0000000 --- a/packages/harness/src/effect/tools.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const bash = "bash" as const; - -export * as Tools from "./tools.ts"; diff --git a/packages/harness/src/plugin/catalog.ts b/packages/harness/src/plugin/catalog.ts new file mode 100644 index 0000000..aba7b5b --- /dev/null +++ b/packages/harness/src/plugin/catalog.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; +import * as Loader from "./loader.ts"; +import type { Plugin } from "./plugin.ts"; + +export type PluginRef = Plugin | string; +export interface Catalog { + readonly add: (plugin: Plugin, source?: string, version?: string) => void; + readonly get: (id: string) => Plugin | undefined; + readonly has: (id: string) => boolean; +} + +/** Catalogs belong to one harness configuration. Definitions never execute here. */ +export const make = (): Catalog => { + const entries = new Map(); + return { + add: (plugin, source, version) => { + entries.set(plugin.id, { + plugin, + ...(source === undefined ? {} : { source }), + ...(version === undefined ? {} : { version }), + }); + }, + get: (id) => entries.get(id)?.plugin, + has: (id) => entries.has(id), + }; +}; + +export interface Options extends Loader.Options { + readonly builtins: ReadonlyArray; +} + +export const prepare = Effect.fn("PluginCatalog.prepare")(function* ( + references: ReadonlyArray, + options: Options, +) { + const catalog = make(); + const base = options.base ?? process.cwd(); + for (const builtin of options.builtins) { + catalog.add(yield* Loader.validate(builtin, { index: -1, reference: builtin.id }, true), "builtin"); + } + const operations = new Map(); + const loaded = new Map(); + for (const [index, reference] of references.entries()) { + const origin = { index, reference: typeof reference === "string" ? reference : reference.id }; + if (typeof reference !== "string") { + const plugin = yield* Loader.validate(reference, origin); + catalog.add(plugin, "object"); + operations.set(plugin.id, { enabled: true, origin }); + continue; + } + const source = yield* Effect.try({ + try: () => Loader.classify(reference, base), + catch: (cause) => Loader.failure(origin, "source", cause), + }); + if (source.kind === "id" || source.kind === "disable") { + operations.set(source.id, { enabled: source.kind === "id", origin }); + continue; + } + const key = source.kind === "package" ? source.request.spec : source.path; + const definition = loaded.get(key) ?? (yield* Loader.load(source, origin, options)); + loaded.set(key, definition); + catalog.add(definition.plugin, definition.source, definition.version); + operations.set(definition.plugin.id, { enabled: true, origin }); + } + const plugins: Plugin[] = []; + for (const [id, operation] of [...operations].sort((a, b) => a[1].origin.index - b[1].origin.index)) { + if (!operation.enabled) continue; + const plugin = catalog.get(id); + if (!plugin) return yield* Loader.failure(operation.origin, "resolve", new Error(`Unknown plugin ID: ${id}`), id); + plugins.push(plugin); + } + return Object.freeze(plugins); +}); diff --git a/packages/harness/src/plugin/context.ts b/packages/harness/src/plugin/context.ts new file mode 100644 index 0000000..4b6408f --- /dev/null +++ b/packages/harness/src/plugin/context.ts @@ -0,0 +1,37 @@ +import type { Model } from "@codeworksh/aikit"; +import { Effect } from "effect"; +import type { Event } from "../event/event.ts"; +import { EventList } from "../event/list.ts"; +import type { Location } from "../location/location.ts"; +import type { SandboxIO } from "../sandbox/io.ts"; +import type { SessionSchema } from "../session/schema.ts"; +import type { Info } from "../settings/schema.ts"; +import type { PluginRegistry } from "./registry.ts"; + +export type PromptResolver = (ctx: SharedPluginContext) => string | Promise; +export interface Config { + readonly promptCustom?: PromptResolver; + readonly promptSystemAppend?: PromptResolver; +} +export interface Events { + readonly publish: Event.Interface["publish"]; +} +export interface SharedPluginContext { + readonly sessionId: SessionSchema.ID; + readonly sandbox: SandboxIO.Identity; + readonly location: Location.Info; + readonly settings: Info; + readonly model: Model.Info; + readonly config: Config; + readonly events: Events; + readonly plugin: PluginRegistry; +} + +const reserved = new Set(EventList.DurableDefinitions.map((definition) => definition.type)); +export const makeEvents = (events: Events): Events => + Object.freeze({ + publish: (definition, data, options) => + reserved.has(definition.type) + ? Effect.die(new Error(`Plugins cannot publish kernel journal event: ${definition.type}`)) + : events.publish(definition, data, options), + }); diff --git a/packages/harness/src/plugin/host.ts b/packages/harness/src/plugin/host.ts new file mode 100644 index 0000000..5e501d7 --- /dev/null +++ b/packages/harness/src/plugin/host.ts @@ -0,0 +1,57 @@ +import { Cause, Effect, Schema } from "effect"; +import type { SharedPluginContext } from "./context.ts"; +import type { Plugin } from "./plugin.ts"; +import { make } from "./registry.ts"; + +export class SetupError extends Schema.TaggedError()("Plugin.SetupError", { + pluginId: Schema.optional(Schema.String), + message: Schema.String, + cause: Schema.Defect(), +}) {} + +export const run = Effect.fn("PluginHost.run")(function* ( + plugins: ReadonlyArray, + input: Omit, +) { + const buckets = make(); + const ctx = Object.freeze({ ...input, plugin: buckets.registry }); + const setup = Effect.gen(function* () { + for (const plugin of plugins) { + yield* Effect.suspend(() => { + const result = plugin.setup(ctx); + if (Effect.isEffect(result)) + // Plugin authors may fail with domain-specific errors; normalize them at this boundary. + // @effect-diagnostics-next-line anyUnknownInErrorContext:off + return result.pipe( + Effect.mapError( + (cause) => + new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }), + ), + ); + if (result === undefined) return Effect.void; + return Effect.tryPromise({ + try: () => result, + catch: (cause) => + new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }), + }); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.fail( + new SetupError({ + pluginId: plugin.id, + message: `Plugin setup failed: ${plugin.id}`, + cause: Cause.squash(cause), + }), + ), + ), + ); + } + return yield* Effect.try({ + try: buckets.freeze, + catch: (cause) => new SetupError({ message: "Plugin snapshot freeze failed", cause }), + }); + }); + return yield* setup.pipe(Effect.ensuring(Effect.sync(buckets.close))); +}); diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts new file mode 100644 index 0000000..a9ff1e3 --- /dev/null +++ b/packages/harness/src/plugin/index.ts @@ -0,0 +1,5 @@ +export { define, type Plugin, type Mount } from "./plugin.ts"; +export type { SharedPluginContext, Config, Events, PromptResolver } from "./context.ts"; +export type { PluginRegistry } from "./registry.ts"; +export * as Tool from "./tool/schema.ts"; +export * as Prompt from "./prompt/schema.ts"; diff --git a/packages/harness/src/plugin/internal.ts b/packages/harness/src/plugin/internal.ts new file mode 100644 index 0000000..c6ee20e --- /dev/null +++ b/packages/harness/src/plugin/internal.ts @@ -0,0 +1,7 @@ +import { bashPlugin } from "./internal/tool/bash.ts"; +import { defaultPromptPlugin } from "./internal/prompt/default.ts"; +import { guidelinesPromptPlugin } from "./internal/prompt/guidelines.ts"; + +export const builtins = Object.freeze([bashPlugin, defaultPromptPlugin, guidelinesPromptPlugin]); +export const defaults = builtins; +export const defaultRefs = Object.freeze(builtins.map((plugin) => plugin.id)); diff --git a/packages/harness/src/plugin/internal/prompt/default.ts b/packages/harness/src/plugin/internal/prompt/default.ts new file mode 100644 index 0000000..7149c89 --- /dev/null +++ b/packages/harness/src/plugin/internal/prompt/default.ts @@ -0,0 +1,17 @@ +import { Effect } from "effect"; +import { define } from "../../plugin.ts"; + +export const defaultPromptPlugin = define({ + id: "codework.prompt.default", + setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) { + const custom = ctx.config.promptCustom; + const append = ctx.config.promptSystemAppend; + const foundation = + custom === undefined + ? "You are a coding assistant." + : yield* Effect.tryPromise(() => Promise.resolve().then(() => custom(ctx))); + const extra = + append === undefined ? undefined : yield* Effect.tryPromise(() => Promise.resolve().then(() => append(ctx))); + ctx.plugin.prompt.set([foundation, extra].filter((part) => part !== undefined).join("\n\n")); + }), +}); diff --git a/packages/harness/src/plugin/internal/prompt/guidelines.ts b/packages/harness/src/plugin/internal/prompt/guidelines.ts new file mode 100644 index 0000000..7cfe687 --- /dev/null +++ b/packages/harness/src/plugin/internal/prompt/guidelines.ts @@ -0,0 +1,10 @@ +import { define } from "../../plugin.ts"; + +export const guidelinesPromptPlugin = define({ + id: "codework.prompt.guidelines", + setup(ctx) { + const current = ctx.plugin.prompt.get(); + if (current === undefined) throw new Error("Expected an existing prompt"); + ctx.plugin.prompt.set(`${current}\n\nPrefer rg for searches. Keep changes focused.`); + }, +}); diff --git a/packages/harness/src/plugin/internal/tool/bash.ts b/packages/harness/src/plugin/internal/tool/bash.ts new file mode 100644 index 0000000..5f96c8e --- /dev/null +++ b/packages/harness/src/plugin/internal/tool/bash.ts @@ -0,0 +1,15 @@ +import { Effect, Layer } from "effect"; +import { SandboxIO } from "../../../sandbox/io.ts"; +import { bashTool } from "../../../tools/bash.ts"; +import { fromSandboxShell } from "../../../tools/shell.ts"; +import * as Tool from "../../../tools/tool.ts"; +import { define } from "../../plugin.ts"; + +export const bashPlugin = define({ + id: "codework.tool.bash", + setup: Effect.fn("BashPlugin.setup")(function* (ctx) { + const shell = yield* SandboxIO.Shell; + const mounted = fromSandboxShell.pipe(Layer.provide(Layer.succeed(SandboxIO.Shell, shell))); + ctx.plugin.tools.add(Tool.provide(bashTool, mounted)); + }), +}); diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts new file mode 100644 index 0000000..3a5181f --- /dev/null +++ b/packages/harness/src/plugin/loader.ts @@ -0,0 +1,135 @@ +import { Effect, Predicate, Schema } from "effect"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { exports as packageExports } from "resolve.exports"; +import { fileSystem as fs, hostPath as path } from "../host.ts"; +import * as Package from "./package.ts"; +import type { Plugin } from "./plugin.ts"; + +export const idPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9.-]*$/; +const Id = Schema.String.check(Schema.isPattern(idPattern)); +const Definition = Schema.Struct({ + id: Id, + setup: Schema.declare((value): value is Plugin["setup"] => Predicate.isFunction(value)), +}); + +export class PreparationError extends Schema.TaggedError()("PluginPreparationError", { + phase: Schema.Literals(["source", "install", "import", "definition", "resolve"]), + index: Schema.Finite, + reference: Schema.String, + id: Schema.optional(Schema.String), + cause: Schema.Defect(), +}) {} + +export interface Origin { + readonly index: number; + readonly reference: string; +} + +export const failure = (origin: Origin, phase: PreparationError["phase"], cause: unknown, id?: string) => + new PreparationError({ ...origin, phase, cause, ...(id === undefined ? {} : { id }) }); + +export const validate = Effect.fn("PluginLoader.validate")(function* (input: unknown, origin: Origin, builtin = false) { + const plugin = yield* Schema.decodeUnknownEffect(Definition)(input).pipe( + Effect.mapError((cause) => failure(origin, "definition", cause)), + ); + if (!builtin && plugin.id.startsWith("codework.")) { + return yield* failure( + origin, + "definition", + new Error("The codework namespace is reserved for built-ins"), + plugin.id, + ); + } + return plugin; +}); + +export type Source = + | { readonly kind: "disable"; readonly id: string } + | { readonly kind: "id"; readonly id: string } + | { readonly kind: "local"; readonly path: string } + | { readonly kind: "package"; readonly request: Package.Request }; + +export const classify = (source: string, base: string): Source => { + if (source.startsWith("!")) { + const id = Schema.decodeSync(Id)(source.slice(1)); + return { kind: "disable", id }; + } + if (source.startsWith("file:") || source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) { + return { kind: "local", path: source.startsWith("file:") ? fileURLToPath(source) : path.resolve(base, source) }; + } + if (Schema.is(Id)(source)) return { kind: "id", id: source }; + return { kind: "package", request: Package.parse(source) }; +}; + +const Manifest = Schema.Struct({ + name: Schema.optional(Schema.String), + exports: Schema.optional(Schema.Unknown), +}); + +const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string, origin: Origin) { + const stat = yield* fs.stat(location); + if (stat.type !== "Directory") return pathToFileURL(location).href; + const manifestPath = path.join(location, "package.json"); + if (!(yield* fs.exists(manifestPath))) return pathToFileURL(path.join(location, "index.js")).href; + const manifest = yield* fs + .readFileString(manifestPath) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest)))); + if (manifest.exports !== undefined) { + const targets = yield* Effect.try(() => packageExports(manifest, ".")); + const target = targets?.[0]; + if (target === undefined || !target.startsWith("./")) + return yield* failure(origin, "source", new Error("No valid root package export")); + const resolved = path.resolve(location, target); + if (path.relative(location, resolved).startsWith("..")) + return yield* failure(origin, "source", new Error("Package export escapes its root")); + return pathToFileURL(resolved).href; + } + return yield* Effect.try(() => pathToFileURL(createRequire(pathToFileURL(manifestPath)).resolve(location)).href); +}); + +export interface Options { + readonly cache: string; + readonly base?: string; + readonly import?: (url: string) => Promise; + readonly install?: ( + request: Package.Request, + cache: string, + ) => Effect.Effect; +} + +export interface Loaded { + readonly plugin: Plugin; + readonly source: string; + readonly version?: string; +} + +export const load = Effect.fn("PluginLoader.load")(function* ( + source: Extract, + origin: Origin, + options: Options, +) { + const installed = + source.kind === "package" + ? yield* (options.install ?? Package.install)(source.request, options.cache).pipe( + Effect.mapError((cause) => failure(origin, "install", cause)), + ) + : { + url: yield* localUrl(source.path, origin).pipe( + Effect.mapError((cause) => failure(origin, "source", cause)), + ), + }; + const module = yield* Effect.tryPromise({ + try: () => (options.import ?? ((url) => import(/* @vite-ignore */ url)))(installed.url), + catch: (cause) => failure(origin, "import", cause), + }); + const plugin = yield* validate( + Predicate.isObject(module) && "default" in module ? module.default : undefined, + origin, + ); + return { + plugin, + source: origin.reference, + ...("version" in installed ? { version: installed.version } : {}), + } satisfies Loaded; +}); diff --git a/packages/harness/src/plugin/package.ts b/packages/harness/src/plugin/package.ts new file mode 100644 index 0000000..d4ff785 --- /dev/null +++ b/packages/harness/src/plugin/package.ts @@ -0,0 +1,115 @@ +import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"; +import { Effect, Layer, Schema } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import { resolve } from "import-meta-resolve"; +import { createHash } from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import npa from "npm-package-arg"; +import { fileSystem as fs, hostPath as path } from "../host.ts"; + +export interface Request { + readonly name: string; + readonly spec: string; +} + +export const parse = (source: string): Request => { + const parsed = npa(source); + if (!parsed.name || !["version", "range", "tag"].includes(parsed.type)) { + throw new Error(`Unsupported plugin package source: ${source}`); + } + return { name: parsed.name, spec: `${parsed.name}@${parsed.raw === parsed.name ? "latest" : parsed.rawSpec}` }; +}; + +const Manifest = Schema.Struct({ version: Schema.String }); +const Cached = Schema.Struct({ version: Schema.String, spec: Schema.String, entrypoint: Schema.String }); + +export interface Installed { + readonly url: string; + readonly version: string; +} + +/** Runs inside the isolated staging directory. Override only for deterministic tests. */ +export class InstallError extends Schema.TaggedError()("PluginInstallError", { + cause: Schema.Defect(), +}) {} +export type Runner = (request: Request, directory: string) => Effect.Effect; + +const run: Runner = Effect.fn("PluginPackage.run")( + function* (request, directory) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const process = yield* spawner.spawn( + ChildProcess.make("pnpm", ["add", "--ignore-scripts", "--ignore-workspace", "--save-exact", request.spec], { + cwd: directory, + stdout: "ignore", + stderr: "inherit", + }), + ); + const code = yield* process.exitCode; + if (code !== 0) + return yield* new InstallError({ cause: new Error(`pnpm installation failed with exit code ${code}`) }); + }, + Effect.scoped, + Effect.provide(NodeChildProcessSpawner.layer.pipe(Layer.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)))), + Effect.mapError((cause) => new InstallError({ cause })), +); + +const lock = Effect.fn("PluginPackage.lock")(function* (directory: string) { + while (true) { + const acquired = yield* Effect.acquireRelease( + fs.makeDirectory(directory).pipe( + Effect.as(true), + Effect.catch((error) => + error.reason._tag === "AlreadyExists" ? Effect.succeed(false) : Effect.fail(error), + ), + ), + (acquired) => (acquired ? fs.remove(directory, { recursive: true }).pipe(Effect.orDie) : Effect.void), + ); + if (acquired) return; + yield* Effect.sleep("50 millis"); + } +}); + +export const install = Effect.fn("PluginPackage.install")( + function* (request: Request, cache: string, runner: Runner = run) { + const root = path.join(cache, "plugins"); + const key = createHash("sha256").update(request.spec).digest("hex"); + const directory = path.join(root, key); + const marker = path.join(directory, ".complete.json"); + yield* fs.makeDirectory(root, { recursive: true }); + // mkdir is atomic across processes; scoped release also runs on interruption. + yield* lock(`${directory}.lock`); + if (!(yield* fs.exists(marker))) { + const staging = yield* Effect.acquireRelease( + fs.makeTempDirectory({ directory: root, prefix: `${key}-` }), + (staging) => fs.remove(staging, { recursive: true, force: true }).pipe(Effect.orDie), + ); + yield* fs.writeFileString(path.join(staging, "package.json"), '{"private":true,"type":"module"}'); + yield* runner(request, staging); + const manifest = yield* fs.readFileString(path.join(staging, "node_modules", request.name, "package.json")); + const installed = yield* Schema.decodeEffect(Schema.fromJsonString(Manifest))(manifest); + // Validate root resolution before marking this installation complete. + const entrypoint = yield* Effect.try(() => + resolve(request.name, pathToFileURL(path.join(staging, "package.json")).href), + ); + yield* fs.writeFileString( + path.join(staging, ".complete.json"), + yield* Schema.encodeEffect(Schema.fromJsonString(Cached))({ + spec: request.spec, + version: installed.version, + entrypoint: path.relative(staging, fileURLToPath(entrypoint)), + }), + ); + if (yield* fs.exists(directory)) yield* fs.remove(directory, { recursive: true }); + yield* fs.rename(staging, directory); + } + const saved = yield* fs + .readFileString(marker) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Cached)))); + if (saved.spec !== request.spec) + return yield* new InstallError({ cause: new Error("Plugin package cache request mismatch") }); + const url = pathToFileURL(path.resolve(directory, saved.entrypoint)).href; + return { url, version: saved.version } satisfies Installed; + }, + Effect.scoped, + Effect.mapError((cause) => new InstallError({ cause })), +); diff --git a/packages/harness/src/plugin/plugin.ts b/packages/harness/src/plugin/plugin.ts new file mode 100644 index 0000000..9fc42f4 --- /dev/null +++ b/packages/harness/src/plugin/plugin.ts @@ -0,0 +1,12 @@ +import type { Effect } from "effect"; +import type { SharedPluginContext } from "./context.ts"; +import type { Location } from "../location/location.ts"; +import type { SandboxIO } from "../sandbox/io.ts"; + +export type Mount = SandboxIO.Provides | Location.Service; +export interface Plugin { + readonly id: string; + readonly setup: (ctx: SharedPluginContext) => void | Promise | Effect.Effect; +} + +export const define = (plugin: Plugin): Plugin => plugin; diff --git a/packages/harness/src/plugin/prompt/registry.ts b/packages/harness/src/plugin/prompt/registry.ts new file mode 100644 index 0000000..0559570 --- /dev/null +++ b/packages/harness/src/plugin/prompt/registry.ts @@ -0,0 +1,20 @@ +import type { PromptRegistry } from "./schema.ts"; + +export const make = () => { + let open = true; + let value: string | undefined; + const registry: PromptRegistry = Object.freeze({ + set: (prompt: string) => { + if (!open) throw new Error("Prompt registry is closed"); + value = prompt; + }, + get: () => value, + }); + return { + registry, + close: () => { + open = false; + }, + value: () => value, + }; +}; diff --git a/packages/harness/src/plugin/prompt/schema.ts b/packages/harness/src/plugin/prompt/schema.ts new file mode 100644 index 0000000..f17a9a5 --- /dev/null +++ b/packages/harness/src/plugin/prompt/schema.ts @@ -0,0 +1,4 @@ +export interface PromptRegistry { + readonly set: (systemPrompt: string) => void; + readonly get: () => string | undefined; +} diff --git a/packages/harness/src/plugin/registry.ts b/packages/harness/src/plugin/registry.ts new file mode 100644 index 0000000..1c4000e --- /dev/null +++ b/packages/harness/src/plugin/registry.ts @@ -0,0 +1,28 @@ +import { make as makeTools } from "./tool/registry.ts"; +import { make as makePrompt } from "./prompt/registry.ts"; +import { make as makeCatalog } from "../tools/registry.ts"; +import type { ToolRegistry } from "./tool/schema.ts"; +import type { PromptRegistry } from "./prompt/schema.ts"; + +export interface PluginRegistry { + readonly tools: ToolRegistry; + readonly prompt: PromptRegistry; +} +export const make = () => { + const tools = makeTools(); + const prompt = makePrompt(); + const close = () => { + tools.close(); + prompt.close(); + }; + return { + registry: Object.freeze({ tools: tools.registry, prompt: prompt.registry }), + close, + freeze: () => { + close(); + const systemPrompt = prompt.value(); + if (systemPrompt === undefined) throw new Error("No Prompt plugin set a system prompt"); + return { tools: makeCatalog(tools.entries()).resolve(), systemPrompt }; + }, + }; +}; diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts new file mode 100644 index 0000000..7792b91 --- /dev/null +++ b/packages/harness/src/plugin/tool/registry.ts @@ -0,0 +1,67 @@ +import type { RegisteredTool } from "../../tools/tool.ts"; +import type { ToolAddOptions, ToolDefPatch, ToolRegistry } from "./schema.ts"; + +export interface ToolRegistration { + readonly tool: RegisteredTool; + readonly hooks: ToolAddOptions; +} + +const definition = (tool: RegisteredTool): RegisteredTool => ({ + ...tool, + definition: Object.freeze({ + ...tool.definition, + ...(tool.definition.promptGuidelines === undefined + ? {} + : { + promptGuidelines: Object.freeze([...tool.definition.promptGuidelines]), + }), + }), +}); + +export const make = () => { + let open = true; + const entries = new Map(); + const assertOpen = () => { + if (!open) throw new Error("Tool registry is closed"); + }; + const registry: ToolRegistry = Object.freeze({ + add: (tool: RegisteredTool, hooks: ToolAddOptions = {}) => { + assertOpen(); + entries.set( + tool.definition.name, + Object.freeze({ tool: definition(tool), hooks: Object.freeze({ ...hooks }) }), + ); + }, + update: (name: string, patch: ToolDefPatch) => { + assertOpen(); + const current = entries.get(name); + if (!current) throw new Error(`Unknown tool: ${name}`); + entries.set( + name, + Object.freeze({ + ...current, + tool: definition({ + ...current.tool, + definition: { + ...current.tool.definition, + ...(patch.description === undefined ? {} : { description: patch.description }), + ...(patch.label === undefined ? {} : { label: patch.label }), + ...(patch.promptSnippet === undefined ? {} : { promptSnippet: patch.promptSnippet }), + ...(patch.promptGuidelines === undefined ? {} : { promptGuidelines: patch.promptGuidelines }), + }, + }), + }), + ); + }, + list: () => Object.freeze([...entries.values()].map(({ tool }) => tool.definition)), + get: (name: string) => entries.get(name)?.tool.definition, + has: (name: string) => entries.has(name), + }); + return { + registry, + close: () => { + open = false; + }, + entries: () => Object.freeze([...entries.values()]), + }; +}; diff --git a/packages/harness/src/plugin/tool/schema.ts b/packages/harness/src/plugin/tool/schema.ts new file mode 100644 index 0000000..08855b8 --- /dev/null +++ b/packages/harness/src/plugin/tool/schema.ts @@ -0,0 +1,43 @@ +import { type Effect, Schema } from "effect"; +import * as EventSchema from "../../event/schema.ts"; +import { SessionMessageSchema } from "../../session/message/schema.ts"; +import { SessionSchema } from "../../session/schema.ts"; +import { type AnyToolDef, type ModelContent, type RegisteredTool, ToolCallContext } from "../../tools/tool.ts"; + +export const ToolBefore = Schema.Struct({ + ...ToolCallContext.fields, + sessionId: SessionSchema.ID, + messageId: SessionMessageSchema.ID, + params: Schema.Unknown, +}); +export type ToolBefore = typeof ToolBefore.Type; +export const ToolAfter = Schema.Struct({ ...ToolBefore.fields, terminal: EventSchema.AikitToolCallTerminalPart }); +export type ToolAfter = typeof ToolAfter.Type; +export const ToolBeforeResult = Schema.Struct({ block: Schema.Boolean, reason: Schema.optional(Schema.String) }); +export type ToolBeforeResult = typeof ToolBeforeResult.Type; +export type ToolResultContent = ModelContent; +export interface ToolAfterResult { + readonly content?: ToolResultContent; + readonly details?: unknown; + readonly isError?: boolean; +} +export type HookReturn = A | void | Promise | Effect.Effect; +export type ToolBeforeFn = (call: ToolBefore) => HookReturn; +export type ToolAfterFn = (call: ToolAfter) => HookReturn; +export interface ToolAddOptions { + readonly beforeToolCall?: ToolBeforeFn; + readonly afterToolCall?: ToolAfterFn; +} +export interface ToolDefPatch { + readonly description?: string; + readonly label?: string; + readonly promptSnippet?: string; + readonly promptGuidelines?: ReadonlyArray; +} +export interface ToolRegistry { + readonly add: (tool: RegisteredTool, options?: ToolAddOptions) => void; + readonly update: (name: string, patch: ToolDefPatch) => void; + readonly list: () => ReadonlyArray; + readonly get: (name: string) => AnyToolDef | undefined; + readonly has: (name: string) => boolean; +} diff --git a/packages/harness/src/runner/llm.ts b/packages/harness/src/runner/llm.ts index 8361a97..1ca0769 100644 --- a/packages/harness/src/runner/llm.ts +++ b/packages/harness/src/runner/llm.ts @@ -23,6 +23,7 @@ export interface Input { readonly context: Message.Context; readonly provider: string; readonly model: string; + readonly resolvedModel: Model.Info; readonly thinkingLevel?: Model.ThinkingLevel; readonly options?: State.RequestOptions; readonly settings?: Block; @@ -147,8 +148,13 @@ export const messageFailure = (message: Message.AssistantMessage): AikitFailure. : AikitFailure.fromMessage(message.errorMessage ?? "The provider turn failed."); }; -/** Resolve the configured model and start aikit's provider stream. */ -export const open: Open = Effect.fn("LLM.open")(function* (input, signal) { +export type ResolutionInput = Pick; +export type Resolve = ( + input: ResolutionInput, +) => Effect.Effect; + +/** Resolve once before exchange setup; execution reuses this exact instance. */ +export const resolve: Resolve = Effect.fn("LLM.resolve")(function* (input) { const model = yield* Effect.tryPromise({ try: () => llm(input.provider, input.model, resolveOverrides(input.settings ?? {})), catch: (cause) => { @@ -166,6 +172,12 @@ export const open: Open = Effect.fn("LLM.open")(function* (input, signal) { return yield* new Runner.ModelNotFoundError({ provider: input.provider, model: input.model }); } + return model; +}); + +/** Start a provider stream using the model pinned by State. */ +export const open: Open = Effect.fn("LLM.open")(function* (input, signal) { + const model = input.resolvedModel; return yield* Effect.try({ try: () => aikitStream(model, input.context, runtimeOptions(input, model, signal)), catch: (cause) => providerErrorFromUnknown(input, cause), diff --git a/packages/harness/src/runner/loop.ts b/packages/harness/src/runner/loop.ts index aa58bea..b5b0cfb 100644 --- a/packages/harness/src/runner/loop.ts +++ b/packages/harness/src/runner/loop.ts @@ -186,6 +186,8 @@ export const layer = (options: Options = {}) => }); const handled = yield* snapshot.tools .handle(call, { + sessionId: snapshot.sessionId, + messageId, onProgress: (progress) => events .publish(EventList.ToolProgress, { @@ -199,8 +201,8 @@ export const layer = (options: Options = {}) => }) .pipe( Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.interrupt + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) : Effect.map( Effect.clockWith((clock) => clock.currentTimeMillis), (now) => errorPart(call, cause, now), @@ -224,7 +226,7 @@ export const layer = (options: Options = {}) => }); const exit = yield* restore(execution).pipe(Effect.exit); if (Exit.isFailure(exit)) { - if (!Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause); + if (!Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(exit.cause); interruptedCause = exit.cause; for (const call of pending) { if (settled.has(call.callID)) continue; @@ -273,6 +275,7 @@ export const layer = (options: Options = {}) => }, provider: snapshot.provider, model: snapshot.model, + resolvedModel: snapshot.resolvedModel, thinkingLevel: snapshot.thinkingLevel, options: snapshot.request, settings: snapshot.settings, @@ -300,7 +303,7 @@ export const layer = (options: Options = {}) => return yield* turnWindow.pipe( Effect.catchCause((cause) => { if (committed) return Effect.failCause(cause); - const turnCause: EventList.TurnAbortCause = Cause.hasInterruptsOnly(cause) + const turnCause: EventList.TurnAbortCause = Cause.hasInterrupts(cause) ? { _tag: "interrupted" } : { _tag: "error", message: errorMessage(cause) }; const record = Effect.gen(function* () { diff --git a/packages/harness/src/state/prompt.ts b/packages/harness/src/state/prompt.ts deleted file mode 100644 index be248d2..0000000 --- a/packages/harness/src/state/prompt.ts +++ /dev/null @@ -1,138 +0,0 @@ -/* - * @file Pure system-prompt construction. - * - * Nothing here reads a service, a clock, or a filesystem. Given the same inputs - * it returns the same string, byte for byte -- which is what lets the prompt be - * asserted in a test and cached by a provider across a turn's continuations. - * - * The chain has three caller slots: - * - * foundation --promptCustom?--> base - * -> tools + guidelines - * -> promptSystemAppend - * -> working directory - * -> promptSystemOverride(rendered) -> final - * - * `promptSystemOverride` is applied by the caller of {@link build}, not here: it - * may be async, and this module stays pure. - */ - -import type { Model } from "@codeworksh/aikit"; -import type { SandboxIO } from "../sandbox/io.ts"; -import type { Location } from "../location/location.ts"; -import type { AnyToolDef } from "../tools/tool.ts"; -import type { ToolExecutionMode } from "./state.ts"; - -/** The default coding-agent foundation, used unless `promptCustom` replaces it. */ -export const foundation = `You are an expert coding assistant operating inside codework, a coding agent harness.`; - -/** - * Guidelines that hold regardless of which tools are registered. - * - * Kept short on purpose. Every line here is spent on every request, so a line - * earns its place only if a model measurably behaves worse without it. - */ -export const standingGuidelines: ReadonlyArray = [ - "Be concise. Report what you did and what you found, not what you are about to do.", - "Quote exact paths and command output rather than paraphrasing them.", - "If a command fails, read the error before retrying.", -]; - -export interface BuildInput { - /** - * The effective tool set, in registry order. Only tools carrying a - * `promptSnippet` reach the rendered index; the rest stay callable but - * unlisted. - */ - readonly tools: ReadonlyArray; - /** The working directory, always `Location.directory` */ - readonly directory: string; - /** Replaces {@link foundation} when supplied. */ - readonly promptCustom?: string; - /** Appended after the tool sections, before the working-directory line. */ - readonly promptSystemAppend?: string; -} - -/** Collapse whitespace so two spellings of one guideline dedupe against each other. */ -const normalize = (value: string): string => value.trim().replace(/\s+/g, " "); - -/** - * Tool-contributed guidelines followed by the standing ones, normalized and - * deduplicated with first occurrence winning. - * - * Tool order is registry order, which is the same order the index renders in and - * the same order the provider receives definitions in -- one ordering, not three. - */ -export const guidelines = (tools: ReadonlyArray): ReadonlyArray => { - const seen = new Set(); - const collected: string[] = []; - const add = (value: string) => { - const line = normalize(value); - if (line.length === 0 || seen.has(line)) return; - seen.add(line); - collected.push(line); - }; - for (const tool of tools) for (const line of tool.promptGuidelines ?? []) add(line); - for (const line of standingGuidelines) add(line); - return collected; -}; - -/** - * The rendered tool index. - * - * `(none)` rather than an omitted section: a model told it has no tools behaves - * better than one left to infer it from silence. - */ -export const toolIndex = (tools: ReadonlyArray): string => { - const listed = tools.filter((tool) => tool.promptSnippet !== undefined && tool.promptSnippet.length > 0); - if (listed.length === 0) return "(none)"; - return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n"); -}; - -export const build = (input: BuildInput): string => { - const sections: string[] = [input.promptCustom ?? foundation]; - - sections.push(`Available tools:\n${toolIndex(input.tools)}`); - - const lines = guidelines(input.tools); - if (lines.length > 0) sections.push(`Guidelines:\n${lines.map((line) => `- ${line}`).join("\n")}`); - - const append = input.promptSystemAppend?.trim(); - if (append !== undefined && append.length > 0) sections.push(append); - - sections.push(`Current working directory: ${input.directory}`); - - return sections.join("\n\n"); -}; - -/** - * What the override sees. The rendered prompt plus the runtime facts it was - * rendered from, so a caller can rebuild any part of it rather than only append. - * - * `tools` is the same resolved set the provider receives, which is what makes a - * full override viable without losing the index. - * - * Conversation messages are deliberately absent. A hook that could read the - * transcript is a provider-step concern, not runtime state. - */ -export interface PromptSystemOverrideInput { - readonly systemPrompt: string; - readonly tools: ReadonlyArray; - readonly sandbox: SandboxIO.Identity; - readonly location: Location.Info; - readonly provider: string; - readonly model: string; - readonly thinkingLevel: Model.ThinkingLevel; - readonly toolExecution: ToolExecutionMode; -} - -/** - * The final link in the chain. Its return value is the prompt, verbatim. - * - * Sync or async: a caller integrating a non-Effect SDK should not have to reach - * for Effect to change a string. A throw or rejection becomes a typed - * `State.SnapshotError`, never a defect. - */ -export type PromptSystemOverride = (input: PromptSystemOverrideInput) => string | PromiseLike; - -export * as StatePrompt from "./prompt.ts"; diff --git a/packages/harness/src/state/state.ts b/packages/harness/src/state/state.ts index f2c6d67..0e7064e 100644 --- a/packages/harness/src/state/state.ts +++ b/packages/harness/src/state/state.ts @@ -14,6 +14,13 @@ import type { Model, Protocol } from "@codeworksh/aikit"; import { Context, Effect, Layer, Option, Schema } from "effect"; +import { Event } from "../event/event.ts"; +import { makeEvents, type PromptResolver } from "../plugin/context.ts"; +import { run as setup } from "../plugin/host.ts"; +import { defaults } from "../plugin/internal.ts"; +import type { Plugin } from "../plugin/plugin.ts"; +import { LLM } from "../runner/llm.ts"; +import type { Runner } from "../runner/run.ts"; import { Location } from "../location/location.ts"; import { SandboxIO } from "../sandbox/io.ts"; import { SessionRuntime } from "../session/runtime.ts"; @@ -22,11 +29,7 @@ import { merge } from "../settings/merge.ts"; import { compose, resolveOptions } from "../settings/resolve.ts"; import type { Block } from "../settings/schema.ts"; import { Settings } from "../settings/settings.ts"; -import { bashTool } from "../tools/bash.ts"; -import { make as makeRegistry, type Resolved } from "../tools/registry.ts"; -import { fromSandboxShell } from "../tools/shell.ts"; -import * as Tool from "../tools/tool.ts"; -import { StatePrompt } from "./prompt.ts"; +import type { Resolved } from "../tools/registry.ts"; /** * How a turn's tool calls are scheduled once the array has been re-read. @@ -63,23 +66,9 @@ export type RequestOptions = Omit; - /** Built-in tool names enabled for this session. Omitted keeps the Bash default. */ - readonly builtinTools?: ReadonlyArray<"bash">; + /** Optional caller inputs interpreted by prompt plugins. */ + readonly promptCustom?: string | PromptResolver; + readonly promptSystemAppend?: string | PromptResolver; readonly provider?: string; readonly model?: string; readonly thinkingLevel?: Model.ThinkingLevel; @@ -87,7 +76,7 @@ export interface Options extends RequestOptions { } /** - * The prompt override threw or rejected. + * Exchange plugin setup failed. * * Typed rather than a defect: a caller's callback failing is a caller bug, but it * is one the session should report and survive rather than crash on. The turn @@ -118,9 +107,10 @@ export interface Snapshot { readonly tools: Resolved; readonly provider: string; readonly model: string; + readonly resolvedModel: Model.Info; readonly thinkingLevel: Model.ThinkingLevel; readonly request: RequestOptions; - /** Matched file attributes; catalog resolution stays at the LLM boundary. */ + /** Matched file attributes used to construct provider requests. */ readonly settings: Block; readonly toolExecution: ToolExecutionMode; } @@ -132,50 +122,32 @@ export interface Interface { */ readonly snapshot: ( sessionId: SessionId, - ) => Effect.Effect; + ) => Effect.Effect< + Snapshot, + SnapshotError | Runner.ModelCatalogError | Runner.ModelNotFoundError | Runner.ProviderError, + SandboxIO.Provides | Location.Service + >; } export class Service extends Context.Service()("@codeworksh/harness/state/state/Service") {} -/** - * Run a caller's override, converting a synchronous throw or a rejected promise - * into {@link SnapshotError}. - */ -const applyOverride = Effect.fn("State.applyOverride")(function* ( - sessionId: SessionId, - override: StatePrompt.PromptSystemOverride, - input: StatePrompt.PromptSystemOverrideInput, -) { - const fail = (cause: unknown) => - new SnapshotError({ - sessionId, - reason: "`promptSystemOverride` callback failed", - cause, - }); - const result = yield* Effect.try({ - try: () => override(input), - catch: fail, - }); - if (typeof result === "string") return result; - return yield* Effect.tryPromise({ try: () => result, catch: fail }); -}); +const resolver = (input: string | PromptResolver): PromptResolver => (typeof input === "string" ? () => input : input); -export const layer = (options: Options = {}) => { +export const layer = (options: Options = {}, plugins: ReadonlyArray = defaults) => { return Layer.effect( Service, Effect.gen(function* () { const runtime = yield* SessionRuntime.Service; const settings = yield* Settings.Service; + const events = makeEvents(yield* Event.Service); return Service.of({ snapshot: Effect.fn("State.snapshot")(function* (sessionId: SessionId) { const sessionOptions = Option.getOrElse(yield* runtime.get(sessionId), () => ({})); - const configured = compose(yield* settings.load, options, sessionOptions); + const loadedSettings = yield* settings.load; + const configured = compose(loadedSettings, options, sessionOptions); const { promptCustom, promptSystemAppend, - promptSystemOverride, - tools: callerTools = [], - builtinTools = ["bash"], provider: _provider, model: _model, thinkingLevel: _thinkingLevel, @@ -187,55 +159,28 @@ export const layer = (options: Options = {}) => { const sandbox = yield* SandboxIO.Current; const location = yield* Location.Service; - /* - * Bash is bound to the shell of the mount that is open right now. - * `ToolShell.local()` would look equivalent and be wrong: it executes on - * the host, bypassing whichever in-memory or remote namespace the session - * actually selected. - * - * Capturing the service and re-providing it keeps the binding valid for - * the whole exchange -- the drain owns the mount for longer than any turn - * inside it, so a handler captured here is still executable when a later - * turn calls it. - */ - const shell = yield* SandboxIO.Shell; - const mountedShell = fromSandboxShell.pipe(Layer.provide(Layer.succeed(SandboxIO.Shell, shell))); - - // Bash first, caller tools after: last registration wins, so a caller can - // replace Bash by name on purpose. - const builtins = builtinTools.includes("bash") ? [Tool.provide(bashTool, mountedShell)] : []; - const registry = makeRegistry([...builtins, ...callerTools]); - const resolved = registry.resolve(); - - const rendered = StatePrompt.build({ - tools: resolved.defs, - directory: location.directory, - ...(promptCustom === undefined ? {} : { promptCustom }), - ...(promptSystemAppend === undefined ? {} : { promptSystemAppend }), - }); - - const systemPrompt = - promptSystemOverride === undefined - ? rendered - : yield* applyOverride(sessionId, promptSystemOverride, { - systemPrompt: rendered, - tools: resolved.defs, - sandbox, - location, - provider, - model, - thinkingLevel, - toolExecution, - }); + const resolvedModel = yield* LLM.resolve({ provider, model, settings: configured.block }); + const contributions = yield* setup(plugins, { + sessionId, + sandbox, + location, + settings: loadedSettings, + model: resolvedModel, + events, + config: { + ...(promptCustom === undefined ? {} : { promptCustom: resolver(promptCustom) }), + ...(promptSystemAppend === undefined ? {} : { promptSystemAppend: resolver(promptSystemAppend) }), + }, + }).pipe(Effect.mapError((cause) => new SnapshotError({ sessionId, reason: cause.message, cause }))); return { sessionId, sandbox, location, - systemPrompt, - tools: resolved, + ...contributions, provider, model, + resolvedModel, thinkingLevel, request, settings: configured.block, diff --git a/packages/harness/src/tools/executor.ts b/packages/harness/src/tools/executor.ts index ea35d79..2582b35 100644 --- a/packages/harness/src/tools/executor.ts +++ b/packages/harness/src/tools/executor.ts @@ -1,5 +1,7 @@ +import type { ToolRegistration } from "../plugin/tool/registry.ts"; +import type { HookReturn, ToolAfterResult, ToolBefore } from "../plugin/tool/schema.ts"; import { Message } from "@codeworksh/aikit"; -import { Cause, Duration, Effect, Exit, Option, Queue, Ref, Result, Schedule, Schema, Scope } from "effect"; +import { Cause, Duration, Effect, Exit, Fiber, Option, Queue, Ref, Result, Schedule, Schema, Scope } from "effect"; import { ToolExecutionError } from "./error.ts"; import { ToolProgress, type ToolProgressPartial } from "./progress.ts"; import { type AnyToolDef, type ModelContent, type RegisteredTool, toAikitTool, type ToolCallContext } from "./tool.ts"; @@ -7,15 +9,15 @@ import { type AnyToolDef, type ModelContent, type RegisteredTool, toAikitTool, t /** * `ToolExecutor` — the uniform pipeline run for every tool call: * - * resolve def → decode args (Effect Schema) → run handler (scoped, exit) → - * encode typed Success/Failure into aikit's message protocol → complete terminal part. + * resolve def → decode args → before → handler (scoped, exit) → + * encode / normalize terminal → after → apply result patch. * * Tool handlers never touch event plumbing or aikit message shapes. The executor owns * the whole pending → terminal value transition. Result mapping: * - success → completed (content + encoded details) * - declared failure → error (content + encoded details), fed to the model * - interruption → aborted - * - undeclared / defect → run error (re-raised as a defect) + * - undeclared / defect → error terminal */ /** A complete terminal tool-call part, ready for persistence and event publication. */ @@ -33,6 +35,8 @@ export interface ProgressEvent { * best-effort UI telemetry — see the `handle` progress path in {@link make}. */ export interface HandleOptions { + readonly sessionId?: ToolBefore["sessionId"]; + readonly messageId?: ToolBefore["messageId"]; /** Sliding-queue capacity for best-effort progress. Bounded default (64); tunable. */ readonly progressBuffer?: number; /** @@ -55,7 +59,7 @@ export interface Executor { * Atomically transform one complete pending tool-call part into a complete terminal * part. Most failures become a terminal * `ToolOutcome`; declared failures retain their encoded details, while undeclared - * failures/defects propagate as defects. + * failures/defects become error terminals. * * Tools enter as {@link RegisteredTool}s (capability `R` already discharged at * registration), so the only requirement left in the result is a progress sink's own @@ -178,42 +182,86 @@ const encodeOutcome = ( return errored(call, content, now, encoded); } - // Undeclared failure or genuine defect → the loop is broken, not the tool. + // The execution boundary below normalizes undeclared failures and defects for after. return yield* Effect.die(Cause.squash(cause)); }); +class HookExecutionError extends Schema.TaggedError()("HookExecutionError", { + cause: Schema.Unknown, +}) {} + +/** Invoke author callbacks only when the returned Effect runs. */ +const invoke = (callback: () => HookReturn): Effect.Effect => + Effect.suspend(() => { + const value = callback(); + if (Effect.isEffect(value)) { + const result: Effect.Effect = value; + // Normalize arbitrary plugin errors at the executor boundary. + // @effect-diagnostics-next-line anyUnknownInErrorContext:off + return result.pipe(Effect.mapError((cause) => new HookExecutionError({ cause }))); + } + if (value instanceof Promise) + return Effect.tryPromise({ try: () => value, catch: (cause) => new HookExecutionError({ cause }) }); + return Effect.succeed(value); + }); + +const failureOutcome = (call: Message.ToolCallPendingPart, cause: Cause.Cause, phase: string) => + Effect.map( + Effect.clockWith((clock) => clock.currentTimeMillis), + (now) => errored(call, [text(`${phase}: ${Cause.pretty(cause)}`)], now), + ); + +const patchOutcome = (terminal: ToolOutcome, patch: ToolAfterResult | void): ToolOutcome => { + if (!patch || (terminal.status !== "completed" && terminal.status !== "error")) return terminal; + const isError = patch.isError ?? terminal.result.isError; + return { + ...terminal, + status: isError ? "error" : "completed", + result: { + ...terminal.result, + ...(patch.content === undefined ? {} : { content: [...patch.content] }), + ...(patch.details === undefined ? {} : { details: patch.details }), + isError, + }, + } as ToolOutcome; +}; + +const ABORT_HOOK_GRACE = Duration.seconds(1); + /** * Build an executor over a set of {@link RegisteredTool}s — tools whose capability `R` was * already discharged at registration (`Tool.provide`). The executor therefore needs no tool * `R`; only a progress sink's `RProgress` (if any) surfaces from `handle`. */ -export const make = (tools: ReadonlyArray): Executor => { - const impls = new Map(); - for (const tool of tools) { - const name = tool.definition.name; +export const make = (tools: ReadonlyArray): Executor => { + const impls = new Map(); + for (const item of tools) { + const entry = "tool" in item ? item : { tool: item, hooks: {} }; + const name = entry.tool.definition.name; // Fail fast: a duplicate name would expose two tools on the wire but only // run the last-registered handler. if (impls.has(name)) { throw new Error(`Executor.make: duplicate tool name "${name}" — tool names must be unique.`); } - impls.set(name, tool); + impls.set(name, entry); } - const wire = tools.map((tool) => toAikitTool(tool.definition)); + const wire = [...impls.values()].map(({ tool }) => toAikitTool(tool.definition)); const handle = ( call: Message.ToolCallPendingPart, options?: HandleOptions, ): Effect.Effect => Effect.gen(function* () { - const impl = impls.get(call.name); - if (impl === undefined) { + const entry = impls.get(call.name); + if (entry === undefined) { const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); return errored(call, [text(`Unknown tool: ${call.name}`)], now, { error: "unknown_tool", name: call.name, }); } + const { tool: impl, hooks } = entry; const def = impl.definition; const decoded = yield* Effect.result(Schema.decodeEffect(asCodec(def.parameters))(call.arguments)); @@ -227,69 +275,153 @@ export const make = (tools: ReadonlyArray): Executor => { const ctx: ToolCallContext = { callID: call.callID, toolName: call.name, rawArgs: call.arguments }; + const hasHooks = hooks.beforeToolCall !== undefined || hooks.afterToolCall !== undefined; + if (hasHooks && (options?.sessionId === undefined || options.messageId === undefined)) { + return yield* Effect.die(new Error("Hooked tools require sessionId and messageId")); + } + const hookCall: ToolBefore | undefined = + options?.sessionId !== undefined && options.messageId !== undefined + ? { ...ctx, sessionId: options.sessionId, messageId: options.messageId, params: decoded.success } + : undefined; + if (hooks.beforeToolCall && hookCall) { + const before = yield* invoke(() => hooks.beforeToolCall!(hookCall)).pipe(Effect.exit); + if (Exit.isFailure(before)) { + if (Cause.hasInterrupts(before.cause)) + return yield* Effect.failCause(before.cause as Cause.Cause); + return yield* failureOutcome(call, before.cause, "beforeToolCall failed"); + } + if (before.value?.block) { + const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + return errored(call, [text(before.value.reason || "Tool execution was blocked")], now); + } + } + // Latest partial: captured for aborted-call output regardless of any sink. const latest = yield* Ref.make(Option.none()); - // True while an onProgress write is in flight, so "drained" means the queue is empty - // AND the last sink write finished — not merely dequeued. - const activeProgress = yield* Ref.make(false); - - const onProgress = options?.onProgress; - const progressQueue = onProgress - ? yield* Queue.sliding(options?.progressBuffer ?? DEFAULT_PROGRESS_BUFFER) - : undefined; - - // Best-effort delivery off the hot path: swallow (log-drop) sink failures, never fail the - // tool. This is the only place onProgress runs, so its RProgress/error live here. Typed - // explicitly so `RProgress` is pinned through `Effect.gen`'s requirement inference. - const forkDrain: Effect.Effect = - progressQueue && onProgress - ? Queue.take(progressQueue).pipe( - Effect.flatMap((event) => - Ref.set(activeProgress, true).pipe( - Effect.andThen(onProgress(event).pipe(Effect.ignore)), - Effect.ensuring(Ref.set(activeProgress, false)), - ), - ), - Effect.forever, - Effect.forkScoped, - Effect.asVoid, - ) - : Effect.void; - yield* forkDrain; - - // report is fast + infallible: set latest, then a non-blocking offer (sliding drops the - // oldest when full). No sink latency reaches the tool. - const report = Effect.fn("ToolExecutor.reportProgress")(function* (partial: ToolProgressPartial) { - yield* Ref.set(latest, Option.some(partial)); - if (progressQueue === undefined) return; - const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); - yield* Queue.offer(progressQueue, { partial, toolCall: running(call, partial, now), ctx }); + let handlerStarted = false; + let afterStarted = false; + const notifyAbort = Effect.fn("ToolExecutor.notifyAbort")(function* (terminal: ToolOutcome) { + if (!handlerStarted || afterStarted || !hooks.afterToolCall || !hookCall) return; + afterStarted = true; + const callback = hooks.afterToolCall; + const fiber = yield* invoke(() => callback({ ...hookCall, terminal })).pipe( + Effect.interruptible, + Effect.timeout(ABORT_HOOK_GRACE), + Effect.catchCause((cause) => + Effect.logWarning("afterToolCall abort notification failed", Cause.pretty(cause)), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* Fiber.join(fiber); }); - const progress = ToolProgress.of({ report }); - - // The handler keeps its OWN inner scope, so its resources release the moment it finishes - // — not after the drain grace (which is bounded by the outer `Effect.scoped` below). - const exit = yield* impl - .handler(decoded.success, ctx) - .pipe(Effect.scoped, Effect.provideService(ToolProgress, progress), Effect.exit); - - // Graceful bounded drain on NORMAL completion: wait until the queue is empty AND no sink - // write is in flight, bounded by progressDrainGrace. Skipped on interruption (snappy abort). - const interrupted = Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause); - if (progressQueue && !interrupted) { - const drained = Effect.gen(function* () { - const size = yield* Queue.size(progressQueue); - const active = yield* Ref.get(activeProgress); - return size === 0 && !active; + const execute = Effect.gen(function* () { + // True while an onProgress write is in flight, so "drained" means the queue is empty + // AND the last sink write finished — not merely dequeued. + const activeProgress = yield* Ref.make(false); + + const onProgress = options?.onProgress; + const progressQueue = onProgress + ? yield* Queue.sliding(options?.progressBuffer ?? DEFAULT_PROGRESS_BUFFER) + : undefined; + + // Best-effort delivery off the hot path: swallow (log-drop) sink failures, never fail the + // tool. This is the only place onProgress runs, so its RProgress/error live here. Typed + // explicitly so `RProgress` is pinned through `Effect.gen`'s requirement inference. + const forkDrain: Effect.Effect = + progressQueue && onProgress + ? Queue.take(progressQueue).pipe( + Effect.flatMap((event) => + Ref.set(activeProgress, true).pipe( + Effect.andThen(onProgress(event).pipe(Effect.ignore)), + Effect.ensuring(Ref.set(activeProgress, false)), + ), + ), + Effect.forever, + Effect.forkScoped, + Effect.asVoid, + ) + : Effect.void; + yield* forkDrain; + + // report is fast + infallible: set latest, then a non-blocking offer (sliding drops the + // oldest when full). No sink latency reaches the tool. + const report = Effect.fn("ToolExecutor.reportProgress")(function* (partial: ToolProgressPartial) { + yield* Ref.set(latest, Option.some(partial)); + if (progressQueue === undefined) return; + const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + yield* Queue.offer(progressQueue, { partial, toolCall: running(call, partial, now), ctx }); }); - yield* drained.pipe( - Effect.repeat({ schedule: Schedule.spaced(DRAIN_POLL), until: (done) => done }), - Effect.timeout(options?.progressDrainGrace ?? DEFAULT_DRAIN_GRACE), - Effect.ignore, + const progress = ToolProgress.of({ report }); + + // The handler keeps its OWN inner scope, so its resources release the moment it finishes + // — not after the drain grace (which is bounded by the outer `Effect.scoped` below). + handlerStarted = true; + const exit = yield* Effect.suspend(() => impl.handler(decoded.success, ctx)).pipe( + Effect.scoped, + Effect.provideService(ToolProgress, progress), + Effect.exit, ); - } - return yield* encodeOutcome(def, call, exit, latest); + // Graceful bounded drain on NORMAL completion: wait until the queue is empty AND no sink + // write is in flight, bounded by progressDrainGrace. Skipped on interruption (snappy abort). + const interrupted = Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause); + if (progressQueue && !interrupted) { + const drained = Effect.gen(function* () { + const size = yield* Queue.size(progressQueue); + const active = yield* Ref.get(activeProgress); + return size === 0 && !active; + }); + yield* drained.pipe( + Effect.repeat({ schedule: Schedule.spaced(DRAIN_POLL), until: (done) => done }), + Effect.timeout(options?.progressDrainGrace ?? DEFAULT_DRAIN_GRACE), + Effect.ignore, + ); + } + + return yield* encodeOutcome(def, call, exit, latest).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause as Cause.Cause) + : failureOutcome(call, cause, "Tool execution failed"), + ), + ); + }).pipe(Effect.scoped); + return yield* execute.pipe( + Effect.flatMap((terminal) => { + if (terminal.status === "aborted") + return notifyAbort(terminal).pipe(Effect.uninterruptible, Effect.as(terminal)); + if (!hooks.afterToolCall || !hookCall) return Effect.succeed(terminal); + const callback = hooks.afterToolCall; + return Effect.suspend(() => { + afterStarted = true; + return invoke(() => callback({ ...hookCall, terminal })).pipe( + Effect.map((patch) => patchOutcome(terminal, patch)), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause as Cause.Cause) + : failureOutcome(call, cause, "afterToolCall failed"), + ), + ); + }); + }), + Effect.onExit((exit) => + Effect.gen(function* () { + if (Exit.isSuccess(exit)) return; + // A failing author finalizer can replace its interruption in Effect. Probe the + // pending cancellation while cleanup coordination is protected, retaining both. + const pending = yield* Effect.void.pipe(Effect.interruptible, Effect.exit); + const cause = Exit.isFailure(pending) ? Cause.combine(exit.cause, pending.cause) : exit.cause; + if (!Cause.hasInterrupts(cause)) return; + yield* encodeOutcome( + def, + call, + Exit.failCause(cause as Cause.Cause), + latest, + ).pipe(Effect.flatMap(notifyAbort)); + if (!Cause.hasInterrupts(exit.cause)) return yield* Effect.failCause(cause); + }), + ), + ); }).pipe(Effect.scoped); return { wire, handle }; diff --git a/packages/harness/src/tools/registry.ts b/packages/harness/src/tools/registry.ts index bf07c1f..66a71bc 100644 --- a/packages/harness/src/tools/registry.ts +++ b/packages/harness/src/tools/registry.ts @@ -1,3 +1,4 @@ +import type { ToolRegistration } from "../plugin/tool/registry.ts"; import type { Message } from "@codeworksh/aikit"; import { Context, Layer } from "effect"; import * as Executor from "./executor.ts"; @@ -54,15 +55,18 @@ export interface Registry { * order — only the bound implementation changes. Override resolution happens here, before * `Executor.make`, which requires unique effective names. */ -export const make = (tools: ReadonlyArray): Registry => { - const byName = new Map(); - for (const tool of tools) byName.set(tool.definition.name, tool); +export const make = (tools: ReadonlyArray): Registry => { + const byName = new Map(); + for (const item of tools) { + const entry = "tool" in item ? item : { tool: item, hooks: {} }; + byName.set(entry.tool.definition.name, entry); + } // Capture the effective set ONCE; the snapshot closes over these frozen arrays, so it // keeps its winning implementations and membership regardless of any later change. const effective = Object.freeze([...byName.values()]); - const defs = Object.freeze(effective.map((tool) => tool.definition)); - const names = Object.freeze(effective.map((tool) => tool.definition.name)); + const defs = Object.freeze(effective.map((entry) => entry.tool.definition)); + const names = Object.freeze(effective.map((entry) => entry.tool.definition.name)); // The effective set is unique, so the executor's own duplicate guard never trips. const executor = Executor.make(effective); @@ -76,7 +80,7 @@ export const make = (tools: ReadonlyArray): Registry => { return Object.freeze({ defs, names, - getDef: (name: string) => byName.get(name)?.definition, + getDef: (name: string) => byName.get(name)?.tool.definition, resolve: () => resolved, }); }; @@ -91,5 +95,5 @@ export class ToolRegistry extends Context.Service()( ) {} /** Provide a catalog of registered tools as the {@link ToolRegistry} service. */ -export const layer = (tools: ReadonlyArray): Layer.Layer => +export const layer = (tools: ReadonlyArray): Layer.Layer => Layer.succeed(ToolRegistry, ToolRegistry.of(make(tools))); diff --git a/packages/harness/src/tools/tool.ts b/packages/harness/src/tools/tool.ts index 0c87142..9d275f2 100644 --- a/packages/harness/src/tools/tool.ts +++ b/packages/harness/src/tools/tool.ts @@ -24,11 +24,12 @@ import type { ToolProgress } from "./progress.ts"; export type ModelContent = ReadonlyArray; /** Per-call metadata handed to a handler instead of positional args. */ -export interface ToolCallContext { - readonly callID: string; - readonly toolName: string; - readonly rawArgs: Record; -} +export const ToolCallContext = Schema.Struct({ + callID: Schema.String, + toolName: Schema.String, + rawArgs: Schema.Record(Schema.String, Schema.Unknown), +}); +export type ToolCallContext = typeof ToolCallContext.Type; /** * A pure tool definition. Parametrised over the *schema* instances so the diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index f2f64c4..21f36f0 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -154,7 +154,6 @@ export const runnerCycleSpec = (resourceId: () => Promise) => const bindings = yield* SessionRuntime.Service; yield* bindings.update(session.id, { // This conversation expects one response per prompt; tools have their own remote suite. - builtinTools: [], onPayload: async (payload, model) => { const params = payload as Record; const bag = params.providerOptions as Record> | undefined; diff --git a/packages/harness/test/plugin.catalog.test.ts b/packages/harness/test/plugin.catalog.test.ts new file mode 100644 index 0000000..536e0e8 --- /dev/null +++ b/packages/harness/test/plugin.catalog.test.ts @@ -0,0 +1,207 @@ +import { Deferred, Effect, Fiber } from "effect"; +import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { prepare } from "../src/plugin/catalog.ts"; +import { classify, validate } from "../src/plugin/loader.ts"; +import { install, InstallError, parse, type Runner } from "../src/plugin/package.ts"; +import { define } from "../src/plugin/plugin.ts"; + +const a = define({ id: "acme.tool.a", setup: () => {} }); +const b = define({ id: "acme.tool.b", setup: () => {} }); +const options = { builtins: [], cache: "/unused", base: "/project" }; +const withDirectory = async (body: (directory: string) => Promise) => { + const directory = await mkdtemp(join(tmpdir(), "plugin-catalog-")); + try { + await body(directory); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}; +const fixture: Runner = (request, directory) => + Effect.tryPromise({ + try: async () => { + const root = join(directory, "node_modules", request.name); + await mkdir(root, { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: request.name, + version: request.spec.endsWith("2.0.0") ? "2.0.0" : "1.0.0", + type: "module", + exports: "./index.js", + }), + ); + await writeFile(join(root, "index.js"), "export default { id: 'acme.tool.fixture', setup() {} }"); + }, + catch: (cause) => new InstallError({ cause }), + }); + +describe("plugin catalog and source resolution", () => { + it("resolves forward IDs, last definitions and last operation order without setup", async () => { + let calls = 0; + const latest = define({ + id: a.id, + setup: () => { + calls++; + }, + }); + const result = await Effect.runPromise(prepare([a.id, a, b, latest, b.id], options)); + expect(result.map((p) => p.id)).toEqual([a.id, b.id]); + expect(result[0]?.setup).toBe(latest.setup); + expect(calls).toBe(0); + }); + it("disables unknown IDs and re-enables known IDs", async () => { + expect(await Effect.runPromise(prepare([a, b, `!${a.id}`, "!acme.tool.unknown", a.id], options))).toEqual([b, a]); + expect(await Effect.runPromise(prepare([a, `!${a.id}`], options))).toEqual([]); + const error = await Effect.runPromise(prepare([a.id], options).pipe(Effect.flip)); + expect(error).toMatchObject({ phase: "resolve", index: 0, reference: a.id, id: a.id }); + }); + it("seeds builtins without selecting them and reserves their namespace", async () => { + const builtin = define({ id: "codework.tool.fixture", setup: () => {} }); + expect(await Effect.runPromise(prepare([], { ...options, builtins: [builtin] }))).toEqual([]); + expect(await Effect.runPromise(prepare([builtin.id], { ...options, builtins: [builtin] }))).toEqual([builtin]); + expect(await Effect.runPromise(prepare([builtin], options).pipe(Effect.flip))).toMatchObject({ + phase: "definition", + }); + }); + it.each([{}, [], () => a, { setup: () => {} }, { id: "invalid", setup: () => {} }, { id: a.id, setup: 1 }])( + "rejects malformed definitions: %j", + async (input) => { + expect( + await Effect.runPromise(validate(input, { index: 2, reference: "fixture" }).pipe(Effect.flip)), + ).toMatchObject({ phase: "definition", index: 2 }); + }, + ); + it("normalizes package specs and classifies local sources", () => { + expect(parse("@acme/plugin")).toEqual({ name: "@acme/plugin", spec: "@acme/plugin@latest" }); + expect(parse("@acme/plugin@latest")).toEqual(parse("@acme/plugin")); + expect(parse("@acme/plugin@1.2.0").spec).toBe("@acme/plugin@1.2.0"); + expect(parse("plugin@*").spec).toBe("plugin@*"); + expect(classify("./plugin.ts", "/project")).toEqual({ kind: "local", path: "/project/plugin.ts" }); + expect(classify("file:///project/plugin.ts", "/elsewhere")).toEqual({ + kind: "local", + path: "/project/plugin.ts", + }); + expect(classify(a.id, "/project")).toEqual({ kind: "id", id: a.id }); + expect(() => parse("https://example.com/plugin.tgz")).toThrow(); + }); + it("loads each normalized source once and validates default exports", async () => { + let installs = 0; + let imports = 0; + const seams = { + ...options, + install: () => { + installs++; + return Effect.succeed({ url: "file:///fixture.js", version: "1.0.0" }); + }, + import: async () => { + imports++; + return { default: a }; + }, + }; + expect(await Effect.runPromise(prepare(["@acme/plugin", "@acme/plugin@latest", a.id], seams))).toEqual([a]); + expect(installs).toBe(1); + expect(imports).toBe(1); + expect( + await Effect.runPromise( + prepare(["@acme/plugin"], { ...seams, import: async () => ({ plugin: a }) }).pipe(Effect.flip), + ), + ).toMatchObject({ phase: "definition", reference: "@acme/plugin" }); + }); + it("resolves local directory import conditions and reports broken exports as source errors", () => + withDirectory(async (directory) => { + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name: "fixture", exports: { ".": { import: "./entry.js", require: "./wrong.cjs" } } }), + ); + await writeFile(join(directory, "entry.js"), ""); + let url = ""; + expect( + await Effect.runPromise( + prepare([directory], { + ...options, + import: async (input) => { + url = input; + return { default: a }; + }, + }), + ), + ).toEqual([a]); + expect(url).toBe(pathToFileURL(join(directory, "entry.js")).href); + await writeFile( + join(directory, "package.json"), + JSON.stringify({ name: "fixture", exports: { "./other": "./entry.js" } }), + ); + expect(await Effect.runPromise(prepare([directory], options).pipe(Effect.flip))).toMatchObject({ + phase: "source", + index: 0, + reference: directory, + }); + })); + it("stages installs, reuses complete cache and isolates explicit versions", () => + withDirectory(async (cache) => { + let runs = 0; + const runner: Runner = (request, directory) => { + runs++; + return fixture(request, directory); + }; + const first = await Effect.runPromise(install(parse("fixture@1.0.0"), cache, runner)); + const again = await Effect.runPromise(install(parse("fixture@1.0.0"), cache, runner)); + const second = await Effect.runPromise(install(parse("fixture@2.0.0"), cache, runner)); + expect(first).toEqual(again); + expect(first.version).toBe("1.0.0"); + expect(second.version).toBe("2.0.0"); + expect(first.url).not.toBe(second.url); + expect(runs).toBe(2); + expect(first.url).toContain("/plugins/"); + expect(await readdir(cache)).toEqual(["plugins"]); + })); + it("does not reuse failed installations", () => + withDirectory(async (cache) => { + const failed = await Effect.runPromise( + install(parse("fixture"), cache, () => Effect.fail(new InstallError({ cause: new Error("offline") }))).pipe( + Effect.flip, + ), + ); + expect(failed._tag).toBe("PluginInstallError"); + expect(await readdir(join(cache, "plugins"))).toEqual([]); + expect((await Effect.runPromise(install(parse("fixture"), cache, fixture))).version).toBe("1.0.0"); + })); + it("serializes concurrent installs and permits cancellation while waiting for the lock", () => + withDirectory(async (cache) => { + await Effect.runPromise( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + let runs = 0; + const runner: Runner = (request, directory) => + Effect.gen(function* () { + runs++; + yield* Deferred.succeed(entered, undefined); + yield* Deferred.await(release); + yield* fixture(request, directory); + }); + const first = yield* install(parse("fixture"), cache, runner).pipe(Effect.forkChild); + yield* Deferred.await(entered); + const waiting = yield* install(parse("fixture"), cache, runner).pipe(Effect.forkChild); + yield* Effect.yieldNow; + yield* Fiber.interrupt(waiting); + const second = yield* install(parse("fixture"), cache, runner).pipe(Effect.forkChild); + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toEqual(yield* Fiber.join(second)); + expect(runs).toBe(1); + }).pipe(Effect.scoped), + ); + })); +}); + +it("imports an actual local module default export", () => + withDirectory(async (directory) => { + const source = join(directory, "plugin.mjs"); + await writeFile(source, "export default { id: 'acme.tool.local', setup() {} }"); + const plugins = await Effect.runPromise(prepare([source], options)); + expect(plugins.map((plugin) => plugin.id)).toEqual(["acme.tool.local"]); + })); diff --git a/packages/harness/test/plugin.hooks.test.ts b/packages/harness/test/plugin.hooks.test.ts new file mode 100644 index 0000000..07ef0ee --- /dev/null +++ b/packages/harness/test/plugin.hooks.test.ts @@ -0,0 +1,310 @@ +import { Cause, Deferred, Effect, Exit, Fiber, Schema } from "effect"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, expect } from "vite-plus/test"; +import { make } from "../src/tools/executor.ts"; +import { ToolProgress } from "../src/tools/progress.ts"; +import * as Tool from "../src/tools/tool.ts"; +import type { ToolAddOptions, ToolAfter } from "../src/plugin/tool/schema.ts"; +import { SessionSchema } from "../src/session/schema.ts"; +import { SessionMessageSchema } from "../src/session/message/schema.ts"; +import { pendingCall } from "./tools.fixture.ts"; +import { it } from "./utils/effect.ts"; + +const options = { sessionId: SessionSchema.ID.create(), messageId: SessionMessageSchema.ID.create() }; +const call = pendingCall("echo", { value: "hello" }); +const tool = (handler: () => Effect.Effect = () => Effect.succeed("hello")) => + Tool.register( + Tool.make({ + name: "echo", + description: "Echo", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + encodeContent: (text: string) => [{ type: "text", text }], + handler, + }), + ); +const executor = (hooks: ToolAddOptions, handler?: () => Effect.Effect) => + make([{ tool: tool(handler), hooks }]); + +describe("per-tool hooks", () => { + it.effect("runs decoded before, handler cleanup, and encoded after in order", () => + Effect.gen(function* () { + const trace: string[] = []; + const afters: ToolAfter[] = []; + const run = executor( + { + beforeToolCall: async (input) => { + expect(input).toMatchObject({ + ...options, + params: { value: "hello" }, + rawArgs: { value: "hello" }, + toolName: "echo", + callID: call.callID, + }); + trace.push("before"); + }, + afterToolCall: (input) => { + trace.push("after"); + afters.push(input); + return { content: [], details: null, isError: true }; + }, + }, + () => + Effect.sync(() => { + trace.push("handler"); + return "hello"; + }).pipe( + Effect.ensuring( + Effect.sync(() => { + trace.push("cleanup"); + }), + ), + ), + ); + const result = yield* run.handle(call, options); + expect(trace).toEqual(["before", "handler", "cleanup", "after"]); + expect(afters[0]?.terminal).toMatchObject({ + status: "completed", + result: { content: [{ type: "text", text: "hello" }], isError: false }, + }); + expect(result).toMatchObject({ + callID: call.callID, + arguments: call.arguments, + status: "error", + result: { content: [], details: null, isError: true }, + }); + }), + ); + it.effect("skips hooks for unknown or invalid calls and skips handler/after when blocked", () => + Effect.gen(function* () { + const trace: string[] = []; + const run = executor( + { + beforeToolCall: () => { + trace.push("before"); + return { block: true, reason: "Denied" }; + }, + afterToolCall: () => { + trace.push("after"); + }, + }, + () => + Effect.sync(() => { + trace.push("handler"); + return "hello"; + }), + ); + expect((yield* run.handle(pendingCall("missing"), options)).status).toBe("error"); + expect((yield* run.handle(pendingCall("echo"), options)).status).toBe("error"); + expect(trace).toEqual([]); + expect(yield* run.handle(call, options)).toMatchObject({ + status: "error", + result: { content: [{ type: "text", text: "Denied" }] }, + }); + expect(trace).toEqual(["before"]); + }), + ); + it.effect("normalizes thrown, rejected and Effect hook failures", () => + Effect.gen(function* () { + for (const beforeToolCall of [ + () => { + throw new Error("sync"); + }, + () => Promise.reject(new Error("promise")), + () => Effect.fail(new Error("effect")), + ]) { + expect((yield* executor({ beforeToolCall }).handle(call, options)).status).toBe("error"); + } + expect( + (yield* executor({ + afterToolCall: () => { + throw new Error("after"); + }, + }).handle(call, options)).status, + ).toBe("error"); + }), + ); + it.effect("passes undeclared handler errors and encoder defects to after for recovery", () => + Effect.gen(function* () { + for (const handler of [ + () => Effect.fail(new Error("failed")), + () => Effect.die(new Error("defect")), + () => { + throw new Error("thrown"); + }, + ]) { + const result = yield* executor( + { + afterToolCall: ({ terminal }) => { + expect(terminal.status).toBe("error"); + return { isError: false }; + }, + }, + handler, + ).handle(call, options); + expect(result).toMatchObject({ status: "completed", result: { isError: false } }); + } + const broken = Tool.register( + Tool.make({ + name: "echo", + description: "broken encoder", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + handler: () => Effect.succeed("ok"), + encodeContent: () => { + throw new Error("encode"); + }, + }), + ); + let seen = false; + const result = yield* make([ + { + tool: broken, + hooks: { + afterToolCall: ({ terminal }) => { + seen = true; + expect(terminal.status).toBe("error"); + }, + }, + }, + ]).handle(call, options); + expect(seen).toBe(true); + expect(result.status).toBe("error"); + }), + ); + it.effect("cancelling before skips handler and after", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + let count = 0; + const run = executor( + { + beforeToolCall: () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + afterToolCall: () => { + count++; + }, + }, + () => + Effect.sync(() => { + count++; + return "ok"; + }), + ); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + expect(count).toBe(0); + }), + ); + it.effect("notifies aborted once with partial output and ignores patches", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const observed: ToolAfter[] = []; + const streaming = Tool.register( + Tool.make({ + name: "echo", + description: "stream", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + handler: () => + Effect.gen(function* () { + const progress = yield* ToolProgress; + yield* progress.report({ content: [{ type: "text", text: "partial" }], details: { part: 1 } }); + yield* Deferred.succeed(entered, undefined); + return yield* Effect.never; + }), + }), + ); + const run = make([ + { + tool: streaming, + hooks: { + afterToolCall: (input) => { + observed.push(input); + return { isError: false, content: [] }; + }, + }, + }, + ]); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + expect(observed).toHaveLength(1); + expect(observed[0]?.terminal).toMatchObject({ + status: "aborted", + result: { content: [{ type: "text", text: "partial" }], details: { part: 1 } }, + }); + const exit = yield* Fiber.await(fiber); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + }), + ); + it.effect("times out cooperative abort notification and runs its finalizer", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const afterEntered = yield* Deferred.make(); + let finalized = false; + const run = executor( + { + afterToolCall: () => + Deferred.succeed(afterEntered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring( + Effect.sync(() => { + finalized = true; + }), + ), + ), + }, + () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)), + ); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + yield* Deferred.await(entered); + const interrupt = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild); + yield* Deferred.await(afterEntered); + yield* TestClock.adjust("1 second"); + yield* Fiber.join(interrupt); + expect(finalized).toBe(true); + }), + ); + it.effect("interrupts normal after without invoking it again for abort", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + let count = 0; + const run = executor({ + afterToolCall: () => + Effect.gen(function* () { + count++; + yield* Deferred.succeed(entered, undefined); + yield* Effect.never; + }), + }); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + expect(count).toBe(1); + }), + ); + it.effect("preserves interruption mixed with failing handler cleanup", () => + Effect.gen(function* () { + const entered = yield* Deferred.make(); + let status: string | undefined; + const run = executor( + { + afterToolCall: ({ terminal }) => { + status = terminal.status; + }, + }, + () => + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Effect.never), + Effect.ensuring(Effect.die(new Error("cleanup"))), + ), + ); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + const exit = yield* Fiber.await(fiber); + expect(status).toBe("aborted"); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + }), + ); +}); diff --git a/packages/harness/test/plugin.host.test.ts b/packages/harness/test/plugin.host.test.ts new file mode 100644 index 0000000..eaf7dae --- /dev/null +++ b/packages/harness/test/plugin.host.test.ts @@ -0,0 +1,369 @@ +import { createAssistantMessageEventStream, type Model } from "@codeworksh/aikit"; +import { Deferred, Effect, Fiber, Schema } from "effect"; +import { describe, expect, it } from "vite-plus/test"; +import { join } from "node:path"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import type { SharedPluginContext } from "../src/plugin/context.ts"; +import { make } from "../src/plugin/registry.ts"; +import * as Tool from "../src/tools/tool.ts"; +import { pendingCall } from "./tools.fixture.ts"; +import { assistant, immediateOpen } from "./fixtures/llm.ts"; +import { withSettings } from "./fixtures/settings.ts"; + +const echo = (value: string) => + Tool.register( + Tool.make({ + name: "echo", + description: value, + parameters: Schema.Struct({}), + success: Schema.String, + handler: () => Effect.succeed(value), + }), + ); + +describe("plugin domains and exchange host", () => { + it("replaces tool and hooks together, patches prose, and closes retained buckets", async () => { + const buckets = make(); + const tools = buckets.registry.tools; + let stale = 0; + tools.add(echo("first"), { + beforeToolCall: () => { + stale++; + return { block: true }; + }, + }); + tools.add(echo("winner")); + tools.update("echo", { description: "patched", promptGuidelines: ["one"] }); + expect(tools.list().map((t) => t.name)).toEqual(["echo"]); + buckets.registry.prompt.set(""); + const snapshot = buckets.freeze(); + expect(snapshot.systemPrompt).toBe(""); + expect(snapshot.tools.defs[0]?.description).toBe("patched"); + expect(snapshot.tools.wire[0]?.description).toBe("patched"); + expect((await Effect.runPromise(snapshot.tools.handle(pendingCall("echo")))).status).toBe("completed"); + expect(stale).toBe(0); + expect(() => tools.add(echo("late"))).toThrow(); + expect(() => tools.update("echo", { description: "late" })).toThrow(); + expect(() => buckets.registry.prompt.set("late")).toThrow(); + }); + it("requires a prompt string, preserves full replacement, and rejects unknown tool patches", () => { + const empty = make(); + expect(empty.registry.prompt.get()).toBeUndefined(); + expect(() => empty.freeze()).toThrow("No Prompt plugin"); + const buckets = make(); + buckets.registry.prompt.set("old"); + buckets.registry.prompt.set("new"); + expect(buckets.freeze().systemPrompt).toBe("new"); + expect(() => make().registry.tools.update("unknown", {})).toThrow("Unknown tool"); + }); + it("runs setup in declared order with a fresh context and pinned model each exchange", () => + withSettings(async ({ root }) => { + const contexts: SharedPluginContext[] = []; + const models: Model.Info[] = []; + const observed: string[] = []; + const open = immediateOpen(); + await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ + directory: root, + systemPrompt: { + custom: async (ctx) => `custom:${ctx.plugin.tools.list().length}`, + append: () => "append", + }, + }); + yield* session.run("first"); + yield* session.run("second"); + expect(contexts).toHaveLength(2); + expect(contexts[0]).not.toBe(contexts[1]); + expect(contexts[0]?.plugin).not.toBe(contexts[1]?.plugin); + expect(contexts[0]?.model).toBe(models[0]); + expect(contexts[1]?.model).toBe(models[1]); + expect(observed).toEqual(["custom:1\n\nappend\nwrapped", "custom:1\n\nappend\nwrapped"]); + expect(contexts[0]?.events).not.toHaveProperty("subscribe"); + expect(() => contexts[0]?.plugin.prompt.set("late")).toThrow(); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: (input, signal) => { + models.push(input.resolvedModel); + observed.push(input.context.systemPrompt ?? ""); + return open(input, signal); + }, + plugins: [ + { + id: "acme.tool.echo", + setup: (ctx) => { + contexts.push(ctx); + ctx.plugin.tools.add(echo("test")); + }, + }, + "codework.prompt.default", + { + id: "acme.prompt.wrap", + setup: async (ctx) => { + await Promise.resolve(); + ctx.plugin.prompt.set(`${ctx.plugin.prompt.get()}\nwrapped`); + }, + }, + ], + }), + ), + Effect.scoped, + ), + ); + })); + it("lets prompt plugins observe only earlier tool registrations", () => + withSettings(async ({ root }) => { + const prompts: string[] = []; + const open = immediateOpen(); + await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.run("hello"); + expect(prompts).toEqual(["0"]); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: (input, signal) => { + prompts.push(input.context.systemPrompt ?? ""); + return open(input, signal); + }, + plugins: [ + { + id: "acme.prompt.count", + setup: (ctx) => ctx.plugin.prompt.set(String(ctx.plugin.tools.list().length)), + }, + { id: "acme.tool.echo", setup: (ctx) => ctx.plugin.tools.add(echo("test")) }, + ], + }), + ), + Effect.scoped, + ), + ); + })); + it("runs no setup when model resolution fails", () => + withSettings(async ({ root }) => { + let setups = 0; + await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ + directory: root, + model: { provider: "openai", id: "no-such-model" }, + }); + yield* session.run("hello"); + expect(setups).toBe(0); + expect(yield* session.path()).toEqual([]); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + plugins: [ + { + id: "acme.prompt.count", + setup: (ctx) => { + setups++; + ctx.plugin.prompt.set(""); + }, + }, + ], + }), + ), + Effect.scoped, + ), + ); + })); + it("stops setup on failure and closes a retained context", () => + withSettings(async ({ root }) => { + for (const failure of [ + () => { + throw new Error("sync"); + }, + () => Promise.reject(new Error("async")), + () => Effect.fail(new Error("effect")), + () => Effect.die(new Error("defect")), + ]) { + let retained: SharedPluginContext | undefined; + let later = false; + let requested = false; + await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.run("hello"); + expect(later).toBe(false); + expect(requested).toBe(false); + expect(() => retained?.plugin.prompt.set("late")).toThrow(); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: () => { + requested = true; + return Effect.never; + }, + plugins: [ + { + id: "acme.prompt.fail", + setup: (ctx) => { + retained = ctx; + return failure(); + }, + }, + { + id: "acme.prompt.later", + setup: () => { + later = true; + }, + }, + ], + }), + ), + Effect.scoped, + ), + ); + } + })); + it("closes the bucket when async setup is cancelled", () => + withSettings(async ({ root }) => { + let retained: SharedPluginContext | undefined; + await Effect.runPromise( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + yield* Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + const fiber = yield* session.run("hello").pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* session.interrupt(); + yield* Fiber.join(fiber); + expect(() => retained?.plugin.prompt.set("late")).toThrow(); + yield* Deferred.succeed(release, undefined); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + plugins: [ + { + id: "acme.prompt.wait", + setup: (ctx) => { + retained = ctx; + return Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(release)), + ); + }, + }, + ], + }), + ), + Effect.scoped, + ); + }).pipe(Effect.scoped), + ); + })); +}); + +describe("plugin pipelines in the kernel loop", () => { + it.each(["sequential", "parallel"] as const)("schedules the complete pipeline in %s mode", (mode) => + withSettings(async ({ root }) => { + const trace: string[] = []; + await Effect.runPromise( + Effect.gen(function* () { + const firstAfter = yield* Deferred.make(); + const secondAfter = yield* Deferred.make(); + const release = yield* Deferred.make(); + let requests = 0; + const pipeline = Tool.register( + Tool.make({ + name: "pipeline", + description: "pipeline", + parameters: Schema.Struct({ id: Schema.String }), + success: Schema.String, + handler: ({ id }) => + Effect.sync(() => { + trace.push(`${id}:handler`); + return id; + }), + }), + ); + yield* Effect.gen(function* () { + const session = yield* Session.create({ directory: root, tools: { execution: mode } }); + const run = yield* session.run("hello").pipe(Effect.forkChild); + yield* Deferred.await(firstAfter); + if (mode === "parallel") yield* Deferred.await(secondAfter); + expect(trace.includes("b:handler")).toBe(mode === "parallel"); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(run); + for (const id of ["a", "b"]) + expect(trace.filter((item) => item.startsWith(id))).toEqual([ + `${id}:before`, + `${id}:handler`, + `${id}:after`, + `${id}:done`, + ]); + if (mode === "sequential") expect(trace.indexOf("a:done")).toBeLessThan(trace.indexOf("b:before")); + const path = yield* session.path(); + expect(path).toHaveLength(3); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + plugins: [ + { + id: "acme.tool.pipeline", + setup: (ctx) => + ctx.plugin.tools.add(pipeline, { + beforeToolCall: ({ callID }) => { + trace.push(`${callID}:before`); + }, + afterToolCall: ({ callID }) => + Effect.gen(function* () { + trace.push(`${callID}:after`); + yield* Deferred.succeed(callID === "a" ? firstAfter : secondAfter, undefined); + yield* Deferred.await(release); + trace.push(`${callID}:done`); + }), + }), + }, + "codework.prompt.default", + ], + llm: (input) => + Effect.sync(() => { + requests++; + const first = requests === 1; + const message = assistant( + input, + requests, + first + ? { + stopReason: "toolUse", + parts: [ + pendingCall("pipeline", { id: "a" }, "a"), + pendingCall("pipeline", { id: "b" }, "b"), + ], + } + : {}, + ); + const stream = createAssistantMessageEventStream(); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: first ? "toolUse" : "stop", message }); + return stream; + }), + }), + ), + Effect.scoped, + ); + }).pipe(Effect.scoped), + ); + }), + ); +}); diff --git a/packages/harness/test/runner.llm.live.test.ts b/packages/harness/test/runner.llm.live.test.ts index cf40741..c14226c 100644 --- a/packages/harness/test/runner.llm.live.test.ts +++ b/packages/harness/test/runner.llm.live.test.ts @@ -67,6 +67,7 @@ describe("runner LLM — OpenAI live", () => { ), provider: "openai", model: "gpt-5.6-luna", + resolvedModel: yield* LLM.resolve({ provider: "openai", model: "gpt-5.6-luna" }), thinkingLevel: "low", settings: { reasoningSummary: "auto" }, publisher, @@ -110,6 +111,7 @@ describe("runner LLM — OpenAI live", () => { context: context("List 200 distinct first names, one per line."), provider: "openai", model: "gpt-5.6-luna", + resolvedModel: yield* LLM.resolve({ provider: "openai", model: "gpt-5.6-luna" }), publisher, }).pipe(Effect.forkChild); const interruptedPart = yield* Deferred.await(streaming); diff --git a/packages/harness/test/runner.llm.test.ts b/packages/harness/test/runner.llm.test.ts index b0fcc85..47046a4 100644 --- a/packages/harness/test/runner.llm.test.ts +++ b/packages/harness/test/runner.llm.test.ts @@ -46,13 +46,9 @@ describe("runner LLM", () => { it( "maps an unknown model to ModelNotFoundError", Effect.gen(function* () { - const { sessionId, publisher } = yield* setup; - const failure = yield* LLM.run({ - sessionId, - context, + const failure = yield* LLM.resolve({ provider: "openai", model: "model-that-does-not-exist", - publisher, }).pipe(Effect.flip); expect(failure._tag).toBe("Runner.ModelNotFoundError"); @@ -78,6 +74,7 @@ describe("runner LLM", () => { context, provider: "openai", model: "gpt-4o-mini", + resolvedModel: yield* LLM.resolve({ provider: "openai", model: "gpt-4o-mini" }), publisher, }).pipe(Effect.flip); @@ -104,6 +101,7 @@ describe("runner LLM", () => { context, provider: "openai", model: "gpt-4o-mini", + resolvedModel: yield* LLM.resolve({ provider: "openai", model: "gpt-4o-mini" }), publisher, }).pipe(Effect.flip); diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 8ca0d42..46f0f54 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -1,3 +1,5 @@ +import type { Plugin } from "../src/plugin/plugin.ts"; +import { defaults as plugins } from "../src/plugin/internal.ts"; import { Settings } from "../src/settings/settings.ts"; import { createAssistantMessageEventStream, Message } from "@codeworksh/aikit"; import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"; @@ -29,7 +31,12 @@ import { assistant, immediateOpen } from "./fixtures/llm.ts"; import { testEffect } from "./utils/effect.ts"; const runtime = ( - options: { readonly open?: LLM.Open; readonly contexts?: Message.Context[]; readonly state?: State.Options } = {}, + options: { + readonly open?: LLM.Open; + readonly contexts?: Message.Context[]; + readonly state?: State.Options; + readonly plugins?: ReadonlyArray; + } = {}, ) => { const database = Database.layer(":memory:"); const request = LLM.make(options.open ?? immediateOpen(options.contexts)); @@ -39,7 +46,7 @@ const runtime = ( ); return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(Loop.layer({ request })))), - Layer.provideMerge(State.layer(options.state)), + Layer.provideMerge(State.layer(options.state, options.plugins)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(Layer.succeed(Settings.Service, { load: Effect.succeed(Settings.defaults) })), Layer.provideMerge(sandbox), @@ -283,13 +290,16 @@ describe("runner loop — tool continuation and lifecycle gate", () => { const { effect: it } = testEffect( runtime({ open, - state: { - tools: [echo], - promptSystemOverride: ({ systemPrompt }) => { - snapshots += 1; - return systemPrompt; + plugins: [ + ...plugins, + { + id: "test.tool.echo", + setup: (ctx) => { + snapshots += 1; + ctx.plugin.tools.add(echo); + }, }, - }, + ], }), ); @@ -336,12 +346,15 @@ describe("runner loop — crash healing", () => { const { effect: it } = testEffect( runtime({ contexts, - state: { - promptSystemOverride: ({ systemPrompt }) => { - snapshots += 1; - return systemPrompt; + plugins: [ + ...plugins, + { + id: "test.prompt.count", + setup: () => { + snapshots += 1; + }, }, - }, + ], }), ); @@ -355,6 +368,7 @@ describe("runner loop — crash healing", () => { const sessions = yield* Session.Service; const sessionId = yield* seedSession("heal"); const input: LLM.Input = { + resolvedModel: yield* LLM.resolve({ provider: "openai", model: "gpt-4o-mini" }), sessionId, context: { messages: [] }, provider: "openai", @@ -412,7 +426,13 @@ describe("runner loop — tool interruption", () => { stream.push({ type: "done", reason: "toolUse", message }); return stream; }); - const { live: it } = testEffect(runtime({ open, state: { tools: [blocking], toolExecution: "parallel" } })); + const { live: it } = testEffect( + runtime({ + open, + state: { toolExecution: "parallel" }, + plugins: [...plugins, { id: "test.tool.blocking", setup: (ctx) => ctx.plugin.tools.add(blocking) }], + }), + ); it( "settles every unfinished call as aborted and commits before rethrowing interrupt", diff --git a/packages/harness/test/sdk.test.ts b/packages/harness/test/sdk.test.ts index ffefb0f..983d3d5 100644 --- a/packages/harness/test/sdk.test.ts +++ b/packages/harness/test/sdk.test.ts @@ -65,7 +65,7 @@ describe("Harness Effect SDK", () => { Effect.gen(function* () { const created = yield* Session.create({ directory: process.cwd(), - model: { provider: "test", id: "test-model" }, + model: { provider: "openai", id: "gpt-4o-mini" }, thinkingLevel: "max", }); yield* created.run("first"); @@ -75,8 +75,8 @@ describe("Harness Effect SDK", () => { yield* attached.run("second"); expect(inputs.map(({ provider, model, thinkingLevel }) => ({ provider, model, thinkingLevel }))).toEqual([ - { provider: "test", model: "test-model", thinkingLevel: "max" }, - { provider: "test", model: "test-model", thinkingLevel: "max" }, + { provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" }, + { provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" }, ]); }), llm, @@ -122,7 +122,7 @@ describe("Harness Effect SDK", () => { const sessionId = yield* Effect.gen(function* () { const session = yield* Session.create({ directory: process.cwd(), - model: { provider: "test", id: "test-model" }, + model: { provider: "openai", id: "gpt-4o-mini" }, thinkingLevel: "max", }); yield* session.run("first"); @@ -143,7 +143,7 @@ describe("Harness Effect SDK", () => { yield* Effect.gen(function* () { const session = yield* Session.attach({ sessionId, - model: { provider: "override", id: "override-model" }, + model: { provider: "openai", id: "gpt-4o" }, thinkingLevel: "low", }); yield* session.run("third"); @@ -163,13 +163,13 @@ describe("Harness Effect SDK", () => { */ expect(inputs.map(({ provider, model, thinkingLevel }) => ({ provider, model, thinkingLevel }))).toEqual( [ - { provider: "test", model: "test-model", thinkingLevel: "max" }, + { provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" }, { provider: Settings.defaults.model.provider, model: Settings.defaults.model.id, thinkingLevel: Settings.defaults.model.thinkingLevel, }, - { provider: "override", model: "override-model", thinkingLevel: "low" }, + { provider: "openai", model: "gpt-4o", thinkingLevel: "low" }, { provider: Settings.defaults.model.provider, model: Settings.defaults.model.id, diff --git a/packages/harness/test/settings.live.test.ts b/packages/harness/test/settings.live.test.ts index c20935e..2e74c5c 100644 --- a/packages/harness/test/settings.live.test.ts +++ b/packages/harness/test/settings.live.test.ts @@ -74,7 +74,6 @@ describe("settings against a live provider", () => { const handle = yield* Session.create({ directory: root, // Selection and matching controls come from the host file. - tools: { builtins: [] }, }); const runtime = yield* SessionRuntime.Service; yield* runtime.update(handle.id, { onPayload }); diff --git a/packages/harness/test/settings.llm.test.ts b/packages/harness/test/settings.llm.test.ts index 60357d3..53b47b2 100644 --- a/packages/harness/test/settings.llm.test.ts +++ b/packages/harness/test/settings.llm.test.ts @@ -17,6 +17,7 @@ describe("settings at the LLM boundary", () => { sessionId: SessionSchema.ID.create(), provider: "lmstudio", model: model.id, + resolvedModel: model, context: { messages: [ Message.createUserMessage({ @@ -94,6 +95,9 @@ describe("settings at the LLM boundary", () => { sessionId: SessionSchema.ID.create(), provider: selection.provider, model: selection.model, + resolvedModel: await Effect.runPromise( + LLM.resolve({ provider: selection.provider, model: selection.model, settings: selection.block }), + ), thinkingLevel: "off", context: { messages: [ @@ -119,25 +123,13 @@ describe("settings at the LLM boundary", () => { }); it("keeps typed lookup errors when settings select a model the catalog does not have", async () => { - const input = (provider: string, model: string): LLM.Input => ({ - sessionId: SessionSchema.ID.create(), - provider, - model, - context: { - messages: [ - Message.createUserMessage({ role: "user", time: { created: 1 }, parts: [{ type: "text", text: "hi" }] }), - ], - }, - // A selection is never validated during composition, so overrides ride along unused. - settings: { contextWindow: 999 }, - }); - const signal = new AbortController().signal; - const missing = await Effect.runPromise(LLM.open(input("openai", "no-such-model"), signal).pipe(Effect.flip)); + const input = (provider: string, model: string) => ({ provider, model, settings: { contextWindow: 999 } }); + const missing = await Effect.runPromise(LLM.resolve(input("openai", "no-such-model")).pipe(Effect.flip)); expect(missing._tag).toBe("Runner.ModelNotFoundError"); expect(missing).toMatchObject({ provider: "openai", model: "no-such-model" }); const unknownProvider = await Effect.runPromise( - LLM.open(input("no-such-provider", "no-such-model"), signal).pipe(Effect.flip), + LLM.resolve(input("no-such-provider", "no-such-model")).pipe(Effect.flip), ); expect(unknownProvider._tag).toBe("Runner.ModelNotFoundError"); }); diff --git a/packages/harness/test/settings.loop.test.ts b/packages/harness/test/settings.loop.test.ts index dda844f..e91f097 100644 --- a/packages/harness/test/settings.loop.test.ts +++ b/packages/harness/test/settings.loop.test.ts @@ -82,7 +82,7 @@ describe("settings at exchange boundaries", () => { return terminal(input, index, index === 1); }); yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [], extras: [wait] } }); + const handle = yield* Session.create({ directory: root }); yield* handle.prompt("first"); yield* Deferred.await(requestEntered); yield* Effect.promise(() => file(custom, "medium")); @@ -101,7 +101,7 @@ describe("settings at exchange boundaries", () => { expect(inputs.at(-1)?.thinkingLevel).toBe("off"); // A virtual sandbox uses the same host settings, without any virtual settings file. const sandbox = yield* Sandbox.create({ driver: "memory" }); - const virtual = yield* Session.create({ sandbox, tools: { builtins: [] } }); + const virtual = yield* Session.create({ sandbox }); yield* virtual.run("virtual"); expect(inputs.at(-1)?.thinkingLevel).toBe("off"); }).pipe( @@ -111,6 +111,10 @@ describe("settings at exchange boundaries", () => { database: ":memory:", userConfigDir: custom, llm: open, + plugins: [ + { id: "test.tool.wait", setup: (ctx) => ctx.plugin.tools.add(wait) }, + "codework.prompt.default", + ], }), ), Effect.scoped, @@ -181,7 +185,7 @@ describe("settings at exchange boundaries", () => { return stream; }); yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [], extras: [pair] } }); + const handle = yield* Session.create({ directory: root }); yield* handle.run("parallel, including continuation"); yield* handle.run("fresh sequential"); expect(starts).toEqual([2, 2, 2]); @@ -198,6 +202,10 @@ describe("settings at exchange boundaries", () => { database: ":memory:", userConfigDir: custom, llm: open, + plugins: [ + { id: "test.tool.pair", setup: (ctx) => ctx.plugin.tools.add(pair) }, + "codework.prompt.default", + ], }), ), Effect.scoped, @@ -230,7 +238,7 @@ describe("settings at exchange boundaries", () => { return stream; }); yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [] } }); + const handle = yield* Session.create({ directory: root }); const events = yield* Event.Service; yield* events.listen((event) => event.type === "session.llm.started" @@ -288,7 +296,7 @@ describe("settings at exchange boundaries", () => { return terminal(input, inputs.length, inputs.length === 1); }); yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [], extras: [wait] } }); + const handle = yield* Session.create({ directory: root }); yield* handle.prompt("first"); yield* Deferred.await(toolEntered); // A binding set mid-exchange must not disturb the snapshot already pinned. @@ -306,6 +314,10 @@ describe("settings at exchange boundaries", () => { database: ":memory:", userConfigDir: custom, llm: open, + plugins: [ + { id: "test.tool.wait", setup: (ctx) => ctx.plugin.tools.add(wait) }, + "codework.prompt.default", + ], }), ), Effect.scoped, @@ -328,7 +340,7 @@ describe("settings at exchange boundaries", () => { await Effect.runPromise( Effect.gen(function* () { const sessionId = yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [] } }); + const handle = yield* Session.create({ directory: root }); // A binding is process-local; the file is not. yield* Session.attach({ sessionId: handle.id, thinkingLevel: "max" }); yield* handle.run("first"); @@ -364,13 +376,11 @@ describe("settings at exchange boundaries", () => { await Effect.runPromise( Effect.gen(function* () { yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [] } }); + const handle = yield* Session.create({ directory: root }); yield* handle.run("first"); - // The lookup fails before a draft exists, so the prompt is left unanswered. - expect((yield* handle.path()).map(({ entry }) => [entry.type, entry.state])).toEqual([ - ["user", "committed"], - ]); - expect(inputs.at(-1)?.model).toBe("no-such-model"); + // Lookup fails before promotion; the admitted prompt remains queued. + expect(yield* handle.path()).toEqual([]); + expect(inputs).toHaveLength(0); yield* Effect.promise(() => select("openai", "gpt-5.6-luna")); yield* handle.resume(); @@ -410,7 +420,7 @@ describe("settings at exchange boundaries", () => { return terminal(input, inputs.length); }); yield* Effect.gen(function* () { - const handle = yield* Session.create({ directory: root, tools: { builtins: [] } }); + const handle = yield* Session.create({ directory: root }); yield* handle.run("with A"); expect(inputs.at(-1)?.thinkingLevel).toBe("low"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1f2d1d..39e0cf5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,9 +179,18 @@ importers: effect: specifier: 4.0.0-beta.107 version: 4.0.0-beta.107 + import-meta-resolve: + specifier: ^4.2.0 + version: 4.2.0 just-bash: specifier: ^2.14.5 version: 2.14.5 + npm-package-arg: + specifier: ^14.0.0 + version: 14.0.0 + resolve.exports: + specifier: ^2.0.3 + version: 2.0.3 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -204,6 +213,9 @@ importers: '@types/node': specifier: ^25.9.5 version: 25.9.5 + '@types/npm-package-arg': + specifier: ^6.1.4 + version: 6.1.4 '@vercel/sandbox': specifier: ^2.9.2 version: 2.9.2 @@ -1572,6 +1584,9 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} + '@types/npm-package-arg@6.1.4': + resolution: {integrity: sha512-vDgdbMy2QXHnAruzlv68pUtXCjmqUk3WrBAsRboRovsOmxbfn/WiYCjmecyKjGztnMps5dWp4Uq2prp+Ilo17Q==} + '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} @@ -2450,6 +2465,10 @@ packages: resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==} engines: {node: '>=0.10.0'} + hosted-git-info@10.1.1: + resolution: {integrity: sha512-DeOnSPAvOndYKfw075gt8yZzQ7S2hNztw34zBTfhIzLhmBTswIBg5/y+pqu/VD5cYWm5goAFTusDmUEmKZ0PEQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -2465,6 +2484,9 @@ packages: resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} engines: {node: '>=18'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2621,6 +2643,10 @@ packages: long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2753,6 +2779,10 @@ packages: engines: {node: '>=16.0.0'} hasBin: true + npm-package-arg@14.0.0: + resolution: {integrity: sha512-69XQh3k+dtGa1p+7RaR57IuG3rCko96xr/nUfN4yDYBXbTYICiWcOpsFKLN2GtGE9cyIljE+f1exnaYt9MvM+Q==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -2853,6 +2883,10 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + proc-log@7.0.0: + resolution: {integrity: sha512-FYgfaA69XZ93zaXLoMNQ+ViDXGGBgR8aLh03txzcFhV+9xOXx7+8DLCULrKKpR9+GsH9ZfHm82aSUPpozX0Ztg==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + protobufjs@7.6.5: resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} engines: {node: '>=12.0.0'} @@ -2918,6 +2952,10 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -3193,6 +3231,10 @@ packages: resolution: {integrity: sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==} hasBin: true + validate-npm-package-name@8.0.0: + resolution: {integrity: sha512-SCv6OOV6Xj2/3cXy3dGmADluJTNcL3o7hZAglNPTe+WYuEuvxgJzxPrSDLZhF+CwyQOubqgecjMmTJGMVLWjYQ==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + verkit@0.3.2: resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} engines: {node: '>=18.12.0'} @@ -4684,6 +4726,8 @@ snapshots: dependencies: undici-types: 8.3.0 + '@types/npm-package-arg@6.1.4': {} + '@types/set-cookie-parser@2.4.10': dependencies: '@types/node': 25.9.5 @@ -5439,6 +5483,10 @@ snapshots: dependencies: parse-passwd: 1.0.0 + hosted-git-info@10.1.1: + dependencies: + lru-cache: 11.5.2 + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -5461,6 +5509,8 @@ snapshots: es-module-lexer: 2.3.1 module-details-from-path: 1.0.4 + import-meta-resolve@4.2.0: {} + inherits@2.0.4: {} ini@1.3.8: @@ -5604,6 +5654,8 @@ snapshots: long@5.3.2: {} + lru-cache@11.5.2: {} + lz-string@1.5.0: {} magic-string@0.30.21: @@ -5736,6 +5788,13 @@ snapshots: node-gyp-build: 4.8.4 optional: true + npm-package-arg@14.0.0: + dependencies: + hosted-git-info: 10.1.1 + proc-log: 7.0.0 + semver: 7.8.5 + validate-npm-package-name: 8.0.0 + obug@2.1.4: {} once@1.4.0: @@ -5863,6 +5922,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + proc-log@7.0.0: {} + protobufjs@7.6.5: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -5953,6 +6014,8 @@ snapshots: transitivePeerDependencies: - supports-color + resolve.exports@2.0.3: {} + retry@0.13.1: {} rettime@0.11.11: @@ -6013,8 +6076,7 @@ snapshots: dependencies: commander: 6.2.1 - semver@7.8.5: - optional: true + semver@7.8.5: {} set-cookie-parser@3.1.2: optional: true @@ -6285,6 +6347,8 @@ snapshots: uuidv7@1.2.1: {} + validate-npm-package-name@8.0.0: {} + verkit@0.3.2: {} vite-plus@0.2.8(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(msw@2.13.4(@types/node@26.1.2)(typescript@7.0.2))(tsx@4.23.12)(typescript@7.0.2)(unrun@0.2.36)(vite@8.0.7(@types/node@26.1.2)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.12)(yaml@2.9.0))(yaml@2.9.0): From ff099f17654461acfeb4e70fee17303a4c97619c Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 11 Sep 2026 19:05:31 +0530 Subject: [PATCH 04/12] refactor(harness): require an explicit host cwd, harden plugin setup Two threads on the plugin branch. Host cwd is now passed, never discovered. `process.cwd()` meant two different things depending on who read it -- the OS process's directory, or a session's sandbox mount -- and every consumer guessed. `Harness.layer` now captures it once as `hostCwd` and threads it down; `Settings.Options.cwd`, `PluginLoader.Options.hostCwd` and `SandboxDriverLoader.Options.hostCwd` are required, so omitting one is a type error rather than a silent host read. `Settings.layer` had never been passed a directory at all and resolved against `process.cwd()` on its own. That worked only because the values happened to agree; they stop agreeing the moment a layer is built from elsewhere. `sandbox/io.ts` and `sandbox/control.ts` keep theirs. `SandboxIO.host()` means "the sandbox that is the host process", so the process directory is its definition, not a fallback -- and the controller's local root must agree with it. Parameterizing one half of that pair would let them diverge. Plugin hardening: `setup` no longer double-wraps a typed SetupError; `idPattern` requires exactly three segments; `validate` returns the author's object rather than the Struct-decoded copy, which was stripping undeclared properties and repointing `this` inside the `setup() {}` shorthand; `guidelines.ts` folds into the default prompt plugin; `ToolRegistration` moves to `tool/schema.ts` so the executor shares the contract without importing plugin assembly; `errorMessage` is shared by the executor and the loop's outer catch so one defect reads the same either way. --- packages/harness/README.md | 4 +- packages/harness/src/effect/harness.ts | 23 ++-- packages/harness/src/plugin/catalog.ts | 16 ++- packages/harness/src/plugin/host.ts | 28 +++-- packages/harness/src/plugin/index.ts | 1 + packages/harness/src/plugin/internal.ts | 12 +- .../src/plugin/internal/prompt/default.ts | 110 ++++++++++++++++-- .../src/plugin/internal/prompt/guidelines.ts | 10 -- packages/harness/src/plugin/loader.ts | 37 ++++-- packages/harness/src/plugin/package.ts | 68 +++++++---- packages/harness/src/plugin/tool/registry.ts | 7 +- packages/harness/src/plugin/tool/schema.ts | 9 ++ packages/harness/src/runner/loop.ts | 9 +- packages/harness/src/sandbox/loader.ts | 19 ++- packages/harness/src/settings/settings.ts | 19 +-- packages/harness/src/state/state.ts | 8 +- packages/harness/src/tools/error.ts | 17 ++- packages/harness/src/tools/executor.ts | 77 +++++++++--- packages/harness/src/tools/registry.ts | 2 +- .../test/fixtures/runner.cycle.spec.ts | 3 +- packages/harness/test/plugin.catalog.test.ts | 100 +++++++++++++++- packages/harness/test/plugin.hooks.test.ts | 95 +++++++++++++++ packages/harness/test/plugin.host.test.ts | 40 ++++++- packages/harness/test/plugin.prompt.test.ts | 91 +++++++++++++++ packages/harness/test/runner.loop.test.ts | 5 +- packages/harness/test/sandbox.loader.test.ts | 9 +- .../test/sandbox.remote.driver.e2e.test.ts | 1 + packages/harness/test/sdk.test.ts | 1 + packages/harness/test/settings.loop.test.ts | 1 + 29 files changed, 690 insertions(+), 132 deletions(-) delete mode 100644 packages/harness/src/plugin/internal/prompt/guidelines.ts create mode 100644 packages/harness/test/plugin.prompt.test.ts diff --git a/packages/harness/README.md b/packages/harness/README.md index 1375d91..ea09da5 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -57,9 +57,11 @@ const runtime = Harness.layer({ Hooks belong to the tool registration. Sequential or parallel scheduling, selected with `Session.create({ tools: { execution: "parallel" } })`, covers the entire hook/handler pipeline. After runs for a started, interrupted tool if it has not already started, with a one-second cooperative cleanup grace period. The kernel owns result settlement. +`ctx.plugin.tools.update(name, patch)` rewrites a registration's model-facing prose without replacing the tool or its hooks. A read sees only earlier contributions, so a plugin patching `promptSnippet` or `promptGuidelines` must run _before_ the prompt plugin that indexes them — after it, the patch still reaches the wire description but no longer the system prompt. + Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle. -Omitting `plugins` selects Bash, the default prompt, and guidelines. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. +Omitting `plugins` selects Bash then the default prompt. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. Plugin discovery from settings and daemon lifecycles are not implemented. diff --git a/packages/harness/src/effect/harness.ts b/packages/harness/src/effect/harness.ts index 2e9fc26..ce5d0a9 100644 --- a/packages/harness/src/effect/harness.ts +++ b/packages/harness/src/effect/harness.ts @@ -4,6 +4,8 @@ import { Control } from "../control.ts"; import { Database } from "../db/db.ts"; import { Event } from "../event/event.ts"; import { Global } from "../global.ts"; +import { prepare, type PluginRef } from "../plugin/catalog.ts"; +import { builtins, defaultRefs } from "../plugin/internal.ts"; import { RunnerExecute } from "../runner/execute.ts"; import { LLM } from "../runner/llm.ts"; import { Loop } from "../runner/loop.ts"; @@ -16,8 +18,6 @@ import { SandboxDriverRegistry } from "../sandbox/registry.ts"; import { SessionLive } from "../session/live.ts"; import { SessionRuntime } from "../session/runtime.ts"; import { Settings } from "../settings/settings.ts"; -import { prepare, type PluginRef } from "../plugin/catalog.ts"; -import { builtins, defaultRefs } from "../plugin/internal.ts"; import { State } from "../state/state.ts"; export interface Options { @@ -30,16 +30,19 @@ export interface Options { readonly llm?: LLM.Open; } -export const layer = (options: Options = {}) => { - const base = process.cwd(); - return Layer.unwrap( +export const layer = (options: Options = {}) => + Layer.unwrap( Effect.gen(function* () { const paths = yield* Global.resolve(options.home === undefined ? {} : { home: options.home }); - const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, base }); + // The single sanctioned `process.cwd()` in the harness. Everything downstream + // takes the host directory as a required parameter, so no module can quietly + // fall back to the OS process's directory when it meant a session's mount. + const hostCwd = process.cwd(); + const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, hostCwd }); const configuredDatabase = options.database ?? (yield* Database.locationConfig); const global = Global.layerWith(paths); const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data)); - const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? []); + const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? [], { hostCwd }); const drivers = SandboxDriverRegistry.layer( SandboxDriver.withSource(MemorySandboxDriver.make().driver, "core"), SandboxDriver.withSource(SqldbSandboxDriver.make().driver, "core"), @@ -52,7 +55,10 @@ export const layer = (options: Options = {}) => { Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))), Layer.provideMerge(State.layer({}, plugins)), Layer.provideMerge( - Settings.layer(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }), + Settings.layer({ + cwd: hostCwd, + ...(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }), + }), ), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(sandboxes), @@ -64,6 +70,5 @@ export const layer = (options: Options = {}) => { ); }), ); -}; export * as Harness from "./harness.ts"; diff --git a/packages/harness/src/plugin/catalog.ts b/packages/harness/src/plugin/catalog.ts index aba7b5b..45b90e0 100644 --- a/packages/harness/src/plugin/catalog.ts +++ b/packages/harness/src/plugin/catalog.ts @@ -34,22 +34,31 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* ( options: Options, ) { const catalog = make(); - const base = options.base ?? process.cwd(); + const hostCwd = options.hostCwd; for (const builtin of options.builtins) { catalog.add(yield* Loader.validate(builtin, { index: -1, reference: builtin.id }, true), "builtin"); } + /** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */ + const note = (plugin: Plugin, source: string) => + catalog.has(plugin.id) + ? Effect.logDebug(`Plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`) + : Effect.void; const operations = new Map(); const loaded = new Map(); for (const [index, reference] of references.entries()) { - const origin = { index, reference: typeof reference === "string" ? reference : reference.id }; + // `reference` must stay a string: an unvalidated object may carry no `id` at all, and + // `PreparationError.reference` would then throw instead of reporting the bad entry. + const declared = typeof reference === "string" ? reference : (reference as { id?: unknown }).id; + const origin = { index, reference: typeof declared === "string" ? declared : `` }; if (typeof reference !== "string") { const plugin = yield* Loader.validate(reference, origin); + yield* note(plugin, "a supplied object"); catalog.add(plugin, "object"); operations.set(plugin.id, { enabled: true, origin }); continue; } const source = yield* Effect.try({ - try: () => Loader.classify(reference, base), + try: () => Loader.classify(reference, hostCwd), catch: (cause) => Loader.failure(origin, "source", cause), }); if (source.kind === "id" || source.kind === "disable") { @@ -59,6 +68,7 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* ( const key = source.kind === "package" ? source.request.spec : source.path; const definition = loaded.get(key) ?? (yield* Loader.load(source, origin, options)); loaded.set(key, definition); + yield* note(definition.plugin, definition.version === undefined ? key : `${key}@${definition.version}`); catalog.add(definition.plugin, definition.source, definition.version); operations.set(definition.plugin.id, { enabled: true, origin }); } diff --git a/packages/harness/src/plugin/host.ts b/packages/harness/src/plugin/host.ts index 5e501d7..22d5ad6 100644 --- a/packages/harness/src/plugin/host.ts +++ b/packages/harness/src/plugin/host.ts @@ -35,22 +35,32 @@ export const run = Effect.fn("PluginHost.run")(function* ( new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }), }); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.fail( - new SetupError({ + Effect.catchCause((cause) => { + if (Cause.hasInterrupts(cause)) return Effect.failCause(cause); + // Typed failures are already SetupErrors; wrap only defects (sync throws, dies) + // so the original plugin error is never nested twice. `Schema.is` on a tagged + // error class is an identity check, so a plugin throwing its own + // SetupError-shaped object still gets attributed to it. + const squashed = Cause.squash(cause); + return Effect.fail( + Schema.is(SetupError)(squashed) + ? squashed + : new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, - cause: Cause.squash(cause), + cause: squashed, }), - ), - ), + ); + }), ); } return yield* Effect.try({ try: buckets.freeze, - catch: (cause) => new SetupError({ message: "Plugin snapshot freeze failed", cause }), + catch: (cause) => + new SetupError({ + message: `Plugin snapshot freeze failed: ${cause instanceof Error ? cause.message : String(cause)}`, + cause, + }), }); }); return yield* setup.pipe(Effect.ensuring(Effect.sync(buckets.close))); diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts index a9ff1e3..6b01fa8 100644 --- a/packages/harness/src/plugin/index.ts +++ b/packages/harness/src/plugin/index.ts @@ -1,5 +1,6 @@ export { define, type Plugin, type Mount } from "./plugin.ts"; export type { SharedPluginContext, Config, Events, PromptResolver } from "./context.ts"; export type { PluginRegistry } from "./registry.ts"; +export type { PluginRef } from "./catalog.ts"; export * as Tool from "./tool/schema.ts"; export * as Prompt from "./prompt/schema.ts"; diff --git a/packages/harness/src/plugin/internal.ts b/packages/harness/src/plugin/internal.ts index c6ee20e..661fcfa 100644 --- a/packages/harness/src/plugin/internal.ts +++ b/packages/harness/src/plugin/internal.ts @@ -1,7 +1,11 @@ -import { bashPlugin } from "./internal/tool/bash.ts"; import { defaultPromptPlugin } from "./internal/prompt/default.ts"; -import { guidelinesPromptPlugin } from "./internal/prompt/guidelines.ts"; +import { bashPlugin } from "./internal/tool/bash.ts"; + +/** Every plugin the harness ships. Seeded into the catalog whether selected or not. */ +export const builtins = Object.freeze([bashPlugin, defaultPromptPlugin]); -export const builtins = Object.freeze([bashPlugin, defaultPromptPlugin, guidelinesPromptPlugin]); -export const defaults = builtins; +/** + * The selection used when a caller passes no `plugins`. Tool contributors come + * first so the Prompt plugin that indexes them runs after they registered. + */ export const defaultRefs = Object.freeze(builtins.map((plugin) => plugin.id)); diff --git a/packages/harness/src/plugin/internal/prompt/default.ts b/packages/harness/src/plugin/internal/prompt/default.ts index 7149c89..6c5ccc4 100644 --- a/packages/harness/src/plugin/internal/prompt/default.ts +++ b/packages/harness/src/plugin/internal/prompt/default.ts @@ -1,17 +1,109 @@ +/* + * @file The built-in Prompt plugin: `codework.prompt.default`. + * + * Everything below `setup` is plugin-private. The Prompt domain stores one string + * and imposes no shape, so this file — not the kernel — owns the foundation, the + * tool index, the guideline dedupe and the working-directory line. A third party + * that wants a different prompt omits this plugin or `set`s over it. + */ + import { Effect } from "effect"; +import type { AnyToolDef } from "../../../tools/tool.ts"; +import type { PromptResolver, SharedPluginContext } from "../../context.ts"; import { define } from "../../plugin.ts"; +/** The default coding-agent foundation, used unless `promptCustom` replaces it. */ +const foundation = `You are an expert coding assistant operating inside codework, a coding agent harness.`; + +/** + * Guidelines that hold regardless of which tools are registered. + * + * Kept short on purpose. Every line here is spent on every request, so a line + * earns its place only if a model measurably behaves worse without it. + */ +const standingGuidelines: ReadonlyArray = [ + "Be concise. Report what you did and what you found, not what you are about to do.", + "Quote exact paths and command output rather than paraphrasing them.", + "If a command fails, read the error before retrying.", +]; + +/** Collapse whitespace so two spellings of one guideline dedupe against each other. */ +const normalize = (value: string): string => value.trim().replace(/\s+/g, " "); + +/** + * Tool-contributed guidelines followed by the standing ones, normalized and + * deduplicated with first occurrence winning. + * + * Tool order is bucket order, which is the same order the index renders in and the + * same order the provider receives definitions in -- one ordering, not three. + */ +const guidelines = (tools: ReadonlyArray): ReadonlyArray => { + const seen = new Set(); + const collected: string[] = []; + for (const line of [...tools.flatMap((tool) => tool.promptGuidelines ?? []), ...standingGuidelines]) { + const normalized = normalize(line); + if (normalized.length === 0 || seen.has(normalized)) continue; + seen.add(normalized); + collected.push(normalized); + } + return collected; +}; + +/** + * The rendered tool index. + * + * `(none)` rather than an omitted section: a model told it has no tools behaves + * better than one left to infer it from silence. + */ +const toolIndex = (tools: ReadonlyArray): string => { + const listed = tools.filter((tool) => tool.promptSnippet !== undefined && tool.promptSnippet.length > 0); + if (listed.length === 0) return "(none)"; + return listed.map((tool) => `- ${tool.name}: ${tool.promptSnippet}`).join("\n"); +}; + +interface Input { + readonly tools: ReadonlyArray; + readonly directory: string; + readonly promptCustom?: string; + readonly promptSystemAppend?: string; +} + +const assemble = (input: Input): string => { + const sections: string[] = [input.promptCustom ?? foundation]; + + sections.push(`Available tools:\n${toolIndex(input.tools)}`); + + const lines = guidelines(input.tools); + if (lines.length > 0) sections.push(`Guidelines:\n${lines.map((line) => `- ${line}`).join("\n")}`); + + const append = input.promptSystemAppend?.trim(); + if (append !== undefined && append.length > 0) sections.push(append); + + sections.push(`Current working directory: ${input.directory}`); + + return sections.join("\n\n"); +}; + +/** + * A caller slot, awaited lazily. A throw or rejection fails the snapshot, which the + * host attributes to this plugin's id. + */ +const slot = (ctx: SharedPluginContext, resolver: PromptResolver | undefined) => + Effect.tryPromise(() => Promise.resolve(resolver?.(ctx))); + export const defaultPromptPlugin = define({ id: "codework.prompt.default", setup: Effect.fn("DefaultPromptPlugin.setup")(function* (ctx) { - const custom = ctx.config.promptCustom; - const append = ctx.config.promptSystemAppend; - const foundation = - custom === undefined - ? "You are a coding assistant." - : yield* Effect.tryPromise(() => Promise.resolve().then(() => custom(ctx))); - const extra = - append === undefined ? undefined : yield* Effect.tryPromise(() => Promise.resolve().then(() => append(ctx))); - ctx.plugin.prompt.set([foundation, extra].filter((part) => part !== undefined).join("\n\n")); + const custom = yield* slot(ctx, ctx.config.promptCustom); + const append = yield* slot(ctx, ctx.config.promptSystemAppend); + ctx.plugin.prompt.set( + assemble({ + // Only tools registered by an earlier plugin are visible here. + tools: ctx.plugin.tools.list(), + directory: ctx.location.directory, + ...(custom === undefined ? {} : { promptCustom: custom }), + ...(append === undefined ? {} : { promptSystemAppend: append }), + }), + ); }), }); diff --git a/packages/harness/src/plugin/internal/prompt/guidelines.ts b/packages/harness/src/plugin/internal/prompt/guidelines.ts deleted file mode 100644 index 7cfe687..0000000 --- a/packages/harness/src/plugin/internal/prompt/guidelines.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { define } from "../../plugin.ts"; - -export const guidelinesPromptPlugin = define({ - id: "codework.prompt.guidelines", - setup(ctx) { - const current = ctx.plugin.prompt.get(); - if (current === undefined) throw new Error("Expected an existing prompt"); - ctx.plugin.prompt.set(`${current}\n\nPrefer rg for searches. Keep changes focused.`); - }, -}); diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts index 3a5181f..814e23a 100644 --- a/packages/harness/src/plugin/loader.ts +++ b/packages/harness/src/plugin/loader.ts @@ -6,7 +6,8 @@ import { fileSystem as fs, hostPath as path } from "../host.ts"; import * as Package from "./package.ts"; import type { Plugin } from "./plugin.ts"; -export const idPattern = /^[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9-]*\.[a-z0-9][a-z0-9.-]*$/; +/** `vendor.domain.context`, exactly three segments — a fourth would shadow a package name. */ +export const idPattern = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*){2}$/; const Id = Schema.String.check(Schema.isPattern(idPattern)); const Definition = Schema.Struct({ id: Id, @@ -30,9 +31,13 @@ export const failure = (origin: Origin, phase: PreparationError["phase"], cause: new PreparationError({ ...origin, phase, cause, ...(id === undefined ? {} : { id }) }); export const validate = Effect.fn("PluginLoader.validate")(function* (input: unknown, origin: Origin, builtin = false) { - const plugin = yield* Schema.decodeUnknownEffect(Definition)(input).pipe( + yield* Schema.decodeUnknownEffect(Definition)(input).pipe( Effect.mapError((cause) => failure(origin, "definition", cause)), ); + // The author's own object, not the decoded copy: a `Struct` decode keeps only the + // declared keys, which would strip a plugin's other properties and leave `this` + // pointing at a clone inside the documented `setup() {}` shorthand. + const plugin = input as Plugin; if (!builtin && plugin.id.startsWith("codework.")) { return yield* failure( origin, @@ -50,13 +55,19 @@ export type Source = | { readonly kind: "local"; readonly path: string } | { readonly kind: "package"; readonly request: Package.Request }; -export const classify = (source: string, base: string): Source => { +export const classify = (source: string, hostCwd: string): Source => { if (source.startsWith("!")) { const id = Schema.decodeSync(Id)(source.slice(1)); return { kind: "disable", id }; } - if (source.startsWith("file:") || source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) { - return { kind: "local", path: source.startsWith("file:") ? fileURLToPath(source) : path.resolve(base, source) }; + if (source.startsWith("file:")) { + // Both `new URL` and `fileURLToPath` silently read a relative `file:./x` as `/x`. A file + // URL names an absolute path or it is not one. + if (!source.startsWith("file:///")) throw new Error(`Not an absolute file URL: ${source}`); + return { kind: "local", path: fileURLToPath(source) }; + } + if (source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) { + return { kind: "local", path: path.resolve(hostCwd, source) }; } if (Schema.is(Id)(source)) return { kind: "id", id: source }; return { kind: "package", request: Package.parse(source) }; @@ -71,7 +82,12 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string, const stat = yield* fs.stat(location); if (stat.type !== "Directory") return pathToFileURL(location).href; const manifestPath = path.join(location, "package.json"); - if (!(yield* fs.exists(manifestPath))) return pathToFileURL(path.join(location, "index.js")).href; + if (!(yield* fs.exists(manifestPath))) { + const fallback = path.join(location, "index.js"); + if (!(yield* fs.exists(fallback))) + return yield* failure(origin, "source", new Error(`Directory has no package.json or index.js: ${location}`)); + return pathToFileURL(fallback).href; + } const manifest = yield* fs .readFileString(manifestPath) .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest)))); @@ -90,7 +106,8 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string, export interface Options { readonly cache: string; - readonly base?: string; + /** The OS process's directory, that constructor-relative references resolve against. Never read here. */ + readonly hostCwd: string; readonly import?: (url: string) => Promise; readonly install?: ( request: Package.Request, @@ -116,7 +133,11 @@ export const load = Effect.fn("PluginLoader.load")(function* ( ) : { url: yield* localUrl(source.path, origin).pipe( - Effect.mapError((cause) => failure(origin, "source", cause)), + // `localUrl` already reports its own source failures; only platform errors + // reaching here still need attribution. + Effect.mapError((cause) => + Schema.is(PreparationError)(cause) ? cause : failure(origin, "source", cause), + ), ), }; const module = yield* Effect.tryPromise({ diff --git a/packages/harness/src/plugin/package.ts b/packages/harness/src/plugin/package.ts index d4ff785..19a318f 100644 --- a/packages/harness/src/plugin/package.ts +++ b/packages/harness/src/plugin/package.ts @@ -1,5 +1,5 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Effect, Layer, Schema } from "effect"; +import { Duration, Effect, Layer, Ref, Schedule, Schema } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolve } from "import-meta-resolve"; import { createHash } from "node:crypto"; @@ -17,7 +17,10 @@ export const parse = (source: string): Request => { if (!parsed.name || !["version", "range", "tag"].includes(parsed.type)) { throw new Error(`Unsupported plugin package source: ${source}`); } - return { name: parsed.name, spec: `${parsed.name}@${parsed.raw === parsed.name ? "latest" : parsed.rawSpec}` }; + // npa turns a trailing bare `@` into the `*` range, which would give "no version" a second + // cache key. An explicit `plugin@*` keeps its own meaning. + const omitted = parsed.raw === parsed.name || parsed.raw === `${parsed.name}@`; + return { name: parsed.name, spec: `${parsed.name}@${omitted ? "latest" : parsed.rawSpec}` }; }; const Manifest = Schema.Struct({ version: Schema.String }); @@ -50,23 +53,39 @@ const run: Runner = Effect.fn("PluginPackage.run")( }, Effect.scoped, Effect.provide(NodeChildProcessSpawner.layer.pipe(Layer.provide(Layer.merge(NodeFileSystem.layer, NodePath.layer)))), - Effect.mapError((cause) => new InstallError({ cause })), + Effect.mapError((cause) => (Schema.is(InstallError)(cause) ? cause : new InstallError({ cause }))), ); +/** How long to wait for another installer of the same spec before giving up. */ +const LOCK_TIMEOUT = Duration.minutes(2); + +/** + * Claims `directory` by creating it, polling while another process holds it. + * + * One finalizer for the whole wait, registered before the first attempt: retrying inside + * `acquireRelease` would add a finalizer per poll. A crashed installer leaves its lock + * behind, so the wait is bounded rather than infinite. + */ const lock = Effect.fn("PluginPackage.lock")(function* (directory: string) { - while (true) { - const acquired = yield* Effect.acquireRelease( - fs.makeDirectory(directory).pipe( - Effect.as(true), - Effect.catch((error) => - error.reason._tag === "AlreadyExists" ? Effect.succeed(false) : Effect.fail(error), - ), - ), - (acquired) => (acquired ? fs.remove(directory, { recursive: true }).pipe(Effect.orDie) : Effect.void), - ); - if (acquired) return; - yield* Effect.sleep("50 millis"); - } + const held = yield* Ref.make(false); + yield* Effect.acquireRelease(Effect.void, () => + Ref.get(held).pipe( + Effect.flatMap((owned) => (owned ? fs.remove(directory, { recursive: true, force: true }) : Effect.void)), + Effect.orDie, + ), + ); + const acquired = yield* fs.makeDirectory(directory).pipe( + Effect.as(true), + Effect.catch((error) => (error.reason._tag === "AlreadyExists" ? Effect.succeed(false) : Effect.fail(error))), + Effect.tap((owned) => (owned ? Ref.set(held, true) : Effect.void)), + Effect.repeat({ schedule: Schedule.spaced("50 millis"), until: (owned) => owned }), + Effect.timeout(LOCK_TIMEOUT), + Effect.catchTag("TimeoutError", () => Effect.succeed(false)), + ); + if (!acquired) + return yield* new InstallError({ + cause: new Error(`Timed out waiting for another plugin installation to release ${directory}`), + }); }); export const install = Effect.fn("PluginPackage.install")( @@ -76,8 +95,10 @@ export const install = Effect.fn("PluginPackage.install")( const directory = path.join(root, key); const marker = path.join(directory, ".complete.json"); yield* fs.makeDirectory(root, { recursive: true }); - // mkdir is atomic across processes; scoped release also runs on interruption. - yield* lock(`${directory}.lock`); + // A published entry is immutable, so reading one never contends with an installer. + // mkdir is atomic across processes; the scoped release also runs on interruption. + if (!(yield* fs.exists(marker))) yield* lock(`${directory}.lock`); + // Re-check under the lock: the installer we waited for may have just published. if (!(yield* fs.exists(marker))) { const staging = yield* Effect.acquireRelease( fs.makeTempDirectory({ directory: root, prefix: `${key}-` }), @@ -91,12 +112,19 @@ export const install = Effect.fn("PluginPackage.install")( const entrypoint = yield* Effect.try(() => resolve(request.name, pathToFileURL(path.join(staging, "package.json")).href), ); + // `resolve` realpaths its answer while `makeTempDirectory` does not, so relate the + // two through the realpath or a symlinked cache root escapes the published entry. + const entry = path.relative(yield* fs.realPath(staging), fileURLToPath(entrypoint)); + if (entry.startsWith("..") || path.isAbsolute(entry)) + return yield* new InstallError({ + cause: new Error(`Plugin entrypoint escapes its installation: ${entry}`), + }); yield* fs.writeFileString( path.join(staging, ".complete.json"), yield* Schema.encodeEffect(Schema.fromJsonString(Cached))({ spec: request.spec, version: installed.version, - entrypoint: path.relative(staging, fileURLToPath(entrypoint)), + entrypoint: entry, }), ); if (yield* fs.exists(directory)) yield* fs.remove(directory, { recursive: true }); @@ -111,5 +139,5 @@ export const install = Effect.fn("PluginPackage.install")( return { url, version: saved.version } satisfies Installed; }, Effect.scoped, - Effect.mapError((cause) => new InstallError({ cause })), + Effect.mapError((cause) => (Schema.is(InstallError)(cause) ? cause : new InstallError({ cause }))), ); diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts index 7792b91..ecb9c3f 100644 --- a/packages/harness/src/plugin/tool/registry.ts +++ b/packages/harness/src/plugin/tool/registry.ts @@ -1,10 +1,5 @@ import type { RegisteredTool } from "../../tools/tool.ts"; -import type { ToolAddOptions, ToolDefPatch, ToolRegistry } from "./schema.ts"; - -export interface ToolRegistration { - readonly tool: RegisteredTool; - readonly hooks: ToolAddOptions; -} +import type { ToolAddOptions, ToolDefPatch, ToolRegistration, ToolRegistry } from "./schema.ts"; const definition = (tool: RegisteredTool): RegisteredTool => ({ ...tool, diff --git a/packages/harness/src/plugin/tool/schema.ts b/packages/harness/src/plugin/tool/schema.ts index 08855b8..0fb1ed3 100644 --- a/packages/harness/src/plugin/tool/schema.ts +++ b/packages/harness/src/plugin/tool/schema.ts @@ -34,6 +34,15 @@ export interface ToolDefPatch { readonly promptSnippet?: string; readonly promptGuidelines?: ReadonlyArray; } +/** + * One frozen bucket entry: the winning tool for a name together with the hooks that + * `add` declared alongside it. Kept here rather than in the bucket implementation so + * the executor shares the contract without importing plugin assembly. + */ +export interface ToolRegistration { + readonly tool: RegisteredTool; + readonly hooks: ToolAddOptions; +} export interface ToolRegistry { readonly add: (tool: RegisteredTool, options?: ToolAddOptions) => void; readonly update: (name: string, patch: ToolDefPatch) => void; diff --git a/packages/harness/src/runner/loop.ts b/packages/harness/src/runner/loop.ts index b5b0cfb..6165351 100644 --- a/packages/harness/src/runner/loop.ts +++ b/packages/harness/src/runner/loop.ts @@ -16,6 +16,7 @@ import { SessionMessageSchema } from "../session/message/schema.ts"; import type { SessionSchema } from "../session/schema.ts"; import { Session } from "../session/session.ts"; import { State } from "../state/state.ts"; +import { errorMessage } from "../tools/error.ts"; import { LLMEventPublisher } from "./event.ts"; import { LLM } from "./llm.ts"; import { Runner } from "./run.ts"; @@ -32,14 +33,6 @@ export interface Options { readonly request?: LLM.Request; } -const errorMessage = (cause: Cause.Cause): string => { - const squashed = Cause.squash(cause); - if (squashed instanceof Error && squashed.message.trim().length > 0) return squashed.message; - if (typeof squashed === "string" && squashed.trim().length > 0) return squashed; - if (typeof squashed === "object" && squashed !== null && "_tag" in squashed) return String(squashed._tag); - return "the turn failed for an unknown reason"; -}; - const terminalResult = (text: string) => ({ content: [{ type: "text" as const, text }], isError: true as const, diff --git a/packages/harness/src/sandbox/loader.ts b/packages/harness/src/sandbox/loader.ts index 48f28da..bf05d74 100644 --- a/packages/harness/src/sandbox/loader.ts +++ b/packages/harness/src/sandbox/loader.ts @@ -99,8 +99,15 @@ const exportedTarget = ( const official = new Set(["@codeworksh/harness/sandboxes/vercel", "@codeworksh/harness/sandboxes/daytona"]); +/** + * Resolves driver package specifiers against `hostCwd` -- the OS process's + * directory, supplied by the caller. This is host module resolution, not sandbox + * addressing: a driver package lives on the host filesystem no matter which + * namespace the sandbox it builds will serve. Required rather than defaulted so + * it can never silently disagree with the directory the harness was started in. + */ export const packageResolver = ( - base = process.cwd(), + hostCwd: string, conditions: ReadonlyArray = ["node", "import", "default"], ): Resolver => Effect.fn("SandboxDriverLoader.resolve")(function* (specifier: string) { @@ -113,7 +120,7 @@ export const packageResolver = ( } const parsed = splitPackage(specifier)!; const packageJson = yield* Effect.try({ - try: () => findPackageJSON(parsed.name, pathToFileURL(hostPath.resolve(base, "package.json"))), + try: () => findPackageJSON(parsed.name, pathToFileURL(hostPath.resolve(hostCwd, "package.json"))), catch: (reason) => new SandboxDriverLoadError({ specifier, phase: "resolve", reason: String(reason) }), }); if (packageJson === undefined) { @@ -207,15 +214,17 @@ const failure = (specifier: string, phase: SandboxDriverLoadError["phase"], reas }); export interface Options { + /** The OS process's directory. See {@link packageResolver}. */ + readonly hostCwd: string; readonly resolve?: Resolver; readonly import?: Importer; } -export const load = Effect.fn("SandboxDriverLoader.load")(function* (entry: Entry, options: Options = {}) { +export const load = Effect.fn("SandboxDriverLoader.load")(function* (entry: Entry, options: Options) { if (isRegistration(entry)) return entry; const specifier = typeof entry === "string" ? entry : entry.package; const rawOptions = typeof entry === "string" ? {} : (entry.options ?? {}); - const resolved = yield* (options.resolve ?? packageResolver())(specifier); + const resolved = yield* (options.resolve ?? packageResolver(options.hostCwd))(specifier); const imported = yield* Effect.tryPromise({ try: () => (options.import ?? ((url) => import(/* @vite-ignore */ url)))(resolved.url), catch: (reason) => failure(specifier, "import", reason), @@ -253,7 +262,7 @@ export const load = Effect.fn("SandboxDriverLoader.load")(function* (entry: Entr export const loadAll = ( entries: ReadonlyArray, - options: Options = {}, + options: Options, ): Effect.Effect, SandboxDriverLoadError> => Effect.forEach(entries, (entry) => load(entry, options)); diff --git a/packages/harness/src/settings/settings.ts b/packages/harness/src/settings/settings.ts index bc667a9..905f1ee 100644 --- a/packages/harness/src/settings/settings.ts +++ b/packages/harness/src/settings/settings.ts @@ -6,15 +6,15 @@ * valid result: * * 1. `/settings.json` -- the user's own, `~/.codework/config` by default - * 2. `/.codework/config/settings.json` -- committed with the project + * 2. `/.codework/config/settings.json` -- committed with the project * 3. `<--user-config-dir>/settings.json` -- explicit override, `~` expanded, relative to cwd * * **All three are host paths.** They are resolved from the process's startup directory and * `Global.config`, never from a session's `--cwd`, its working directory, or its sandbox * mount. A session running in a remote or in-memory sandbox reads the same host files as * every other session in the process; there is no per-project or per-sandbox settings file, - * and no parent-directory search. The startup directory is captured once, so later `cd` or - * a session pointed elsewhere changes nothing. + * and no parent-directory search. The startup directory arrives as `options.cwd` and is + * resolved once, so later `cd` or a session pointed elsewhere changes nothing. * * `load` re-reads all three on every call. There is no cache to invalidate and no reload * API: an edit lands at the next exchange capture because the next capture goes to disk. @@ -31,8 +31,13 @@ import { defaults, Patch, type Info } from "./schema.ts"; export interface Options { readonly userConfigDir?: string; - /** Host startup directory, independent of the session/sandbox cwd. */ - readonly cwd?: string; + /** + * Host startup directory, supplied by the caller -- never `process.cwd()` read + * here. The distinction it protects is between the OS process's directory and a + * session's sandbox mount, which are unrelated and easy to confuse; requiring it + * means a caller cannot get the host layer by forgetting to say which it meant. + */ + readonly cwd: string; } export function paths(config: string, cwd: string, custom?: string): ReadonlyArray { @@ -89,12 +94,12 @@ export interface Interface { } export class Service extends Context.Service()("@codeworksh/harness/settings/settings/Service") {} -export const layer = (options: Options = {}) => +export const layer = (options: Options) => Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service; - const files = paths(global.config, hostPath.resolve(options.cwd ?? process.cwd()), options.userConfigDir); + const files = paths(global.config, hostPath.resolve(options.cwd), options.userConfigDir); const load = Effect.fn("Settings.load")(function* () { let settings = merge(defaults); for (const path of files) { diff --git a/packages/harness/src/state/state.ts b/packages/harness/src/state/state.ts index 0e7064e..664076a 100644 --- a/packages/harness/src/state/state.ts +++ b/packages/harness/src/state/state.ts @@ -17,7 +17,6 @@ import { Context, Effect, Layer, Option, Schema } from "effect"; import { Event } from "../event/event.ts"; import { makeEvents, type PromptResolver } from "../plugin/context.ts"; import { run as setup } from "../plugin/host.ts"; -import { defaults } from "../plugin/internal.ts"; import type { Plugin } from "../plugin/plugin.ts"; import { LLM } from "../runner/llm.ts"; import type { Runner } from "../runner/run.ts"; @@ -133,7 +132,12 @@ export class Service extends Context.Service()("@codeworksh/ const resolver = (input: string | PromptResolver): PromptResolver => (typeof input === "string" ? () => input : input); -export const layer = (options: Options = {}, plugins: ReadonlyArray = defaults) => { +/** + * `plugins` is the prepared, ordered list — resolved once during harness construction + * (`plugin/catalog.ts`). State runs it as given: no insertion, reordering, or rerun, and no + * default of its own. + */ +export const layer = (options: Options, plugins: ReadonlyArray) => { return Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/harness/src/tools/error.ts b/packages/harness/src/tools/error.ts index 8651b6c..0301c73 100644 --- a/packages/harness/src/tools/error.ts +++ b/packages/harness/src/tools/error.ts @@ -1,7 +1,22 @@ -import { Schema } from "effect"; +import { Cause, Schema } from "effect"; /** A typed wrapper for a tool failure crossing the heterogeneous registry boundary. */ export class ToolExecutionError extends Schema.TaggedError()("ToolExecutionError", { toolName: Schema.String, cause: Schema.Defect(), }) {} + +/** + * One line of model-facing text for a cause. + * + * Shared by the executor's outcome stage and the loop's outer catch so the same defect + * reads the same either way. Never `Cause.pretty`: that joins stack traces, which would + * put host paths and hundreds of tokens into the next request. + */ +export const errorMessage = (cause: Cause.Cause): string => { + const squashed = Cause.squash(cause); + if (squashed instanceof Error && squashed.message.trim().length > 0) return squashed.message; + if (typeof squashed === "string" && squashed.trim().length > 0) return squashed; + if (typeof squashed === "object" && squashed !== null && "_tag" in squashed) return String(squashed._tag); + return "an unknown error"; +}; diff --git a/packages/harness/src/tools/executor.ts b/packages/harness/src/tools/executor.ts index 2582b35..8cad208 100644 --- a/packages/harness/src/tools/executor.ts +++ b/packages/harness/src/tools/executor.ts @@ -1,8 +1,22 @@ -import type { ToolRegistration } from "../plugin/tool/registry.ts"; -import type { HookReturn, ToolAfterResult, ToolBefore } from "../plugin/tool/schema.ts"; +import type { HookReturn, ToolAfterResult, ToolBefore, ToolRegistration } from "../plugin/tool/schema.ts"; import { Message } from "@codeworksh/aikit"; -import { Cause, Duration, Effect, Exit, Fiber, Option, Queue, Ref, Result, Schedule, Schema, Scope } from "effect"; -import { ToolExecutionError } from "./error.ts"; +import { + Cause, + Duration, + Effect, + Exit, + Fiber, + Option, + Predicate, + Queue, + Ref, + Result, + Schedule, + Schema, + Scope, +} from "effect"; +import { isAikitToolCallTerminalPart } from "../schema.ts"; +import { errorMessage, ToolExecutionError } from "./error.ts"; import { ToolProgress, type ToolProgressPartial } from "./progress.ts"; import { type AnyToolDef, type ModelContent, type RegisteredTool, toAikitTool, type ToolCallContext } from "./tool.ts"; @@ -175,6 +189,10 @@ const encodeOutcome = ( if (def.failure === undefined || !Schema.is(asCodec(def.failure))(failure)) { return yield* Effect.die(failure); } + // A failing finalizer alongside the declared failure is not model-facing, but losing + // it entirely makes the tool look like it failed cleanly. + if (Cause.hasDies(cause)) + yield* Effect.logError("tool finalizer defect alongside a declared failure", Cause.pretty(cause)); // Encode declared failures as model-facing tool error results. const encoded = yield* Schema.encodeUnknownEffect(asCodec(def.failure))(failure).pipe(Effect.orDie); const content = def.encodeFailureContent ? def.encodeFailureContent(failure) : [yield* jsonText(encoded)]; @@ -200,15 +218,21 @@ const invoke = (callback: () => HookReturn): Effect.Effect new HookExecutionError({ cause }))); } - if (value instanceof Promise) - return Effect.tryPromise({ try: () => value, catch: (cause) => new HookExecutionError({ cause }) }); + // Any thenable: a foreign-realm or userland promise would otherwise fall through and be + // read as the hook's own return value. + if (Predicate.isPromiseLike(value)) + return Effect.tryPromise({ + try: () => Promise.resolve(value), + catch: (cause) => new HookExecutionError({ cause }), + }); return Effect.succeed(value); }); const failureOutcome = (call: Message.ToolCallPendingPart, cause: Cause.Cause, phase: string) => Effect.map( Effect.clockWith((clock) => clock.currentTimeMillis), - (now) => errored(call, [text(`${phase}: ${Cause.pretty(cause)}`)], now), + // Never `Cause.pretty` here: this text goes to the model, and it joins stack traces. + (now) => errored(call, [text(`${phase}: ${errorMessage(cause)}`)], now), ); const patchOutcome = (terminal: ToolOutcome, patch: ToolAfterResult | void): ToolOutcome => { @@ -302,9 +326,13 @@ export const make = (tools: ReadonlyArray): E let afterStarted = false; const notifyAbort = Effect.fn("ToolExecutor.notifyAbort")(function* (terminal: ToolOutcome) { if (!handlerStarted || afterStarted || !hooks.afterToolCall || !hookCall) return; - afterStarted = true; const callback = hooks.afterToolCall; - const fiber = yield* invoke(() => callback({ ...hookCall, terminal })).pipe( + // `invoke` suspends, so the marker and the call land in one step: a scheduler + // yield between them would let cancellation skip the hook entirely. + const fiber = yield* invoke(() => { + afterStarted = true; + return callback({ ...hookCall, terminal }); + }).pipe( Effect.interruptible, Effect.timeout(ABORT_HOOK_GRACE), Effect.catchCause((cause) => @@ -392,17 +420,28 @@ export const make = (tools: ReadonlyArray): E return notifyAbort(terminal).pipe(Effect.uninterruptible, Effect.as(terminal)); if (!hooks.afterToolCall || !hookCall) return Effect.succeed(terminal); const callback = hooks.afterToolCall; - return Effect.suspend(() => { + return invoke(() => { afterStarted = true; - return invoke(() => callback({ ...hookCall, terminal })).pipe( - Effect.map((patch) => patchOutcome(terminal, patch)), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause as Cause.Cause) - : failureOutcome(call, cause, "afterToolCall failed"), - ), - ); - }); + return callback({ ...hookCall, terminal }); + }).pipe( + Effect.flatMap((patch) => { + const patched = patchOutcome(terminal, patch); + // A patch is arbitrary plugin data and the journal validates the part on + // write. Reject it here, or one bad hook aborts the whole turn and takes + // its sibling calls with it. + if (isAikitToolCallTerminalPart(patched)) return Effect.succeed(patched); + return failureOutcome( + call, + Cause.die(new Error("afterToolCall returned an invalid result patch")), + "afterToolCall failed", + ); + }), + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause as Cause.Cause) + : failureOutcome(call, cause, "afterToolCall failed"), + ), + ); }), Effect.onExit((exit) => Effect.gen(function* () { diff --git a/packages/harness/src/tools/registry.ts b/packages/harness/src/tools/registry.ts index 66a71bc..e007f7c 100644 --- a/packages/harness/src/tools/registry.ts +++ b/packages/harness/src/tools/registry.ts @@ -1,4 +1,4 @@ -import type { ToolRegistration } from "../plugin/tool/registry.ts"; +import type { ToolRegistration } from "../plugin/tool/schema.ts"; import type { Message } from "@codeworksh/aikit"; import { Context, Layer } from "effect"; import * as Executor from "./executor.ts"; diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index 21f36f0..e056739 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -25,6 +25,7 @@ import { SessionLive } from "../../src/session/live.ts"; import { SessionRuntime } from "../../src/session/runtime.ts"; import type { SessionSchema } from "../../src/session/schema.ts"; import { Session } from "../../src/session/session.ts"; +import { builtins } from "../../src/plugin/internal.ts"; import { State } from "../../src/state/state.ts"; const database = Database.layer(":memory:"); @@ -36,7 +37,7 @@ const sandbox = SandboxController.layer().pipe( const runtime = (root: string, custom: string) => Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(Loop.layer()))), - Layer.provideMerge(State.layer()), + Layer.provideMerge(State.layer({}, builtins)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge( Settings.layer({ cwd: root, userConfigDir: custom }).pipe( diff --git a/packages/harness/test/plugin.catalog.test.ts b/packages/harness/test/plugin.catalog.test.ts index 536e0e8..07768ea 100644 --- a/packages/harness/test/plugin.catalog.test.ts +++ b/packages/harness/test/plugin.catalog.test.ts @@ -1,8 +1,9 @@ import { Deferred, Effect, Fiber } from "effect"; +import { existsSync, realpathSync } from "node:fs"; import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { pathToFileURL } from "node:url"; +import { join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { prepare } from "../src/plugin/catalog.ts"; import { classify, validate } from "../src/plugin/loader.ts"; @@ -11,7 +12,7 @@ import { define } from "../src/plugin/plugin.ts"; const a = define({ id: "acme.tool.a", setup: () => {} }); const b = define({ id: "acme.tool.b", setup: () => {} }); -const options = { builtins: [], cache: "/unused", base: "/project" }; +const options = { builtins: [], cache: "/unused", hostCwd: "/project" }; const withDirectory = async (body: (directory: string) => Promise) => { const directory = await mkdtemp(join(tmpdir(), "plugin-catalog-")); try { @@ -75,17 +76,51 @@ describe("plugin catalog and source resolution", () => { ).toMatchObject({ phase: "definition", index: 2 }); }, ); + it("reports a malformed supplied object as a typed definition failure", async () => { + // Nothing has validated the entry yet, so `origin.reference` cannot read an `id` off it. + for (const input of [{}, [], () => a, { id: 123, setup: () => {} }]) { + const error = await Effect.runPromise(prepare([b, input as never], options).pipe(Effect.flip)); + expect(error).toMatchObject({ phase: "definition", index: 1 }); + expect(typeof error.reference).toBe("string"); + } + }); + it("selects the caller's own object rather than a validated copy", async () => { + // A `Struct` decode would return a clone holding only `id`/`setup`, which breaks both + // object identity and `this` inside the documented `setup() {}` shorthand. + const seen: string[] = []; + const plugin = { + id: "acme.tool.self", + label: "kept", + setup() { + seen.push((this as { label: string }).label); + }, + }; + const [resolved] = await Effect.runPromise(prepare([plugin], options)); + expect(resolved).toBe(plugin); + void resolved?.setup({} as never); + expect(seen).toEqual(["kept"]); + }); it("normalizes package specs and classifies local sources", () => { expect(parse("@acme/plugin")).toEqual({ name: "@acme/plugin", spec: "@acme/plugin@latest" }); expect(parse("@acme/plugin@latest")).toEqual(parse("@acme/plugin")); expect(parse("@acme/plugin@1.2.0").spec).toBe("@acme/plugin@1.2.0"); expect(parse("plugin@*").spec).toBe("plugin@*"); + // A trailing bare `@` is no version at all, not the `*` range npa reports for it. + expect(parse("plugin@")).toEqual(parse("plugin")); expect(classify("./plugin.ts", "/project")).toEqual({ kind: "local", path: "/project/plugin.ts" }); expect(classify("file:///project/plugin.ts", "/elsewhere")).toEqual({ kind: "local", path: "/project/plugin.ts", }); expect(classify(a.id, "/project")).toEqual({ kind: "id", id: a.id }); + // A dotted package name needs an explicit version to be read as a package. + expect(classify(`${a.id}@latest`, "/project")).toEqual({ kind: "package", request: parse(`${a.id}@latest`) }); + // An ID is exactly three segments; a fourth belongs to a package name. + expect(classify("acme.tool.deep.name", "/project").kind).toBe("package"); + expect(classify(`!${a.id}`, "/project")).toEqual({ kind: "disable", id: a.id }); + expect(() => classify("!", "/project")).toThrow(); + // `fileURLToPath` would silently turn this into `/rel.ts`. + expect(() => classify("file:./rel.ts", "/project")).toThrow(); expect(() => parse("https://example.com/plugin.tgz")).toThrow(); }); it("loads each normalized source once and validates default exports", async () => { @@ -141,6 +176,44 @@ describe("plugin catalog and source resolution", () => { reference: directory, }); })); + it("falls back to index.js and reports a directory with no entry as a source error", () => + withDirectory(async (directory) => { + const empty = join(directory, "empty"); + await mkdir(empty); + expect(await Effect.runPromise(prepare([empty], options).pipe(Effect.flip))).toMatchObject({ + phase: "source", + reference: empty, + }); + await writeFile(join(empty, "index.js"), ""); + let url = ""; + await Effect.runPromise( + prepare([empty], { + ...options, + import: async (input) => { + url = input; + return { default: a }; + }, + }), + ); + expect(url).toBe(pathToFileURL(join(empty, "index.js")).href); + })); + it("resolves a manifest without exports through legacy main", () => + withDirectory(async (directory) => { + await writeFile(join(directory, "package.json"), JSON.stringify({ name: "fixture", main: "./legacy.js" })); + await writeFile(join(directory, "legacy.js"), ""); + let url = ""; + await Effect.runPromise( + prepare([directory], { + ...options, + import: async (input) => { + url = input; + return { default: a }; + }, + }), + ); + // The legacy branch resolves through `createRequire`, which realpaths its answer. + expect(url).toBe(pathToFileURL(realpathSync(join(directory, "legacy.js"))).href); + })); it("stages installs, reuses complete cache and isolates explicit versions", () => withDirectory(async (cache) => { let runs = 0; @@ -159,6 +232,27 @@ describe("plugin catalog and source resolution", () => { expect(first.url).toContain("/plugins/"); expect(await readdir(cache)).toEqual(["plugins"]); })); + it("records an entrypoint inside the published installation", () => + withDirectory(async (cache) => { + // `import-meta-resolve` realpaths its answer while the staging directory is not + // realpathed, so a symlinked cache root used to record a path outside the entry. + const installed = await Effect.runPromise(install(parse("fixture"), cache, fixture)); + const file = fileURLToPath(installed.url); + expect(existsSync(file)).toBe(true); + expect(relative(join(cache, "plugins"), file).startsWith("..")).toBe(false); + })); + it("reads a complete cache without waiting on a leftover lock", () => + withDirectory(async (cache) => { + const first = await Effect.runPromise(install(parse("fixture"), cache, fixture)); + // A killed installer leaves its lock directory behind; a cache hit must not block on it. + const stale = (await readdir(join(cache, "plugins"))).map((entry) => join(cache, "plugins", `${entry}.lock`)); + await Promise.all(stale.map((lock) => mkdir(lock, { recursive: true }))); + expect( + await Effect.runPromise( + install(parse("fixture"), cache, fixture).pipe(Effect.timeout("2 seconds"), Effect.orDie), + ), + ).toEqual(first); + })); it("does not reuse failed installations", () => withDirectory(async (cache) => { const failed = await Effect.runPromise( diff --git a/packages/harness/test/plugin.hooks.test.ts b/packages/harness/test/plugin.hooks.test.ts index 07ef0ee..9447f82 100644 --- a/packages/harness/test/plugin.hooks.test.ts +++ b/packages/harness/test/plugin.hooks.test.ts @@ -2,6 +2,7 @@ import { Cause, Deferred, Effect, Exit, Fiber, Schema } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { describe, expect } from "vite-plus/test"; import { make } from "../src/tools/executor.ts"; +import { make as makeBuckets } from "../src/plugin/registry.ts"; import { ToolProgress } from "../src/tools/progress.ts"; import * as Tool from "../src/tools/tool.ts"; import type { ToolAddOptions, ToolAfter } from "../src/plugin/tool/schema.ts"; @@ -307,4 +308,98 @@ describe("per-tool hooks", () => { expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); }), ); + it.effect("invokes after exactly once when cancellation races hook entry", () => + Effect.gen(function* () { + // The after-started marker and the callback must land in one step. A yield between + // them lets cancellation see the marker with no invocation having happened, and the + // abort notification then declines a started call that is owed one. Whether the + // scheduler yields there is not controllable from here, so this is a property check + // of the invariant across interrupt points, not a reproduction of that window. + for (let yields = 0; yields < 40; yields++) { + const statuses: string[] = []; + let started = false; + const run = executor({ afterToolCall: ({ terminal }) => void statuses.push(terminal.status) }, () => + Effect.sync(() => { + started = true; + return "hello"; + }), + ); + const fiber = yield* run.handle(call, options).pipe(Effect.forkChild); + for (let step = 0; step < yields; step++) yield* Effect.yieldNow; + yield* Fiber.interrupt(fiber); + yield* Fiber.await(fiber); + // Started means owed exactly one notification; never started means none. + expect(statuses.length, `after invocations at ${yields} yields (started: ${started})`).toBe( + started ? 1 : 0, + ); + } + }), + ); + it.effect("keeps one registration's hooks off another tool", () => + Effect.gen(function* () { + let hooked = 0; + const other = Tool.register( + Tool.make({ + name: "other", + description: "Other", + parameters: Schema.Struct({}), + success: Schema.String, + handler: () => Effect.succeed("other"), + }), + ); + const run = make([ + { + tool: tool(), + hooks: { + beforeToolCall: () => void hooked++, + afterToolCall: () => void hooked++, + }, + }, + { tool: other, hooks: {} }, + ]); + expect((yield* run.handle(pendingCall("other"), options)).status).toBe("completed"); + expect(hooked).toBe(0); + yield* run.handle(call, options); + expect(hooked).toBe(2); + }), + ); + it.effect("normalizes an unexpected failure identically with no hook and with an empty after", () => + Effect.gen(function* () { + // The executor owns this normalization, so a registration without hooks must produce + // the same terminal as one whose after returns nothing. + const boom = () => Effect.die(new Error("kaboom")); + const bare = yield* make([tool(boom)]).handle(call, options); + const empty = yield* executor({ afterToolCall: () => {} }, boom).handle(call, options); + expect(bare.status).toBe("error"); + expect(bare.result).toEqual(empty.result); + // Model-facing text carries the message, never a rendered stack trace. + const rendered = bare.result.content[0]; + expect(rendered?.type === "text" && rendered.text).toBe("Tool execution failed: kaboom"); + }), + ); + it.effect("turns an invalid result patch into a tool error rather than a bad terminal", () => + Effect.gen(function* () { + const terminal = yield* executor({ + afterToolCall: () => ({ content: [{ type: "video", url: "nope" }] as never }), + }).handle(call, options); + expect(terminal.status).toBe("error"); + expect(terminal.result.isError).toBe(true); + const rendered = terminal.result.content[0]; + expect(rendered?.type === "text" && rendered.text).toContain("afterToolCall failed"); + }), + ); + it.effect("preserves hooks and kernel timestamps across a prose update", () => + Effect.gen(function* () { + const buckets = makeBuckets(); + let seen = 0; + buckets.registry.tools.add(tool(), { afterToolCall: () => void seen++ }); + buckets.registry.tools.update("echo", { description: "patched", promptSnippet: "echoes" }); + buckets.registry.prompt.set(""); + const snapshot = buckets.freeze(); + expect(snapshot.tools.defs[0]).toMatchObject({ description: "patched", promptSnippet: "echoes" }); + const terminal = yield* snapshot.tools.handle(call, options); + expect(seen).toBe(1); + expect(terminal.time.start).toBe(call.time.start); + }), + ); }); diff --git a/packages/harness/test/plugin.host.test.ts b/packages/harness/test/plugin.host.test.ts index eaf7dae..e6c6246 100644 --- a/packages/harness/test/plugin.host.test.ts +++ b/packages/harness/test/plugin.host.test.ts @@ -1,3 +1,4 @@ +import "./utils/env.ts"; import { createAssistantMessageEventStream, type Model } from "@codeworksh/aikit"; import { Deferred, Effect, Fiber, Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; @@ -79,7 +80,13 @@ describe("plugin domains and exchange host", () => { expect(contexts[0]?.plugin).not.toBe(contexts[1]?.plugin); expect(contexts[0]?.model).toBe(models[0]); expect(contexts[1]?.model).toBe(models[1]); - expect(observed).toEqual(["custom:1\n\nappend\nwrapped", "custom:1\n\nappend\nwrapped"]); + // The body belongs to `codework.prompt.default`; what the host owes is that both + // slots were awaited against this exchange's bucket and the wrap saw the result. + expect(observed).toHaveLength(2); + expect(observed[0]).toBe(observed[1]); + expect(observed[0]?.startsWith("custom:1\n\n")).toBe(true); + expect(observed[0]).toContain("\n\nappend\n\n"); + expect(observed[0]?.endsWith("\nwrapped")).toBe(true); expect(contexts[0]?.events).not.toHaveProperty("subscribe"); expect(() => contexts[0]?.plugin.prompt.set("late")).toThrow(); }).pipe( @@ -146,6 +153,37 @@ describe("plugin domains and exchange host", () => { ), ); })); + it("runs no setup when preparation fails", () => + withSettings(async ({ root }) => { + let setups = 0; + const counted = { + id: "acme.prompt.counted", + setup: () => { + setups++; + }, + }; + const failure = await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.run("hello"); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + // A malformed entry after a valid one: nothing may run, not even the + // definition that resolved. + plugins: [counted, { id: "nope" } as never], + }), + ), + Effect.scoped, + Effect.flip, + ), + ); + expect(failure).toMatchObject({ _tag: "PluginPreparationError", phase: "definition", index: 1 }); + expect(setups).toBe(0); + })); it("runs no setup when model resolution fails", () => withSettings(async ({ root }) => { let setups = 0; diff --git a/packages/harness/test/plugin.prompt.test.ts b/packages/harness/test/plugin.prompt.test.ts new file mode 100644 index 0000000..0e24e2e --- /dev/null +++ b/packages/harness/test/plugin.prompt.test.ts @@ -0,0 +1,91 @@ +import "./utils/env.ts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vite-plus/test"; +import { join } from "node:path"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import { immediateOpen } from "./fixtures/llm.ts"; +import { withSettings } from "./fixtures/settings.ts"; + +/** + * The Prompt domain stores one string and imposes no shape, so these assertions belong + * to `codework.prompt.default`, not to the bucket. They exist because that body is what + * every embedder gets when it passes no `plugins`, and nothing else pins it. + */ +describe("codework.prompt.default", () => { + /** Runs one exchange and returns the system prompts the provider was handed. */ + const prompts = ( + root: string, + harness: Omit[0], "home" | "database" | "llm"> = {}, + session: Omit[0], "directory"> = {}, + ) => { + const observed: string[] = []; + const open = immediateOpen(); + return Effect.runPromise( + Effect.gen(function* () { + const handle = yield* Session.create({ directory: root, ...session }); + yield* handle.run("hello"); + return yield* handle.path(); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: (input, signal) => { + observed.push(input.context.systemPrompt ?? ""); + return open(input, signal); + }, + ...harness, + }), + ), + Effect.scoped, + ), + ).then((path) => ({ observed, path })); + }; + + it("indexes the default selection's tools, guidelines and working directory", () => + withSettings(async ({ root }) => { + const { observed } = await prompts(root); + const prompt = observed[0] ?? ""; + expect(prompt.startsWith("You are an expert coding assistant")).toBe(true); + expect(prompt).toContain("Available tools:\n- bash: Execute bash commands"); + expect(prompt).toContain("\n\nGuidelines:\n- Be concise."); + expect(prompt.endsWith(`Current working directory: ${root}`)).toBe(true); + })); + + it("replaces the foundation with promptCustom and places promptSystemAppend before the directory line", () => + withSettings(async ({ root }) => { + const { observed } = await prompts( + root, + { plugins: ["codework.prompt.default"] }, + { systemPrompt: { custom: "Only this.", append: " Extra section. " } }, + ); + // No tool plugin ran, the append is trimmed, and the directory line stays last. + expect(observed[0]).toBe( + [ + "Only this.", + "Available tools:\n(none)", + "Guidelines:\n- Be concise. Report what you did and what you found, not what you are about to do.\n- Quote exact paths and command output rather than paraphrasing them.\n- If a command fails, read the error before retrying.", + "Extra section.", + `Current working directory: ${root}`, + ].join("\n\n"), + ); + })); + + it("fails the snapshot when a caller slot throws, before any request", () => + withSettings(async ({ root }) => { + const { observed, path } = await prompts( + root, + { plugins: ["codework.prompt.default"] }, + { + systemPrompt: { + custom: () => { + throw new Error("slot exploded"); + }, + }, + }, + ); + expect(observed).toEqual([]); + expect(path).toEqual([]); + })); +}); diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 46f0f54..caff7d2 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -1,5 +1,6 @@ +import "./utils/env.ts"; import type { Plugin } from "../src/plugin/plugin.ts"; -import { defaults as plugins } from "../src/plugin/internal.ts"; +import { builtins as plugins } from "../src/plugin/internal.ts"; import { Settings } from "../src/settings/settings.ts"; import { createAssistantMessageEventStream, Message } from "@codeworksh/aikit"; import { Cause, DateTime, Deferred, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"; @@ -46,7 +47,7 @@ const runtime = ( ); return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(Loop.layer({ request })))), - Layer.provideMerge(State.layer(options.state, options.plugins)), + Layer.provideMerge(State.layer(options.state ?? {}, options.plugins ?? plugins)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(Layer.succeed(Settings.Service, { load: Effect.succeed(Settings.defaults) })), Layer.provideMerge(sandbox), diff --git a/packages/harness/test/sandbox.loader.test.ts b/packages/harness/test/sandbox.loader.test.ts index bda6a3b..f756978 100644 --- a/packages/harness/test/sandbox.loader.test.ts +++ b/packages/harness/test/sandbox.loader.test.ts @@ -32,7 +32,7 @@ describe("SandboxDriverLoader", () => { Effect.gen(function* () { const loaded = yield* SandboxDriverLoader.load( { package: "@acme/codework-sandbox-test", options: { token: "secret" } }, - { resolve: resolver, import: () => Promise.resolve({ default: moduleFor() }) }, + { hostCwd: process.cwd(), resolve: resolver, import: () => Promise.resolve({ default: moduleFor() }) }, ); expect(loaded.registered.name).toBe("acme.test"); expect(loaded.source).toBe("package"); @@ -42,7 +42,7 @@ describe("SandboxDriverLoader", () => { it.effect("rejects path references until settings supplies a trusted resolver", () => Effect.gen(function* () { for (const specifier of ["./plugin.ts", "../plugin.ts", "/tmp/plugin.ts", "file:///tmp/plugin.ts"]) { - const exit = yield* Effect.exit(SandboxDriverLoader.packageResolver()(specifier)); + const exit = yield* Effect.exit(SandboxDriverLoader.packageResolver(process.cwd())(specifier)); const error = errorFrom(exit); expect(error).toBeInstanceOf(SandboxDriverLoadError); expect((error as SandboxDriverLoadError).phase).toBe("resolve"); @@ -55,7 +55,7 @@ describe("SandboxDriverLoader", () => { const exit = yield* Effect.exit( SandboxDriverLoader.load( { package: "@acme/codework-sandbox-test", options: { token: 123_456 } }, - { resolve: resolver, import: () => Promise.resolve({ default: moduleFor() }) }, + { hostCwd: process.cwd(), resolve: resolver, import: () => Promise.resolve({ default: moduleFor() }) }, ), ); const error = errorFrom(exit); @@ -70,6 +70,7 @@ describe("SandboxDriverLoader", () => { return Effect.gen(function* () { const exit = yield* Effect.exit( SandboxDriverLoader.load("@acme/codework-sandbox-test", { + hostCwd: process.cwd(), resolve: resolver, import: () => Promise.resolve({ @@ -95,6 +96,7 @@ describe("SandboxDriverLoader", () => { it.effect("loads the installed external Vercel copy by package name", () => Effect.gen(function* () { const loaded = yield* SandboxDriverLoader.load("@codeworksh-test/codework-sandbox-vercel", { + hostCwd: process.cwd(), resolve: SandboxDriverLoader.packageResolver(packageDirectory, [ "development", "node", @@ -117,6 +119,7 @@ describe("SandboxDriverLoader", () => { it.effect("loads a first-party provider through its public package subpath", () => Effect.gen(function* () { const loaded = yield* SandboxDriverLoader.load("@codeworksh/harness/sandboxes/vercel", { + hostCwd: process.cwd(), resolve: SandboxDriverLoader.packageResolver(packageDirectory, [ "development", "node", diff --git a/packages/harness/test/sandbox.remote.driver.e2e.test.ts b/packages/harness/test/sandbox.remote.driver.e2e.test.ts index ac93714..0ac8aa0 100644 --- a/packages/harness/test/sandbox.remote.driver.e2e.test.ts +++ b/packages/harness/test/sandbox.remote.driver.e2e.test.ts @@ -144,6 +144,7 @@ vercelSuite("installed third-party Vercel lifecycle driver", () => { it("runs the copied package through the same controller lifecycle", async () => { const remote = await Effect.runPromise( SandboxDriverLoader.load("@codeworksh-test/codework-sandbox-vercel", { + hostCwd: process.cwd(), resolve: SandboxDriverLoader.packageResolver(process.cwd(), ["development", "node", "import", "default"]), }), ); diff --git a/packages/harness/test/sdk.test.ts b/packages/harness/test/sdk.test.ts index 983d3d5..f3cd088 100644 --- a/packages/harness/test/sdk.test.ts +++ b/packages/harness/test/sdk.test.ts @@ -1,3 +1,4 @@ +import "./utils/env.ts"; import { Settings } from "../src/settings/settings.ts"; import { Effect, Option } from "effect"; import fs from "node:fs/promises"; diff --git a/packages/harness/test/settings.loop.test.ts b/packages/harness/test/settings.loop.test.ts index e91f097..92a151e 100644 --- a/packages/harness/test/settings.loop.test.ts +++ b/packages/harness/test/settings.loop.test.ts @@ -1,3 +1,4 @@ +import "./utils/env.ts"; import { createAssistantMessageEventStream } from "@codeworksh/aikit"; import { Deferred, Effect, Fiber, Schema } from "effect"; import { writeFile } from "node:fs/promises"; From aa32de1cab2aa0458d618b79f80e0782b747428b Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Fri, 11 Sep 2026 21:41:11 +0530 Subject: [PATCH 05/12] feat(harness): harden plugin setup --- packages/codework/test/cli.test.ts | 2 +- packages/harness/README.md | 2 + packages/harness/src/plugin/index.ts | 3 + packages/harness/src/plugin/loader.ts | 6 +- packages/harness/src/plugin/package.ts | 49 ++- packages/harness/src/plugin/tool/registry.ts | 32 ++ packages/harness/src/tools/executor.ts | 18 +- packages/harness/test/plugin.catalog.test.ts | 62 ++- packages/harness/test/plugin.external.test.ts | 358 ++++++++++++++++++ .../harness/test/plugins/acme-bad-tool.mjs | 7 + .../test/plugins/acme-bash-override.mjs | 19 + packages/harness/test/plugins/acme-broken.mjs | 2 + .../harness/test/plugins/acme-echo/index.mjs | 28 ++ .../test/plugins/acme-echo/package.json | 9 + .../harness/test/plugins/acme-guarded.mjs | 24 ++ packages/harness/test/plugins/acme-hangs.mjs | 5 + .../harness/test/plugins/acme-journal.mjs | 23 ++ packages/harness/test/plugins/acme-prompt.mjs | 7 + .../harness/test/plugins/acme-relabel.mjs | 7 + packages/harness/test/plugins/acme-throws.mjs | 7 + 20 files changed, 658 insertions(+), 12 deletions(-) create mode 100644 packages/harness/test/plugin.external.test.ts create mode 100644 packages/harness/test/plugins/acme-bad-tool.mjs create mode 100644 packages/harness/test/plugins/acme-bash-override.mjs create mode 100644 packages/harness/test/plugins/acme-broken.mjs create mode 100644 packages/harness/test/plugins/acme-echo/index.mjs create mode 100644 packages/harness/test/plugins/acme-echo/package.json create mode 100644 packages/harness/test/plugins/acme-guarded.mjs create mode 100644 packages/harness/test/plugins/acme-hangs.mjs create mode 100644 packages/harness/test/plugins/acme-journal.mjs create mode 100644 packages/harness/test/plugins/acme-prompt.mjs create mode 100644 packages/harness/test/plugins/acme-relabel.mjs create mode 100644 packages/harness/test/plugins/acme-throws.mjs diff --git a/packages/codework/test/cli.test.ts b/packages/codework/test/cli.test.ts index dcc2e27..587c60b 100644 --- a/packages/codework/test/cli.test.ts +++ b/packages/codework/test/cli.test.ts @@ -88,7 +88,7 @@ describe("codework CLI", () => { expect(result.status).toBe(0); expect(result.stdout).toContain("--sandbox string"); expect(result.stdout).toContain("default: local"); - expect(result.stdout).toContain("Registered sandbox driver"); + expect(result.stdout).toContain("Sandbox driver for a new session"); expect(result.stdout).toContain("--sandbox-provider-id string"); }); diff --git a/packages/harness/README.md b/packages/harness/README.md index ea09da5..de383bf 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -65,6 +65,8 @@ Omitting `plugins` selects Bash then the default prompt. An explicit array repla Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. Plugin discovery from settings and daemon lifecycles are not implemented. +Failures are attributed: a bad reference, unreadable module, or malformed plugin fails `Harness.layer` construction with `PluginPreparationError`, which carries the failing phase (`source`, `install`, `import`, or `definition`) and the index of the offending reference. A failing package install reports `PluginInstallError`; a plugin's `setup` failure becomes `Plugin.SetupError` with the plugin id, surfacing as a `SnapshotError` for that exchange. Plugins are trusted in-process code — local paths and `file:` references import whatever they point at, so only load sources you trust. + ## Pluggable Sandboxes Harness uses a driver-based sandbox architecture. Drivers share a common lifecycle and I/O surface, keeping provider details out of session and agent-loop code. diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts index 6b01fa8..dc8e316 100644 --- a/packages/harness/src/plugin/index.ts +++ b/packages/harness/src/plugin/index.ts @@ -2,5 +2,8 @@ export { define, type Plugin, type Mount } from "./plugin.ts"; export type { SharedPluginContext, Config, Events, PromptResolver } from "./context.ts"; export type { PluginRegistry } from "./registry.ts"; export type { PluginRef } from "./catalog.ts"; +export { PreparationError } from "./loader.ts"; +export { InstallError } from "./package.ts"; +export { SetupError } from "./host.ts"; export * as Tool from "./tool/schema.ts"; export * as Prompt from "./prompt/schema.ts"; diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts index 814e23a..eb61c08 100644 --- a/packages/harness/src/plugin/loader.ts +++ b/packages/harness/src/plugin/loader.ts @@ -96,8 +96,10 @@ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string, const target = targets?.[0]; if (target === undefined || !target.startsWith("./")) return yield* failure(origin, "source", new Error("No valid root package export")); - const resolved = path.resolve(location, target); - if (path.relative(location, resolved).startsWith("..")) + // Compare real paths: a symlinked target (or root) can point outside while the + // string paths still nest. + const resolved = yield* fs.realPath(path.resolve(location, target)); + if (path.relative(yield* fs.realPath(location), resolved).startsWith("..")) return yield* failure(origin, "source", new Error("Package export escapes its root")); return pathToFileURL(resolved).href; } diff --git a/packages/harness/src/plugin/package.ts b/packages/harness/src/plugin/package.ts index 19a318f..e2e0623 100644 --- a/packages/harness/src/plugin/package.ts +++ b/packages/harness/src/plugin/package.ts @@ -1,5 +1,5 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"; -import { Duration, Effect, Layer, Ref, Schedule, Schema } from "effect"; +import { Duration, Effect, Layer, Option, Ref, Schedule, Schema } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { resolve } from "import-meta-resolve"; import { createHash } from "node:crypto"; @@ -58,13 +58,35 @@ const run: Runner = Effect.fn("PluginPackage.run")( /** How long to wait for another installer of the same spec before giving up. */ const LOCK_TIMEOUT = Duration.minutes(2); +/** The holder refreshes the lock's mtime this often; a lock not refreshed for the timeout is abandoned. */ +const LOCK_HEARTBEAT = Duration.seconds(15); + +/** + * A lock whose holder stopped refreshing it was left by a crashed installer. Staleness is + * measured from the heartbeat, not the install's start, so a slow but live install is never + * stolen from. + */ +const abandoned = (directory: string) => + Effect.gen(function* () { + const info = yield* fs.stat(directory); + const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); + return Option.exists(info.mtime, (mtime) => now - mtime.getTime() > Duration.toMillis(LOCK_TIMEOUT)); + }).pipe(Effect.orElseSucceed(() => false)); + +const heartbeat = (directory: string) => + Effect.clockWith((clock) => clock.currentTimeMillis).pipe( + Effect.flatMap((now) => fs.utimes(directory, now, now)), + Effect.ignore, + Effect.repeat(Schedule.spaced(LOCK_HEARTBEAT)), + ); /** * Claims `directory` by creating it, polling while another process holds it. * * One finalizer for the whole wait, registered before the first attempt: retrying inside * `acquireRelease` would add a finalizer per poll. A crashed installer leaves its lock - * behind, so the wait is bounded rather than infinite. + * behind; an abandoned one is reclaimed rather than waited on forever, and the wait itself + * is bounded. While held, the lock is heartbeated so waiters can tell live from dead. */ const lock = Effect.fn("PluginPackage.lock")(function* (directory: string) { const held = yield* Ref.make(false); @@ -74,10 +96,25 @@ const lock = Effect.fn("PluginPackage.lock")(function* (directory: string) { Effect.orDie, ), ); - const acquired = yield* fs.makeDirectory(directory).pipe( + // One attempt is uninterruptible so `mkdir` and recording ownership cannot be split: an + // interrupt between them would leave a lock the finalizer does not know to remove. + const attempt = fs.makeDirectory(directory).pipe( + Effect.andThen(Ref.set(held, true)), Effect.as(true), - Effect.catch((error) => (error.reason._tag === "AlreadyExists" ? Effect.succeed(false) : Effect.fail(error))), - Effect.tap((owned) => (owned ? Ref.set(held, true) : Effect.void)), + Effect.catch((error) => + error.reason._tag === "AlreadyExists" + ? abandoned(directory).pipe( + Effect.flatMap((stale) => + stale + ? fs.remove(directory, { recursive: true, force: true }).pipe(Effect.as(false)) + : Effect.succeed(false), + ), + ) + : Effect.fail(error), + ), + Effect.uninterruptible, + ); + const acquired = yield* attempt.pipe( Effect.repeat({ schedule: Schedule.spaced("50 millis"), until: (owned) => owned }), Effect.timeout(LOCK_TIMEOUT), Effect.catchTag("TimeoutError", () => Effect.succeed(false)), @@ -86,6 +123,8 @@ const lock = Effect.fn("PluginPackage.lock")(function* (directory: string) { return yield* new InstallError({ cause: new Error(`Timed out waiting for another plugin installation to release ${directory}`), }); + // Scoped to the install: the heartbeat dies with the scope, before the lock is removed. + yield* Effect.forkScoped(heartbeat(directory)); }); export const install = Effect.fn("PluginPackage.install")( diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts index ecb9c3f..5bd6d71 100644 --- a/packages/harness/src/plugin/tool/registry.ts +++ b/packages/harness/src/plugin/tool/registry.ts @@ -1,3 +1,4 @@ +import { Predicate, Schema } from "effect"; import type { RegisteredTool } from "../../tools/tool.ts"; import type { ToolAddOptions, ToolDefPatch, ToolRegistration, ToolRegistry } from "./schema.ts"; @@ -13,6 +14,35 @@ const definition = (tool: RegisteredTool): RegisteredTool => ({ }), }); +/** + * The registration boundary for code the compiler cannot see. A plugin written in + * plain JS can hand in anything; catching a malformed registration here keeps the + * failure inside its `setup`, attributed to the plugin, rather than surfacing it + * later as an unexplained freeze or mid-turn executor defect. + */ +const assertTool = (tool: RegisteredTool) => { + const def = Predicate.isObject(tool) ? tool.definition : undefined; + if (!Predicate.isObject(def) || !Predicate.isString(def.name) || def.name.length === 0) + throw new Error("Registered tool needs a definition with a non-empty string name"); + const name = def.name; + if (!Schema.isSchema(def.parameters)) throw new Error(`Tool ${name}: parameters must be an Effect Schema`); + if (!Schema.isSchema(def.success)) throw new Error(`Tool ${name}: success must be an Effect Schema`); + if (def.failure !== undefined && !Schema.isSchema(def.failure)) + throw new Error(`Tool ${name}: failure must be an Effect Schema`); + if (def.encodeContent !== undefined && !Predicate.isFunction(def.encodeContent)) + throw new Error(`Tool ${name}: encodeContent must be a function`); + if (def.encodeFailureContent !== undefined && !Predicate.isFunction(def.encodeFailureContent)) + throw new Error(`Tool ${name}: encodeFailureContent must be a function`); + if (!Predicate.isFunction(tool.handler)) throw new Error(`Tool ${name}: handler must be a function`); +}; + +const assertHooks = (name: string, hooks: ToolAddOptions) => { + if (hooks.beforeToolCall !== undefined && !Predicate.isFunction(hooks.beforeToolCall)) + throw new Error(`Tool ${name}: beforeToolCall must be a function`); + if (hooks.afterToolCall !== undefined && !Predicate.isFunction(hooks.afterToolCall)) + throw new Error(`Tool ${name}: afterToolCall must be a function`); +}; + export const make = () => { let open = true; const entries = new Map(); @@ -22,6 +52,8 @@ export const make = () => { const registry: ToolRegistry = Object.freeze({ add: (tool: RegisteredTool, hooks: ToolAddOptions = {}) => { assertOpen(); + assertTool(tool); + assertHooks(tool.definition.name, hooks); entries.set( tool.definition.name, Object.freeze({ tool: definition(tool), hooks: Object.freeze({ ...hooks }) }), diff --git a/packages/harness/src/tools/executor.ts b/packages/harness/src/tools/executor.ts index 8cad208..985e7dd 100644 --- a/packages/harness/src/tools/executor.ts +++ b/packages/harness/src/tools/executor.ts @@ -1,4 +1,10 @@ -import type { HookReturn, ToolAfterResult, ToolBefore, ToolRegistration } from "../plugin/tool/schema.ts"; +import { + ToolBeforeResult, + type HookReturn, + type ToolAfterResult, + type ToolBefore, + type ToolRegistration, +} from "../plugin/tool/schema.ts"; import { Message } from "@codeworksh/aikit"; import { Cause, @@ -314,6 +320,16 @@ export const make = (tools: ReadonlyArray): E return yield* Effect.failCause(before.cause as Cause.Cause); return yield* failureOutcome(call, before.cause, "beforeToolCall failed"); } + // A malformed verdict is as much a hook failure as a thrown one: silently + // reading `.block` off arbitrary data would let `{block: "yes"}` block and + // `"denied"` run the handler. + if (before.value !== undefined && !Schema.is(ToolBeforeResult)(before.value)) { + return yield* failureOutcome( + call, + Cause.die(new Error("beforeToolCall returned an invalid result")), + "beforeToolCall failed", + ); + } if (before.value?.block) { const now = yield* Effect.clockWith((clock) => clock.currentTimeMillis); return errored(call, [text(before.value.reason || "Tool execution was blocked")], now); diff --git a/packages/harness/test/plugin.catalog.test.ts b/packages/harness/test/plugin.catalog.test.ts index 07768ea..6c98869 100644 --- a/packages/harness/test/plugin.catalog.test.ts +++ b/packages/harness/test/plugin.catalog.test.ts @@ -1,6 +1,7 @@ -import { Deferred, Effect, Fiber } from "effect"; +import { Deferred, Effect, Exit, Fiber } from "effect"; +import { createHash } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; -import { mkdtemp, mkdir, writeFile, rm, readdir } from "node:fs/promises"; +import { mkdtemp, mkdir, writeFile, rm, readdir, symlink, utimes } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -165,7 +166,8 @@ describe("plugin catalog and source resolution", () => { }), ), ).toEqual([a]); - expect(url).toBe(pathToFileURL(join(directory, "entry.js")).href); + // Containment compares real paths, so the exported URL is the real one too. + expect(url).toBe(pathToFileURL(realpathSync(join(directory, "entry.js"))).href); await writeFile( join(directory, "package.json"), JSON.stringify({ name: "fixture", exports: { "./other": "./entry.js" } }), @@ -214,6 +216,20 @@ describe("plugin catalog and source resolution", () => { // The legacy branch resolves through `createRequire`, which realpaths its answer. expect(url).toBe(pathToFileURL(realpathSync(join(directory, "legacy.js"))).href); })); + it("rejects a root export that only nests by symlink", () => + withDirectory(async (directory) => { + // The manifest points at `./entry.js`, which sits inside the package by name but is a + // symlink to a file outside it. String containment passes; real-path containment must not. + const outside = join(directory, "outside.js"); + const pkg = join(directory, "pkg"); + await mkdir(pkg); + await writeFile(outside, "export default { id: 'acme.tool.escaped', setup() {} }"); + await writeFile(join(pkg, "package.json"), JSON.stringify({ name: "pkg", exports: "./entry.js" })); + await symlink(outside, join(pkg, "entry.js")); + const error = await Effect.runPromise(prepare([pkg], options).pipe(Effect.flip)); + expect(error).toMatchObject({ _tag: "PluginPreparationError", phase: "source" }); + expect(String(error.cause)).toContain("escapes its root"); + })); it("stages installs, reuses complete cache and isolates explicit versions", () => withDirectory(async (cache) => { let runs = 0; @@ -253,6 +269,46 @@ describe("plugin catalog and source resolution", () => { ), ).toEqual(first); })); + it("reclaims a lock abandoned by a crashed installer instead of timing out", () => + withDirectory(async (cache) => { + // The crash left a lock but no published entry, so the only way forward is to take + // the lock over. Age it past the timeout; a fresh one would still be waited on. + const key = createHash("sha256").update("fixture@latest").digest("hex"); + const lock = join(cache, "plugins", `${key}.lock`); + await mkdir(lock, { recursive: true }); + const old = new Date(Date.now() - 3 * 60_000); + await utimes(lock, old, old); + const installed = await Effect.runPromise( + install(parse("fixture"), cache, fixture).pipe(Effect.timeout("2 seconds"), Effect.orDie), + ); + expect(installed.version).toBe("1.0.0"); + expect(existsSync(lock)).toBe(false); + })); + it("keeps waiting on a live lock rather than stealing it", () => + withDirectory(async (cache) => { + const key = createHash("sha256").update("fixture@latest").digest("hex"); + await mkdir(join(cache, "plugins", `${key}.lock`), { recursive: true }); + const exit = await Effect.runPromiseExit( + install(parse("fixture"), cache, fixture).pipe(Effect.timeout("300 millis")), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect(await readdir(join(cache, "plugins"))).toEqual([`${key}.lock`]); + })); + it("leaves neither staging nor lock behind when interrupted mid-install", () => + withDirectory(async (cache) => { + await Effect.runPromise( + Effect.gen(function* () { + const entered = yield* Deferred.make(); + const runner: Runner = () => Deferred.succeed(entered, undefined).pipe(Effect.andThen(Effect.never)); + const fiber = yield* install(parse("fixture"), cache, runner).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(fiber); + }).pipe(Effect.scoped), + ); + expect(await readdir(join(cache, "plugins"))).toEqual([]); + // And the next installer finds a clean slate rather than a lock to wait on. + expect((await Effect.runPromise(install(parse("fixture"), cache, fixture))).version).toBe("1.0.0"); + })); it("does not reuse failed installations", () => withDirectory(async (cache) => { const failed = await Effect.runPromise( diff --git a/packages/harness/test/plugin.external.test.ts b/packages/harness/test/plugin.external.test.ts new file mode 100644 index 0000000..722d7df --- /dev/null +++ b/packages/harness/test/plugin.external.test.ts @@ -0,0 +1,358 @@ +import "./utils/env.ts"; +import { createAssistantMessageEventStream, type Message } from "@codeworksh/aikit"; +import { Cause, Effect, Exit, Fiber, Schema, Stream } from "effect"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { Harness } from "../src/effect/harness.ts"; +import { Session } from "../src/effect/session.ts"; +import { Event } from "../src/event/event.ts"; +import { EventSchema } from "../src/event/schema.ts"; +import { prepare } from "../src/plugin/catalog.ts"; +import type { LLM } from "../src/runner/llm.ts"; +import { SessionSchema } from "../src/session/schema.ts"; +import { assistant, immediateOpen } from "./fixtures/llm.ts"; +import { withSettings } from "./fixtures/settings.ts"; +import { pendingCall } from "./tools.fixture.ts"; + +/** + * Third-party plugins as they exist in production: plain `.mjs` modules loaded by + * path or directory, written against the structural contract rather than the SDK + * types. Every scenario below goes through the real import, never the seams. + */ +const dir = fileURLToPath(new URL("./plugins", import.meta.url)); +const pluginPath = (name: string) => join(dir, name); + +/** First request asks for the calls, every request after stops. */ +const toolTurn = (...calls: ReadonlyArray): LLM.Open => { + let index = 0; + return (input) => + Effect.sync(() => { + index += 1; + const first = index === 1; + const message = assistant(input, index, first ? { stopReason: "toolUse", parts: [...calls] } : {}); + const stream = createAssistantMessageEventStream(); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: first ? "toolUse" : "stop", message }); + return stream; + }); +}; + +/** One `Session.create` + one `run`, capturing every provider request. */ +const exchange = (input: { + readonly root: string; + readonly plugins: ReadonlyArray; + readonly llm?: LLM.Open; + readonly prompt?: string; +}) => { + const contexts: Message.Context[] = []; + const prompts: string[] = []; + const open = input.llm ?? immediateOpen(); + return Effect.gen(function* () { + const session = yield* Session.create({ directory: input.root }); + yield* session.run(input.prompt ?? "hello"); + // Read inside the scope: the memory database closes with the layer. + const path = yield* session.path(); + return { contexts, prompts, path } as const; + }).pipe( + Effect.provide( + Harness.layer({ + home: join(input.root, "home"), + database: ":memory:", + llm: (request, signal) => { + contexts.push(request.context); + prompts.push(request.context.systemPrompt ?? ""); + return open(request, signal); + }, + plugins: input.plugins, + }), + ), + Effect.scoped, + Effect.runPromise, + ); +}; + +describe("third-party plugins", () => { + it("loads a directory package and a single file through real imports", async () => { + const plugins = await Effect.runPromise( + prepare([pluginPath("acme-echo"), `file://${pluginPath("acme-prompt.mjs")}`], { + builtins: [], + cache: "/unused", + hostCwd: "/project", + }), + ); + expect(plugins.map((plugin) => plugin.id)).toEqual(["acme.tool.echo", "acme.prompt.marker"]); + }); + + it("rejects a malformed module and a reserved namespace through real imports", async () => { + const failure = await Effect.runPromise( + prepare([pluginPath("acme-broken.mjs")], { + builtins: [], + cache: "/unused", + hostCwd: "/project", + }).pipe(Effect.flip), + ); + expect(failure).toMatchObject({ _tag: "PluginPreparationError", phase: "definition", index: 0 }); + }); + + it("runs a package tool through the loop, with its after hook applied", () => + withSettings(async ({ root }) => { + const { contexts, prompts, path } = await exchange({ + root, + plugins: [pluginPath("acme-echo"), "codework.prompt.default"], + llm: toolTurn(pendingCall("acme_echo", { value: "hello" }, "call_echo")), + }); + expect(contexts[0]?.tools?.map((tool) => tool.name)).toEqual(["acme_echo"]); + expect(prompts[0]).toContain("- acme_echo: Echo a value back"); + + expect(path.map((item) => item.entry.type)).toEqual(["user", "assistant", "assistant"]); + const settled = JSON.parse(path[1]?.parts[0]?.data ?? "{}"); + expect(settled).toMatchObject({ + status: "completed", + result: { + content: [ + { type: "text", text: "hello" }, + { type: "text", text: "(acme-checked)" }, + ], + isError: false, + }, + }); + // The continuation request carries the terminal part, not a pending one. + const continued = contexts[1]?.messages.at(-1)?.parts.find((part) => part.type === "toolCall"); + expect(continued).toMatchObject({ callID: "call_echo", status: "completed" }); + })); + + it("blocks a denied call without running the handler, and settles the rest", () => + withSettings(async ({ root }) => { + const { path } = await exchange({ + root, + plugins: [pluginPath("acme-guarded.mjs"), "codework.prompt.default"], + llm: toolTurn( + pendingCall("acme_secret", { value: "deny" }, "call_blocked"), + pendingCall("acme_secret", { value: "allow" }, "call_allowed"), + ), + }); + const parts = (path[1]?.parts ?? []).map((part) => JSON.parse(part.data)); + const blocked = parts.find((part) => part.callID === "call_blocked"); + const allowed = parts.find((part) => part.callID === "call_allowed"); + expect(blocked).toMatchObject({ + status: "error", + result: { content: [{ type: "text", text: "denied by acme policy" }], isError: true }, + }); + expect(allowed).toMatchObject({ + status: "completed", + result: { content: [{ type: "text", text: "classified:allow" }], isError: false }, + }); + })); + + it("replaces the built-in bash tool by name", () => + withSettings(async ({ root }) => { + const { prompts, path } = await exchange({ + root, + plugins: ["codework.tool.bash", pluginPath("acme-bash-override.mjs"), "codework.prompt.default"], + llm: toolTurn(pendingCall("bash", { command: "echo hi" }, "call_bash")), + }); + expect(prompts[0]).toContain("- bash: Run a command through the acme shell"); + const settled = JSON.parse(path[1]?.parts[0]?.data ?? "{}"); + expect(settled).toMatchObject({ + status: "completed", + result: { content: [{ type: "text", text: "acme-override:echo hi" }] }, + }); + })); + + it("composes a prompt plugin over the default, after tool contributors", () => + withSettings(async ({ root }) => { + const { prompts } = await exchange({ + root, + plugins: [pluginPath("acme-echo"), "codework.prompt.default", pluginPath("acme-prompt.mjs")], + }); + expect(prompts[0]).toContain("You are an expert coding assistant"); + expect(prompts[0]).toContain("- acme_echo: Echo a value back"); + expect(prompts[0]?.endsWith("\n\nacme-marker")).toBe(true); + })); + + it("publishes plugin-owned durable and ephemeral events", () => + withSettings(async ({ root }) => { + const seen: string[] = []; + const Marker = EventSchema.define({ + type: "acme.journal.marker", + durable: { aggregate: "sessionId", version: 1 }, + schema: { sessionId: SessionSchema.ID, note: Schema.String }, + }); + const { rows, sessionId } = await Effect.runPromise( + Effect.gen(function* () { + const events = yield* Event.Service; + yield* events.listen((event) => Effect.sync(() => void seen.push(event.type))); + const session = yield* Session.create({ directory: root }); + yield* session.run("hello"); + // The row lands on the session aggregate; the kernel manifest skips it, + // the plugin's own definitions decode it. Read inside the scope: the + // memory database closes with the layer. + const rows = Array.from( + yield* events + .log({ aggregateId: session.id, definitions: EventSchema.durable([Marker]) }) + .pipe(Stream.runCollect), + ); + return { rows, sessionId: session.id }; + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + plugins: [pluginPath("acme-journal.mjs"), "codework.prompt.default"], + }), + ), + Effect.scoped, + ), + ); + expect(seen).toContain("acme.ready"); + const marker = rows.find((item) => !Event.isSynced(item)); + expect(marker).toMatchObject({ + type: "acme.journal.marker", + durable: { aggregateId: sessionId, seq: expect.any(Number), version: 1 }, + data: { sessionId, note: "acme was here" }, + }); + })); + + it("attributes a setup throw to the plugin id and never reaches the model", () => + withSettings(async ({ root }) => { + const contexts: Message.Context[] = []; + const failure = await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.prompt("hello"); + return yield* session.resume().pipe(Effect.flip); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: (request, signal) => { + contexts.push(request.context); + return immediateOpen()(request, signal); + }, + plugins: [pluginPath("acme-throws.mjs"), "codework.prompt.default"], + }), + ), + Effect.scoped, + ), + ); + expect(failure).toMatchObject({ + _tag: "State.SnapshotError", + cause: { _tag: "Plugin.SetupError", pluginId: "acme.setup.throws" }, + }); + expect(contexts).toEqual([]); + })); + + it("attributes a malformed tool registration to the plugin at setup", () => + withSettings(async ({ root }) => { + const failure = await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.prompt("hello"); + return yield* session.resume().pipe(Effect.flip); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + plugins: [pluginPath("acme-bad-tool.mjs"), "codework.prompt.default"], + }), + ), + Effect.scoped, + ), + ); + expect(failure).toMatchObject({ + _tag: "State.SnapshotError", + cause: { _tag: "Plugin.SetupError", pluginId: "acme.tool.malformed" }, + }); + })); + + it("fails harness construction with the preparation error", () => + withSettings(async ({ root }) => { + const failure = await Effect.runPromise( + Effect.gen(function* () { + yield* Session.create({ directory: root }); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: immediateOpen(), + plugins: [pluginPath("acme-broken.mjs")], + }), + ), + Effect.scoped, + Effect.flip, + ), + ); + expect(failure).toMatchObject({ _tag: "PluginPreparationError", phase: "definition" }); + })); + + it("relabels an overridden tool on the winner, keeping its hooks", () => + withSettings(async ({ root }) => { + // acme-echo registers acme_echo with an after hook; a later plugin patches only + // its prose. The wire description changes, the winner's hook still fires, and the + // default prompt (placed after both) indexes the patched description. + const { contexts, prompts, path } = await exchange({ + root, + plugins: [pluginPath("acme-echo"), pluginPath("acme-relabel.mjs"), "codework.prompt.default"], + llm: toolTurn(pendingCall("acme_echo", { value: "hi" }, "call_echo")), + }); + expect(contexts[0]?.tools?.[0]).toMatchObject({ name: "acme_echo", description: "Echo, relabelled by acme" }); + expect(prompts[0]).toContain("- acme_echo: Echo a value back"); + const settled = JSON.parse(path[1]?.parts[0]?.data ?? "{}"); + expect(settled.result.content).toEqual([ + { type: "text", text: "hi" }, + { type: "text", text: "(acme-checked)" }, + ]); + })); + + it("lets interruption end an exchange stuck in a plugin's setup", () => + withSettings(async ({ root }) => { + const contexts: Message.Context[] = []; + const exit = await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.prompt("hello"); + const running = yield* session.resume().pipe(Effect.forkChild); + // Give the drain time to reach setup and block there, then pull the plug. + yield* Effect.sleep("100 millis"); + yield* session.interrupt(); + return yield* Fiber.await(running); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + llm: (request, signal) => { + contexts.push(request.context); + return immediateOpen()(request, signal); + }, + plugins: [pluginPath("acme-hangs.mjs"), "codework.prompt.default"], + }), + ), + Effect.scoped, + Effect.timeout("5 seconds"), + ), + ); + expect(Exit.isFailure(exit) && Cause.hasInterrupts(exit.cause)).toBe(true); + expect(contexts).toEqual([]); + })); + + it("honours a disabled built-in end to end", () => + withSettings(async ({ root }) => { + const { contexts, prompts, path } = await exchange({ + root, + plugins: ["codework.tool.bash", "!codework.tool.bash", "codework.prompt.default"], + llm: toolTurn(pendingCall("bash", { command: "echo hi" }, "call_bash")), + }); + expect(contexts[0]?.tools).toEqual([]); + expect(prompts[0]).toContain("Available tools:\n(none)"); + const settled = JSON.parse(path[1]?.parts[0]?.data ?? "{}"); + expect(settled).toMatchObject({ status: "error", result: { isError: true } }); + expect(settled.result.content[0].text).toContain("Unknown tool: bash"); + })); +}); diff --git a/packages/harness/test/plugins/acme-bad-tool.mjs b/packages/harness/test/plugins/acme-bad-tool.mjs new file mode 100644 index 0000000..67cf8c2 --- /dev/null +++ b/packages/harness/test/plugins/acme-bad-tool.mjs @@ -0,0 +1,7 @@ +// Registers a malformed tool (no schemas, no handler) — untyped JS can reach here. +export default { + id: "acme.tool.malformed", + setup(ctx) { + ctx.plugin.tools.add({ definition: { name: "not_a_tool" } }); + }, +}; diff --git a/packages/harness/test/plugins/acme-bash-override.mjs b/packages/harness/test/plugins/acme-bash-override.mjs new file mode 100644 index 0000000..e285ab0 --- /dev/null +++ b/packages/harness/test/plugins/acme-bash-override.mjs @@ -0,0 +1,19 @@ +// Replaces the built-in bash tool by name: the later registration wins. +import { Effect, Schema } from "effect"; + +export default { + id: "acme.tool.bash-override", + setup(ctx) { + ctx.plugin.tools.add({ + definition: { + name: "bash", + description: "Run a command through the acme shell", + promptSnippet: "Run a command through the acme shell", + parameters: Schema.Struct({ command: Schema.String }), + success: Schema.String, + encodeContent: (value) => [{ type: "text", text: value }], + }, + handler: ({ command }) => Effect.succeed(`acme-override:${command}`), + }); + }, +}; diff --git a/packages/harness/test/plugins/acme-broken.mjs b/packages/harness/test/plugins/acme-broken.mjs new file mode 100644 index 0000000..5f99865 --- /dev/null +++ b/packages/harness/test/plugins/acme-broken.mjs @@ -0,0 +1,2 @@ +// Not a plugin: no setup function. Must fail definition validation at prepare. +export default { id: "acme.broken.definition" }; diff --git a/packages/harness/test/plugins/acme-echo/index.mjs b/packages/harness/test/plugins/acme-echo/index.mjs new file mode 100644 index 0000000..ea8593a --- /dev/null +++ b/packages/harness/test/plugins/acme-echo/index.mjs @@ -0,0 +1,28 @@ +// A third-party tool plugin written in plain JS: no TypeScript, no harness import. +// The contract it codes against is structural — a `{ id, setup }` default export. +import { Effect, Schema } from "effect"; + +export default { + id: "acme.tool.echo", + setup(ctx) { + ctx.plugin.tools.add( + { + definition: { + name: "acme_echo", + description: "Echo a value back", + promptSnippet: "Echo a value back", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + encodeContent: (value) => [{ type: "text", text: value }], + }, + handler: ({ value }) => Effect.succeed(value), + }, + { + afterToolCall: ({ terminal }) => + terminal.status === "completed" + ? { content: [...terminal.result.content, { type: "text", text: "(acme-checked)" }] } + : undefined, + }, + ); + }, +}; diff --git a/packages/harness/test/plugins/acme-echo/package.json b/packages/harness/test/plugins/acme-echo/package.json new file mode 100644 index 0000000..3cd8125 --- /dev/null +++ b/packages/harness/test/plugins/acme-echo/package.json @@ -0,0 +1,9 @@ +{ + "name": "acme-echo-plugin", + "version": "1.4.2", + "private": true, + "type": "module", + "exports": { + ".": "./index.mjs" + } +} diff --git a/packages/harness/test/plugins/acme-guarded.mjs b/packages/harness/test/plugins/acme-guarded.mjs new file mode 100644 index 0000000..8b71ded --- /dev/null +++ b/packages/harness/test/plugins/acme-guarded.mjs @@ -0,0 +1,24 @@ +// A policy-style plugin: its own tool gated by a beforeToolCall block verdict. +import { Effect, Schema } from "effect"; + +export default { + id: "acme.tool.guarded", + setup(ctx) { + ctx.plugin.tools.add( + { + definition: { + name: "acme_secret", + description: "Read a secret", + parameters: Schema.Struct({ value: Schema.String }), + success: Schema.String, + encodeContent: (value) => [{ type: "text", text: value }], + }, + handler: ({ value }) => Effect.succeed(`classified:${value}`), + }, + { + beforeToolCall: ({ params }) => + params.value === "deny" ? { block: true, reason: "denied by acme policy" } : undefined, + }, + ); + }, +}; diff --git a/packages/harness/test/plugins/acme-hangs.mjs b/packages/harness/test/plugins/acme-hangs.mjs new file mode 100644 index 0000000..66d3493 --- /dev/null +++ b/packages/harness/test/plugins/acme-hangs.mjs @@ -0,0 +1,5 @@ +// A setup that never settles: only interruption can end the exchange it blocks. +export default { + id: "acme.setup.hangs", + setup: () => new Promise(() => {}), +}; diff --git a/packages/harness/test/plugins/acme-journal.mjs b/packages/harness/test/plugins/acme-journal.mjs new file mode 100644 index 0000000..313daaa --- /dev/null +++ b/packages/harness/test/plugins/acme-journal.mjs @@ -0,0 +1,23 @@ +// A plugin owning durable event types outside the kernel manifest. `publish` only +// reads { type, durable, data } off the definition, so a plain object suffices. +import { Effect, Schema } from "effect"; + +export const Marker = { + type: "acme.journal.marker", + durable: { aggregate: "sessionId", version: 1 }, + data: Schema.Struct({ sessionId: Schema.String, note: Schema.String }), +}; + +export const Ready = { + type: "acme.ready", + data: Schema.Struct({ sessionId: Schema.String }), +}; + +export default { + id: "acme.journal.writer", + setup: (ctx) => + Effect.gen(function* () { + yield* ctx.events.publish(Ready, { sessionId: ctx.sessionId }); + yield* ctx.events.publish(Marker, { sessionId: ctx.sessionId, note: "acme was here" }); + }), +}; diff --git a/packages/harness/test/plugins/acme-prompt.mjs b/packages/harness/test/plugins/acme-prompt.mjs new file mode 100644 index 0000000..d42ccdc --- /dev/null +++ b/packages/harness/test/plugins/acme-prompt.mjs @@ -0,0 +1,7 @@ +// Single-file prompt plugin: composes on whatever an earlier prompt plugin set. +export default { + id: "acme.prompt.marker", + setup(ctx) { + ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ""}\n\nacme-marker`); + }, +}; diff --git a/packages/harness/test/plugins/acme-relabel.mjs b/packages/harness/test/plugins/acme-relabel.mjs new file mode 100644 index 0000000..1fe0594 --- /dev/null +++ b/packages/harness/test/plugins/acme-relabel.mjs @@ -0,0 +1,7 @@ +// Patches prose on a tool someone else owns, without touching its handler or hooks. +export default { + id: "acme.tool.relabel", + setup(ctx) { + ctx.plugin.tools.update("acme_echo", { description: "Echo, relabelled by acme" }); + }, +}; diff --git a/packages/harness/test/plugins/acme-throws.mjs b/packages/harness/test/plugins/acme-throws.mjs new file mode 100644 index 0000000..1007c90 --- /dev/null +++ b/packages/harness/test/plugins/acme-throws.mjs @@ -0,0 +1,7 @@ +// A plugin whose setup explodes: the host must attribute the failure to its id. +export default { + id: "acme.setup.throws", + setup() { + throw new Error("acme setup exploded"); + }, +}; From 3ec08e59ae92cc0592e20a882889f371e0ba9ab5 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 12 Sep 2026 09:53:45 +0530 Subject: [PATCH 06/12] chore: rename --- packages/harness/src/plugin/catalog.ts | 2 +- packages/harness/src/plugin/context.ts | 2 +- packages/harness/src/plugin/host.ts | 8 ++++---- packages/harness/src/plugin/index.ts | 10 +++++----- packages/harness/src/plugin/loader.ts | 9 ++------- packages/harness/src/plugin/prompt/registry.ts | 2 +- packages/harness/src/plugin/registry.ts | 2 +- packages/harness/src/plugin/tool/registry.ts | 2 +- 8 files changed, 16 insertions(+), 21 deletions(-) diff --git a/packages/harness/src/plugin/catalog.ts b/packages/harness/src/plugin/catalog.ts index 45b90e0..a41bce6 100644 --- a/packages/harness/src/plugin/catalog.ts +++ b/packages/harness/src/plugin/catalog.ts @@ -41,7 +41,7 @@ export const prepare = Effect.fn("PluginCatalog.prepare")(function* ( /** Source metadata exists for diagnostics; a silent ID replacement is where it earns that. */ const note = (plugin: Plugin, source: string) => catalog.has(plugin.id) - ? Effect.logDebug(`Plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`) + ? Effect.logDebug(`plugin ${plugin.id} redefined by ${source}; the earlier definition is discarded`) : Effect.void; const operations = new Map(); const loaded = new Map(); diff --git a/packages/harness/src/plugin/context.ts b/packages/harness/src/plugin/context.ts index 4b6408f..327f716 100644 --- a/packages/harness/src/plugin/context.ts +++ b/packages/harness/src/plugin/context.ts @@ -32,6 +32,6 @@ export const makeEvents = (events: Events): Events => Object.freeze({ publish: (definition, data, options) => reserved.has(definition.type) - ? Effect.die(new Error(`Plugins cannot publish kernel journal event: ${definition.type}`)) + ? Effect.die(new Error(`plugins cannot publish kernel journal event: ${definition.type}`)) : events.publish(definition, data, options), }); diff --git a/packages/harness/src/plugin/host.ts b/packages/harness/src/plugin/host.ts index 22d5ad6..36aca29 100644 --- a/packages/harness/src/plugin/host.ts +++ b/packages/harness/src/plugin/host.ts @@ -25,14 +25,14 @@ export const run = Effect.fn("PluginHost.run")(function* ( return result.pipe( Effect.mapError( (cause) => - new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }), + new SetupError({ pluginId: plugin.id, message: `plugin setup failed: ${plugin.id}`, cause }), ), ); if (result === undefined) return Effect.void; return Effect.tryPromise({ try: () => result, catch: (cause) => - new SetupError({ pluginId: plugin.id, message: `Plugin setup failed: ${plugin.id}`, cause }), + new SetupError({ pluginId: plugin.id, message: `plugin setup failed: ${plugin.id}`, cause }), }); }).pipe( Effect.catchCause((cause) => { @@ -47,7 +47,7 @@ export const run = Effect.fn("PluginHost.run")(function* ( ? squashed : new SetupError({ pluginId: plugin.id, - message: `Plugin setup failed: ${plugin.id}`, + message: `plugin setup failed: ${plugin.id}`, cause: squashed, }), ); @@ -58,7 +58,7 @@ export const run = Effect.fn("PluginHost.run")(function* ( try: buckets.freeze, catch: (cause) => new SetupError({ - message: `Plugin snapshot freeze failed: ${cause instanceof Error ? cause.message : String(cause)}`, + message: `plugin snapshot freeze failed: ${cause instanceof Error ? cause.message : String(cause)}`, cause, }), }); diff --git a/packages/harness/src/plugin/index.ts b/packages/harness/src/plugin/index.ts index dc8e316..5bff990 100644 --- a/packages/harness/src/plugin/index.ts +++ b/packages/harness/src/plugin/index.ts @@ -1,9 +1,9 @@ -export { define, type Plugin, type Mount } from "./plugin.ts"; -export type { SharedPluginContext, Config, Events, PromptResolver } from "./context.ts"; -export type { PluginRegistry } from "./registry.ts"; export type { PluginRef } from "./catalog.ts"; +export type { Config, Events, PromptResolver, SharedPluginContext } from "./context.ts"; +export { SetupError } from "./host.ts"; export { PreparationError } from "./loader.ts"; export { InstallError } from "./package.ts"; -export { SetupError } from "./host.ts"; -export * as Tool from "./tool/schema.ts"; +export { define, type Mount, type Plugin } from "./plugin.ts"; export * as Prompt from "./prompt/schema.ts"; +export type { PluginRegistry } from "./registry.ts"; +export * as Tool from "./tool/schema.ts"; diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts index eb61c08..401b01b 100644 --- a/packages/harness/src/plugin/loader.ts +++ b/packages/harness/src/plugin/loader.ts @@ -39,12 +39,7 @@ export const validate = Effect.fn("PluginLoader.validate")(function* (input: unk // pointing at a clone inside the documented `setup() {}` shorthand. const plugin = input as Plugin; if (!builtin && plugin.id.startsWith("codework.")) { - return yield* failure( - origin, - "definition", - new Error("The codework namespace is reserved for built-ins"), - plugin.id, - ); + return yield* failure(origin, "definition", new Error("codework namespace is reserved for builtins"), plugin.id); } return plugin; }); @@ -63,7 +58,7 @@ export const classify = (source: string, hostCwd: string): Source => { if (source.startsWith("file:")) { // Both `new URL` and `fileURLToPath` silently read a relative `file:./x` as `/x`. A file // URL names an absolute path or it is not one. - if (!source.startsWith("file:///")) throw new Error(`Not an absolute file URL: ${source}`); + if (!source.startsWith("file:///")) throw new Error(`not an absolute file URL: ${source}`); return { kind: "local", path: fileURLToPath(source) }; } if (source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) { diff --git a/packages/harness/src/plugin/prompt/registry.ts b/packages/harness/src/plugin/prompt/registry.ts index 0559570..e49ec06 100644 --- a/packages/harness/src/plugin/prompt/registry.ts +++ b/packages/harness/src/plugin/prompt/registry.ts @@ -5,7 +5,7 @@ export const make = () => { let value: string | undefined; const registry: PromptRegistry = Object.freeze({ set: (prompt: string) => { - if (!open) throw new Error("Prompt registry is closed"); + if (!open) throw new Error("prompt registry is closed"); value = prompt; }, get: () => value, diff --git a/packages/harness/src/plugin/registry.ts b/packages/harness/src/plugin/registry.ts index 1c4000e..f84dea5 100644 --- a/packages/harness/src/plugin/registry.ts +++ b/packages/harness/src/plugin/registry.ts @@ -21,7 +21,7 @@ export const make = () => { freeze: () => { close(); const systemPrompt = prompt.value(); - if (systemPrompt === undefined) throw new Error("No Prompt plugin set a system prompt"); + if (systemPrompt === undefined) throw new Error("no prompt plugin set a system prompt"); return { tools: makeCatalog(tools.entries()).resolve(), systemPrompt }; }, }; diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts index 5bd6d71..5074240 100644 --- a/packages/harness/src/plugin/tool/registry.ts +++ b/packages/harness/src/plugin/tool/registry.ts @@ -47,7 +47,7 @@ export const make = () => { let open = true; const entries = new Map(); const assertOpen = () => { - if (!open) throw new Error("Tool registry is closed"); + if (!open) throw new Error("tool registry is closed"); }; const registry: ToolRegistry = Object.freeze({ add: (tool: RegisteredTool, hooks: ToolAddOptions = {}) => { From 357d2b8940ebeb64f71571091563b3af9eb30e6d Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 12 Sep 2026 10:34:22 +0530 Subject: [PATCH 07/12] fix(harness): refactor bash tool plugin into its own. --- packages/harness/src/effect.ts | 2 +- .../src/plugin/internal/prompt/default.ts | 2 +- .../harness/src/plugin/internal/tool/bash.ts | 241 +++++++++++++++++- packages/harness/src/plugin/plugin.ts | 2 +- packages/harness/src/plugin/registry.ts | 6 +- packages/harness/src/plugin/tool/registry.ts | 2 +- packages/harness/src/plugin/tool/schema.ts | 2 +- packages/harness/src/runner/loop.ts | 2 +- packages/harness/src/sandbox/shell/shell.ts | 2 +- packages/harness/src/state/state.ts | 2 +- .../src/{tools => tool}/accumulator.ts | 0 packages/harness/src/{tools => tool}/error.ts | 0 .../harness/src/{tools => tool}/executor.ts | 0 .../src/{tools/tools.ts => tool/index.ts} | 2 +- .../harness/src/{tools => tool}/progress.ts | 2 +- .../harness/src/{tools => tool}/registry.ts | 2 +- packages/harness/src/{tools => tool}/shell.ts | 2 +- packages/harness/src/{tools => tool}/tool.ts | 0 .../harness/src/{tools => tool}/truncate.ts | 0 packages/harness/src/tools/bash.ts | 231 ----------------- packages/harness/test/fixtures/remote.spec.ts | 2 +- .../fixtures/tools.registry.vercel.spec.ts | 10 +- packages/harness/test/plugin.hooks.test.ts | 6 +- packages/harness/test/plugin.host.test.ts | 2 +- packages/harness/test/runner.loop.test.ts | 2 +- .../harness/test/sandbox.flue.parity.test.ts | 2 +- packages/harness/test/settings.loop.test.ts | 2 +- packages/harness/test/tools.bash.test.ts | 10 +- .../harness/test/tools.local.shell.test.ts | 10 +- .../test/tools.registry.remote.test.ts | 10 +- packages/harness/test/tools.registry.test.ts | 12 +- packages/harness/test/tools.tool.test.ts | 6 +- 32 files changed, 289 insertions(+), 287 deletions(-) rename packages/harness/src/{tools => tool}/accumulator.ts (100%) rename packages/harness/src/{tools => tool}/error.ts (100%) rename packages/harness/src/{tools => tool}/executor.ts (100%) rename packages/harness/src/{tools/tools.ts => tool/index.ts} (89%) rename packages/harness/src/{tools => tool}/progress.ts (96%) rename packages/harness/src/{tools => tool}/registry.ts (98%) rename packages/harness/src/{tools => tool}/shell.ts (99%) rename packages/harness/src/{tools => tool}/tool.ts (100%) rename packages/harness/src/{tools => tool}/truncate.ts (100%) delete mode 100644 packages/harness/src/tools/bash.ts diff --git a/packages/harness/src/effect.ts b/packages/harness/src/effect.ts index d90d84b..b40629c 100644 --- a/packages/harness/src/effect.ts +++ b/packages/harness/src/effect.ts @@ -4,7 +4,7 @@ export { Session } from "./effect/session.ts"; export { EventList } from "./event/list.ts"; export { EventSchema } from "./event/schema.ts"; export { Runner } from "./runner/run.ts"; -export * as Tool from "./tools/tool.ts"; +export * as Tool from "./tool/tool.ts"; export { Settings } from "./settings/settings.ts"; diff --git a/packages/harness/src/plugin/internal/prompt/default.ts b/packages/harness/src/plugin/internal/prompt/default.ts index 6c5ccc4..60199b4 100644 --- a/packages/harness/src/plugin/internal/prompt/default.ts +++ b/packages/harness/src/plugin/internal/prompt/default.ts @@ -8,7 +8,7 @@ */ import { Effect } from "effect"; -import type { AnyToolDef } from "../../../tools/tool.ts"; +import type { AnyToolDef } from "../../../tool/tool.ts"; import type { PromptResolver, SharedPluginContext } from "../../context.ts"; import { define } from "../../plugin.ts"; diff --git a/packages/harness/src/plugin/internal/tool/bash.ts b/packages/harness/src/plugin/internal/tool/bash.ts index 5f96c8e..9750922 100644 --- a/packages/harness/src/plugin/internal/tool/bash.ts +++ b/packages/harness/src/plugin/internal/tool/bash.ts @@ -1,10 +1,243 @@ -import { Effect, Layer } from "effect"; +import { Duration, Effect, Layer, Option, Ref, Schema, Stream } from "effect"; +import { randomBytes } from "node:crypto"; +import { tmpdir } from "node:os"; +import { fileSystem } from "../../../host.ts"; import { SandboxIO } from "../../../sandbox/io.ts"; -import { bashTool } from "../../../tools/bash.ts"; -import { fromSandboxShell } from "../../../tools/shell.ts"; -import * as Tool from "../../../tools/tool.ts"; +import { Accumulator, type OutputSnapshot } from "../../../tool/accumulator.ts"; +import { ToolProgress } from "../../../tool/progress.ts"; +import { fromSandboxShell, type IToolShell, ToolShell } from "../../../tool/shell.ts"; +import * as Tool from "../../../tool/tool.ts"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateTail, + type TruncationResult, +} from "../../../tool/truncate.ts"; +import { posix } from "../../../util/posix.ts"; import { define } from "../../plugin.ts"; +/** + * The self-contained bash plugin — a worked example for tool plugins. The definition is pure data; + * the handler depends only on {@link ToolShell} / {@link ToolProgress}, never + * `sandbox/Shell`, so the backend (local OS / just-bash / remote provider) is + * swapped by changing the provided Layer with no change to the tool. + * + * Two execution paths, picked by capability: + * - **variant A (buffered)** otherwise: `ToolShell.exec` returns the full output, + * truncated once after completion. + * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output + * flows through an {@link Accumulator} (bounded memory + temp-file spill) + * and is reported live via {@link ToolProgress}; on timeout the partial output + * produced so far is preserved. + * + * Success and failure carry the *same* structured shape + * (`output`, `exitCode`, `truncated`, `fullOutputPath`) + * — a non-zero exit is just `exitCode !== 0`, not a different kind of result. + */ + +const BashParams = Schema.Struct({ + command: Schema.String.annotate({ description: "The bash command to execute." }), + timeout: Schema.optional( + Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ + description: "Optional timeout in seconds (must be greater than 0).", + }), + ), +}); + +// Shared structured fields, so a programmatic consumer reads truncation / the +// full-output path the same way whether the command succeeded or failed. +const outputFields = { + /** Combined stdout + stderr, truncated for display (full output is on disk when `truncated`). */ + output: Schema.String, + truncated: Schema.Boolean, + fullOutputPath: Schema.optional(Schema.String), +}; + +const BashSuccess = Schema.Struct({ ...outputFields, exitCode: Schema.Finite }); + +/** Non-zero exit — expected, model-visible. Carries the same shape as success. */ +class BashFailed extends Schema.TaggedError()("BashFailed", { + ...outputFields, + exitCode: Schema.Finite, +}) {} + +/** Deadline exceeded — expected, model-visible. Carries the partial output produced so far. */ +class BashTimedOut extends Schema.TaggedError()("BashTimedOut", { + ...outputFields, + timeoutSeconds: Schema.Finite, +}) {} + +const BashFailure = Schema.Union([BashFailed, BashTimedOut]); +type BashFailureError = BashFailed | BashTimedOut; + +export const bashDef = Tool.define({ + name: "bash", + label: "bash", + promptSnippet: "Execute bash commands (ls, grep, find, etc.).", + description: + "Execute a bash command in the working directory and return its combined stdout/stderr output. " + + `Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES}KB (whichever is hit first); when truncated, the full ` + + "output is saved to a temp file. A non-zero exit code is reported as an error carrying the captured output.", + parameters: BashParams, + success: BashSuccess, + failure: BashFailure, + // The model reads just the command output, not the JSON envelope. + encodeContent: (success) => [{ type: "text", text: success.output }], + encodeFailureContent: (failure) => [{ type: "text", text: failure.output }], +}); + +/** The structured, model-facing shape of a presented result (ready to spread). */ +interface Presented { + readonly output: string; + readonly truncated: boolean; + readonly fullOutputPath?: string; +} + +/** Combine stdout and stderr into one stream of text, stderr after stdout. */ +const combineOutput = (stdout: string, stderr: string): string => { + if (stderr.length === 0) return stdout; + if (stdout.length === 0) return stderr; + return `${stdout}\n${stderr}`; +}; + +/** One-line footer appended to truncated, model-facing output. */ +const footer = (t: TruncationResult, fullOutputPath: string | undefined, lastLineBytes?: number): string => { + const where = fullOutputPath ? ` Full output: ${fullOutputPath}` : ""; + const startLine = t.totalLines - t.outputLines + 1; + if (t.lastLinePartial) { + const lineSize = lastLineBytes !== undefined ? ` (line is ${formatSize(lastLineBytes)})` : ""; + return `\n\n[showing last ${formatSize(t.outputBytes)} of line ${t.totalLines}${lineSize}.${where}]`; + } + if (t.truncatedBy === "lines") { + return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`; + } + return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`; +}; + +/** Write full output to a host temp file (best-effort; undefined on failure). */ +const spillToTempFile = (content: string): Effect.Effect => + Effect.suspend(() => { + const path = posix.join(tmpdir(), `codework-bash-${randomBytes(6).toString("hex")}.log`); + return fileSystem.writeFileString(path, content).pipe(Effect.as(path)); + }).pipe(Effect.orElseSucceed(() => undefined)); + +/** Variant A: truncate the buffered output once, spilling the full output if truncated. */ +const presentBuffered = (combined: string): Effect.Effect => + Effect.gen(function* () { + const t = truncateTail(combined); + if (!t.truncated) return { output: t.content, truncated: false }; + const fullOutputPath = yield* spillToTempFile(combined); + return { + output: t.content + footer(t, fullOutputPath), + truncated: true, + ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), + }; + }); + +/** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */ +const presentSnapshot = (snap: OutputSnapshot, lastLineBytes: number): Presented => { + if (!snap.truncation.truncated) return { output: snap.content, truncated: false }; + return { + output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes), + truncated: true, + ...(snap.fullOutputPath !== undefined ? { fullOutputPath: snap.fullOutputPath } : {}), + }; +}; + +/** Variant A — buffered: one `exec`, truncate after completion. No live progress. */ +const runBuffered = ( + shell: IToolShell, + params: typeof BashParams.Type, +): Effect.Effect => + Effect.gen(function* () { + const result = yield* shell + .exec(params.command, params.timeout !== undefined ? { timeout: Duration.seconds(params.timeout) } : undefined) + .pipe( + // A deadline becomes a model-visible BashTimedOut (no partial output is + // available from a buffered exec); an infra/spawn failure is not + // model-actionable, so it becomes a defect (run error). + Effect.catchTags({ + ToolShellTimeout: (timeout) => + Effect.fail( + new BashTimedOut({ timeoutSeconds: timeout.timeoutMillis / 1000, output: "", truncated: false }), + ), + ToolShellError: (cause) => Effect.die(cause), + }), + ); + + const present = yield* presentBuffered(combineOutput(result.stdout, result.stderr)); + if (result.exitCode !== 0) { + return yield* new BashFailed({ exitCode: result.exitCode, ...present }); + } + return { exitCode: result.exitCode, ...present } satisfies typeof BashSuccess.Type; + }); + +/** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */ +const runStreaming = ( + stream: NonNullable, + params: typeof BashParams.Type, +): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const acc = new Accumulator({ tempFilePrefix: "codework-bash" }); + const progress = yield* ToolProgress; + const exitCode = yield* Ref.make(null); + // executes command and streams output + const consume = stream(params.command).pipe( + Stream.runForEach((event) => + event._tag === "Exit" + ? Ref.set(exitCode, event.exitCode) + : // `gen` so the snapshot is read *after* the append runs (not eagerly + // at pipeline-construction time, which would lag a chunk behind). + Effect.gen(function* () { + yield* acc.append(Buffer.from(event.bytes)); + yield* progress.report({ content: [{ type: "text", text: acc.snapshot().content }] }); + }), + ), + // Infra/spawn failure → defect (run error), like variant A. + Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)), + ); + + // Consume with the deadline; on timeout the accumulator keeps what arrived. + let timedOutAfter: number | undefined; + if (params.timeout !== undefined) { + const finished = yield* consume.pipe(Effect.timeoutOption(Duration.seconds(params.timeout))); + if (Option.isNone(finished)) timedOutAfter = params.timeout; + } else { + yield* consume; + } + + yield* acc.finish(); + const present = presentSnapshot(acc.snapshot(), acc.getLastLineBytes()); + + if (timedOutAfter !== undefined) { + return yield* new BashTimedOut({ timeoutSeconds: timedOutAfter, ...present }); + } + const code = yield* Ref.get(exitCode); + // No Exit event (e.g. killed before reporting one) → treat as a failure. + if (code === null || code !== 0) { + return yield* new BashFailed({ exitCode: code ?? -1, ...present }); + } + return { exitCode: code, ...present } satisfies typeof BashSuccess.Type; + }), + ); + +export const bashHandler: Tool.Handler< + typeof BashParams, + typeof BashSuccess, + typeof BashFailure, + ToolShell | ToolProgress +> = (params) => + Effect.gen(function* () { + const shell = yield* ToolShell; + // Prefer streaming when the backend supports it; fall back to buffered exec. + return shell.stream !== undefined ? yield* runStreaming(shell.stream, params) : yield* runBuffered(shell, params); + }); + +/** The bash tool: definition + handler, wired the testable (def/exec split) way. */ +export const bashTool = Tool.implement(bashDef, bashHandler); + export const bashPlugin = define({ id: "codework.tool.bash", setup: Effect.fn("BashPlugin.setup")(function* (ctx) { diff --git a/packages/harness/src/plugin/plugin.ts b/packages/harness/src/plugin/plugin.ts index 9fc42f4..905773e 100644 --- a/packages/harness/src/plugin/plugin.ts +++ b/packages/harness/src/plugin/plugin.ts @@ -1,7 +1,7 @@ import type { Effect } from "effect"; -import type { SharedPluginContext } from "./context.ts"; import type { Location } from "../location/location.ts"; import type { SandboxIO } from "../sandbox/io.ts"; +import type { SharedPluginContext } from "./context.ts"; export type Mount = SandboxIO.Provides | Location.Service; export interface Plugin { diff --git a/packages/harness/src/plugin/registry.ts b/packages/harness/src/plugin/registry.ts index f84dea5..3c89465 100644 --- a/packages/harness/src/plugin/registry.ts +++ b/packages/harness/src/plugin/registry.ts @@ -1,8 +1,8 @@ -import { make as makeTools } from "./tool/registry.ts"; +import { make as makeCatalog } from "../tool/registry.ts"; import { make as makePrompt } from "./prompt/registry.ts"; -import { make as makeCatalog } from "../tools/registry.ts"; -import type { ToolRegistry } from "./tool/schema.ts"; import type { PromptRegistry } from "./prompt/schema.ts"; +import { make as makeTools } from "./tool/registry.ts"; +import type { ToolRegistry } from "./tool/schema.ts"; export interface PluginRegistry { readonly tools: ToolRegistry; diff --git a/packages/harness/src/plugin/tool/registry.ts b/packages/harness/src/plugin/tool/registry.ts index 5074240..f7885a1 100644 --- a/packages/harness/src/plugin/tool/registry.ts +++ b/packages/harness/src/plugin/tool/registry.ts @@ -1,5 +1,5 @@ import { Predicate, Schema } from "effect"; -import type { RegisteredTool } from "../../tools/tool.ts"; +import type { RegisteredTool } from "../../tool/tool.ts"; import type { ToolAddOptions, ToolDefPatch, ToolRegistration, ToolRegistry } from "./schema.ts"; const definition = (tool: RegisteredTool): RegisteredTool => ({ diff --git a/packages/harness/src/plugin/tool/schema.ts b/packages/harness/src/plugin/tool/schema.ts index 0fb1ed3..8467eaa 100644 --- a/packages/harness/src/plugin/tool/schema.ts +++ b/packages/harness/src/plugin/tool/schema.ts @@ -2,7 +2,7 @@ import { type Effect, Schema } from "effect"; import * as EventSchema from "../../event/schema.ts"; import { SessionMessageSchema } from "../../session/message/schema.ts"; import { SessionSchema } from "../../session/schema.ts"; -import { type AnyToolDef, type ModelContent, type RegisteredTool, ToolCallContext } from "../../tools/tool.ts"; +import { type AnyToolDef, type ModelContent, type RegisteredTool, ToolCallContext } from "../../tool/tool.ts"; export const ToolBefore = Schema.Struct({ ...ToolCallContext.fields, diff --git a/packages/harness/src/runner/loop.ts b/packages/harness/src/runner/loop.ts index 6165351..d0be2da 100644 --- a/packages/harness/src/runner/loop.ts +++ b/packages/harness/src/runner/loop.ts @@ -16,7 +16,7 @@ import { SessionMessageSchema } from "../session/message/schema.ts"; import type { SessionSchema } from "../session/schema.ts"; import { Session } from "../session/session.ts"; import { State } from "../state/state.ts"; -import { errorMessage } from "../tools/error.ts"; +import { errorMessage } from "../tool/error.ts"; import { LLMEventPublisher } from "./event.ts"; import { LLM } from "./llm.ts"; import { Runner } from "./run.ts"; diff --git a/packages/harness/src/sandbox/shell/shell.ts b/packages/harness/src/sandbox/shell/shell.ts index fd2c24c..9516808 100644 --- a/packages/harness/src/sandbox/shell/shell.ts +++ b/packages/harness/src/sandbox/shell/shell.ts @@ -33,7 +33,7 @@ export interface ShellOptions { /** * A streamed chunk of command output, terminated by a single `exit` carrying the * exit code. (A backend-level mirror of the tool layer's event; kept here so - * `sandbox/` does not depend on `tools/`.) + * `sandbox/` does not depend on `tool/`.) */ export type ExecChunk = | { readonly _tag: "stdout"; readonly bytes: Uint8Array } diff --git a/packages/harness/src/state/state.ts b/packages/harness/src/state/state.ts index 664076a..ecd5a63 100644 --- a/packages/harness/src/state/state.ts +++ b/packages/harness/src/state/state.ts @@ -28,7 +28,7 @@ import { merge } from "../settings/merge.ts"; import { compose, resolveOptions } from "../settings/resolve.ts"; import type { Block } from "../settings/schema.ts"; import { Settings } from "../settings/settings.ts"; -import type { Resolved } from "../tools/registry.ts"; +import type { Resolved } from "../tool/registry.ts"; /** * How a turn's tool calls are scheduled once the array has been re-read. diff --git a/packages/harness/src/tools/accumulator.ts b/packages/harness/src/tool/accumulator.ts similarity index 100% rename from packages/harness/src/tools/accumulator.ts rename to packages/harness/src/tool/accumulator.ts diff --git a/packages/harness/src/tools/error.ts b/packages/harness/src/tool/error.ts similarity index 100% rename from packages/harness/src/tools/error.ts rename to packages/harness/src/tool/error.ts diff --git a/packages/harness/src/tools/executor.ts b/packages/harness/src/tool/executor.ts similarity index 100% rename from packages/harness/src/tools/executor.ts rename to packages/harness/src/tool/executor.ts diff --git a/packages/harness/src/tools/tools.ts b/packages/harness/src/tool/index.ts similarity index 89% rename from packages/harness/src/tools/tools.ts rename to packages/harness/src/tool/index.ts index 497e254..b727364 100644 --- a/packages/harness/src/tools/tools.ts +++ b/packages/harness/src/tool/index.ts @@ -36,4 +36,4 @@ export { type ToolProgressPartial, } from "./progress.ts"; -export { bashDef, bashTool } from "./bash.ts"; +export { Accumulator, type OutputAccumulatorOptions, type OutputSnapshot } from "./accumulator.ts"; diff --git a/packages/harness/src/tools/progress.ts b/packages/harness/src/tool/progress.ts similarity index 96% rename from packages/harness/src/tools/progress.ts rename to packages/harness/src/tool/progress.ts index e3961d9..68afb4e 100644 --- a/packages/harness/src/tools/progress.ts +++ b/packages/harness/src/tool/progress.ts @@ -22,7 +22,7 @@ export interface IToolProgress { } export class ToolProgress extends Context.Service()( - "@codeworksh/harness/tools/progress/ToolProgress", + "@codeworksh/harness/tool/progress/ToolProgress", ) {} /** Build a `ToolProgress` Layer from a `report` implementation. */ diff --git a/packages/harness/src/tools/registry.ts b/packages/harness/src/tool/registry.ts similarity index 98% rename from packages/harness/src/tools/registry.ts rename to packages/harness/src/tool/registry.ts index e007f7c..2dc1b5a 100644 --- a/packages/harness/src/tools/registry.ts +++ b/packages/harness/src/tool/registry.ts @@ -91,7 +91,7 @@ export const make = (tools: ReadonlyArray): R * `R` is discharged at registration), so there is no capability union to fix here. */ export class ToolRegistry extends Context.Service()( - "@codeworksh/harness/tools/registry/ToolRegistry", + "@codeworksh/harness/tool/registry/ToolRegistry", ) {} /** Provide a catalog of registered tools as the {@link ToolRegistry} service. */ diff --git a/packages/harness/src/tools/shell.ts b/packages/harness/src/tool/shell.ts similarity index 99% rename from packages/harness/src/tools/shell.ts rename to packages/harness/src/tool/shell.ts index 013a102..ed34171 100644 --- a/packages/harness/src/tools/shell.ts +++ b/packages/harness/src/tool/shell.ts @@ -81,7 +81,7 @@ export interface IToolShell { readonly stream?: (command: string, options?: ToolShellExecOptions) => Stream.Stream; } -export class ToolShell extends Context.Service()("@codeworksh/harness/tools/shell/ToolShell") {} +export class ToolShell extends Context.Service()("@codeworksh/harness/tool/shell/ToolShell") {} /** * Bridge the existing `sandbox/Shell` into a `ToolShell`. Used for just-bash diff --git a/packages/harness/src/tools/tool.ts b/packages/harness/src/tool/tool.ts similarity index 100% rename from packages/harness/src/tools/tool.ts rename to packages/harness/src/tool/tool.ts diff --git a/packages/harness/src/tools/truncate.ts b/packages/harness/src/tool/truncate.ts similarity index 100% rename from packages/harness/src/tools/truncate.ts rename to packages/harness/src/tool/truncate.ts diff --git a/packages/harness/src/tools/bash.ts b/packages/harness/src/tools/bash.ts deleted file mode 100644 index 3052bbe..0000000 --- a/packages/harness/src/tools/bash.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { Duration, Effect, Option, Ref, Schema, Stream } from "effect"; -import { randomBytes } from "node:crypto"; -import { tmpdir } from "node:os"; -import { fileSystem } from "../host.ts"; -import { posix } from "../util/posix.ts"; -import { Accumulator, type OutputSnapshot } from "./accumulator.ts"; -import { ToolProgress } from "./progress.ts"; -import { type IToolShell, ToolShell } from "./shell.ts"; -import * as Tool from "./tool.ts"; -import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail, type TruncationResult } from "./truncate.ts"; - -/** - * The bash tool — the worked example of the design. The definition is pure data; - * the handler depends only on {@link ToolShell} / {@link ToolProgress}, never - * `sandbox/Shell`, so the backend (local OS / just-bash / remote provider) is - * swapped by changing the provided Layer with no change to the tool. - * - * Two execution paths, picked by capability: - * - **variant A (buffered)** otherwise: `ToolShell.exec` returns the full output, - * truncated once after completion. - * - **variant B (streaming)** when the backend offers `ToolShell.stream`: output - * flows through an {@link Accumulator} (bounded memory + temp-file spill) - * and is reported live via {@link ToolProgress}; on timeout the partial output - * produced so far is preserved. - * - * Success and failure carry the *same* structured shape - * (`output`, `exitCode`, `truncated`, `fullOutputPath`) - * — a non-zero exit is just `exitCode !== 0`, not a different kind of result. - */ - -const BashParams = Schema.Struct({ - command: Schema.String.annotate({ description: "The bash command to execute." }), - timeout: Schema.optional( - Schema.Finite.check(Schema.isGreaterThan(0)).annotate({ - description: "Optional timeout in seconds (must be greater than 0).", - }), - ), -}); - -// Shared structured fields, so a programmatic consumer reads truncation / the -// full-output path the same way whether the command succeeded or failed. -const outputFields = { - /** Combined stdout + stderr, truncated for display (full output is on disk when `truncated`). */ - output: Schema.String, - truncated: Schema.Boolean, - fullOutputPath: Schema.optional(Schema.String), -}; - -const BashSuccess = Schema.Struct({ ...outputFields, exitCode: Schema.Finite }); - -/** Non-zero exit — expected, model-visible. Carries the same shape as success. */ -class BashFailed extends Schema.TaggedError()("BashFailed", { - ...outputFields, - exitCode: Schema.Finite, -}) {} - -/** Deadline exceeded — expected, model-visible. Carries the partial output produced so far. */ -class BashTimedOut extends Schema.TaggedError()("BashTimedOut", { - ...outputFields, - timeoutSeconds: Schema.Finite, -}) {} - -const BashFailure = Schema.Union([BashFailed, BashTimedOut]); -type BashFailureError = BashFailed | BashTimedOut; - -export const bashDef = Tool.define({ - name: "bash", - label: "bash", - promptSnippet: "Execute bash commands (ls, grep, find, etc.).", - description: - "Execute a bash command in the working directory and return its combined stdout/stderr output. " + - `Output is truncated to the last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES}KB (whichever is hit first); when truncated, the full ` + - "output is saved to a temp file. A non-zero exit code is reported as an error carrying the captured output.", - parameters: BashParams, - success: BashSuccess, - failure: BashFailure, - // The model reads just the command output, not the JSON envelope. - encodeContent: (success) => [{ type: "text", text: success.output }], - encodeFailureContent: (failure) => [{ type: "text", text: failure.output }], -}); - -/** The structured, model-facing shape of a presented result (ready to spread). */ -interface Presented { - readonly output: string; - readonly truncated: boolean; - readonly fullOutputPath?: string; -} - -/** Combine stdout and stderr into one stream of text, stderr after stdout. */ -const combineOutput = (stdout: string, stderr: string): string => { - if (stderr.length === 0) return stdout; - if (stdout.length === 0) return stderr; - return `${stdout}\n${stderr}`; -}; - -/** One-line footer appended to truncated, model-facing output. */ -const footer = (t: TruncationResult, fullOutputPath: string | undefined, lastLineBytes?: number): string => { - const where = fullOutputPath ? ` Full output: ${fullOutputPath}` : ""; - const startLine = t.totalLines - t.outputLines + 1; - if (t.lastLinePartial) { - const lineSize = lastLineBytes !== undefined ? ` (line is ${formatSize(lastLineBytes)})` : ""; - return `\n\n[showing last ${formatSize(t.outputBytes)} of line ${t.totalLines}${lineSize}.${where}]`; - } - if (t.truncatedBy === "lines") { - return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines}.${where}]`; - } - return `\n\n[showing lines ${startLine}-${t.totalLines} of ${t.totalLines} (${formatSize(t.maxBytes)} limit).${where}]`; -}; - -/** Write full output to a host temp file (best-effort; undefined on failure). */ -const spillToTempFile = (content: string): Effect.Effect => - Effect.suspend(() => { - const path = posix.join(tmpdir(), `codework-bash-${randomBytes(6).toString("hex")}.log`); - return fileSystem.writeFileString(path, content).pipe(Effect.as(path)); - }).pipe(Effect.orElseSucceed(() => undefined)); - -/** Variant A: truncate the buffered output once, spilling the full output if truncated. */ -const presentBuffered = (combined: string): Effect.Effect => - Effect.gen(function* () { - const t = truncateTail(combined); - if (!t.truncated) return { output: t.content, truncated: false }; - const fullOutputPath = yield* spillToTempFile(combined); - return { - output: t.content + footer(t, fullOutputPath), - truncated: true, - ...(fullOutputPath !== undefined ? { fullOutputPath } : {}), - }; - }); - -/** Variant B: present an accumulator snapshot (the temp file is spilled incrementally). */ -const presentSnapshot = (snap: OutputSnapshot, lastLineBytes: number): Presented => { - if (!snap.truncation.truncated) return { output: snap.content, truncated: false }; - return { - output: snap.content + footer(snap.truncation, snap.fullOutputPath, lastLineBytes), - truncated: true, - ...(snap.fullOutputPath !== undefined ? { fullOutputPath: snap.fullOutputPath } : {}), - }; -}; - -/** Variant A — buffered: one `exec`, truncate after completion. No live progress. */ -const runBuffered = ( - shell: IToolShell, - params: typeof BashParams.Type, -): Effect.Effect => - Effect.gen(function* () { - const result = yield* shell - .exec(params.command, params.timeout !== undefined ? { timeout: Duration.seconds(params.timeout) } : undefined) - .pipe( - // A deadline becomes a model-visible BashTimedOut (no partial output is - // available from a buffered exec); an infra/spawn failure is not - // model-actionable, so it becomes a defect (run error). - Effect.catchTags({ - ToolShellTimeout: (timeout) => - Effect.fail( - new BashTimedOut({ timeoutSeconds: timeout.timeoutMillis / 1000, output: "", truncated: false }), - ), - ToolShellError: (cause) => Effect.die(cause), - }), - ); - - const present = yield* presentBuffered(combineOutput(result.stdout, result.stderr)); - if (result.exitCode !== 0) { - return yield* new BashFailed({ exitCode: result.exitCode, ...present }); - } - return { exitCode: result.exitCode, ...present } satisfies typeof BashSuccess.Type; - }); - -/** Variant B — streaming: accumulate output, report progress, keep partial output on timeout. */ -const runStreaming = ( - stream: NonNullable, - params: typeof BashParams.Type, -): Effect.Effect => - Effect.scoped( - Effect.gen(function* () { - const acc = new Accumulator({ tempFilePrefix: "codework-bash" }); - const progress = yield* ToolProgress; - const exitCode = yield* Ref.make(null); - // executes command and streams output - const consume = stream(params.command).pipe( - Stream.runForEach((event) => - event._tag === "Exit" - ? Ref.set(exitCode, event.exitCode) - : // `gen` so the snapshot is read *after* the append runs (not eagerly - // at pipeline-construction time, which would lag a chunk behind). - Effect.gen(function* () { - yield* acc.append(Buffer.from(event.bytes)); - yield* progress.report({ content: [{ type: "text", text: acc.snapshot().content }] }); - }), - ), - // Infra/spawn failure → defect (run error), like variant A. - Effect.catchTag("ToolShellError", (cause) => Effect.die(cause)), - ); - - // Consume with the deadline; on timeout the accumulator keeps what arrived. - let timedOutAfter: number | undefined; - if (params.timeout !== undefined) { - const finished = yield* consume.pipe(Effect.timeoutOption(Duration.seconds(params.timeout))); - if (Option.isNone(finished)) timedOutAfter = params.timeout; - } else { - yield* consume; - } - - yield* acc.finish(); - const present = presentSnapshot(acc.snapshot(), acc.getLastLineBytes()); - - if (timedOutAfter !== undefined) { - return yield* new BashTimedOut({ timeoutSeconds: timedOutAfter, ...present }); - } - const code = yield* Ref.get(exitCode); - // No Exit event (e.g. killed before reporting one) → treat as a failure. - if (code === null || code !== 0) { - return yield* new BashFailed({ exitCode: code ?? -1, ...present }); - } - return { exitCode: code, ...present } satisfies typeof BashSuccess.Type; - }), - ); - -export const bashHandler: Tool.Handler< - typeof BashParams, - typeof BashSuccess, - typeof BashFailure, - ToolShell | ToolProgress -> = (params) => - Effect.gen(function* () { - const shell = yield* ToolShell; - // Prefer streaming when the backend supports it; fall back to buffered exec. - return shell.stream !== undefined ? yield* runStreaming(shell.stream, params) : yield* runBuffered(shell, params); - }); - -/** The bash tool: definition + handler, wired the testable (def/exec split) way. */ -export const bashTool = Tool.implement(bashDef, bashHandler); diff --git a/packages/harness/test/fixtures/remote.spec.ts b/packages/harness/test/fixtures/remote.spec.ts index 3027ff1..9d2cef3 100644 --- a/packages/harness/test/fixtures/remote.spec.ts +++ b/packages/harness/test/fixtures/remote.spec.ts @@ -15,7 +15,7 @@ import { type ISandboxExe, Shell } from "../../src/sandbox/shell/shell.ts"; import { SandboxStore } from "../../src/sandbox/store.ts"; import { AbsolutePath } from "../../src/schema.ts"; import { Session } from "../../src/session/session.ts"; -import { fromSandboxShell, ToolShell, ToolShellTimeout } from "../../src/tools/shell.ts"; +import { fromSandboxShell, ToolShell, ToolShellTimeout } from "../../src/tool/shell.ts"; import { Hash } from "../../src/util/hash.ts"; type Run = (program: Effect.Effect) => Promise; diff --git a/packages/harness/test/fixtures/tools.registry.vercel.spec.ts b/packages/harness/test/fixtures/tools.registry.vercel.spec.ts index fd91454..eff1c3a 100644 --- a/packages/harness/test/fixtures/tools.registry.vercel.spec.ts +++ b/packages/harness/test/fixtures/tools.registry.vercel.spec.ts @@ -5,11 +5,11 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import { SandboxInstance } from "../../src/sandbox/instance.ts"; import * as EnvVercel from "../../src/sandboxes/vercel/provider.ts"; -import { bashTool } from "../../src/tools/bash.ts"; -import type * as Executor from "../../src/tools/executor.ts"; -import * as Registry from "../../src/tools/registry.ts"; -import { fromSandboxShell, ToolShell } from "../../src/tools/shell.ts"; -import * as Tool from "../../src/tools/tool.ts"; +import { bashTool } from "../../src/plugin/internal/tool/bash.ts"; +import type * as Executor from "../../src/tool/executor.ts"; +import * as Registry from "../../src/tool/registry.ts"; +import { fromSandboxShell, ToolShell } from "../../src/tool/shell.ts"; +import * as Tool from "../../src/tool/tool.ts"; import { pendingCall } from "../tools.fixture.ts"; // Uses the Vercel sandbox owned by sandbox.vercel.e2e.test.ts; never provisions one. diff --git a/packages/harness/test/plugin.hooks.test.ts b/packages/harness/test/plugin.hooks.test.ts index 9447f82..9e9e98f 100644 --- a/packages/harness/test/plugin.hooks.test.ts +++ b/packages/harness/test/plugin.hooks.test.ts @@ -1,10 +1,10 @@ import { Cause, Deferred, Effect, Exit, Fiber, Schema } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { describe, expect } from "vite-plus/test"; -import { make } from "../src/tools/executor.ts"; +import { make } from "../src/tool/executor.ts"; import { make as makeBuckets } from "../src/plugin/registry.ts"; -import { ToolProgress } from "../src/tools/progress.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { ToolProgress } from "../src/tool/progress.ts"; +import * as Tool from "../src/tool/tool.ts"; import type { ToolAddOptions, ToolAfter } from "../src/plugin/tool/schema.ts"; import { SessionSchema } from "../src/session/schema.ts"; import { SessionMessageSchema } from "../src/session/message/schema.ts"; diff --git a/packages/harness/test/plugin.host.test.ts b/packages/harness/test/plugin.host.test.ts index e6c6246..2385c18 100644 --- a/packages/harness/test/plugin.host.test.ts +++ b/packages/harness/test/plugin.host.test.ts @@ -7,7 +7,7 @@ import { Harness } from "../src/effect/harness.ts"; import { Session } from "../src/effect/session.ts"; import type { SharedPluginContext } from "../src/plugin/context.ts"; import { make } from "../src/plugin/registry.ts"; -import * as Tool from "../src/tools/tool.ts"; +import * as Tool from "../src/tool/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; import { assistant, immediateOpen } from "./fixtures/llm.ts"; import { withSettings } from "./fixtures/settings.ts"; diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index caff7d2..d09d56b 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -27,7 +27,7 @@ import { SessionSchema } from "../src/session/schema.ts"; import { Session } from "../src/session/session.ts"; import { SessionRuntime } from "../src/session/runtime.ts"; import { State } from "../src/state/state.ts"; -import * as Tool from "../src/tools/tool.ts"; +import * as Tool from "../src/tool/tool.ts"; import { assistant, immediateOpen } from "./fixtures/llm.ts"; import { testEffect } from "./utils/effect.ts"; diff --git a/packages/harness/test/sandbox.flue.parity.test.ts b/packages/harness/test/sandbox.flue.parity.test.ts index a836375..717bd22 100644 --- a/packages/harness/test/sandbox.flue.parity.test.ts +++ b/packages/harness/test/sandbox.flue.parity.test.ts @@ -7,7 +7,7 @@ import { SandboxFileSystem } from "../src/sandbox/fs/filesystem.ts"; import { SandboxIO } from "../src/sandbox/io.ts"; import { Sandbox } from "../src/sandbox/sandbox.ts"; import { quote, Shell } from "../src/sandbox/shell/shell.ts"; -import { fromSandboxShell, ToolShell, ToolShellTimeout } from "../src/tools/shell.ts"; +import { fromSandboxShell, ToolShell, ToolShellTimeout } from "../src/tool/shell.ts"; import { tmpdir } from "./fixtures/tempdir.ts"; import "./utils/env.ts"; diff --git a/packages/harness/test/settings.loop.test.ts b/packages/harness/test/settings.loop.test.ts index 92a151e..94bdd61 100644 --- a/packages/harness/test/settings.loop.test.ts +++ b/packages/harness/test/settings.loop.test.ts @@ -10,7 +10,7 @@ import { Session } from "../src/effect/session.ts"; import { Event } from "../src/event/event.ts"; import { LLM } from "../src/runner/llm.ts"; import { defaults } from "../src/settings/schema.ts"; -import * as Tool from "../src/tools/tool.ts"; +import * as Tool from "../src/tool/tool.ts"; import { assistant } from "./fixtures/llm.ts"; import { withSettings } from "./fixtures/settings.ts"; diff --git a/packages/harness/test/tools.bash.test.ts b/packages/harness/test/tools.bash.test.ts index ecfcd0c..fc3492f 100644 --- a/packages/harness/test/tools.bash.test.ts +++ b/packages/harness/test/tools.bash.test.ts @@ -2,10 +2,10 @@ import { Effect, Exit, Layer, Stream } from "effect"; import { describe, expect, it } from "vite-plus/test"; import { Sandbox } from "../src/sandbox/sandbox.ts"; import { type ExecChunk, fromExec, Shell as SandboxShell } from "../src/sandbox/shell/shell.ts"; -import { bashTool } from "../src/tools/bash.ts"; -import * as Executor from "../src/tools/executor.ts"; -import { make as makeProgress, noop as progressNoop } from "../src/tools/progress.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { bashTool } from "../src/plugin/internal/tool/bash.ts"; +import * as Executor from "../src/tool/executor.ts"; +import { make as makeProgress, noop as progressNoop } from "../src/tool/progress.ts"; +import * as Tool from "../src/tool/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; import { fromSandboxShell, @@ -13,7 +13,7 @@ import { ToolShell, type ToolShellEvent, ToolShellTimeout, -} from "../src/tools/shell.ts"; +} from "../src/tool/shell.ts"; // ToolShell backed by the in-process just-bash sandbox — the bootstrap backend // (buffered exec, no `stream` → the tool takes variant A). diff --git a/packages/harness/test/tools.local.shell.test.ts b/packages/harness/test/tools.local.shell.test.ts index 2a35170..82cf8fd 100644 --- a/packages/harness/test/tools.local.shell.test.ts +++ b/packages/harness/test/tools.local.shell.test.ts @@ -4,11 +4,11 @@ import path from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { Sandbox } from "../src/sandbox/sandbox.ts"; import { type ExecChunk, Shell } from "../src/sandbox/shell/shell.ts"; -import { bashTool } from "../src/tools/bash.ts"; -import * as Executor from "../src/tools/executor.ts"; -import { noop as progressNoop } from "../src/tools/progress.ts"; -import { fromSandboxShell, local, ToolShell, ToolShellTimeout } from "../src/tools/shell.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { bashTool } from "../src/plugin/internal/tool/bash.ts"; +import * as Executor from "../src/tool/executor.ts"; +import { noop as progressNoop } from "../src/tool/progress.ts"; +import { fromSandboxShell, local, ToolShell, ToolShellTimeout } from "../src/tool/shell.ts"; +import * as Tool from "../src/tool/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; import { tmpdir } from "./fixtures/tempdir.ts"; diff --git a/packages/harness/test/tools.registry.remote.test.ts b/packages/harness/test/tools.registry.remote.test.ts index 5ea0964..c1af6ff 100644 --- a/packages/harness/test/tools.registry.remote.test.ts +++ b/packages/harness/test/tools.registry.remote.test.ts @@ -6,11 +6,11 @@ import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import * as EnvDaytona from "../src/sandboxes/daytona/provider.ts"; import { SandboxInstance } from "../src/sandbox/instance.ts"; -import { bashTool } from "../src/tools/bash.ts"; -import type * as Executor from "../src/tools/executor.ts"; -import * as Registry from "../src/tools/registry.ts"; -import { fromSandboxShell, ToolShell } from "../src/tools/shell.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { bashTool } from "../src/plugin/internal/tool/bash.ts"; +import type * as Executor from "../src/tool/executor.ts"; +import * as Registry from "../src/tool/registry.ts"; +import { fromSandboxShell, ToolShell } from "../src/tool/shell.ts"; +import * as Tool from "../src/tool/tool.ts"; import { labels, makeRemoteOwner } from "./fixtures/remote-owner.ts"; import { pendingCall } from "./tools.fixture.ts"; import "./utils/env.ts"; diff --git a/packages/harness/test/tools.registry.test.ts b/packages/harness/test/tools.registry.test.ts index a372a5a..bd8d143 100644 --- a/packages/harness/test/tools.registry.test.ts +++ b/packages/harness/test/tools.registry.test.ts @@ -5,12 +5,12 @@ import { join } from "node:path"; import { describe, expect, it } from "vite-plus/test"; import { Sandbox } from "../src/sandbox/sandbox.ts"; import { type ExecChunk, fromExec, type ISandboxExe, Shell as SandboxShell } from "../src/sandbox/shell/shell.ts"; -import { bashTool } from "../src/tools/bash.ts"; -import * as Executor from "../src/tools/executor.ts"; -import { ToolProgress } from "../src/tools/progress.ts"; -import * as Registry from "../src/tools/registry.ts"; -import { fromSandboxShell, local, ToolShell, type ToolShellEvent } from "../src/tools/shell.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { bashTool } from "../src/plugin/internal/tool/bash.ts"; +import * as Executor from "../src/tool/executor.ts"; +import { ToolProgress } from "../src/tool/progress.ts"; +import * as Registry from "../src/tool/registry.ts"; +import { fromSandboxShell, local, ToolShell, type ToolShellEvent } from "../src/tool/shell.ts"; +import * as Tool from "../src/tool/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; // A tiny fake tool with no capabilities. Returns a fixed string diff --git a/packages/harness/test/tools.tool.test.ts b/packages/harness/test/tools.tool.test.ts index b952c1b..d9d74b2 100644 --- a/packages/harness/test/tools.tool.test.ts +++ b/packages/harness/test/tools.tool.test.ts @@ -1,8 +1,8 @@ import { Effect, Schema } from "effect"; import { describe, expect, it } from "vite-plus/test"; -import { bashDef } from "../src/tools/bash.ts"; -import * as Executor from "../src/tools/executor.ts"; -import * as Tool from "../src/tools/tool.ts"; +import { bashDef } from "../src/plugin/internal/tool/bash.ts"; +import * as Executor from "../src/tool/executor.ts"; +import * as Tool from "../src/tool/tool.ts"; import { pendingCall } from "./tools.fixture.ts"; class ExpectedFailure extends Schema.TaggedError()("ExpectedFailure", { From 7fcd11496840e5411e8679c3cd601a0f6f21424f Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 12 Sep 2026 11:16:47 +0530 Subject: [PATCH 08/12] test(harness): group plugin fixtures by kind and fix the installed-package smoke Plugin fixtures move to typed tool/, prompt/, event/, and host/ modules; malformed ones stay invalid so runtime validation is still covered. Adds shared remote Bash coverage through Harness plus tool output retention tests, and moves errorMessage to src/util so the loop and executor share it. The package smoke now declines native build scripts explicitly and pins @effect/platform-node-shared, whose caret range drifts across prerelease tags. Co-Authored-By: Claude Opus 5 --- .../tsconfig.build.json | 6 +- packages/harness/package.json | 2 +- packages/harness/src/runner/loop.ts | 2 +- packages/harness/src/tool/error.ts | 17 +-- packages/harness/src/tool/executor.ts | 3 +- packages/harness/src/util/error.ts | 16 ++ packages/harness/test/fixtures/bash.spec.ts | 140 ++++++++++++++++++ packages/harness/test/fixtures/live.ts | 14 ++ packages/harness/test/fixtures/llm.ts | 15 ++ packages/harness/test/fixtures/progress.ts | 33 +++++ .../harness/test/fixtures/remote-owner.ts | 4 +- .../fixtures/tools.registry.vercel.spec.ts | 108 ++++++-------- packages/harness/test/plugin.external.test.ts | 48 ++---- packages/harness/test/plugin.host.test.ts | 2 +- .../harness/test/plugins/acme-bad-tool.mjs | 7 - .../test/plugins/acme-bash-override.mjs | 19 --- packages/harness/test/plugins/acme-hangs.mjs | 5 - .../harness/test/plugins/acme-journal.mjs | 23 --- .../test/plugins/event/acme-journal.ts | 24 +++ .../{acme-broken.mjs => host/acme-broken.ts} | 0 .../harness/test/plugins/host/acme-hangs.ts | 6 + .../{acme-throws.mjs => host/acme-throws.ts} | 5 +- .../acme-prompt.ts} | 5 +- .../test/plugins/tool/acme-bad-tool.ts | 9 ++ .../test/plugins/tool/acme-bash-override.ts | 23 +++ .../index.mjs => tool/acme-echo/index.ts} | 19 +-- .../plugins/{ => tool}/acme-echo/package.json | 2 +- .../acme-guarded.ts} | 20 ++- .../acme-relabel.ts} | 5 +- packages/harness/test/remote.owner.test.ts | 4 +- .../harness/test/sandbox.daytona.e2e.test.ts | 11 +- .../test/sandbox.remote.driver.e2e.test.ts | 15 +- .../harness/test/sandbox.vercel.e2e.test.ts | 26 ++-- .../scripts/codework-sandbox-vercel-smoke.mjs | 45 +++++- packages/harness/test/tool.output.test.ts | 65 ++++++++ ...t.ts => tools.registry.remote.e2e.test.ts} | 48 ++---- 36 files changed, 527 insertions(+), 269 deletions(-) create mode 100644 packages/harness/src/util/error.ts create mode 100644 packages/harness/test/fixtures/bash.spec.ts create mode 100644 packages/harness/test/fixtures/live.ts create mode 100644 packages/harness/test/fixtures/progress.ts delete mode 100644 packages/harness/test/plugins/acme-bad-tool.mjs delete mode 100644 packages/harness/test/plugins/acme-bash-override.mjs delete mode 100644 packages/harness/test/plugins/acme-hangs.mjs delete mode 100644 packages/harness/test/plugins/acme-journal.mjs create mode 100644 packages/harness/test/plugins/event/acme-journal.ts rename packages/harness/test/plugins/{acme-broken.mjs => host/acme-broken.ts} (100%) create mode 100644 packages/harness/test/plugins/host/acme-hangs.ts rename packages/harness/test/plugins/{acme-throws.mjs => host/acme-throws.ts} (66%) rename packages/harness/test/plugins/{acme-prompt.mjs => prompt/acme-prompt.ts} (70%) create mode 100644 packages/harness/test/plugins/tool/acme-bad-tool.ts create mode 100644 packages/harness/test/plugins/tool/acme-bash-override.ts rename packages/harness/test/plugins/{acme-echo/index.mjs => tool/acme-echo/index.ts} (64%) rename packages/harness/test/plugins/{ => tool}/acme-echo/package.json (83%) rename packages/harness/test/plugins/{acme-guarded.mjs => tool/acme-guarded.ts} (53%) rename packages/harness/test/plugins/{acme-relabel.mjs => tool/acme-relabel.ts} (71%) create mode 100644 packages/harness/test/tool.output.test.ts rename packages/harness/test/{tools.registry.remote.test.ts => tools.registry.remote.e2e.test.ts} (70%) diff --git a/extras/codework-sandbox-vercel/tsconfig.build.json b/extras/codework-sandbox-vercel/tsconfig.build.json index e851288..4e3f3f2 100644 --- a/extras/codework-sandbox-vercel/tsconfig.build.json +++ b/extras/codework-sandbox-vercel/tsconfig.build.json @@ -7,6 +7,10 @@ "noEmit": false, "outDir": "dist", "rootDir": "src", - "rewriteRelativeImportExtensions": true + "rewriteRelativeImportExtensions": true, + "paths": { + "@codeworksh/harness/sandbox": ["../../packages/harness/dist/pack/sandbox.d.mts"] + }, + "noEmitOnError": true } } diff --git a/packages/harness/package.json b/packages/harness/package.json index 5784f4e..f1ac0a4 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -74,7 +74,7 @@ "test": "vp test", "test:sandbox:package": "node test/scripts/codework-sandbox-vercel-smoke.mjs", "test:sandbox:package:e2e": "node test/scripts/codework-sandbox-vercel-smoke.mjs --e2e", - "test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts", + "test:sandbox:e2e": "CODEWORK_SANDBOX_E2E_REQUIRED=1 vp test --no-file-parallelism test/sandbox.vercel.e2e.test.ts test/sandbox.daytona.e2e.test.ts test/sandbox.remote.driver.e2e.test.ts test/tools.registry.remote.e2e.test.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/harness/src/runner/loop.ts b/packages/harness/src/runner/loop.ts index d0be2da..d09d8e6 100644 --- a/packages/harness/src/runner/loop.ts +++ b/packages/harness/src/runner/loop.ts @@ -16,7 +16,7 @@ import { SessionMessageSchema } from "../session/message/schema.ts"; import type { SessionSchema } from "../session/schema.ts"; import { Session } from "../session/session.ts"; import { State } from "../state/state.ts"; -import { errorMessage } from "../tool/error.ts"; +import { errorMessage } from "../util/error.ts"; import { LLMEventPublisher } from "./event.ts"; import { LLM } from "./llm.ts"; import { Runner } from "./run.ts"; diff --git a/packages/harness/src/tool/error.ts b/packages/harness/src/tool/error.ts index 0301c73..8651b6c 100644 --- a/packages/harness/src/tool/error.ts +++ b/packages/harness/src/tool/error.ts @@ -1,22 +1,7 @@ -import { Cause, Schema } from "effect"; +import { Schema } from "effect"; /** A typed wrapper for a tool failure crossing the heterogeneous registry boundary. */ export class ToolExecutionError extends Schema.TaggedError()("ToolExecutionError", { toolName: Schema.String, cause: Schema.Defect(), }) {} - -/** - * One line of model-facing text for a cause. - * - * Shared by the executor's outcome stage and the loop's outer catch so the same defect - * reads the same either way. Never `Cause.pretty`: that joins stack traces, which would - * put host paths and hundreds of tokens into the next request. - */ -export const errorMessage = (cause: Cause.Cause): string => { - const squashed = Cause.squash(cause); - if (squashed instanceof Error && squashed.message.trim().length > 0) return squashed.message; - if (typeof squashed === "string" && squashed.trim().length > 0) return squashed; - if (typeof squashed === "object" && squashed !== null && "_tag" in squashed) return String(squashed._tag); - return "an unknown error"; -}; diff --git a/packages/harness/src/tool/executor.ts b/packages/harness/src/tool/executor.ts index 985e7dd..7d735fc 100644 --- a/packages/harness/src/tool/executor.ts +++ b/packages/harness/src/tool/executor.ts @@ -22,7 +22,8 @@ import { Scope, } from "effect"; import { isAikitToolCallTerminalPart } from "../schema.ts"; -import { errorMessage, ToolExecutionError } from "./error.ts"; +import { errorMessage } from "../util/error.ts"; +import { ToolExecutionError } from "./error.ts"; import { ToolProgress, type ToolProgressPartial } from "./progress.ts"; import { type AnyToolDef, type ModelContent, type RegisteredTool, toAikitTool, type ToolCallContext } from "./tool.ts"; diff --git a/packages/harness/src/util/error.ts b/packages/harness/src/util/error.ts new file mode 100644 index 0000000..8625813 --- /dev/null +++ b/packages/harness/src/util/error.ts @@ -0,0 +1,16 @@ +import { Cause } from "effect"; + +/** + * One line of model-facing text for a cause. + * + * Shared by the executor's outcome stage and the loop's outer catch so the same defect + * reads the same either way. Never `Cause.pretty`: that joins stack traces, which would + * put host paths and hundreds of tokens into the next request. + */ +export const errorMessage = (cause: Cause.Cause): string => { + const squashed = Cause.squash(cause); + if (squashed instanceof Error && squashed.message.trim().length > 0) return squashed.message; + if (typeof squashed === "string" && squashed.trim().length > 0) return squashed; + if (typeof squashed === "object" && squashed !== null && "_tag" in squashed) return String(squashed._tag); + return "an unknown error"; +}; diff --git a/packages/harness/test/fixtures/bash.spec.ts b/packages/harness/test/fixtures/bash.spec.ts new file mode 100644 index 0000000..7ef8dea --- /dev/null +++ b/packages/harness/test/fixtures/bash.spec.ts @@ -0,0 +1,140 @@ +import { Effect } from "effect"; +import { randomUUID } from "node:crypto"; +import { readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vite-plus/test"; +import { ContextCodec } from "../../src/context/codec.ts"; +import { Harness } from "../../src/effect/harness.ts"; +import { Sandbox } from "../../src/effect/sandbox.ts"; +import { Session } from "../../src/effect/session.ts"; +import type { SandboxDriver } from "../../src/sandbox/driver.ts"; +import { pendingCall } from "../tools.fixture.ts"; +import { toolTurn } from "./llm.ts"; +import { remoteSuite } from "./live.ts"; +import { withSettings } from "./settings.ts"; + +/** Every call goes through plugin setup, the runner, the mounted driver and durable settlement. */ +export const bashPluginSpec = (options: { + readonly driver: SandboxDriver.Registration; + readonly resourceId: () => Promise; + readonly streaming: boolean; +}) => { + const exchange = (input: { root: string; custom: string }, command: string, live = false, timeout?: number) => + Effect.gen(function* () { + const sandbox = yield* Sandbox.register({ + driver: options.driver.registered.name, + providerResourceId: yield* Effect.promise(options.resourceId), + }); + const session = yield* Session.create({ + sandbox, + directory: "/tmp", + model: { provider: "openai", id: "gpt-5.6-luna" }, + }); + yield* session.run(`Use bash exactly once to execute this command: ${command}\nReport its output verbatim.`); + const path = yield* session.path(); + const messages = yield* Effect.forEach(path, ContextCodec.decodeMessage); + expect(path.every((entry) => entry.entry.state === "committed")).toBe(true); + const calls = messages.flatMap((message) => message.parts.filter((part) => part.type === "toolCall")); + expect(calls).toHaveLength(1); + const call = calls[0]; + if (call === undefined || call.type !== "toolCall" || (call.status !== "completed" && call.status !== "error")) + throw new Error("bash was not settled"); + expect(call.name).toBe("bash"); + return { call, messages }; + }).pipe( + Effect.provide( + Harness.layer({ + home: join(input.root, "home"), + userConfigDir: input.custom, + database: ":memory:", + sandboxes: [options.driver], + plugins: ["codework.tool.bash", "codework.prompt.default"], + ...(live + ? {} + : { llm: toolTurn(pendingCall("bash", { command, ...(timeout === undefined ? {} : { timeout }) })) }), + }), + ), + Effect.timeout("120 seconds"), + Effect.scoped, + Effect.runPromise, + ); + + describe("bash plugin through the complete harness", () => { + it( + "uses the session's remote cwd and persists the Bash failure shape", + () => + withSettings(async (input) => { + const { call } = await exchange(input, "pwd; printf 'failure-output\\n'; exit 7"); + expect(call.status).toBe("error"); + expect(call.result).toMatchObject({ + isError: true, + details: { _tag: "BashFailed", exitCode: 7, output: "/tmp\nfailure-output\n", truncated: false }, + }); + }), + 180_000, + ); + + it( + "truncates real output and preserves the entire spill file", + () => + withSettings(async (input) => { + const { call } = await exchange(input, "seq 1 2500"); + expect(call.status).toBe("completed"); + const details = call.result.details as { truncated: boolean; fullOutputPath?: string; output: string }; + expect(details.truncated).toBe(true); + if (details.fullOutputPath === undefined) throw new Error("missing full output path"); + try { + expect(await readFile(details.fullOutputPath, "utf8")).toBe( + Array.from({ length: 2500 }, (_, index) => `${index + 1}\n`).join(""), + ); + expect(details.output).toContain("2500\n"); + expect(details.output).not.toMatch(/^1\n/); + } finally { + await rm(details.fullOutputPath, { force: true }); + } + }), + 180_000, + ); + + it( + "settles a real deadline with the backend's partial-output contract", + () => + withSettings(async (input) => { + const { call } = await exchange(input, "printf 'partial-output\\n'; sleep 20", false, 5); + expect(call.status).toBe("error"); + expect(call.result).toMatchObject({ + isError: true, + details: { + _tag: "BashTimedOut", + timeoutSeconds: 5, + truncated: false, + output: options.streaming ? "partial-output\n" : "", + }, + }); + }), + 180_000, + ); + + remoteSuite("OPENAI_API_KEY", Boolean(process.env.OPENAI_API_KEY?.trim()))( + "live model and real Bash plugin", + () => { + it( + "executes a tool call and continues with its persisted result", + () => + withSettings(async (input) => { + const marker = `bash-plugin-${randomUUID()}`; + const { call, messages } = await exchange(input, `printf '${marker}'`, true); + expect(call.status).toBe("completed"); + expect(call.result).toMatchObject({ isError: false, details: { output: marker, exitCode: 0 } }); + const final = messages.at(-1); + expect(final?.role).toBe("assistant"); + expect( + final?.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join(""), + ).toContain(marker); + }), + 180_000, + ); + }, + ); + }); +}; diff --git a/packages/harness/test/fixtures/live.ts b/packages/harness/test/fixtures/live.ts new file mode 100644 index 0000000..62475ef --- /dev/null +++ b/packages/harness/test/fixtures/live.ts @@ -0,0 +1,14 @@ +import { beforeAll, describe } from "vite-plus/test"; +import "../utils/env.ts"; + +/** Required runs report missing credentials per suite, without blocking other providers. */ +export const remoteSuite = (credential: string, available: boolean) => (name: string, tests: () => void) => { + const required = process.env.CODEWORK_SANDBOX_E2E_REQUIRED === "1"; + const suite = available || required ? describe : describe.skip; + suite(name, () => { + beforeAll(() => { + if (!available) throw new Error(`${credential} is missing or invalid for ${name}`); + }); + tests(); + }); +}; diff --git a/packages/harness/test/fixtures/llm.ts b/packages/harness/test/fixtures/llm.ts index a738c92..b401576 100644 --- a/packages/harness/test/fixtures/llm.ts +++ b/packages/harness/test/fixtures/llm.ts @@ -43,3 +43,18 @@ export const immediateOpen = (contexts: Message.Context[] = []): LLM.Open => { return events; }); }; + +/** First request asks for the calls, every request after stops. */ +export const toolTurn = (...calls: ReadonlyArray): LLM.Open => { + let index = 0; + return (input) => + Effect.sync(() => { + index += 1; + const first = index === 1; + const message = assistant(input, index, first ? { stopReason: "toolUse", parts: [...calls] } : {}); + const stream = createAssistantMessageEventStream(); + stream.push({ type: "start", partial: message }); + stream.push({ type: "done", reason: first ? "toolUse" : "stop", message }); + return stream; + }); +}; diff --git a/packages/harness/test/fixtures/progress.ts b/packages/harness/test/fixtures/progress.ts new file mode 100644 index 0000000..ace3d30 --- /dev/null +++ b/packages/harness/test/fixtures/progress.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { appendFile, readFile } from "node:fs/promises"; +import type * as Executor from "../../src/tool/executor.ts"; + +interface SinkEntry { + readonly callID: string; + readonly text: string; +} + +export const fileSink = + (path: string) => + (event: Executor.ProgressEvent): Effect.Effect => + Effect.promise(() => { + const first = event.partial.content?.[0]; + const entry: SinkEntry = { callID: event.ctx.callID, text: first?.type === "text" ? first.text : "" }; + return appendFile(path, `${JSON.stringify(entry)}\n`); + }); + +export const readSink = async (path: string): Promise => + ( + await readFile(path, "utf8").catch((cause: unknown) => { + if (cause instanceof Error && "code" in cause && cause.code === "ENOENT") return ""; + throw cause; + }) + ) + .split("\n") + .filter(Boolean) + .map((raw) => JSON.parse(raw) as SinkEntry); + +export const outputTextOf = (outcome: Executor.ToolOutcome): string => { + const first = outcome.result.content[0]; + return first && first.type === "text" ? first.text : ""; +}; diff --git a/packages/harness/test/fixtures/remote-owner.ts b/packages/harness/test/fixtures/remote-owner.ts index d1f1718..2a9766c 100644 --- a/packages/harness/test/fixtures/remote-owner.ts +++ b/packages/harness/test/fixtures/remote-owner.ts @@ -30,9 +30,7 @@ export const makeRemoteOwner = (label: string) => { const cleanup = async ({ destroy, dispose }: Cleanup): Promise => { const failures: unknown[] = []; - if (locator === undefined) { - failures.push(new Error(`${label} resource locator was never captured`)); - } else { + if (locator !== undefined) { try { await destroy(locator); } catch (cause) { diff --git a/packages/harness/test/fixtures/tools.registry.vercel.spec.ts b/packages/harness/test/fixtures/tools.registry.vercel.spec.ts index eff1c3a..42422d2 100644 --- a/packages/harness/test/fixtures/tools.registry.vercel.spec.ts +++ b/packages/harness/test/fixtures/tools.registry.vercel.spec.ts @@ -1,12 +1,11 @@ +import { fileSink, readSink, outputTextOf } from "./progress.ts"; +import { tmpdir } from "./tempdir.ts"; import { Effect, Layer, ManagedRuntime } from "effect"; -import { appendFile, mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; import { SandboxInstance } from "../../src/sandbox/instance.ts"; import * as EnvVercel from "../../src/sandboxes/vercel/provider.ts"; import { bashTool } from "../../src/plugin/internal/tool/bash.ts"; -import type * as Executor from "../../src/tool/executor.ts"; import * as Registry from "../../src/tool/registry.ts"; import { fromSandboxShell, ToolShell } from "../../src/tool/shell.ts"; import * as Tool from "../../src/tool/tool.ts"; @@ -18,34 +17,6 @@ const LINES = 120; const line = (index: number) => `progress-line-${index}/${LINES}`; const STREAMING_COMMAND = `for i in $(seq 1 ${LINES}); do echo "progress-line-$i/${LINES}"; sleep 0.02; done`; -interface SinkEntry { - readonly callID: string; - readonly text: string; -} - -const tempSinkFile = async (): Promise => - join(await mkdtemp(join(tmpdir(), "codework-registry-vercel-")), "progress.ndjson"); - -const fileSink = - (path: string) => - (event: Executor.ProgressEvent): Effect.Effect => - Effect.promise(() => { - const first = event.partial.content?.[0]; - const entry: SinkEntry = { callID: event.ctx.callID, text: first?.type === "text" ? first.text : "" }; - return appendFile(path, `${JSON.stringify(entry)}\n`); - }); - -const readSink = async (path: string): Promise => - (await readFile(path, "utf8").catch(() => "")) - .split("\n") - .filter(Boolean) - .map((raw) => JSON.parse(raw) as SinkEntry); - -const outputTextOf = (outcome: Executor.ToolOutcome): string => { - const first = outcome.result.content[0]; - return first && first.type === "text" ? first.text : ""; -}; - const makeRuntime = (sandboxName: string, instanceId: SandboxInstance.ID) => ManagedRuntime.make(Layer.provideMerge(fromSandboxShell, EnvVercel.services({ sandboxName, instanceId }))); @@ -57,7 +28,7 @@ export const toolsRegistryVercelSpec = (resourceId: () => Promise) => beforeAll(async () => { runtime = makeRuntime(await resourceId(), SandboxInstance.ID.create()); }, PROVISION_TIMEOUT); - afterAll(() => runtime.dispose(), PROVISION_TIMEOUT); + afterAll(() => runtime?.dispose() ?? Promise.resolve(), PROVISION_TIMEOUT); it( "streams 100+ lines of live progress into the temp-file sink", @@ -65,46 +36,57 @@ export const toolsRegistryVercelSpec = (resourceId: () => Promise) => const shell = await runtime.runPromise(Effect.flatMap(ToolShell, Effect.succeed)); expect(shell.stream).toBeDefined(); const resolved = Registry.make([Tool.provide(bashTool, Layer.succeed(ToolShell, shell))]).resolve(); - const path = await tempSinkFile(); + await using temp = await tmpdir(); + const path = join(temp.path, "progress.ndjson"); + const abort = new AbortController(); let settled = false; const pending = Effect.runPromise( - resolved.handle(pendingCall("bash", { command: STREAMING_COMMAND }, "vercel-bash"), { - onProgress: fileSink(path), - progressBuffer: LINES * 2, - }), + resolved + .handle(pendingCall("bash", { command: STREAMING_COMMAND }, "vercel-bash"), { + onProgress: fileSink(path), + progressBuffer: LINES * 2, + }) + .pipe(Effect.timeout("30 seconds")), + { signal: abort.signal }, ).finally(() => { settled = true; }); - let sawEntryBeforeSettlement = false; - while (!settled) { - if (!settled && (await readSink(path)).length > 0) { - sawEntryBeforeSettlement = true; - break; + // Observe rejection immediately; the awaited result below still fails the test. + void pending.catch(() => undefined); + try { + let sawEntryBeforeSettlement = false; + while (!settled) { + const entries = await readSink(path); + if (!settled && entries.length > 0) { + sawEntryBeforeSettlement = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 50)); } - await new Promise((resolve) => setTimeout(resolve, 50)); - } - expect(sawEntryBeforeSettlement).toBe(true); + const outcome = await pending; + expect(sawEntryBeforeSettlement).toBe(true); + expect(outcome.status).toBe("completed"); + const finalText = outputTextOf(outcome); + const finalLines = finalText.split("\n").filter(Boolean); + expect(finalLines).toHaveLength(LINES); + expect(finalLines).toEqual(Array.from({ length: LINES }, (_, index) => line(index + 1))); - const outcome = await pending; - expect(outcome.status).toBe("completed"); - const finalText = outputTextOf(outcome); - const finalLines = finalText.split("\n").filter(Boolean); - expect(finalLines).toHaveLength(LINES); - expect(finalLines[0]).toBe(line(1)); - expect(finalLines.at(-1)).toBe(line(LINES)); - - const written = await readSink(path); - expect(written.length).toBeGreaterThanOrEqual(1); - expect(written.every((entry) => entry.callID === "vercel-bash")).toBe(true); - for (const entry of written) expect(finalText.startsWith(entry.text)).toBe(true); - for (let index = 1; index < written.length; index += 1) { - const previous = written[index - 1]; - const current = written[index]; - if (previous === undefined || current === undefined) throw new Error("unreachable: checked length"); - expect(current.text.length).toBeGreaterThan(previous.text.length); - expect(current.text.startsWith(previous.text)).toBe(true); + const written = await readSink(path); + expect(written.length).toBeGreaterThanOrEqual(1); + expect(written.every((entry) => entry.callID === "vercel-bash")).toBe(true); + for (const entry of written) expect(finalText.startsWith(entry.text)).toBe(true); + for (let index = 1; index < written.length; index += 1) { + const previous = written[index - 1]; + const current = written[index]; + if (previous === undefined || current === undefined) throw new Error("unreachable: checked length"); + expect(current.text.length).toBeGreaterThan(previous.text.length); + expect(current.text.startsWith(previous.text)).toBe(true); + } + } finally { + abort.abort(); + await pending.catch(() => undefined); } }, PROVISION_TIMEOUT, diff --git a/packages/harness/test/plugin.external.test.ts b/packages/harness/test/plugin.external.test.ts index 722d7df..e895902 100644 --- a/packages/harness/test/plugin.external.test.ts +++ b/packages/harness/test/plugin.external.test.ts @@ -1,5 +1,5 @@ import "./utils/env.ts"; -import { createAssistantMessageEventStream, type Message } from "@codeworksh/aikit"; +import type { Message } from "@codeworksh/aikit"; import { Cause, Effect, Exit, Fiber, Schema, Stream } from "effect"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,33 +11,17 @@ import { EventSchema } from "../src/event/schema.ts"; import { prepare } from "../src/plugin/catalog.ts"; import type { LLM } from "../src/runner/llm.ts"; import { SessionSchema } from "../src/session/schema.ts"; -import { assistant, immediateOpen } from "./fixtures/llm.ts"; +import { immediateOpen, toolTurn } from "./fixtures/llm.ts"; import { withSettings } from "./fixtures/settings.ts"; import { pendingCall } from "./tools.fixture.ts"; /** - * Third-party plugins as they exist in production: plain `.mjs` modules loaded by - * path or directory, written against the structural contract rather than the SDK - * types. Every scenario below goes through the real import, never the seams. + * Third-party plugins as they exist in production: TypeScript modules loaded by + * path or directory, using the plugin and tool contracts. Every scenario below goes through the real import, never the seams. */ const dir = fileURLToPath(new URL("./plugins", import.meta.url)); const pluginPath = (name: string) => join(dir, name); -/** First request asks for the calls, every request after stops. */ -const toolTurn = (...calls: ReadonlyArray): LLM.Open => { - let index = 0; - return (input) => - Effect.sync(() => { - index += 1; - const first = index === 1; - const message = assistant(input, index, first ? { stopReason: "toolUse", parts: [...calls] } : {}); - const stream = createAssistantMessageEventStream(); - stream.push({ type: "start", partial: message }); - stream.push({ type: "done", reason: first ? "toolUse" : "stop", message }); - return stream; - }); -}; - /** One `Session.create` + one `run`, capturing every provider request. */ const exchange = (input: { readonly root: string; @@ -75,7 +59,7 @@ const exchange = (input: { describe("third-party plugins", () => { it("loads a directory package and a single file through real imports", async () => { const plugins = await Effect.runPromise( - prepare([pluginPath("acme-echo"), `file://${pluginPath("acme-prompt.mjs")}`], { + prepare([pluginPath("tool/acme-echo"), `file://${pluginPath("prompt/acme-prompt.ts")}`], { builtins: [], cache: "/unused", hostCwd: "/project", @@ -86,7 +70,7 @@ describe("third-party plugins", () => { it("rejects a malformed module and a reserved namespace through real imports", async () => { const failure = await Effect.runPromise( - prepare([pluginPath("acme-broken.mjs")], { + prepare([pluginPath("host/acme-broken.ts")], { builtins: [], cache: "/unused", hostCwd: "/project", @@ -99,7 +83,7 @@ describe("third-party plugins", () => { withSettings(async ({ root }) => { const { contexts, prompts, path } = await exchange({ root, - plugins: [pluginPath("acme-echo"), "codework.prompt.default"], + plugins: [pluginPath("tool/acme-echo"), "codework.prompt.default"], llm: toolTurn(pendingCall("acme_echo", { value: "hello" }, "call_echo")), }); expect(contexts[0]?.tools?.map((tool) => tool.name)).toEqual(["acme_echo"]); @@ -126,7 +110,7 @@ describe("third-party plugins", () => { withSettings(async ({ root }) => { const { path } = await exchange({ root, - plugins: [pluginPath("acme-guarded.mjs"), "codework.prompt.default"], + plugins: [pluginPath("tool/acme-guarded.ts"), "codework.prompt.default"], llm: toolTurn( pendingCall("acme_secret", { value: "deny" }, "call_blocked"), pendingCall("acme_secret", { value: "allow" }, "call_allowed"), @@ -149,7 +133,7 @@ describe("third-party plugins", () => { withSettings(async ({ root }) => { const { prompts, path } = await exchange({ root, - plugins: ["codework.tool.bash", pluginPath("acme-bash-override.mjs"), "codework.prompt.default"], + plugins: ["codework.tool.bash", pluginPath("tool/acme-bash-override.ts"), "codework.prompt.default"], llm: toolTurn(pendingCall("bash", { command: "echo hi" }, "call_bash")), }); expect(prompts[0]).toContain("- bash: Run a command through the acme shell"); @@ -164,7 +148,7 @@ describe("third-party plugins", () => { withSettings(async ({ root }) => { const { prompts } = await exchange({ root, - plugins: [pluginPath("acme-echo"), "codework.prompt.default", pluginPath("acme-prompt.mjs")], + plugins: [pluginPath("tool/acme-echo"), "codework.prompt.default", pluginPath("prompt/acme-prompt.ts")], }); expect(prompts[0]).toContain("You are an expert coding assistant"); expect(prompts[0]).toContain("- acme_echo: Echo a value back"); @@ -200,7 +184,7 @@ describe("third-party plugins", () => { home: join(root, "home"), database: ":memory:", llm: immediateOpen(), - plugins: [pluginPath("acme-journal.mjs"), "codework.prompt.default"], + plugins: [pluginPath("event/acme-journal.ts"), "codework.prompt.default"], }), ), Effect.scoped, @@ -232,7 +216,7 @@ describe("third-party plugins", () => { contexts.push(request.context); return immediateOpen()(request, signal); }, - plugins: [pluginPath("acme-throws.mjs"), "codework.prompt.default"], + plugins: [pluginPath("host/acme-throws.ts"), "codework.prompt.default"], }), ), Effect.scoped, @@ -258,7 +242,7 @@ describe("third-party plugins", () => { home: join(root, "home"), database: ":memory:", llm: immediateOpen(), - plugins: [pluginPath("acme-bad-tool.mjs"), "codework.prompt.default"], + plugins: [pluginPath("tool/acme-bad-tool.ts"), "codework.prompt.default"], }), ), Effect.scoped, @@ -281,7 +265,7 @@ describe("third-party plugins", () => { home: join(root, "home"), database: ":memory:", llm: immediateOpen(), - plugins: [pluginPath("acme-broken.mjs")], + plugins: [pluginPath("host/acme-broken.ts")], }), ), Effect.scoped, @@ -298,7 +282,7 @@ describe("third-party plugins", () => { // default prompt (placed after both) indexes the patched description. const { contexts, prompts, path } = await exchange({ root, - plugins: [pluginPath("acme-echo"), pluginPath("acme-relabel.mjs"), "codework.prompt.default"], + plugins: [pluginPath("tool/acme-echo"), pluginPath("tool/acme-relabel.ts"), "codework.prompt.default"], llm: toolTurn(pendingCall("acme_echo", { value: "hi" }, "call_echo")), }); expect(contexts[0]?.tools?.[0]).toMatchObject({ name: "acme_echo", description: "Echo, relabelled by acme" }); @@ -331,7 +315,7 @@ describe("third-party plugins", () => { contexts.push(request.context); return immediateOpen()(request, signal); }, - plugins: [pluginPath("acme-hangs.mjs"), "codework.prompt.default"], + plugins: [pluginPath("host/acme-hangs.ts"), "codework.prompt.default"], }), ), Effect.scoped, diff --git a/packages/harness/test/plugin.host.test.ts b/packages/harness/test/plugin.host.test.ts index 2385c18..65eaaf2 100644 --- a/packages/harness/test/plugin.host.test.ts +++ b/packages/harness/test/plugin.host.test.ts @@ -51,7 +51,7 @@ describe("plugin domains and exchange host", () => { it("requires a prompt string, preserves full replacement, and rejects unknown tool patches", () => { const empty = make(); expect(empty.registry.prompt.get()).toBeUndefined(); - expect(() => empty.freeze()).toThrow("No Prompt plugin"); + expect(() => empty.freeze()).toThrow("no prompt plugin set a system prompt"); const buckets = make(); buckets.registry.prompt.set("old"); buckets.registry.prompt.set("new"); diff --git a/packages/harness/test/plugins/acme-bad-tool.mjs b/packages/harness/test/plugins/acme-bad-tool.mjs deleted file mode 100644 index 67cf8c2..0000000 --- a/packages/harness/test/plugins/acme-bad-tool.mjs +++ /dev/null @@ -1,7 +0,0 @@ -// Registers a malformed tool (no schemas, no handler) — untyped JS can reach here. -export default { - id: "acme.tool.malformed", - setup(ctx) { - ctx.plugin.tools.add({ definition: { name: "not_a_tool" } }); - }, -}; diff --git a/packages/harness/test/plugins/acme-bash-override.mjs b/packages/harness/test/plugins/acme-bash-override.mjs deleted file mode 100644 index e285ab0..0000000 --- a/packages/harness/test/plugins/acme-bash-override.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Replaces the built-in bash tool by name: the later registration wins. -import { Effect, Schema } from "effect"; - -export default { - id: "acme.tool.bash-override", - setup(ctx) { - ctx.plugin.tools.add({ - definition: { - name: "bash", - description: "Run a command through the acme shell", - promptSnippet: "Run a command through the acme shell", - parameters: Schema.Struct({ command: Schema.String }), - success: Schema.String, - encodeContent: (value) => [{ type: "text", text: value }], - }, - handler: ({ command }) => Effect.succeed(`acme-override:${command}`), - }); - }, -}; diff --git a/packages/harness/test/plugins/acme-hangs.mjs b/packages/harness/test/plugins/acme-hangs.mjs deleted file mode 100644 index 66d3493..0000000 --- a/packages/harness/test/plugins/acme-hangs.mjs +++ /dev/null @@ -1,5 +0,0 @@ -// A setup that never settles: only interruption can end the exchange it blocks. -export default { - id: "acme.setup.hangs", - setup: () => new Promise(() => {}), -}; diff --git a/packages/harness/test/plugins/acme-journal.mjs b/packages/harness/test/plugins/acme-journal.mjs deleted file mode 100644 index 313daaa..0000000 --- a/packages/harness/test/plugins/acme-journal.mjs +++ /dev/null @@ -1,23 +0,0 @@ -// A plugin owning durable event types outside the kernel manifest. `publish` only -// reads { type, durable, data } off the definition, so a plain object suffices. -import { Effect, Schema } from "effect"; - -export const Marker = { - type: "acme.journal.marker", - durable: { aggregate: "sessionId", version: 1 }, - data: Schema.Struct({ sessionId: Schema.String, note: Schema.String }), -}; - -export const Ready = { - type: "acme.ready", - data: Schema.Struct({ sessionId: Schema.String }), -}; - -export default { - id: "acme.journal.writer", - setup: (ctx) => - Effect.gen(function* () { - yield* ctx.events.publish(Ready, { sessionId: ctx.sessionId }); - yield* ctx.events.publish(Marker, { sessionId: ctx.sessionId, note: "acme was here" }); - }), -}; diff --git a/packages/harness/test/plugins/event/acme-journal.ts b/packages/harness/test/plugins/event/acme-journal.ts new file mode 100644 index 0000000..92ffa6c --- /dev/null +++ b/packages/harness/test/plugins/event/acme-journal.ts @@ -0,0 +1,24 @@ +import { EventSchema } from "../../../src/event/schema.ts"; +import { define } from "../../../src/plugin/plugin.ts"; +// A plugin owning durable event types outside the kernel manifest. +import { Effect, Schema } from "effect"; + +export const Marker = EventSchema.define({ + type: "acme.journal.marker", + durable: { aggregate: "sessionId", version: 1 } as const, + schema: { sessionId: Schema.String, note: Schema.String }, +}); + +export const Ready = EventSchema.define({ + type: "acme.ready", + schema: { sessionId: Schema.String }, +}); + +export default define({ + id: "acme.journal.writer", + setup: (ctx) => + Effect.gen(function* () { + yield* ctx.events.publish(Ready, { sessionId: ctx.sessionId }); + yield* ctx.events.publish(Marker, { sessionId: ctx.sessionId, note: "acme was here" }); + }), +}); diff --git a/packages/harness/test/plugins/acme-broken.mjs b/packages/harness/test/plugins/host/acme-broken.ts similarity index 100% rename from packages/harness/test/plugins/acme-broken.mjs rename to packages/harness/test/plugins/host/acme-broken.ts diff --git a/packages/harness/test/plugins/host/acme-hangs.ts b/packages/harness/test/plugins/host/acme-hangs.ts new file mode 100644 index 0000000..25336f8 --- /dev/null +++ b/packages/harness/test/plugins/host/acme-hangs.ts @@ -0,0 +1,6 @@ +import { define } from "../../../src/plugin/plugin.ts"; +// A setup that never settles: only interruption can end the exchange it blocks. +export default define({ + id: "acme.setup.hangs", + setup: () => new Promise(() => {}), +}); diff --git a/packages/harness/test/plugins/acme-throws.mjs b/packages/harness/test/plugins/host/acme-throws.ts similarity index 66% rename from packages/harness/test/plugins/acme-throws.mjs rename to packages/harness/test/plugins/host/acme-throws.ts index 1007c90..949988e 100644 --- a/packages/harness/test/plugins/acme-throws.mjs +++ b/packages/harness/test/plugins/host/acme-throws.ts @@ -1,7 +1,8 @@ +import { define } from "../../../src/plugin/plugin.ts"; // A plugin whose setup explodes: the host must attribute the failure to its id. -export default { +export default define({ id: "acme.setup.throws", setup() { throw new Error("acme setup exploded"); }, -}; +}); diff --git a/packages/harness/test/plugins/acme-prompt.mjs b/packages/harness/test/plugins/prompt/acme-prompt.ts similarity index 70% rename from packages/harness/test/plugins/acme-prompt.mjs rename to packages/harness/test/plugins/prompt/acme-prompt.ts index d42ccdc..f95983f 100644 --- a/packages/harness/test/plugins/acme-prompt.mjs +++ b/packages/harness/test/plugins/prompt/acme-prompt.ts @@ -1,7 +1,8 @@ +import { define } from "../../../src/plugin/plugin.ts"; // Single-file prompt plugin: composes on whatever an earlier prompt plugin set. -export default { +export default define({ id: "acme.prompt.marker", setup(ctx) { ctx.plugin.prompt.set(`${ctx.plugin.prompt.get() ?? ""}\n\nacme-marker`); }, -}; +}); diff --git a/packages/harness/test/plugins/tool/acme-bad-tool.ts b/packages/harness/test/plugins/tool/acme-bad-tool.ts new file mode 100644 index 0000000..712fdb2 --- /dev/null +++ b/packages/harness/test/plugins/tool/acme-bad-tool.ts @@ -0,0 +1,9 @@ +import { define } from "../../../src/plugin/plugin.ts"; +// Intentionally bypasses the type contract to verify runtime validation. +export default define({ + id: "acme.tool.malformed", + setup(ctx) { + // @ts-expect-error Deliberately malformed registration must fail at runtime. + ctx.plugin.tools.add({ definition: { name: "not_a_tool" } }); + }, +}); diff --git a/packages/harness/test/plugins/tool/acme-bash-override.ts b/packages/harness/test/plugins/tool/acme-bash-override.ts new file mode 100644 index 0000000..9436586 --- /dev/null +++ b/packages/harness/test/plugins/tool/acme-bash-override.ts @@ -0,0 +1,23 @@ +import * as Tool from "../../../src/tool/tool.ts"; +import { define } from "../../../src/plugin/plugin.ts"; +// Replaces the built-in bash tool by name: the later registration wins. +import { Effect, Schema } from "effect"; + +export default define({ + id: "acme.tool.bash-override", + setup(ctx) { + ctx.plugin.tools.add( + Tool.register( + Tool.make({ + name: "bash", + description: "Run a command through the acme shell", + promptSnippet: "Run a command through the acme shell", + parameters: Schema.Struct({ command: Schema.String }), + success: Schema.String, + encodeContent: (value) => [{ type: "text", text: value }], + handler: ({ command }) => Effect.succeed(`acme-override:${command}`), + }), + ), + ); + }, +}); diff --git a/packages/harness/test/plugins/acme-echo/index.mjs b/packages/harness/test/plugins/tool/acme-echo/index.ts similarity index 64% rename from packages/harness/test/plugins/acme-echo/index.mjs rename to packages/harness/test/plugins/tool/acme-echo/index.ts index ea8593a..5407e28 100644 --- a/packages/harness/test/plugins/acme-echo/index.mjs +++ b/packages/harness/test/plugins/tool/acme-echo/index.ts @@ -1,22 +1,23 @@ -// A third-party tool plugin written in plain JS: no TypeScript, no harness import. -// The contract it codes against is structural — a `{ id, setup }` default export. +import * as Tool from "../../../../src/tool/tool.ts"; +import { define } from "../../../../src/plugin/plugin.ts"; +// A typed third-party package fixture loaded through its package export. import { Effect, Schema } from "effect"; -export default { +export default define({ id: "acme.tool.echo", setup(ctx) { ctx.plugin.tools.add( - { - definition: { + Tool.register( + Tool.make({ name: "acme_echo", description: "Echo a value back", promptSnippet: "Echo a value back", parameters: Schema.Struct({ value: Schema.String }), success: Schema.String, encodeContent: (value) => [{ type: "text", text: value }], - }, - handler: ({ value }) => Effect.succeed(value), - }, + handler: ({ value }) => Effect.succeed(value), + }), + ), { afterToolCall: ({ terminal }) => terminal.status === "completed" @@ -25,4 +26,4 @@ export default { }, ); }, -}; +}); diff --git a/packages/harness/test/plugins/acme-echo/package.json b/packages/harness/test/plugins/tool/acme-echo/package.json similarity index 83% rename from packages/harness/test/plugins/acme-echo/package.json rename to packages/harness/test/plugins/tool/acme-echo/package.json index 3cd8125..55b5ca9 100644 --- a/packages/harness/test/plugins/acme-echo/package.json +++ b/packages/harness/test/plugins/tool/acme-echo/package.json @@ -4,6 +4,6 @@ "private": true, "type": "module", "exports": { - ".": "./index.mjs" + ".": "./index.ts" } } diff --git a/packages/harness/test/plugins/acme-guarded.mjs b/packages/harness/test/plugins/tool/acme-guarded.ts similarity index 53% rename from packages/harness/test/plugins/acme-guarded.mjs rename to packages/harness/test/plugins/tool/acme-guarded.ts index 8b71ded..50a5d0a 100644 --- a/packages/harness/test/plugins/acme-guarded.mjs +++ b/packages/harness/test/plugins/tool/acme-guarded.ts @@ -1,24 +1,28 @@ +import * as Tool from "../../../src/tool/tool.ts"; +import { define } from "../../../src/plugin/plugin.ts"; // A policy-style plugin: its own tool gated by a beforeToolCall block verdict. import { Effect, Schema } from "effect"; -export default { +export default define({ id: "acme.tool.guarded", setup(ctx) { ctx.plugin.tools.add( - { - definition: { + Tool.register( + Tool.make({ name: "acme_secret", description: "Read a secret", parameters: Schema.Struct({ value: Schema.String }), success: Schema.String, encodeContent: (value) => [{ type: "text", text: value }], - }, - handler: ({ value }) => Effect.succeed(`classified:${value}`), - }, + handler: ({ value }) => Effect.succeed(`classified:${value}`), + }), + ), { beforeToolCall: ({ params }) => - params.value === "deny" ? { block: true, reason: "denied by acme policy" } : undefined, + Schema.is(Schema.Struct({ value: Schema.Literal("deny") }))(params) + ? { block: true, reason: "denied by acme policy" } + : undefined, }, ); }, -}; +}); diff --git a/packages/harness/test/plugins/acme-relabel.mjs b/packages/harness/test/plugins/tool/acme-relabel.ts similarity index 71% rename from packages/harness/test/plugins/acme-relabel.mjs rename to packages/harness/test/plugins/tool/acme-relabel.ts index 1fe0594..47afb2a 100644 --- a/packages/harness/test/plugins/acme-relabel.mjs +++ b/packages/harness/test/plugins/tool/acme-relabel.ts @@ -1,7 +1,8 @@ +import { define } from "../../../src/plugin/plugin.ts"; // Patches prose on a tool someone else owns, without touching its handler or hooks. -export default { +export default define({ id: "acme.tool.relabel", setup(ctx) { ctx.plugin.tools.update("acme_echo", { description: "Echo, relabelled by acme" }); }, -}; +}); diff --git a/packages/harness/test/remote.owner.test.ts b/packages/harness/test/remote.owner.test.ts index cc68474..6b95a46 100644 --- a/packages/harness/test/remote.owner.test.ts +++ b/packages/harness/test/remote.owner.test.ts @@ -59,7 +59,7 @@ describe("remote fixture owner", () => { expect((failure as AggregateError).errors).toEqual([deletion, disposal]); }); - it("reports an uncaptured locator without skipping disposal", async () => { + it("disposes without a secondary failure when provisioning never captured a resource", async () => { const owner = makeRemoteOwner("test"); let disposed = false; @@ -72,7 +72,7 @@ describe("remote fixture owner", () => { disposed = true; }, }), - ).rejects.toThrow("resource locator was never captured"); + ).resolves.toBeUndefined(); expect(disposed).toBe(true); }); }); diff --git a/packages/harness/test/sandbox.daytona.e2e.test.ts b/packages/harness/test/sandbox.daytona.e2e.test.ts index c47bfa6..d71b735 100644 --- a/packages/harness/test/sandbox.daytona.e2e.test.ts +++ b/packages/harness/test/sandbox.daytona.e2e.test.ts @@ -1,6 +1,9 @@ +import { bashPluginSpec } from "./fixtures/bash.spec.ts"; +import * as Driver from "../src/sandboxes/daytona/index.ts"; +import { remoteSuite } from "./fixtures/live.ts"; import { Daytona } from "@daytona/sdk"; import { Effect, ManagedRuntime } from "effect"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, beforeAll, expect, it } from "vite-plus/test"; import { SandboxInstance } from "../src/sandbox/instance.ts"; import { SandboxIO } from "../src/sandbox/io.ts"; import * as EnvDaytona from "../src/sandboxes/daytona/provider.ts"; @@ -14,7 +17,7 @@ import "./utils/env.ts"; const apiKey = process.env.DAYTONA_API_KEY; const githubPat = process.env.GITHUB_PAT; -const suite = apiKey ? describe : describe.skip; +const suite = remoteSuite("DAYTONA_API_KEY", Boolean(apiKey?.trim())); const PROVISION_TIMEOUT = 180_000; const SANDBOX_CWD = "/tmp"; @@ -52,7 +55,7 @@ suite("Sandbox.EnvDaytona (fresh sandbox)", () => { const sdk = new Daytona({ apiKey }); await sdk.delete(await sdk.get(id)); }, - dispose: () => runtime.dispose(), + dispose: () => runtime?.dispose() ?? Promise.resolve(), }), PROVISION_TIMEOUT, ); @@ -79,6 +82,8 @@ suite("Sandbox.EnvDaytona (fresh sandbox)", () => { ), ); + bashPluginSpec({ driver: Driver.make(), resourceId, streaming: false }); + remoteSandboxSpec({ kind: "daytona", cwd: SANDBOX_CWD, diff --git a/packages/harness/test/sandbox.remote.driver.e2e.test.ts b/packages/harness/test/sandbox.remote.driver.e2e.test.ts index 0ac8aa0..5a1109f 100644 --- a/packages/harness/test/sandbox.remote.driver.e2e.test.ts +++ b/packages/harness/test/sandbox.remote.driver.e2e.test.ts @@ -1,6 +1,7 @@ +import { remoteSuite } from "./fixtures/live.ts"; import { Effect, Layer, ManagedRuntime, Option } from "effect"; import { SqlClient } from "effect/unstable/sql"; -import { describe, expect, it } from "vite-plus/test"; +import { expect, it } from "vite-plus/test"; import { Database } from "../src/db/db.ts"; import { SandboxController } from "../src/sandbox/control.ts"; import { SandboxDriver } from "../src/sandbox/driver.ts"; @@ -16,17 +17,9 @@ import "./utils/env.ts"; // These lifecycle cases provision independently and run serially via the E2E script. const apiKey = process.env.DAYTONA_API_KEY; -const daytonaSuite = apiKey ? describe : describe.skip; +const daytonaSuite = remoteSuite("DAYTONA_API_KEY", Boolean(apiKey?.trim())); const vercelToken = process.env.VERCEL_OIDC_TOKEN; -const vercelSuite = hasLiveOidc(vercelToken) ? describe : describe.skip; - -if (process.env.CODEWORK_SANDBOX_E2E_REQUIRED === "1") { - const missing = [ - ...(apiKey === undefined ? ["DAYTONA_API_KEY"] : []), - ...(!hasLiveOidc(vercelToken) ? ["VERCEL_OIDC_TOKEN"] : []), - ]; - if (missing.length > 0) throw new Error(`sandbox E2E credentials are missing or invalid: ${missing.join(", ")}`); -} +const vercelSuite = remoteSuite("VERCEL_OIDC_TOKEN", hasLiveOidc(vercelToken)); const cleanup = (controller: SandboxController.Controller["Service"], id: SandboxInstance.ID) => Effect.gen(function* () { diff --git a/packages/harness/test/sandbox.vercel.e2e.test.ts b/packages/harness/test/sandbox.vercel.e2e.test.ts index 38482b3..9972a35 100644 --- a/packages/harness/test/sandbox.vercel.e2e.test.ts +++ b/packages/harness/test/sandbox.vercel.e2e.test.ts @@ -1,6 +1,9 @@ +import { bashPluginSpec } from "./fixtures/bash.spec.ts"; +import * as Driver from "../src/sandboxes/vercel/index.ts"; +import { remoteSuite } from "./fixtures/live.ts"; import { Sandbox as RemoteSandbox } from "@vercel/sandbox"; import { Effect, ManagedRuntime, Stream } from "effect"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, beforeAll, expect, it } from "vite-plus/test"; import { SandboxFileSystem } from "../src/sandbox/fs/filesystem.ts"; import { SandboxInstance } from "../src/sandbox/instance.ts"; import { SandboxIO } from "../src/sandbox/io.ts"; @@ -18,7 +21,7 @@ import "./utils/env.ts"; const token = process.env.VERCEL_OIDC_TOKEN; const githubPat = process.env.GITHUB_PAT; -const suite = hasLiveOidc(token) ? describe : describe.skip; +const suite = remoteSuite("VERCEL_OIDC_TOKEN", hasLiveOidc(token)); const PROVISION_TIMEOUT = 180_000; @@ -102,6 +105,8 @@ suite("Sandbox.EnvVercel (fresh sandbox)", () => { ), ); + bashPluginSpec({ driver: Driver.make(), resourceId, streaming: true }); + remoteSandboxSpec({ kind: "vercel", cwd: SANDBOX_CWD, @@ -189,13 +194,15 @@ suite("Sandbox.EnvVercel (fresh sandbox)", () => { let exitCode: number | undefined; for (const chunk of result.chunks) { if (chunk._tag === "exit") exitCode = chunk.exitCode; - else text += decoder.decode(chunk.bytes); + else text += decoder.decode(chunk.bytes, { stream: true }); } expect(text).toContain(result.cwd); expect(text).toContain("hello"); expect(text).toContain("oops"); expect(exitCode).toBe(0); + expect(result.chunks.filter((chunk) => chunk._tag === "exit")).toEqual([{ _tag: "exit", exitCode: 0 }]); + expect(result.chunks.at(-1)).toEqual({ _tag: "exit", exitCode: 0 }); }, PROVISION_TIMEOUT, ); @@ -216,14 +223,16 @@ suite("Sandbox.EnvVercel (fresh sandbox)", () => { let exitCode: number | undefined; for (const chunk of chunks) { if (chunk._tag === "exit") exitCode = chunk.exitCode; - else text += decoder.decode(chunk.bytes); + else text += decoder.decode(chunk.bytes, { stream: true }); } + text += decoder.decode(); const lines = text.split("\n").filter((line) => line.length > 0); expect(lines.length).toBe(50); - expect(lines[0]).toBe("1"); - expect(lines.at(-1)).toBe("50"); + expect(lines).toEqual(Array.from({ length: 50 }, (_, index) => String(index + 1))); expect(exitCode).toBe(0); + expect(chunks.filter((chunk) => chunk._tag === "exit")).toEqual([{ _tag: "exit", exitCode: 0 }]); + expect(chunks.at(-1)).toEqual({ _tag: "exit", exitCode: 0 }); }, PROVISION_TIMEOUT, ); @@ -239,9 +248,8 @@ suite("Sandbox.EnvVercel (fresh sandbox)", () => { }), ); - const exit = chunks.find((chunk) => chunk._tag === "exit"); - expect(exit).toBeDefined(); - expect((exit as { exitCode: number }).exitCode).toBe(3); + expect(chunks.filter((chunk) => chunk._tag === "exit")).toEqual([{ _tag: "exit", exitCode: 3 }]); + expect(chunks.at(-1)).toEqual({ _tag: "exit", exitCode: 3 }); }, PROVISION_TIMEOUT, ); diff --git a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs index 132e99b..dc393fe 100644 --- a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs +++ b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs @@ -1,4 +1,5 @@ import dedent from "dedent"; +import { existsSync } from "node:fs"; import { spawn } from "node:child_process"; import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -8,6 +9,9 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); const harness = resolve(root, "packages/harness"); const external = resolve(root, "extras/codework-sandbox-vercel"); +const envFile = resolve(harness, ".env.local"); +if (existsSync(envFile)) process.loadEnvFile(envFile); + const temporary = await mkdtemp(resolve(tmpdir(), "codework-sandbox-package-")); const artifacts = resolve(temporary, "artifacts"); const consumer = resolve(temporary, "consumer"); @@ -57,10 +61,30 @@ try { )}\n`, ); + // The smoke only loads JavaScript, so the optional native accelerators stay unbuilt. + // pnpm fails the install on undeclared ignored build scripts, so decline them explicitly. + // Effect's own caret ranges match across prerelease tags, so a fresh install otherwise + // pairs a newer platform-node-shared with the effect version the harness is built against. + await writeFile( + resolve(consumer, "pnpm-workspace.yaml"), + `${dedent` + overrides: + "@effect/platform-node-shared": 4.0.0-beta.107 + allowBuilds: + "@mongodb-js/zstd": false + esbuild: false + lmdb: false + msgpackr-extract: false + node-liblzma: false + protobufjs: false + `}\n`, + ); + await writeFile( resolve(consumer, "smoke.mjs"), `${dedent` - import { Effect, ManagedRuntime } from "effect"; + import assert from "node:assert/strict"; + import { Effect, ManagedRuntime, Option } from "effect"; import { Harness, Sandbox } from "@codeworksh/harness/effect"; const runtime = ManagedRuntime.make(Harness.layer({ @@ -69,6 +93,7 @@ try { sandboxes: ["@codeworksh-test/codework-sandbox-vercel"], })); let created; + const failures = []; try { const drivers = await runtime.runPromise(Sandbox.drivers()); @@ -82,20 +107,24 @@ try { driver: "codework.test.vercel", config: { runtime: "node24", timeout: 300000, execTimeout: 30000 }, })); - await runtime.runPromise(Sandbox.refresh(created.id)); - await runtime.runPromise(Sandbox.stop(created.id)); - await runtime.runPromise(Sandbox.wake(created.id)); - await runtime.runPromise(Sandbox.stop(created.id)); + assert.equal((await runtime.runPromise(Sandbox.refresh(created.id))).status, "online"); + assert.equal((await runtime.runPromise(Sandbox.stop(created.id))).status, "offline"); + assert.equal((await runtime.runPromise(Sandbox.wake(created.id))).status, "online"); + assert.equal((await runtime.runPromise(Sandbox.stop(created.id))).status, "offline"); await runtime.runPromise(Sandbox.destroy(created.id)); + assert.equal(Option.getOrThrow(await runtime.runPromise(Sandbox.get(created.id))).status, "removed"); created = undefined; } + } catch (cause) { + failures.push(cause); } finally { if (created !== undefined) { - await runtime.runPromise(Sandbox.stop(created.id)).catch(() => undefined); - await runtime.runPromise(Sandbox.destroy(created.id)).catch(() => undefined); + await runtime.runPromise(Sandbox.stop(created.id)).catch((cause) => failures.push(cause)); + await runtime.runPromise(Sandbox.destroy(created.id)).catch((cause) => failures.push(cause)); } - await runtime.dispose(); + await runtime.dispose().catch((cause) => failures.push(cause)); } + if (failures.length > 0) throw new AggregateError(failures, "installed-package smoke failed"); `}\n`, ); diff --git a/packages/harness/test/tool.output.test.ts b/packages/harness/test/tool.output.test.ts new file mode 100644 index 0000000..b79f41f --- /dev/null +++ b/packages/harness/test/tool.output.test.ts @@ -0,0 +1,65 @@ +import { Effect } from "effect"; +import { readFile, rm } from "node:fs/promises"; +import { describe, expect, it } from "vite-plus/test"; +import { Accumulator } from "../src/tool/accumulator.ts"; +import { truncateTail } from "../src/tool/truncate.ts"; + +const accumulate = async (text: string, options: { maxLines: number; maxBytes: number }) => { + const acc = new Accumulator(options); + try { + return await Effect.runPromise( + Effect.gen(function* () { + // Single-byte chunks split every multibyte UTF-8 code point. + for (const byte of Buffer.from(text)) yield* acc.append(Buffer.from([byte])); + yield* acc.finish(); + const snapshot = acc.snapshot(); + const full = + snapshot.fullOutputPath === undefined + ? undefined + : yield* Effect.promise(() => readFile(snapshot.fullOutputPath!, "utf8")); + return { snapshot, full }; + }).pipe(Effect.scoped), + ); + } finally { + const path = acc.snapshot().fullOutputPath; + if (path !== undefined) await rm(path, { force: true }); + } +}; + +describe("tool output retention", () => { + it("keeps UTF-8 intact at the exact byte limit without spilling", async () => { + const text = "🙂é\n"; + const { snapshot, full } = await accumulate(text, { maxLines: 1, maxBytes: Buffer.byteLength(text) }); + expect(snapshot.content).toBe(text); + expect(snapshot.truncation.truncated).toBe(false); + expect(full).toBeUndefined(); + }); + + it("retains the ordered tail and every original byte after incremental spilling", async () => { + const text = "first\nsecond\n第三\n🙂last\n"; + const { snapshot, full } = await accumulate(text, { maxLines: 2, maxBytes: 100 }); + expect(snapshot.content).toBe("第三\n🙂last"); + expect(snapshot.truncation).toMatchObject({ truncated: true, truncatedBy: "lines", totalLines: 4 }); + expect(full).toBe(text); + }); + + it("bounds an oversized unterminated Unicode line without replacement characters", async () => { + const text = "🙂".repeat(100); + const { snapshot, full } = await accumulate(text, { maxLines: 2, maxBytes: 17 }); + expect(snapshot.content).toBe("🙂".repeat(4)); + expect(Buffer.byteLength(snapshot.content)).toBeLessThanOrEqual(17); + expect(snapshot.truncation).toMatchObject({ truncated: true, truncatedBy: "bytes", lastLinePartial: true }); + expect(full).toBe(text); + }); + + it("applies the first reached line or byte bound to buffered output", () => { + expect(truncateTail("one\ntwo\nthree\n", { maxLines: 2, maxBytes: 100 })).toMatchObject({ + content: "two\nthree", + truncatedBy: "lines", + }); + expect(truncateTail("one\ntwo\nthree\n", { maxLines: 100, maxBytes: 6 })).toMatchObject({ + content: "three", + truncatedBy: "bytes", + }); + }); +}); diff --git a/packages/harness/test/tools.registry.remote.test.ts b/packages/harness/test/tools.registry.remote.e2e.test.ts similarity index 70% rename from packages/harness/test/tools.registry.remote.test.ts rename to packages/harness/test/tools.registry.remote.e2e.test.ts index c1af6ff..3dab420 100644 --- a/packages/harness/test/tools.registry.remote.test.ts +++ b/packages/harness/test/tools.registry.remote.e2e.test.ts @@ -1,13 +1,13 @@ +import { fileSink, readSink, outputTextOf } from "./fixtures/progress.ts"; +import { tmpdir } from "./fixtures/tempdir.ts"; +import { remoteSuite } from "./fixtures/live.ts"; import { Daytona } from "@daytona/sdk"; import { Effect, Layer, ManagedRuntime } from "effect"; -import { appendFile, mkdtemp, readFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; +import { afterAll, beforeAll, expect, it } from "vite-plus/test"; import * as EnvDaytona from "../src/sandboxes/daytona/provider.ts"; import { SandboxInstance } from "../src/sandbox/instance.ts"; import { bashTool } from "../src/plugin/internal/tool/bash.ts"; -import type * as Executor from "../src/tool/executor.ts"; import * as Registry from "../src/tool/registry.ts"; import { fromSandboxShell, ToolShell } from "../src/tool/shell.ts"; import * as Tool from "../src/tool/tool.ts"; @@ -24,7 +24,7 @@ import "./utils/env.ts"; // contract shares that file's single free-tier resource. const daytonaKey = process.env.DAYTONA_API_KEY; -const daytonaSuite = daytonaKey ? describe : describe.skip; +const daytonaSuite = remoteSuite("DAYTONA_API_KEY", Boolean(daytonaKey?.trim())); const PROVISION_TIMEOUT = 180_000; @@ -33,36 +33,6 @@ const line = (i: number) => `progress-line-${i}/${LINES}`; /** Long-running: one line every 20ms → ~2.4s of paced output, so progress arrives over time. */ const BUFFERED_COMMAND = `for i in $(seq 1 ${LINES}); do echo "progress-line-$i/${LINES}"; done`; -interface SinkEntry { - readonly callID: string; - readonly text: string; -} - -const tempSinkFile = async (): Promise => - join(await mkdtemp(join(tmpdir(), "codework-registry-remote-")), "progress.ndjson"); - -// The File IO progress sink: one NDJSON line per delivered event, carrying the partial's -// cumulative text — exactly what a live UI would render for the user at that moment. -const fileSink = - (path: string) => - (event: Executor.ProgressEvent): Effect.Effect => - Effect.promise(() => { - const first = event.partial.content?.[0]; - const entry: SinkEntry = { callID: event.ctx.callID, text: first?.type === "text" ? first.text : "" }; - return appendFile(path, `${JSON.stringify(entry)}\n`); - }); - -const readSink = async (path: string): Promise => - (await readFile(path, "utf8").catch(() => "")) - .split("\n") - .filter(Boolean) - .map((raw) => JSON.parse(raw) as SinkEntry); - -const outputTextOf = (outcome: Executor.ToolOutcome): string => { - const first = outcome.result.content[0]; - return first && first.type === "text" ? first.text : ""; -}; - const acquireToolShell = (runtime: ManagedRuntime.ManagedRuntime) => runtime.runPromise( Effect.gen(function* () { @@ -98,7 +68,7 @@ daytonaSuite("ToolRegistry × real Daytona sandbox — buffered bash (no streami const sdk = new Daytona({ apiKey: daytonaKey }); await sdk.delete(await sdk.get(id)); }, - dispose: () => runtime.dispose(), + dispose: () => runtime?.dispose() ?? Promise.resolve(), }), PROVISION_TIMEOUT, ); @@ -109,7 +79,8 @@ daytonaSuite("ToolRegistry × real Daytona sandbox — buffered bash (no streami const shell = await acquireToolShell(runtime); expect(shell.stream).toBeUndefined(); // Daytona backend is exec-only → buffered path const resolved = Registry.make([Tool.provide(bashTool, Layer.succeed(ToolShell, shell))]).resolve(); - const path = await tempSinkFile(); + await using temp = await tmpdir(); + const path = join(temp.path, "progress.ndjson"); const outcome = await Effect.runPromise( resolved.handle(pendingCall("bash", { command: BUFFERED_COMMAND }, "daytona-bash"), { @@ -121,8 +92,7 @@ daytonaSuite("ToolRegistry × real Daytona sandbox — buffered bash (no streami expect(outcome.status).toBe("completed"); const finalLines = outputTextOf(outcome).split("\n").filter(Boolean); expect(finalLines).toHaveLength(LINES); - expect(finalLines[0]).toBe(line(1)); - expect(finalLines.at(-1)).toBe(line(LINES)); + expect(finalLines).toEqual(Array.from({ length: LINES }, (_, index) => line(index + 1))); expect(await readSink(path)).toHaveLength(0); }, PROVISION_TIMEOUT, From f448f0756060c67ad839b5cb2536f45c3ca9e371 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 12 Sep 2026 23:04:58 +0530 Subject: [PATCH 09/12] chore(harness): remove unwanted packages --- extras/codework-sandbox-vercel/package.json | 2 +- package.json | 1 + packages/harness/package.json | 4 +- packages/harness/src/plugin/loader.ts | 25 ++--- packages/harness/src/plugin/package.ts | 6 +- packages/harness/src/sandbox/loader.ts | 105 +----------------- packages/harness/src/util/module.ts | 90 +++++++++++++++ packages/harness/test/module.test.ts | 103 +++++++++++++++++ packages/harness/test/plugin.catalog.test.ts | 38 ++++++- .../scripts/codework-sandbox-vercel-smoke.mjs | 4 - pnpm-lock.yaml | 87 ++------------- 11 files changed, 255 insertions(+), 210 deletions(-) create mode 100644 packages/harness/src/util/module.ts create mode 100644 packages/harness/test/module.test.ts diff --git a/extras/codework-sandbox-vercel/package.json b/extras/codework-sandbox-vercel/package.json index 34fb9f1..6cdafb3 100644 --- a/extras/codework-sandbox-vercel/package.json +++ b/extras/codework-sandbox-vercel/package.json @@ -27,7 +27,7 @@ "effect": "4.0.0-beta.107" }, "peerDependencies": { - "@codeworksh/harness": "*", + "@codeworksh/harness": "workspace:*", "effect": "4.0.0-beta.107" }, "engines": { diff --git a/package.json b/package.json index cc04064..eba0960 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "release:harness:dry": "pnpm --filter @codeworksh/harness run release:dry" }, "devDependencies": { + "@codeworksh-test/codework-sandbox-vercel": "workspace:*", "@effect/tsgo": "catalog:", "@types/node": "catalog:", "typescript": "catalog:", diff --git a/packages/harness/package.json b/packages/harness/package.json index f1ac0a4..b5bee0f 100644 --- a/packages/harness/package.json +++ b/packages/harness/package.json @@ -80,20 +80,18 @@ "dependencies": { "@codeworksh/aikit": "workspace:*", "@effect/platform-node": "4.0.0-beta.107", + "@effect/platform-node-shared": "4.0.0-beta.107", "@effect/sql-sqlite-node": "4.0.0-beta.107", "@platformatic/vfs": "^0.4.0", "effect": "4.0.0-beta.107", - "import-meta-resolve": "^4.2.0", "just-bash": "^2.14.5", "npm-package-arg": "^14.0.0", - "resolve.exports": "^2.0.3", "tslib": "^2.8.1", "typebox": "^1.3.10", "uuid": "^14.0.1", "uuidv7": "^1.2.1" }, "devDependencies": { - "@codeworksh-test/codework-sandbox-vercel": "workspace:*", "@daytona/sdk": "^0.187.0", "@types/node": "^25.9.5", "@types/npm-package-arg": "^6.1.4", diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts index 401b01b..fcaf59b 100644 --- a/packages/harness/src/plugin/loader.ts +++ b/packages/harness/src/plugin/loader.ts @@ -1,7 +1,7 @@ import { Effect, Predicate, Schema } from "effect"; import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { exports as packageExports } from "resolve.exports"; +import { importModule, resolveModule } from "../util/module.ts"; import { fileSystem as fs, hostPath as path } from "../host.ts"; import * as Package from "./package.ts"; import type { Plugin } from "./plugin.ts"; @@ -75,30 +75,29 @@ const Manifest = Schema.Struct({ const localUrl = Effect.fn("PluginLoader.localUrl")(function* (location: string, origin: Origin) { const stat = yield* fs.stat(location); - if (stat.type !== "Directory") return pathToFileURL(location).href; + if (stat.type !== "Directory") return yield* Effect.try(() => resolveModule(location, path.dirname(location))); const manifestPath = path.join(location, "package.json"); if (!(yield* fs.exists(manifestPath))) { - const fallback = path.join(location, "index.js"); - if (!(yield* fs.exists(fallback))) - return yield* failure(origin, "source", new Error(`Directory has no package.json or index.js: ${location}`)); - return pathToFileURL(fallback).href; + return yield* Effect.try(() => resolveModule("./index", location)); } const manifest = yield* fs .readFileString(manifestPath) .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(Manifest)))); if (manifest.exports !== undefined) { - const targets = yield* Effect.try(() => packageExports(manifest, ".")); - const target = targets?.[0]; - if (target === undefined || !target.startsWith("./")) - return yield* failure(origin, "source", new Error("No valid root package export")); + const name = manifest.name; + if (!name) + return yield* failure(origin, "source", new Error("A local package with exports must declare its name")); + const url = yield* Effect.try(() => resolveModule(name, location)); // Compare real paths: a symlinked target (or root) can point outside while the // string paths still nest. - const resolved = yield* fs.realPath(path.resolve(location, target)); + const resolved = yield* fs.realPath(fileURLToPath(url)); if (path.relative(yield* fs.realPath(location), resolved).startsWith("..")) return yield* failure(origin, "source", new Error("Package export escapes its root")); return pathToFileURL(resolved).href; } - return yield* Effect.try(() => pathToFileURL(createRequire(pathToFileURL(manifestPath)).resolve(location)).href); + return yield* Effect.try(() => + resolveModule(createRequire(pathToFileURL(manifestPath)).resolve(location), location), + ); }); export interface Options { @@ -138,7 +137,7 @@ export const load = Effect.fn("PluginLoader.load")(function* ( ), }; const module = yield* Effect.tryPromise({ - try: () => (options.import ?? ((url) => import(/* @vite-ignore */ url)))(installed.url), + try: () => (options.import ?? importModule)(installed.url), catch: (cause) => failure(origin, "import", cause), }); const plugin = yield* validate( diff --git a/packages/harness/src/plugin/package.ts b/packages/harness/src/plugin/package.ts index e2e0623..5ad0de2 100644 --- a/packages/harness/src/plugin/package.ts +++ b/packages/harness/src/plugin/package.ts @@ -1,7 +1,7 @@ import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"; import { Duration, Effect, Layer, Option, Ref, Schedule, Schema } from "effect"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; -import { resolve } from "import-meta-resolve"; +import { resolveModule } from "../util/module.ts"; import { createHash } from "node:crypto"; import { fileURLToPath, pathToFileURL } from "node:url"; import npa from "npm-package-arg"; @@ -148,9 +148,7 @@ export const install = Effect.fn("PluginPackage.install")( const manifest = yield* fs.readFileString(path.join(staging, "node_modules", request.name, "package.json")); const installed = yield* Schema.decodeEffect(Schema.fromJsonString(Manifest))(manifest); // Validate root resolution before marking this installation complete. - const entrypoint = yield* Effect.try(() => - resolve(request.name, pathToFileURL(path.join(staging, "package.json")).href), - ); + const entrypoint = yield* Effect.try(() => resolveModule(request.name, staging)); // `resolve` realpaths its answer while `makeTempDirectory` does not, so relate the // two through the realpath or a symlinked cache root escapes the published entry. const entry = path.relative(yield* fs.realPath(staging), fileURLToPath(entrypoint)); diff --git a/packages/harness/src/sandbox/loader.ts b/packages/harness/src/sandbox/loader.ts index bf05d74..6c0a953 100644 --- a/packages/harness/src/sandbox/loader.ts +++ b/packages/harness/src/sandbox/loader.ts @@ -1,7 +1,5 @@ -import { findPackageJSON } from "node:module"; -import { pathToFileURL } from "node:url"; import { Effect, Schema } from "effect"; -import { fileSystem, hostPath } from "../host.ts"; +import { importModule, resolveModule } from "../util/module.ts"; import { SandboxDriver } from "./driver.ts"; import { SandboxDriverLoadError } from "./errors.ts"; @@ -21,12 +19,6 @@ export interface Resolved { export type Resolver = (specifier: string) => Effect.Effect; export type Importer = (url: string) => Promise; -interface PackageManifest { - readonly exports?: unknown; - readonly module?: unknown; - readonly main?: unknown; -} - const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null; @@ -48,55 +40,6 @@ export const isPackageSpecifier = (specifier: string): boolean => !specifier.includes(":") && splitPackage(specifier) !== undefined; -const selectCondition = (value: unknown, conditions: ReadonlyArray): string | undefined => { - if (typeof value === "string") return value; - if (Array.isArray(value)) { - for (const candidate of value) { - const selected = selectCondition(candidate, conditions); - if (selected !== undefined) return selected; - } - return undefined; - } - if (!isRecord(value)) return undefined; - for (const condition of conditions) { - const selected = selectCondition(value[condition], conditions); - if (selected !== undefined) return selected; - } - return undefined; -}; - -const exportedTarget = ( - manifest: PackageManifest, - key: string, - conditions: ReadonlyArray, -): string | undefined => { - const exports = manifest.exports; - if (typeof exports === "string" || Array.isArray(exports)) - return key === "." ? selectCondition(exports, conditions) : undefined; - if (!isRecord(exports)) { - if (key !== ".") return undefined; - return typeof manifest.module === "string" - ? manifest.module - : typeof manifest.main === "string" - ? manifest.main - : "./index.js"; - } - const hasSubpaths = Object.keys(exports).some((name) => name.startsWith(".")); - if (!hasSubpaths) return selectCondition(key === "." ? exports : undefined, conditions); - const exact = selectCondition(exports[key], conditions); - if (exact !== undefined) return exact; - for (const pattern of Object.keys(exports) - .filter((name) => name.includes("*")) - .sort((a, b) => b.length - a.length)) { - const [prefix, suffix = ""] = pattern.split("*"); - if (!key.startsWith(prefix!) || !key.endsWith(suffix)) continue; - const match = key.slice(prefix!.length, key.length - suffix.length); - const target = selectCondition(exports[pattern], conditions); - if (target !== undefined) return target.replaceAll("*", match); - } - return undefined; -}; - const official = new Set(["@codeworksh/harness/sandboxes/vercel", "@codeworksh/harness/sandboxes/daytona"]); /** @@ -118,51 +61,13 @@ export const packageResolver = ( reason: "expected an installed npm package specifier; filesystem paths are not supported yet", }); } - const parsed = splitPackage(specifier)!; - const packageJson = yield* Effect.try({ - try: () => findPackageJSON(parsed.name, pathToFileURL(hostPath.resolve(hostCwd, "package.json"))), + const url = yield* Effect.try({ + try: () => resolveModule(specifier, hostCwd, conditions), catch: (reason) => new SandboxDriverLoadError({ specifier, phase: "resolve", reason: String(reason) }), }); - if (packageJson === undefined) { - return yield* new SandboxDriverLoadError({ - specifier, - phase: "resolve", - reason: `installed package was not found: ${parsed.name}`, - }); - } - const source = yield* fileSystem - .readFileString(packageJson) - .pipe( - Effect.mapError( - (reason) => new SandboxDriverLoadError({ specifier, phase: "resolve", reason: String(reason) }), - ), - ); - const decoded = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown))(source).pipe( - Effect.mapError( - (reason) => new SandboxDriverLoadError({ specifier, phase: "resolve", reason: String(reason) }), - ), - ); - const manifest: PackageManifest = isRecord(decoded) ? decoded : {}; - const target = exportedTarget(manifest, parsed.key, conditions); - if (target === undefined || !target.startsWith("./")) { - return yield* new SandboxDriverLoadError({ - specifier, - phase: "resolve", - reason: `package export is missing or unsupported: ${parsed.key}`, - }); - } - const root = hostPath.dirname(packageJson); - const location = hostPath.resolve(root, target); - if (location !== root && !location.startsWith(`${root}${hostPath.sep}`)) { - return yield* new SandboxDriverLoadError({ - specifier, - phase: "resolve", - reason: "package export resolves outside its package root", - }); - } return { specifier, - url: pathToFileURL(location).href, + url, source: official.has(specifier) ? "builtin" : "package", }; }); @@ -226,7 +131,7 @@ export const load = Effect.fn("SandboxDriverLoader.load")(function* (entry: Entr const rawOptions = typeof entry === "string" ? {} : (entry.options ?? {}); const resolved = yield* (options.resolve ?? packageResolver(options.hostCwd))(specifier); const imported = yield* Effect.tryPromise({ - try: () => (options.import ?? ((url) => import(/* @vite-ignore */ url)))(resolved.url), + try: () => (options.import ?? importModule)(resolved.url), catch: (reason) => failure(specifier, "import", reason), }); const loaded = isRecord(imported) ? imported.default : undefined; diff --git a/packages/harness/src/util/module.ts b/packages/harness/src/util/module.ts new file mode 100644 index 0000000..f87b53e --- /dev/null +++ b/packages/harness/src/util/module.ts @@ -0,0 +1,90 @@ +/*! + * Adapted from OpenCode packages/util/src/runtime/import.node.ts. + * + * MIT License + * + * Copyright (c) 2025 opencode + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +/* oxlint-disable effecttsgo/async-function -- Native module loading exposes a Promise-based importer contract. */ +// Resolution hooks cannot yield to Effect; existence checks must stay synchronous. +// @effect-diagnostics-next-line nodeBuiltinImport:off +import { statSync } from "node:fs"; +import { registerHooks } from "node:module"; +// @effect-diagnostics-next-line nodeBuiltinImport:off +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { Script, constants } from "node:vm"; + +/** Use Node's main loader even when the caller runs inside a bundler or test VM. */ +export async function importModule(specifier: string): Promise { + const imported: unknown = await new Script(`import(${JSON.stringify(specifier)})`, { + importModuleDynamically: constants.USE_MAIN_CONTEXT_DEFAULT_LOADER, + }).runInThisContext(); + if (typeof imported !== "object" || imported === null) return imported; + const module = imported as Record; + const exports = module["module.exports"]; + if (exports !== module.default || (typeof exports !== "object" && typeof exports !== "function") || exports === null) + return imported; + return Object.assign({}, module, exports); +} + +/** Node resolution scoped synchronously to the caller, following OpenCode's runtime importer. */ +export function resolveModule(specifier: string, directory: string, conditions?: ReadonlyArray): string { + const hook = registerHooks({ + resolve(target, context, nextResolve) { + return nextResolve(target, { + ...context, + parentURL: pathToFileURL(path.join(directory, "package.json")).href, + ...(conditions === undefined ? {} : { conditions: [...new Set([...context.conditions, ...conditions])] }), + }); + }, + }); + try { + const resolve = (target: string) => { + const resolved = import.meta.resolve(path.isAbsolute(target) ? pathToFileURL(target).href : target); + if (resolved.startsWith("file:")) statSync(new URL(resolved)); + return resolved; + }; + try { + return resolve(specifier); + } catch (error) { + if (path.extname(specifier) || !missing(error)) throw error; + for (const extension of [".ts", ".tsx", ".js", ".jsx", ".mts", ".mjs", ".cts", ".cjs"]) { + try { + return resolve(specifier + extension); + } catch (cause) { + if (!missing(cause)) throw cause; + } + } + throw error; + } + } finally { + hook.deregister(); + } +} + +function missing(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + ["ENOENT", "ENOTDIR", "ERR_MODULE_NOT_FOUND"].includes(String(error.code)) + ); +} diff --git a/packages/harness/test/module.test.ts b/packages/harness/test/module.test.ts new file mode 100644 index 0000000..bb7633e --- /dev/null +++ b/packages/harness/test/module.test.ts @@ -0,0 +1,103 @@ +import { Effect } from "effect"; +import { realpathSync } from "node:fs"; +import { mkdir, writeFile } from "node:fs/promises"; +import { registerHooks } from "node:module"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vite-plus/test"; +import { resolveModule, importModule } from "../src/util/module.ts"; +import { packageResolver } from "../src/sandbox/loader.ts"; +import { tmpdir } from "./fixtures/tempdir.ts"; + +const fixture = async (root: string, exports: unknown) => { + const directory = join(root, "node_modules", "fixture"); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, "package.json"), JSON.stringify({ name: "fixture", type: "module", exports })); + for (const name of ["import", "node", "development", "entry"]) + await writeFile(join(directory, `${name}.js`), `export default ${JSON.stringify(name)};`); + return directory; +}; + +const url = (file: string) => pathToFileURL(realpathSync(file)).href; + +describe("native module loading", () => { + it("uses the caller's package and preserves export condition key order", async () => { + await using temp = await tmpdir(); + const directory = await fixture(temp.path, { ".": { import: "./import.js", node: "./node.js" } }); + expect(resolveModule("fixture", temp.path)).toBe(url(join(directory, "import.js"))); + expect((await Effect.runPromise(packageResolver(temp.path)("fixture"))).url).toBe( + url(join(directory, "import.js")), + ); + }); + + it("adds development conditions and respects wildcard exports and null exclusions", async () => { + await using temp = await tmpdir(); + const directory = await fixture(temp.path, { + ".": { development: "./development.js", import: "./import.js" }, + "./*": "./*.js", + "./private/*": null, + }); + expect(resolveModule("fixture", temp.path, ["development"])).toBe(url(join(directory, "development.js"))); + expect(resolveModule("fixture/entry", temp.path)).toBe(url(join(directory, "entry.js"))); + expect(() => resolveModule("fixture/private/entry", temp.path)).toThrow(); + }); + + it("does not bypass exports to guess a missing package entry", async () => { + await using temp = await tmpdir(); + await fixture(temp.path, { "./entry": "./entry.js" }); + expect(() => resolveModule("fixture", temp.path)).toThrow(); + }); + + it("resolves extensionless local TypeScript without evaluating it", async () => { + await using temp = await tmpdir(); + await writeFile(join(temp.path, "entry.ts"), 'throw new Error("must not execute while resolving");'); + expect(resolveModule("./entry", temp.path)).toBe(url(join(temp.path, "entry.ts"))); + expect(() => resolveModule("./missing.ts", temp.path)).toThrow(); + }); + + it("deregisters scoped hooks after both success and failure", async () => { + await using temp = await tmpdir(); + const parents: Array = []; + const outer = registerHooks({ + resolve(specifier, context, nextResolve) { + parents.push(context.parentURL); + return nextResolve(specifier, context); + }, + }); + try { + expect(resolveModule("node:fs", temp.path)).toBe("node:fs"); + expect(() => resolveModule("./missing.js", temp.path)).toThrow(); + parents.length = 0; + import.meta.resolve("node:path"); + expect(parents).toEqual([import.meta.url]); + } finally { + outer.deregister(); + } + }); + + it("imports ESM and TypeScript through Node and retains module identity", async () => { + await using temp = await tmpdir(); + await writeFile(join(temp.path, "entry.mts"), "export const value: number = 42; export default { value };"); + const resolved = resolveModule("./entry.mts", temp.path); + const loaded = await importModule(resolved); + expect(loaded).toMatchObject({ value: 42, default: { value: 42 } }); + expect(await importModule(resolved)).toBe(loaded); + }); + + it("exposes dynamic CommonJS object exports like OpenCode", async () => { + await using temp = await tmpdir(); + await writeFile(join(temp.path, "entry.cjs"), 'module.exports = Object.fromEntries([["value", 42]]);'); + expect(await importModule(resolveModule("./entry.cjs", temp.path))).toMatchObject({ + value: 42, + default: { value: 42 }, + }); + }); + + it("propagates module evaluation failures", async () => { + await using temp = await tmpdir(); + await writeFile(join(temp.path, "entry.mjs"), 'throw new Error("plugin initialization failed");'); + await expect(importModule(resolveModule("./entry.mjs", temp.path))).rejects.toThrow( + "plugin initialization failed", + ); + }); +}); diff --git a/packages/harness/test/plugin.catalog.test.ts b/packages/harness/test/plugin.catalog.test.ts index 6c98869..97b6325 100644 --- a/packages/harness/test/plugin.catalog.test.ts +++ b/packages/harness/test/plugin.catalog.test.ts @@ -168,14 +168,17 @@ describe("plugin catalog and source resolution", () => { ).toEqual([a]); // Containment compares real paths, so the exported URL is the real one too. expect(url).toBe(pathToFileURL(realpathSync(join(directory, "entry.js"))).href); + // Node caches package manifests; a different package exercises invalid exports. + const broken = join(directory, "broken"); + await mkdir(broken); await writeFile( - join(directory, "package.json"), - JSON.stringify({ name: "fixture", exports: { "./other": "./entry.js" } }), + join(broken, "package.json"), + JSON.stringify({ name: "broken", exports: { "./other": "./entry.js" } }), ); - expect(await Effect.runPromise(prepare([directory], options).pipe(Effect.flip))).toMatchObject({ + expect(await Effect.runPromise(prepare([broken], options).pipe(Effect.flip))).toMatchObject({ phase: "source", index: 0, - reference: directory, + reference: broken, }); })); it("falls back to index.js and reports a directory with no entry as a source error", () => @@ -197,7 +200,30 @@ describe("plugin catalog and source resolution", () => { }, }), ); - expect(url).toBe(pathToFileURL(join(empty, "index.js")).href); + expect(url).toBe(pathToFileURL(realpathSync(join(empty, "index.js"))).href); + })); + it("resolves an unnamed TypeScript directory through its index", () => + withDirectory(async (directory) => { + await writeFile(join(directory, "index.ts"), ""); + let resolved = ""; + await Effect.runPromise( + prepare([directory], { + ...options, + import: async (url) => { + resolved = url; + return { default: a }; + }, + }), + ); + expect(resolved).toBe(pathToFileURL(realpathSync(join(directory, "index.ts"))).href); + })); + it("requires a package name for native local export resolution", () => + withDirectory(async (directory) => { + await writeFile(join(directory, "package.json"), JSON.stringify({ exports: "./entry.js" })); + await writeFile(join(directory, "entry.js"), ""); + const error = await Effect.runPromise(prepare([directory], options).pipe(Effect.flip)); + expect(error.phase).toBe("source"); + expect(String(error.cause)).toContain("must declare its name"); })); it("resolves a manifest without exports through legacy main", () => withDirectory(async (directory) => { @@ -250,7 +276,7 @@ describe("plugin catalog and source resolution", () => { })); it("records an entrypoint inside the published installation", () => withDirectory(async (cache) => { - // `import-meta-resolve` realpaths its answer while the staging directory is not + // Node resolution realpaths its answer while the staging directory is not // realpathed, so a symlinked cache root used to record a path outside the entry. const installed = await Effect.runPromise(install(parse("fixture"), cache, fixture)); const file = fileURLToPath(installed.url); diff --git a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs index dc393fe..8f3e073 100644 --- a/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs +++ b/packages/harness/test/scripts/codework-sandbox-vercel-smoke.mjs @@ -63,13 +63,9 @@ try { // The smoke only loads JavaScript, so the optional native accelerators stay unbuilt. // pnpm fails the install on undeclared ignored build scripts, so decline them explicitly. - // Effect's own caret ranges match across prerelease tags, so a fresh install otherwise - // pairs a newer platform-node-shared with the effect version the harness is built against. await writeFile( resolve(consumer, "pnpm-workspace.yaml"), `${dedent` - overrides: - "@effect/platform-node-shared": 4.0.0-beta.107 allowBuilds: "@mongodb-js/zstd": false esbuild: false diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39e0cf5..60e2ac1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: .: devDependencies: + '@codeworksh-test/codework-sandbox-vercel': + specifier: workspace:* + version: link:extras/codework-sandbox-vercel '@effect/tsgo': specifier: 'catalog:' version: 0.36.4 @@ -39,8 +42,8 @@ importers: extras/codework-sandbox-vercel: dependencies: '@codeworksh/harness': - specifier: '*' - version: 0.0.1-dev.20260824142157(ioredis@5.11.1)(zod@4.4.3) + specifier: workspace:* + version: link:../../packages/harness '@vercel/sandbox': specifier: ^2.9.2 version: 2.9.2 @@ -170,6 +173,9 @@ importers: '@effect/platform-node': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) + '@effect/platform-node-shared': + specifier: 4.0.0-beta.107 + version: 4.0.0-beta.107(effect@4.0.0-beta.107) '@effect/sql-sqlite-node': specifier: 4.0.0-beta.107 version: 4.0.0-beta.107(effect@4.0.0-beta.107) @@ -179,18 +185,12 @@ importers: effect: specifier: 4.0.0-beta.107 version: 4.0.0-beta.107 - import-meta-resolve: - specifier: ^4.2.0 - version: 4.2.0 just-bash: specifier: ^2.14.5 version: 2.14.5 npm-package-arg: specifier: ^14.0.0 version: 14.0.0 - resolve.exports: - specifier: ^2.0.3 - version: 2.0.3 tslib: specifier: ^2.8.1 version: 2.8.1 @@ -204,9 +204,6 @@ importers: specifier: ^1.2.1 version: 1.2.1 devDependencies: - '@codeworksh-test/codework-sandbox-vercel': - specifier: workspace:* - version: link:../../extras/codework-sandbox-vercel '@daytona/sdk': specifier: ^0.187.0 version: 0.187.0(ws@8.21.1) @@ -379,16 +376,6 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} - '@codeworksh/aikit@0.7.0': - resolution: {integrity: sha512-ZXJrYy/tX8lYaQCNLw6BkdUFfxS6q23Vw8SicoMxIqJLpd1bxklWitSGJ3pWjT0I/CP2MABJWiqHjk3B+5gXwQ==} - engines: {node: '>=22.0.0'} - hasBin: true - - '@codeworksh/harness@0.0.1-dev.20260824142157': - resolution: {integrity: sha512-uoSWKH18GxKwPlMdgiKknUwR6kSerIsYe6DZCaiPQA2U9Eg6H6DOFK5a/AgB6a3T31jP8uWuywfvUejucbPWWQ==} - engines: {node: '>=24.14.1'} - hasBin: true - '@daytona/api-client@0.187.0': resolution: {integrity: sha512-riKOJ6eSuy67DL6iJlAa3Bfjnm4iQmkOdJk0B5hqrYMZeZmVDsgdiZtYvFpyoa+2KCZFNb0Gs5dQwO1d6NhGCw==} @@ -2484,9 +2471,6 @@ packages: resolution: {integrity: sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==} engines: {node: '>=18'} - import-meta-resolve@4.2.0: - resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -2952,10 +2936,6 @@ packages: resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -3671,53 +3651,6 @@ snapshots: '@borewit/text-codec@0.2.2': {} - '@codeworksh/aikit@0.7.0(zod@4.4.3)': - dependencies: - '@ai-sdk/anthropic': 4.0.36(zod@4.4.3) - '@ai-sdk/google': 4.0.39(zod@4.4.3) - '@ai-sdk/google-vertex': 5.0.48(zod@4.4.3) - '@ai-sdk/openai': 4.0.36(zod@4.4.3) - '@ai-sdk/openai-compatible': 3.0.28(zod@4.4.3) - '@ai-sdk/provider': 4.0.7 - '@ai-sdk/xai': 4.0.33(zod@4.4.3) - '@openrouter/ai-sdk-provider': 3.0.0(ai@7.0.58(zod@4.4.3))(zod@4.4.3) - ai: 7.0.58(zod@4.4.3) - dedent: 1.7.2 - partial-json: 0.1.7 - remeda: 2.39.0 - typebox: 1.3.12 - uuidv7: 1.2.1 - yargs: 18.1.0 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - zod - - '@codeworksh/harness@0.0.1-dev.20260824142157(ioredis@5.11.1)(zod@4.4.3)': - dependencies: - '@codeworksh/aikit': 0.7.0(zod@4.4.3) - '@daytona/sdk': 0.187.0(ws@8.21.1) - '@effect/platform-node': 4.0.0-beta.107(effect@4.0.0-beta.107)(ioredis@5.11.1) - '@effect/sql-sqlite-node': 4.0.0-beta.107(effect@4.0.0-beta.107) - '@platformatic/vfs': 0.4.0 - '@vercel/sandbox': 2.9.2 - effect: 4.0.0-beta.107 - just-bash: 2.14.5 - typebox: 1.3.12 - uuid: 14.0.1 - uuidv7: 1.2.1 - transitivePeerDependencies: - - babel-plugin-macros - - bare-abort-controller - - bufferutil - - debug - - ioredis - - react-native-b4a - - supports-color - - utf-8-validate - - ws - - zod - '@daytona/api-client@0.187.0': dependencies: axios: 1.19.0 @@ -5509,8 +5442,6 @@ snapshots: es-module-lexer: 2.3.1 module-details-from-path: 1.0.4 - import-meta-resolve@4.2.0: {} - inherits@2.0.4: {} ini@1.3.8: @@ -6014,8 +5945,6 @@ snapshots: transitivePeerDependencies: - supports-color - resolve.exports@2.0.3: {} - retry@0.13.1: {} rettime@0.11.11: From d6f1c1442ecb63cf644563c76d0da3f413d31c6a Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sat, 12 Sep 2026 23:35:27 +0530 Subject: [PATCH 10/12] feat(harness): flatten settings layout and prefer codework.json for projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the config subdirectory from the harness home, so user settings live at ~/.codework/settings.json directly. The project layer now resolves codework.json first and falls back to .codework/settings.json, so a project can use a single file or a directory — never both. --- packages/harness/src/global.ts | 3 - packages/harness/src/settings/settings.ts | 66 ++++++----- packages/harness/test/fixtures/settings.ts | 4 +- packages/harness/test/global.test.ts | 4 +- packages/harness/test/settings.test.ts | 121 ++++++++++++++++++++- 5 files changed, 158 insertions(+), 40 deletions(-) diff --git a/packages/harness/src/global.ts b/packages/harness/src/global.ts index d7172cc..a5d2311 100644 --- a/packages/harness/src/global.ts +++ b/packages/harness/src/global.ts @@ -24,7 +24,6 @@ export class Service extends Context.Service()("@codeworksh/ export interface Interface { readonly home: string; readonly cache: string; - readonly config: string; readonly data: string; readonly log: string; } @@ -34,7 +33,6 @@ export function make(input: Partial = {}): Interface { return { home, cache: input.cache ?? posix.join(home, "cache"), - config: input.config ?? posix.join(home, "config"), data: input.data ?? posix.join(home, "data"), log: input.log ?? posix.join(home, "log"), }; @@ -50,7 +48,6 @@ const build = (input: Partial) => const paths = yield* resolve(input); yield* Effect.all([ fileSystem.makeDirectory(paths.cache, { recursive: true }), - fileSystem.makeDirectory(paths.config, { recursive: true }), fileSystem.makeDirectory(paths.data, { recursive: true }), fileSystem.makeDirectory(paths.log, { recursive: true }), ]).pipe(Effect.orDie); diff --git a/packages/harness/src/settings/settings.ts b/packages/harness/src/settings/settings.ts index 905f1ee..2475948 100644 --- a/packages/harness/src/settings/settings.ts +++ b/packages/harness/src/settings/settings.ts @@ -1,28 +1,30 @@ /* - * @file Host discovery and loading for `.codework/config/settings.json`. + * @file Host discovery and loading for settings files. * - * Three files are read, lowest priority first, each merged onto the built-in defaults so + * Three layers are read, lowest priority first, each merged onto the built-in defaults so * that "no file anywhere" needs no special case -- zero patches over the defaults is a * valid result: * - * 1. `/settings.json` -- the user's own, `~/.codework/config` by default - * 2. `/.codework/config/settings.json` -- committed with the project + * 1. `/settings.json` -- the user's own, `~/.codework` by default + * 2. `/codework.json` or, when absent, `/.codework/settings.json` + * -- committed with the project; the single file + * wins, it is never merged with the directory * 3. `<--user-config-dir>/settings.json` -- explicit override, `~` expanded, relative to cwd * * **All three are host paths.** They are resolved from the process's startup directory and - * `Global.config`, never from a session's `--cwd`, its working directory, or its sandbox + * `Global.home`, never from a session's `--cwd`, its working directory, or its sandbox * mount. A session running in a remote or in-memory sandbox reads the same host files as - * every other session in the process; there is no per-project or per-sandbox settings file, + * every other session in the process; there is no per-session settings discovery * and no parent-directory search. The startup directory arrives as `options.cwd` and is * resolved once, so later `cd` or a session pointed elsewhere changes nothing. * - * `load` re-reads all three on every call. There is no cache to invalidate and no reload + * `load` re-reads all layers on every call. There is no cache to invalidate and no reload * API: an edit lands at the next exchange capture because the next capture goes to disk. * A missing file is ordinary. A malformed or unreadable one warns and contributes nothing, * so a typo in one layer cannot stop the layers around it from applying. */ -import { Context, Effect, Layer, Schema, SchemaIssue } from "effect"; +import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; import { homedir } from "node:os"; import { Global } from "../global.ts"; import { fileSystem, hostPath } from "../host.ts"; @@ -40,13 +42,19 @@ export interface Options { readonly cwd: string; } -export function paths(config: string, cwd: string, custom?: string): ReadonlyArray { +/** + * Ordered layers, each a group of candidates where the first file that exists is + * selected -- a group never contributes more than one file. The project layer's + * candidates are `codework.json` then `.codework/settings.json`, so a project can + * pick either layout and the single file takes precedence. + */ +export function paths(home: string, cwd: string, custom?: string): ReadonlyArray> { const expanded = custom === "~" ? homedir() : custom?.startsWith("~/") ? hostPath.join(homedir(), custom.slice(2)) : custom; return [ - hostPath.join(config, "settings.json"), - hostPath.join(cwd, Global.appConfigDir, "config", "settings.json"), - ...(expanded === undefined ? [] : [hostPath.resolve(cwd, expanded, "settings.json")]), + [hostPath.join(home, "settings.json")], + [hostPath.join(cwd, "codework.json"), hostPath.join(cwd, Global.appConfigDir, "settings.json")], + ...(expanded === undefined ? [] : [[hostPath.resolve(cwd, expanded, "settings.json")]]), ]; } @@ -99,22 +107,28 @@ export const layer = (options: Options) => Service, Effect.gen(function* () { const global = yield* Global.Service; - const files = paths(global.config, hostPath.resolve(options.cwd), options.userConfigDir); + const files = paths(global.home, hostPath.resolve(options.cwd), options.userConfigDir); + const attempt = (path: string) => + fileSystem.readFileString(path).pipe( + Effect.mapError((error) => new SettingsError({ path, reason: "read", detail: error.reason._tag })), + Effect.flatMap((source) => parse(path, source)), + ); const load = Effect.fn("Settings.load")(function* () { let settings = merge(defaults); - for (const path of files) { - const patch = yield* fileSystem.readFileString(path).pipe( - Effect.mapError((error) => new SettingsError({ path, reason: "read", detail: error.reason._tag })), - Effect.flatMap((source) => parse(path, source)), - Effect.catch((error) => - error.reason === "read" && error.detail === "NotFound" - ? Effect.succeed({}) - : Effect.logWarning(`Settings: ${error.path}: ${error.reason}: ${error.detail}`).pipe( - Effect.as({}), - ), - ), - ); - settings = merge(settings, patch); + for (const group of files) { + for (const path of group) { + const result = yield* Effect.result(attempt(path)); + if (Result.isSuccess(result)) { + settings = merge(settings, result.success); + break; + } + const error = result.failure; + // A missing candidate falls through to the next; anything else selects + // the file, warns, and the group contributes nothing. + if (error.reason === "read" && error.detail === "NotFound") continue; + yield* Effect.logWarning(`Settings: ${error.path}: ${error.reason}: ${error.detail}`); + break; + } } return settings; }); diff --git a/packages/harness/test/fixtures/settings.ts b/packages/harness/test/fixtures/settings.ts index 6b11c4a..5a5dad5 100644 --- a/packages/harness/test/fixtures/settings.ts +++ b/packages/harness/test/fixtures/settings.ts @@ -6,9 +6,9 @@ export const withSettings = async ( test: (input: { root: string; local: string; custom: string; global: string }) => Promise, ) => { const root = await mkdtemp(join(tmpdir(), "codework-settings-")); - const local = join(root, ".codework", "config"); + const local = join(root, ".codework"); const custom = join(root, "custom"); - const global = join(root, "home", "config"); + const global = join(root, "home"); try { await Promise.all([local, custom, global].map((dir) => mkdir(dir, { recursive: true }))); await test({ root, local, custom, global }); diff --git a/packages/harness/test/global.test.ts b/packages/harness/test/global.test.ts index 23b103c..f93a550 100644 --- a/packages/harness/test/global.test.ts +++ b/packages/harness/test/global.test.ts @@ -14,7 +14,6 @@ describe("global", () => { const home = path.resolve("custom-home"); const paths = make({ home }); expect(paths.cache).toBe(path.join(home, "cache")); - expect(paths.config).toBe(path.join(home, "config")); expect(paths.data).toBe(path.join(home, "data")); expect(paths.log).toBe(path.join(home, "log")); }); @@ -26,7 +25,7 @@ describe("global", () => { Effect.gen(function* () { const service = yield* Service; expect(service.home).toBe(home); - for (const directory of [service.cache, service.config, service.data, service.log]) { + for (const directory of [service.cache, service.data, service.log]) { expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true); } }).pipe(Effect.provide(layerWith({ home }))), @@ -65,7 +64,6 @@ describe("global", () => { const service = yield* Service; expect(service.home).toBe(home); expect(service.cache).toBe(cache); - expect(service.config).toBe(path.join(home, "config")); }).pipe( Effect.provide( layerWith({ diff --git a/packages/harness/test/settings.test.ts b/packages/harness/test/settings.test.ts index 90bb4bc..16c0d3d 100644 --- a/packages/harness/test/settings.test.ts +++ b/packages/harness/test/settings.test.ts @@ -1,4 +1,4 @@ -import { Effect, Layer } from "effect"; +import { Effect, Layer, Logger } from "effect"; import { mkdir, unlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; @@ -38,14 +38,123 @@ describe("host settings loader", () => { })); it("uses global, startup-local, custom paths and expands home", () => { - expect(paths("/home/config", "/startup", "relative")).toEqual([ - "/home/config/settings.json", - "/startup/.codework/config/settings.json", - "/startup/relative/settings.json", + expect(paths("/home", "/startup", "relative")).toEqual([ + ["/home/settings.json"], + ["/startup/codework.json", "/startup/.codework/settings.json"], + ["/startup/relative/settings.json"], ]); - expect(paths("/home/config", "/startup", "~/custom").at(-1)).toBe(join(homedir(), "custom/settings.json")); + expect(paths("/home", "/startup", "~/custom").at(-1)).toEqual([join(homedir(), "custom/settings.json")]); }); + it("loads codework.json as the project layer", () => + withSettings(async ({ root, custom }) => { + await writeFile(join(root, "codework.json"), JSON.stringify({ model: { thinkingLevel: "low" } })); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: join(root, "home") }))), + ); + const result = await Effect.runPromise( + Settings.Service.use((settings) => settings.load).pipe(Effect.provide(layer)), + ); + expect(result.model.thinkingLevel).toBe("low"); + expect(result.model.options?.maxRetries).toBe(3); + })); + + it("prefers codework.json over .codework/settings.json when both exist", () => + withSettings(async ({ root, local, custom }) => { + await writeFile( + join(root, "codework.json"), + JSON.stringify({ model: { thinkingLevel: "max", options: { maxRetries: 2 } } }), + ); + await writeFile( + join(local, "settings.json"), + JSON.stringify({ model: { thinkingLevel: "low", options: { maxRetries: 9, timeoutMs: 123 } } }), + ); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: join(root, "home") }))), + ); + const result = await Effect.runPromise( + Settings.Service.use((settings) => settings.load).pipe(Effect.provide(layer)), + ); + // The directory file is never consulted, so none of its values leak through. + expect(result.model.thinkingLevel).toBe("max"); + expect(result.model.options?.maxRetries).toBe(2); + expect(result.model.options?.timeoutMs).toBe(Settings.defaults.model.options?.timeoutMs); + })); + + it("merges global, codework.json, and custom settings without using global codework.json", () => + withSettings(async ({ root, global, custom }) => { + await writeFile( + join(global, "codework.json"), + JSON.stringify({ model: { options: { headers: { excluded: "yes" } } } }), + ); + await writeFile( + join(global, "settings.json"), + JSON.stringify({ model: { thinkingLevel: "low", options: { maxRetries: 2, timeoutMs: 100 } } }), + ); + await writeFile( + join(root, "codework.json"), + JSON.stringify({ model: { thinkingLevel: "high", options: { timeoutMs: 200 } } }), + ); + await writeFile(join(custom, "settings.json"), JSON.stringify({ model: { thinkingLevel: "max" } })); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: global }))), + ); + const result = await Effect.runPromise( + Settings.Service.use((settings) => settings.load).pipe(Effect.provide(layer)), + ); + expect(result.model).toMatchObject({ + thinkingLevel: "max", + options: { maxRetries: 2, timeoutMs: 200 }, + }); + expect(result.model.options?.headers).toEqual(Settings.defaults.model.options?.headers); + })); + + it("falls back to .codework/settings.json only when codework.json is missing", () => + withSettings(async ({ root, local, custom }) => { + await writeFile(join(local, "settings.json"), JSON.stringify({ model: { thinkingLevel: "low" } })); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: join(root, "home") }))), + ); + await Effect.runPromise( + Effect.gen(function* () { + const settings = yield* Settings.Service; + expect((yield* settings.load).model.thinkingLevel).toBe("low"); + yield* Effect.promise(() => + writeFile(join(root, "codework.json"), JSON.stringify({ model: { thinkingLevel: "max" } })), + ); + expect((yield* settings.load).model.thinkingLevel).toBe("max"); + yield* Effect.promise(() => unlink(join(root, "codework.json"))); + expect((yield* settings.load).model.thinkingLevel).toBe("low"); + }).pipe(Effect.provide(layer)), + ); + })); + + it("warns on a malformed codework.json without falling back to the directory file", () => + withSettings(async ({ root, local, custom }) => { + await writeFile(join(root, "codework.json"), '{"model":'); + await writeFile(join(local, "settings.json"), JSON.stringify({ model: { thinkingLevel: "low" } })); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: join(root, "home") }))), + ); + const logs: Array<{ level: string; message: unknown }> = []; + const logger = Logger.make((entry) => { + logs.push({ level: entry.logLevel, message: entry.message }); + }); + const result = await Effect.runPromise( + Settings.Service.use((settings) => settings.load).pipe( + Effect.provide(layer), + Effect.provide(Logger.layer([logger])), + ), + ); + expect(result.model.thinkingLevel).toBe(Settings.defaults.model.thinkingLevel); + expect(logs).toEqual([ + { + level: "Warn", + message: [expect.stringContaining(`Settings: ${join(root, "codework.json")}: parse: Invalid JSON`)], + }, + ]); + })); + it("loads fresh files for each exchange with no shared cache or mutations", () => withSettings(async ({ root, local, global, custom }) => { const write = (dir: string, model: object) => writeFile(join(dir, "settings.json"), JSON.stringify({ model })); From af324b5b657a93de1232ec07cb872dbd22023745 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 13 Sep 2026 00:32:59 +0530 Subject: [PATCH 11/12] feat(harness): adds support for plugins block for settings --- packages/harness/README.md | 19 ++- packages/harness/src/effect/harness.ts | 30 +++- packages/harness/src/settings/schema.ts | 7 + packages/harness/src/settings/settings.ts | 77 ++++++--- packages/harness/test/plugin.external.test.ts | 149 +++++++++++++++++- packages/harness/test/settings.test.ts | 69 ++++++++ 6 files changed, 312 insertions(+), 39 deletions(-) diff --git a/packages/harness/README.md b/packages/harness/README.md index de383bf..ac07d36 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -61,9 +61,24 @@ Hooks belong to the tool registration. Sequential or parallel scheduling, select Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle. -Omitting `plugins` selects Bash then the default prompt. An explicit array replaces that selection. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. +Omitting `plugins` selects Bash then the default prompt, followed by the host settings' `plugins` block. An explicit array replaces all of that, and an empty one runs nothing. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. -Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. Plugin discovery from settings and daemon lifecycles are not implemented. +Settings entries are strings only, and they extend the built-in selection instead of standing in for it, so naming one plugin cannot silently drop Bash or the prompt: + +```jsonc +// codework.json, ~/.codework/settings.json, or a --user-config-dir +{ "plugins": ["codework-acme-plugin", "@acme/codework-plugin@1.2.0", "./plugins/local.ts", "!codework.tool.bash"] } +``` + +The array replaces across settings layers rather than concatenating, so the highest-priority file that names `plugins` owns the whole list. A `./` or `../` path resolves against the directory of the file that declared it — next to `codework.json`, inside `.codework/`, or beside `~/.codework/settings.json` — so one entry means one file in every project. IDs, `!id` disables, `file:` URLs, and package specs are taken as written. + +Entries append after the built-ins, so a tool plugin added here registers after `codework.prompt.default` rendered its index — the usual ordering rule, not a special case for settings. Its tool reaches the provider with its own description but is absent from the system prompt's list. Re-list the prompt plugin to move it, since the last occurrence of an ID owns its position: + +```jsonc +{ "plugins": ["./plugins/read.ts", "codework.prompt.default"] } +``` + +Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. The selection is read once per `Harness.layer`, so an edited `plugins` block applies at the next construction; hot reload and daemon lifecycles are not implemented. Failures are attributed: a bad reference, unreadable module, or malformed plugin fails `Harness.layer` construction with `PluginPreparationError`, which carries the failing phase (`source`, `install`, `import`, or `definition`) and the index of the offending reference. A failing package install reports `PluginInstallError`; a plugin's `setup` failure becomes `Plugin.SetupError` with the plugin id, surfacing as a `SnapshotError` for that exchange. Plugins are trusted in-process code — local paths and `file:` references import whatever they point at, so only load sources you trust. diff --git a/packages/harness/src/effect/harness.ts b/packages/harness/src/effect/harness.ts index ce5d0a9..fb10605 100644 --- a/packages/harness/src/effect/harness.ts +++ b/packages/harness/src/effect/harness.ts @@ -21,6 +21,11 @@ import { Settings } from "../settings/settings.ts"; import { State } from "../state/state.ts"; export interface Options { + /** + * The complete selection, replacing both the built-ins and whatever `settings.plugins` + * asks for -- an embedder that passes this owns the plugin set, and an empty array runs + * none. Omit it to get the built-ins plus the settings block. + */ readonly plugins?: ReadonlyArray; readonly database?: string; readonly home?: string; @@ -38,9 +43,23 @@ export const layer = (options: Options = {}) => // takes the host directory as a required parameter, so no module can quietly // fall back to the OS process's directory when it meant a session's mount. const hostCwd = process.cwd(); - const plugins = yield* prepare(options.plugins ?? defaultRefs, { builtins, cache: paths.cache, hostCwd }); - const configuredDatabase = options.database ?? (yield* Database.locationConfig); const global = Global.layerWith(paths); + const settingsOptions = { + cwd: hostCwd, + ...(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }), + }; + // Settings are read once here because the plugin selection has to be prepared before + // any layer that depends on it; every later read goes through `Settings.Service`. + const config = yield* Settings.load({ ...settingsOptions, home: paths.home }); + // Settings entries extend the built-in selection rather than standing in for it, so + // naming a plugin cannot silently drop Bash or the default prompt. A built-in is turned + // off the same way as any other plugin, with a `!codework.tool.bash` entry. + const plugins = yield* prepare(options.plugins ?? [...defaultRefs, ...config.plugins], { + builtins, + cache: paths.cache, + hostCwd, + }); + const configuredDatabase = options.database ?? (yield* Database.locationConfig); const database = Database.layer(Database.resolveDatabaseLocation(configuredDatabase, paths.data)); const configured = yield* SandboxDriverLoader.loadAll(options.sandboxes ?? [], { hostCwd }); const drivers = SandboxDriverRegistry.layer( @@ -54,12 +73,7 @@ export const layer = (options: Options = {}) => return Control.layer.pipe( Layer.provideMerge(RunnerExecute.layer.pipe(Layer.provide(loop))), Layer.provideMerge(State.layer({}, plugins)), - Layer.provideMerge( - Settings.layer({ - cwd: hostCwd, - ...(options.userConfigDir === undefined ? {} : { userConfigDir: options.userConfigDir }), - }), - ), + Layer.provideMerge(Settings.layer(settingsOptions)), Layer.provideMerge(SessionRuntime.layer), Layer.provideMerge(sandboxes), Layer.provideMerge(Context.layer), diff --git a/packages/harness/src/settings/schema.ts b/packages/harness/src/settings/schema.ts index 83374aa..7a477b1 100644 --- a/packages/harness/src/settings/schema.ts +++ b/packages/harness/src/settings/schema.ts @@ -74,11 +74,17 @@ export const Model = Schema.Struct({ }); export const Patch = Schema.Struct({ $schema: Schema.optional(Schema.String), + plugins: Schema.optional(Schema.Array(Schema.String.check(Schema.isNonEmpty()))), model: Schema.optional(Model), }); export type Patch = typeof Patch.Type; export interface Info { + /** + * Plugin references added to the harness selection, in order. Like every array in a + * patch this replaces rather than concatenates, so one layer owns the whole list. + */ + readonly plugins: ReadonlyArray; readonly model: typeof Model.Type & { readonly provider: string; readonly id: string; @@ -89,6 +95,7 @@ export interface Info { /** Let aikit supply model-aware generation defaults. */ export const defaults: Info = { + plugins: [], model: { provider: "openai", id: "gpt-5.6-luna", diff --git a/packages/harness/src/settings/settings.ts b/packages/harness/src/settings/settings.ts index 2475948..c6468e3 100644 --- a/packages/harness/src/settings/settings.ts +++ b/packages/harness/src/settings/settings.ts @@ -102,37 +102,62 @@ export interface Interface { } export class Service extends Context.Service()("@codeworksh/harness/settings/settings/Service") {} +/** + * Anchor relative plugin entries to the file that declared them. + * + * A reference is otherwise resolved against the host startup directory, which is right for + * a project file sitting in it and meaningless for `~/.codework/settings.json`, where + * `./plugins/x.ts` would name a different file in every project the process is started in. + * IDs, `!id` disables, `file:` URLs, and package specs are left exactly as written. + */ +const anchor = (patch: Patch, file: string): Patch => { + if (patch.plugins === undefined) return patch; + const directory = hostPath.dirname(file); + return { + ...patch, + plugins: patch.plugins.map((entry) => + entry.startsWith("./") || entry.startsWith("../") ? hostPath.resolve(directory, entry) : entry, + ), + }; +}; + +const attempt = (path: string) => + fileSystem.readFileString(path).pipe( + Effect.mapError((error) => new SettingsError({ path, reason: "read", detail: error.reason._tag })), + Effect.flatMap((source) => parse(path, source)), + ); + +/** + * One read of every layer. Exported for the harness constructor, which needs the plugin + * selection before any layer is built; everything else goes through `Service`. + */ +export const load = Effect.fn("Settings.load")(function* (options: Options & { readonly home: string }) { + const files = paths(options.home, hostPath.resolve(options.cwd), options.userConfigDir); + let settings = merge(defaults); + for (const group of files) { + for (const path of group) { + const result = yield* Effect.result(attempt(path)); + if (Result.isSuccess(result)) { + settings = merge(settings, anchor(result.success, path)); + break; + } + const error = result.failure; + // A missing candidate falls through to the next; anything else selects + // the file, warns, and the group contributes nothing. + if (error.reason === "read" && error.detail === "NotFound") continue; + yield* Effect.logWarning(`Settings: ${error.path}: ${error.reason}: ${error.detail}`); + break; + } + } + return settings; +}); + export const layer = (options: Options) => Layer.effect( Service, Effect.gen(function* () { const global = yield* Global.Service; - const files = paths(global.home, hostPath.resolve(options.cwd), options.userConfigDir); - const attempt = (path: string) => - fileSystem.readFileString(path).pipe( - Effect.mapError((error) => new SettingsError({ path, reason: "read", detail: error.reason._tag })), - Effect.flatMap((source) => parse(path, source)), - ); - const load = Effect.fn("Settings.load")(function* () { - let settings = merge(defaults); - for (const group of files) { - for (const path of group) { - const result = yield* Effect.result(attempt(path)); - if (Result.isSuccess(result)) { - settings = merge(settings, result.success); - break; - } - const error = result.failure; - // A missing candidate falls through to the next; anything else selects - // the file, warns, and the group contributes nothing. - if (error.reason === "read" && error.detail === "NotFound") continue; - yield* Effect.logWarning(`Settings: ${error.path}: ${error.reason}: ${error.detail}`); - break; - } - } - return settings; - }); - return Service.of({ load: load() }); + return Service.of({ load: load({ ...options, home: global.home }) }); }), ); diff --git a/packages/harness/test/plugin.external.test.ts b/packages/harness/test/plugin.external.test.ts index e895902..9f4284c 100644 --- a/packages/harness/test/plugin.external.test.ts +++ b/packages/harness/test/plugin.external.test.ts @@ -1,7 +1,8 @@ import "./utils/env.ts"; import type { Message } from "@codeworksh/aikit"; import { Cause, Effect, Exit, Fiber, Schema, Stream } from "effect"; -import { join } from "node:path"; +import { join, relative } from "node:path"; +import { writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vite-plus/test"; import { Harness } from "../src/effect/harness.ts"; @@ -25,7 +26,8 @@ const pluginPath = (name: string) => join(dir, name); /** One `Session.create` + one `run`, capturing every provider request. */ const exchange = (input: { readonly root: string; - readonly plugins: ReadonlyArray; + readonly plugins?: ReadonlyArray; + readonly userConfigDir?: string; readonly llm?: LLM.Open; readonly prompt?: string; }) => { @@ -48,7 +50,8 @@ const exchange = (input: { prompts.push(request.context.systemPrompt ?? ""); return open(request, signal); }, - plugins: input.plugins, + ...(input.plugins === undefined ? {} : { plugins: input.plugins }), + ...(input.userConfigDir === undefined ? {} : { userConfigDir: input.userConfigDir }), }), ), Effect.scoped, @@ -57,6 +60,146 @@ const exchange = (input: { }; describe("third-party plugins", () => { + it.each(["global", "custom"] as const)( + "adds %s settings plugins to the built-ins and executes their hooks", + (source) => + withSettings(async ({ root, global, custom }) => { + const directory = source === "global" ? global : custom; + await writeFile( + join(directory, "settings.json"), + JSON.stringify({ + // The relative entry anchors to this file's directory, not to the process cwd. + plugins: [ + `./${relative(directory, pluginPath("tool/acme-echo"))}`, + pluginPath("prompt/acme-prompt.ts"), + ], + }), + ); + const { contexts, prompts, path } = await exchange({ + root, + userConfigDir: custom, + llm: toolTurn(pendingCall("acme_echo", { value: "settings" }, "call_settings")), + }); + // The built-in Bash tool and prompt survive: a settings entry adds, it does not select. + expect(contexts[0]?.tools?.map((tool) => tool.name)).toEqual(["bash", "acme_echo"]); + expect(prompts[0]?.endsWith("\n\nacme-marker")).toBe(true); + expect(JSON.parse(path[1]?.parts[0]?.data ?? "{}")).toMatchObject({ + status: "completed", + result: { + content: [ + { type: "text", text: "settings" }, + { type: "text", text: "(acme-checked)" }, + ], + }, + }); + }), + ); + + it("leaves the built-ins alone when no settings file asks for plugins", () => + withSettings(async ({ root, custom }) => { + await writeFile(join(custom, "settings.json"), JSON.stringify({ model: { id: "gpt-5.6-luna" } })); + const { contexts } = await exchange({ root, userConfigDir: custom }); + expect(contexts[0]?.tools?.map((tool) => tool.name)).toEqual(["bash"]); + })); + + it("indexes a settings tool in the prompt only when the prompt plugin is re-listed after it", () => + withSettings(async ({ root, custom }) => { + const write = (plugins: ReadonlyArray) => + writeFile(join(custom, "settings.json"), JSON.stringify({ plugins })); + const tool = pluginPath("tool/acme-echo"); + // Appended after the built-in prompt plugin, the tool is registered but unlisted: + // a prompt plugin sees only earlier contributions, from settings as from anywhere. + await write([tool]); + const appended = await exchange({ root, userConfigDir: custom }); + expect(appended.contexts[0]?.tools?.map((entry) => entry.name)).toEqual(["bash", "acme_echo"]); + expect(appended.prompts[0]).not.toContain("acme_echo"); + // Re-listing the prompt plugin moves it, since the last occurrence owns the position. + await write([tool, "codework.prompt.default"]); + const relisted = await exchange({ root, userConfigDir: custom }); + expect(relisted.prompts[0]).toContain("- acme_echo: Echo a value back"); + })); + + it("disables a built-in named with a leading bang in settings", () => + withSettings(async ({ root, custom }) => { + await writeFile(join(custom, "settings.json"), JSON.stringify({ plugins: ["!codework.tool.bash"] })); + const { contexts, prompts } = await exchange({ root, userConfigDir: custom }); + expect(contexts[0]?.tools ?? []).toEqual([]); + expect(prompts[0]).toContain("Available tools:\n(none)"); + })); + + it("lets an explicit option selection replace the settings plugins", () => + withSettings(async ({ root, custom }) => { + // The broken plugin would fail preparation if settings still contributed. + await writeFile( + join(custom, "settings.json"), + JSON.stringify({ plugins: [pluginPath("host/acme-broken.ts")] }), + ); + const { contexts } = await exchange({ + root, + userConfigDir: custom, + plugins: ["codework.tool.bash", "codework.prompt.default"], + }); + expect(contexts[0]?.tools?.map((tool) => tool.name)).toEqual(["bash"]); + })); + + it("honours an empty option selection over both the settings and the built-ins", () => + withSettings(async ({ root, custom }) => { + await writeFile( + join(custom, "settings.json"), + JSON.stringify({ plugins: [pluginPath("host/acme-broken.ts")] }), + ); + let calls = 0; + const failure = await Effect.runPromise( + Effect.gen(function* () { + const session = yield* Session.create({ directory: root }); + yield* session.prompt("hello"); + return yield* session.resume().pipe(Effect.flip); + }).pipe( + Effect.provide( + Harness.layer({ + home: join(root, "home"), + database: ":memory:", + userConfigDir: custom, + plugins: [], + llm: (request, signal) => { + calls++; + return immediateOpen()(request, signal); + }, + }), + ), + Effect.scoped, + ), + ); + expect(failure).toMatchObject({ + _tag: "State.SnapshotError", + cause: { + _tag: "Plugin.SetupError", + message: "plugin snapshot freeze failed: no prompt plugin set a system prompt", + }, + }); + expect(calls).toBe(0); + })); + + it("reports preparation failures from settings before calling the model", () => + withSettings(async ({ root, custom }) => { + await writeFile( + join(custom, "settings.json"), + JSON.stringify({ plugins: [pluginPath("host/acme-broken.ts")] }), + ); + let calls = 0; + await expect( + exchange({ + root, + userConfigDir: custom, + llm: (request, signal) => { + calls++; + return immediateOpen()(request, signal); + }, + }), + ).rejects.toMatchObject({ _tag: "PluginPreparationError", phase: "definition" }); + expect(calls).toBe(0); + })); + it("loads a directory package and a single file through real imports", async () => { const plugins = await Effect.runPromise( prepare([pluginPath("tool/acme-echo"), `file://${pluginPath("prompt/acme-prompt.ts")}`], { diff --git a/packages/harness/test/settings.test.ts b/packages/harness/test/settings.test.ts index 16c0d3d..e5f00a4 100644 --- a/packages/harness/test/settings.test.ts +++ b/packages/harness/test/settings.test.ts @@ -9,6 +9,75 @@ import { Settings, parse, paths } from "../src/settings/settings.ts"; import { withSettings } from "./fixtures/settings.ts"; describe("host settings loader", () => { + it.each(["codework-acme-plugin", [123], [{}], [""]])( + "rejects a plugins block that is not a string array: %j", + async (plugins) => { + const failure = await Effect.runPromise(parse("settings.json", JSON.stringify({ plugins })).pipe(Effect.flip)); + expect(failure).toMatchObject({ reason: "decode" }); + expect(failure.detail).toContain("plugins"); + }, + ); + + it("resolves plugin arrays by layer, replacing rather than concatenating", () => + withSettings(async ({ root, global, local, custom }) => { + const write = (path: string, plugins: ReadonlyArray) => writeFile(path, JSON.stringify({ plugins })); + const layer = Settings.layer({ cwd: root, userConfigDir: custom }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: global }))), + ); + await Effect.runPromise( + Effect.gen(function* () { + const settings = yield* Settings.Service; + expect((yield* settings.load).plugins).toEqual([]); + yield* Effect.promise(() => write(join(global, "settings.json"), ["codework-global-plugin"])); + expect((yield* settings.load).plugins).toEqual(["codework-global-plugin"]); + yield* Effect.promise(() => write(join(local, "settings.json"), ["codework-local-plugin"])); + expect((yield* settings.load).plugins).toEqual(["codework-local-plugin"]); + yield* Effect.promise(() => + write(join(root, "codework.json"), ["codework-acme-plugin", "codework-other-plugin"]), + ); + expect((yield* settings.load).plugins).toEqual(["codework-acme-plugin", "codework-other-plugin"]); + yield* Effect.promise(() => writeFile(join(root, "codework.json"), "{}")); + expect((yield* settings.load).plugins).toEqual(["codework-global-plugin"]); + yield* Effect.promise(() => write(join(root, "codework.json"), [])); + expect((yield* settings.load).plugins).toEqual([]); + yield* Effect.promise(() => write(join(custom, "settings.json"), ["codework-custom-plugin"])); + expect((yield* settings.load).plugins).toEqual(["codework-custom-plugin"]); + }).pipe(Effect.provide(layer)), + ); + })); + + it("anchors relative plugin entries to the file that declared them", () => + withSettings(async ({ root, global, local }) => { + const layer = Settings.layer({ cwd: root }).pipe( + Layer.provide(Layer.succeed(Global.Service, Global.make({ home: global }))), + ); + const entries = ["./plugins/one.ts", "../sibling/two.ts", "codework-acme-plugin", "!codework.tool.bash"]; + await Effect.runPromise( + Effect.gen(function* () { + const settings = yield* Settings.Service; + yield* Effect.promise(() => + writeFile(join(global, "settings.json"), JSON.stringify({ plugins: entries })), + ); + expect((yield* settings.load).plugins).toEqual([ + join(global, "plugins/one.ts"), + join(root, "sibling/two.ts"), + // IDs, disables and package specs pass through untouched. + "codework-acme-plugin", + "!codework.tool.bash", + ]); + // The project layer anchors to its own directory, which differs between the two layouts. + yield* Effect.promise(() => + writeFile(join(local, "settings.json"), JSON.stringify({ plugins: ["./plugins/one.ts"] })), + ); + expect((yield* settings.load).plugins).toEqual([join(local, "plugins/one.ts")]); + yield* Effect.promise(() => + writeFile(join(root, "codework.json"), JSON.stringify({ plugins: ["./plugins/one.ts"] })), + ); + expect((yield* settings.load).plugins).toEqual([join(root, "plugins/one.ts")]); + }).pipe(Effect.provide(layer)), + ); + })); + it("reports syntax locations and decode paths without exposing values", async () => { const malformed = '{\n"model": {"options": {"timeoutMs": 2,, "secret": "do-not-print"}}}'; const syntax = await Effect.runPromise(parse("broken.json", malformed).pipe(Effect.flip)); From 7755079e7ecf4f9865e7278a2bd4e095a1530aa9 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Sun, 13 Sep 2026 01:01:47 +0530 Subject: [PATCH 12/12] fixes(harness): bulletproof --- packages/codework/src/cli/error.ts | 42 ++++++++++++++++++- packages/codework/test/output.test.ts | 44 +++++++++++++++++++- packages/harness/README.md | 6 +-- packages/harness/src/event/event.ts | 18 +++++++- packages/harness/src/global.ts | 7 +--- packages/harness/src/plugin/host.ts | 6 ++- packages/harness/src/plugin/loader.ts | 8 +++- packages/harness/src/settings/settings.ts | 5 +-- packages/harness/src/util/home.ts | 11 +++++ packages/harness/test/event.log.test.ts | 27 ++++++++++++ packages/harness/test/plugin.catalog.test.ts | 8 +++- 11 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 packages/harness/src/util/home.ts diff --git a/packages/codework/src/cli/error.ts b/packages/codework/src/cli/error.ts index 28da997..2096efb 100644 --- a/packages/codework/src/cli/error.ts +++ b/packages/codework/src/cli/error.ts @@ -1,4 +1,4 @@ -import { Runner } from "@codeworksh/harness/effect"; +import { Plugin, Runner } from "@codeworksh/harness/effect"; import { SandboxProvider } from "@codeworksh/harness/sandbox"; import { Duration, Effect, Schema } from "effect"; import { writeError } from "./output.ts"; @@ -26,6 +26,8 @@ const isModelCatalogError = Schema.is(Runner.ModelCatalogError); const isModelNotFoundError = Schema.is(Runner.ModelNotFoundError); const isLLMStreamError = Schema.is(Runner.LLMStreamError); const isSandboxProviderError = Schema.is(SandboxProvider.SandboxProviderError); +const isPluginPreparationError = Schema.is(Plugin.PreparationError); +const isPluginInstallError = Schema.is(Plugin.InstallError); const providerCategory = (reason: Runner.ProviderFailureReason): string => { switch (reason._tag) { @@ -107,6 +109,25 @@ const unknownMessage = (error: unknown): string => { return "the command failed for an unknown reason"; }; +/** + * What the reader can do about a reference that failed to prepare. A plugin list is + * usually hand-written in a settings file, so the phase is worth translating. + */ +const pluginHint = (phase: Plugin.PreparationError["phase"]): string => { + switch (phase) { + case "source": + return "check the spelling; a path entry starts with `./`, `../`, `~/`, or `/`, and anything else is a package"; + case "install": + return "check the package name and version, and that the registry is reachable"; + case "import": + return "the module failed to load; import it directly to see its own error"; + case "definition": + return "a plugin module must default-export one object with a `setup` and a `vendor.domain.name` id"; + case "resolve": + return "the selection enables an ID that no entry defines; check for a typo or a missing source"; + } +}; + /** Render typed SDK errors for humans without exposing Effect causes or provider payloads. */ export const renderError = (error: unknown): string => { if (isInvalidInputError(error)) { @@ -130,6 +151,25 @@ export const renderError = (error: unknown): string => { ].join("\n") + "\n" ); } + if (isPluginPreparationError(error)) { + return ( + [ + `error[plugin]: failed to prepare plugin "${error.reference}"`, + `phase: ${error.phase}`, + ...(error.id === undefined ? [] : [`id: ${error.id}`]), + `detail: ${unknownMessage(error.cause)}`, + `hint: ${pluginHint(error.phase)}`, + ].join("\n") + "\n" + ); + } + if (isPluginInstallError(error)) { + return ( + [ + `error[plugin_install]: ${unknownMessage(error.cause)}`, + "hint: check the package name and version, and that the registry is reachable", + ].join("\n") + "\n" + ); + } if (isProviderError(error)) { const hint = providerHint(error); return ( diff --git a/packages/codework/test/output.test.ts b/packages/codework/test/output.test.ts index 7d3768b..1a728ab 100644 --- a/packages/codework/test/output.test.ts +++ b/packages/codework/test/output.test.ts @@ -1,7 +1,7 @@ /* @effect-diagnostics cryptoRandomUUID:off -- fixtures only need distinct message IDs. */ import { Message } from "@codeworksh/aikit"; import { describe, expect, it } from "vite-plus/test"; -import { Runner } from "@codeworksh/harness/effect"; +import { Plugin, Runner } from "@codeworksh/harness/effect"; import { SandboxProvider } from "@codeworksh/harness/sandbox"; import { renderError } from "../src/cli/error.ts"; import { addUsage, emptyUsage, header, usage } from "../src/cli/output.ts"; @@ -104,6 +104,48 @@ describe("CLI output", () => { expect(output).not.toContain("Runner.ProviderError"); }); + it("renders a plugin preparation failure with its reference and a phase hint", () => { + const output = renderError( + new Plugin.PreparationError({ + phase: "source", + index: 3, + reference: "codework-acme-plugn", + cause: new Error("Unsupported plugin package source: codework-acme-plugn"), + }), + ); + + expect(output).toContain('error[plugin]: failed to prepare plugin "codework-acme-plugn"'); + expect(output).toContain("phase: source"); + expect(output).toContain("detail: Unsupported plugin package source: codework-acme-plugn"); + expect(output).toContain("hint: check the spelling;"); + // The bare tag is what a settings typo used to print on its own. + expect(output).not.toContain("PluginPreparationError"); + }); + + it("names the unknown ID when a selection enables one nothing defines", () => { + const output = renderError( + new Plugin.PreparationError({ + phase: "resolve", + index: 2, + reference: "acme.tool.missing", + id: "acme.tool.missing", + cause: new Error("Unknown plugin ID: acme.tool.missing"), + }), + ); + + expect(output).toContain("id: acme.tool.missing"); + expect(output).toContain("hint: the selection enables an ID that no entry defines"); + }); + + it("renders a plugin install failure without the tag", () => { + const output = renderError( + new Plugin.InstallError({ cause: new Error("pnpm installation failed with exit code 1") }), + ); + + expect(output).toContain("error[plugin_install]: pnpm installation failed with exit code 1"); + expect(output).not.toContain("PluginInstallError"); + }); + it("renders the sanitized sandbox provider failure", () => { const output = renderError( new SandboxProvider.SandboxProviderError({ diff --git a/packages/harness/README.md b/packages/harness/README.md index ac07d36..b93e1a9 100644 --- a/packages/harness/README.md +++ b/packages/harness/README.md @@ -61,7 +61,7 @@ Hooks belong to the tool registration. Sequential or parallel scheduling, select Prompt plugins use `ctx.plugin.prompt.get()` and `set(string)`. Each `set` replaces the entire prompt, including with an empty string. Place a prompt plugin after the tools or prompt contributors it needs. Contributions close after setup; plugins receive event publication but no subscription or background lifecycle. -Omitting `plugins` selects Bash then the default prompt, followed by the host settings' `plugins` block. An explicit array replaces all of that, and an empty one runs nothing. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. +Omitting `plugins` selects Bash then the default prompt, followed by the host settings' `plugins` block. An explicit array replaces all of that, and an empty one runs nothing — which is not a usable harness: `freeze` requires a system prompt, so a selection without a prompt plugin fails every exchange with `SnapshotError("no prompt plugin set a system prompt")`. Every working selection ends with a prompt plugin, whether `codework.prompt.default` or your own. Entries may be plugin objects, IDs, `!vendor.domain.name` to disable an ID, local paths/file URLs, or package specs such as `@acme/codework-plugin@1.2.0`. Source modules must default-export one plugin object. Definitions load before selection; the last occurrence of each ID determines whether it runs and its position. Settings entries are strings only, and they extend the built-in selection instead of standing in for it, so naming one plugin cannot silently drop Bash or the prompt: @@ -70,7 +70,7 @@ Settings entries are strings only, and they extend the built-in selection instea { "plugins": ["codework-acme-plugin", "@acme/codework-plugin@1.2.0", "./plugins/local.ts", "!codework.tool.bash"] } ``` -The array replaces across settings layers rather than concatenating, so the highest-priority file that names `plugins` owns the whole list. A `./` or `../` path resolves against the directory of the file that declared it — next to `codework.json`, inside `.codework/`, or beside `~/.codework/settings.json` — so one entry means one file in every project. IDs, `!id` disables, `file:` URLs, and package specs are taken as written. +The array replaces across settings layers rather than concatenating, so the highest-priority file that names `plugins` owns the whole list. A leading `~` expands to the home directory. A `./` or `../` path resolves against the directory of the file that declared it — next to `codework.json`, inside `.codework/`, or beside `~/.codework/settings.json` — so one entry means one file in every project. IDs, `!id` disables, `file:` URLs, and package specs are taken as written. Entries append after the built-ins, so a tool plugin added here registers after `codework.prompt.default` rendered its index — the usual ordering rule, not a special case for settings. Its tool reaches the provider with its own description but is absent from the system prompt's list. Re-list the prompt plugin to move it, since the last occurrence of an ID owns its position: @@ -78,7 +78,7 @@ Entries append after the built-ins, so a tool plugin added here registers after { "plugins": ["./plugins/read.ts", "codework.prompt.default"] } ``` -Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. The selection is read once per `Harness.layer`, so an edited `plugins` block applies at the next construction; hot reload and daemon lifecycles are not implemented. +Package sources install with pnpm, with lifecycle scripts disabled, under the harness home cache. The installer inherits stderr, so a first install prints pnpm's own progress and errors to the terminal — and a `plugins` entry in a settings file means that can happen during `Harness.layer` construction, before any session exists. An omitted version means `latest` on the first installation; subsequent constructions reuse that completed installation. The selection is read once per `Harness.layer`, so an edited `plugins` block applies at the next construction; hot reload and daemon lifecycles are not implemented. Failures are attributed: a bad reference, unreadable module, or malformed plugin fails `Harness.layer` construction with `PluginPreparationError`, which carries the failing phase (`source`, `install`, `import`, or `definition`) and the index of the offending reference. A failing package install reports `PluginInstallError`; a plugin's `setup` failure becomes `Plugin.SetupError` with the plugin id, surfacing as a `SnapshotError` for that exchange. Plugins are trusted in-process code — local paths and `file:` references import whatever they point at, so only load sources you trust. diff --git a/packages/harness/src/event/event.ts b/packages/harness/src/event/event.ts index 4e804e7..8defc96 100644 --- a/packages/harness/src/event/event.ts +++ b/packages/harness/src/event/event.ts @@ -365,11 +365,21 @@ export const layer = Layer.effect( ) { // The SQL filter is built from these same keys, so a row here always matches. const definition = definitions.get(row.type)!; + // The envelope's version is the stored row's, read back off its definition; a default + // would label the row with a version nothing wrote. Callers filter non-durable + // definitions out before this, so the guard is a backstop, not a code path. + if (definition.durable === undefined) + return yield* Effect.die( + new InvalidDurableEventError({ + type: definition.type, + message: `Unknown durable event type ${definition.type}`, + }), + ); const data = yield* Schema.decodeEffect(definition.data as Schema.Codec)(row.data); return { id: row.id, type: definition.type, - durable: { aggregateId: row.aggregateId, seq: row.seq, version: definition.durable?.version ?? 0 }, + durable: { aggregateId: row.aggregateId, seq: row.seq, version: definition.durable.version }, data, } as Payload; }); @@ -392,7 +402,11 @@ export const layer = Layer.effect( limit: PAGE_SIZE + 1, }); const page = rows.slice(0, PAGE_SIZE); - const events = yield* Effect.forEach(page, (row) => decodeLogRow(row, input.definitions)); + // Skip a type the manifest does not carry as durable rather than failing the read: the + // aggregate may hold rows this process cannot decode. `seq` below comes off the raw + // tail, so cursors advance across the gap. + const decodable = page.filter((row) => input.definitions.get(row.type)?.durable !== undefined); + const events = yield* Effect.forEach(decodable, (row) => decodeLogRow(row, input.definitions)); // `seq` is the stored row's, not one read back off a decoded value: the // window advances on what the table actually holds. return { events, hasMore: rows.length > PAGE_SIZE, seq: page.at(-1)?.seq }; diff --git a/packages/harness/src/global.ts b/packages/harness/src/global.ts index a5d2311..e25513f 100644 --- a/packages/harness/src/global.ts +++ b/packages/harness/src/global.ts @@ -1,6 +1,7 @@ import { Config, Context, Effect, Layer } from "effect"; import * as os from "node:os"; import { fileSystem } from "./host.ts"; +import { expandTilde } from "./util/home.ts"; import { posix } from "./util/posix.ts"; export const appConfigDir = ".codework"; @@ -8,11 +9,7 @@ export const app = "codework"; const defaultHome = posix.join(os.homedir(), appConfigDir); -function expandHome(value: string) { - if (value === "~") return os.homedir(); - if (value.startsWith("~/")) return posix.join(os.homedir(), value.slice(2)); - return posix.resolve(value); -} +const expandHome = (value: string) => posix.resolve(expandTilde(value, posix)); export const homeConfig = Config.string("CODEWORK_HOME_DIR").pipe( Config.withDefault(defaultHome), diff --git a/packages/harness/src/plugin/host.ts b/packages/harness/src/plugin/host.ts index 36aca29..8fee171 100644 --- a/packages/harness/src/plugin/host.ts +++ b/packages/harness/src/plugin/host.ts @@ -39,8 +39,10 @@ export const run = Effect.fn("PluginHost.run")(function* ( if (Cause.hasInterrupts(cause)) return Effect.failCause(cause); // Typed failures are already SetupErrors; wrap only defects (sync throws, dies) // so the original plugin error is never nested twice. `Schema.is` on a tagged - // error class is an identity check, so a plugin throwing its own - // SetupError-shaped object still gets attributed to it. + // error class matches any Error carrying the same `_tag` and fields -- another + // class with this tag passes, while a plain object does not, lacking the Error + // prototype -- so a plugin throwing a bare `{ _tag: "Plugin.SetupError" }` is + // wrapped and attributed here like any other defect. const squashed = Cause.squash(cause); return Effect.fail( Schema.is(SetupError)(squashed) diff --git a/packages/harness/src/plugin/loader.ts b/packages/harness/src/plugin/loader.ts index fcaf59b..79aaa7e 100644 --- a/packages/harness/src/plugin/loader.ts +++ b/packages/harness/src/plugin/loader.ts @@ -1,6 +1,7 @@ import { Effect, Predicate, Schema } from "effect"; import { createRequire } from "node:module"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { expandTilde } from "../util/home.ts"; import { importModule, resolveModule } from "../util/module.ts"; import { fileSystem as fs, hostPath as path } from "../host.ts"; import * as Package from "./package.ts"; @@ -61,8 +62,11 @@ export const classify = (source: string, hostCwd: string): Source => { if (!source.startsWith("file:///")) throw new Error(`not an absolute file URL: ${source}`); return { kind: "local", path: fileURLToPath(source) }; } - if (source.startsWith("./") || source.startsWith("../") || path.isAbsolute(source)) { - return { kind: "local", path: path.resolve(hostCwd, source) }; + // `~` before the package branch: npm names cannot start with it, and `npa` would otherwise + // read `~` as a package and `~/x` as an unsupported spec, reporting a path as a bad package. + const expanded = expandTilde(source, path); + if (expanded.startsWith("./") || expanded.startsWith("../") || path.isAbsolute(expanded)) { + return { kind: "local", path: path.resolve(hostCwd, expanded) }; } if (Schema.is(Id)(source)) return { kind: "id", id: source }; return { kind: "package", request: Package.parse(source) }; diff --git a/packages/harness/src/settings/settings.ts b/packages/harness/src/settings/settings.ts index c6468e3..edaff32 100644 --- a/packages/harness/src/settings/settings.ts +++ b/packages/harness/src/settings/settings.ts @@ -25,9 +25,9 @@ */ import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { homedir } from "node:os"; import { Global } from "../global.ts"; import { fileSystem, hostPath } from "../host.ts"; +import { expandTilde } from "../util/home.ts"; import { merge, normalize } from "./merge.ts"; import { defaults, Patch, type Info } from "./schema.ts"; @@ -49,8 +49,7 @@ export interface Options { * pick either layout and the single file takes precedence. */ export function paths(home: string, cwd: string, custom?: string): ReadonlyArray> { - const expanded = - custom === "~" ? homedir() : custom?.startsWith("~/") ? hostPath.join(homedir(), custom.slice(2)) : custom; + const expanded = custom === undefined ? undefined : expandTilde(custom, hostPath); return [ [hostPath.join(home, "settings.json")], [hostPath.join(cwd, "codework.json"), hostPath.join(cwd, Global.appConfigDir, "settings.json")], diff --git a/packages/harness/src/util/home.ts b/packages/harness/src/util/home.ts new file mode 100644 index 0000000..d220bfe --- /dev/null +++ b/packages/harness/src/util/home.ts @@ -0,0 +1,11 @@ +import { homedir } from "node:os"; + +/** + * Expand a leading `~`, leaving every other value untouched for the caller to resolve. + * + * The path module is the caller's, because the home directory is host-native while some + * callers spell their paths POSIX-only. Anything beyond the tilde is not this function's + * business: it never resolves, normalizes, or validates. + */ +export const expandTilde = (value: string, path: { readonly join: (...parts: ReadonlyArray) => string }) => + value === "~" ? homedir() : value.startsWith("~/") ? path.join(homedir(), value.slice(2)) : value; diff --git a/packages/harness/test/event.log.test.ts b/packages/harness/test/event.log.test.ts index 833cbee..2c6b73e 100644 --- a/packages/harness/test/event.log.test.ts +++ b/packages/harness/test/event.log.test.ts @@ -334,6 +334,33 @@ describe("Event.log", () => { expect(first.durable?.seq).toBe(0); })); + it("skips a row whose manifest entry is not durable instead of failing the read", () => + Effect.gen(function* () { + const events = yield* Event.Service; + const topic = "plugin:test.foreign:nondurable"; + yield* events.publish(Foreign, { topic, note: "one" }); + + // `EventSchema.durable` drops non-durable definitions, so only a hand-built manifest + // can key one under a stored type. The row is skipped, as in opencode's Bus, rather + // than decoded with a version nothing wrote. + const Ephemeral = EventSchema.define({ + type: "test.foreign.happened", + schema: { topic: Schema.String, note: Schema.String }, + }); + const handBuilt = new Map([[EventSchema.versionedType("test.foreign.happened", 1), Ephemeral]]); + const items = Array.from( + yield* events.log({ aggregateId: topic, definitions: handBuilt }).pipe(Stream.runCollect), + ); + expect(items.filter((item) => !Event.isSynced(item))).toEqual([]); + expect(items.filter(Event.isSynced)).toHaveLength(1); + + // The same row still decodes through its durable definition. + const decoded = Array.from( + yield* events.log({ aggregateId: topic, definitions: foreignDefinitions }).pipe(Stream.runCollect), + ); + expect(decoded.filter((item) => !Event.isSynced(item))).toHaveLength(1); + })); + it("pages custom definitions across many pages and resumes from a cursor", () => Effect.gen(function* () { const events = yield* Event.Service; diff --git a/packages/harness/test/plugin.catalog.test.ts b/packages/harness/test/plugin.catalog.test.ts index 97b6325..e778c58 100644 --- a/packages/harness/test/plugin.catalog.test.ts +++ b/packages/harness/test/plugin.catalog.test.ts @@ -2,7 +2,7 @@ import { Deferred, Effect, Exit, Fiber } from "effect"; import { createHash } from "node:crypto"; import { existsSync, realpathSync } from "node:fs"; import { mkdtemp, mkdir, writeFile, rm, readdir, symlink, utimes } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import { join, relative } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vite-plus/test"; @@ -118,6 +118,12 @@ describe("plugin catalog and source resolution", () => { expect(classify(`${a.id}@latest`, "/project")).toEqual({ kind: "package", request: parse(`${a.id}@latest`) }); // An ID is exactly three segments; a fourth belongs to a package name. expect(classify("acme.tool.deep.name", "/project").kind).toBe("package"); + // A `~` reference is a path: npa would read `~` as a package and `~/x` as a bad spec. + expect(classify("~/plugins/one.ts", "/project")).toEqual({ + kind: "local", + path: join(homedir(), "plugins/one.ts"), + }); + expect(classify("~", "/project")).toEqual({ kind: "local", path: homedir() }); expect(classify(`!${a.id}`, "/project")).toEqual({ kind: "disable", id: a.id }); expect(() => classify("!", "/project")).toThrow(); // `fileURLToPath` would silently turn this into `/rel.ts`.