Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
5347a33
Added ACP support...
prem315 Sep 24, 2026
cf39091
fix(acp): replay conversation history on session/load
prem315 Sep 25, 2026
7c5aec1
chore: merge dev into feature/acp-support
claude Sep 26, 2026
9e6fc40
docs: add ACP integration review report
claude Sep 26, 2026
6972716
refactor: map Effect errors to ACP request errors and clean up module…
prem315 Sep 26, 2026
fce0eb8
Merge branch 'dev' into feature/acp-support
sanchitrk Sep 29, 2026
1988fe7
feat(harness): expose tool call input and plugin load failures as events
sanchitrk Sep 29, 2026
0546993
feat(acp): serve ACP v1 on a vendored Effect-native protocol
sanchitrk Sep 29, 2026
7c7c66c
feat(harness): title sessions from their first prompt
sanchitrk Sep 29, 2026
565a158
feat(acp): announce session titles to the editor
sanchitrk Sep 29, 2026
822db8f
feat(aikit): export getEnvApiKey
sanchitrk Sep 29, 2026
df6f84f
feat(harness): list models by credential with ModelCatalog.available
sanchitrk Sep 29, 2026
2d11b1a
fix(acp): offer models from the harness credential file
sanchitrk Sep 29, 2026
ac82cc6
feat(settings): read custom catalog entries from settings.models
sanchitrk Sep 29, 2026
d986339
feat(acp): offer settings-defined models
sanchitrk Sep 29, 2026
581ac92
feat(harness): persist session model and thinking level
sanchitrk Sep 29, 2026
296a0a0
refactor(acp): read session configuration from the harness
sanchitrk Sep 29, 2026
a5cd11d
fix(aikit): replace images with placeholders for non-vision models
sanchitrk Sep 29, 2026
94e99ce
docs: add @codeworksh/aikit@0.9.3 release notes
sanchitrk Sep 29, 2026
a5fdf9d
release: @codeworksh/aikit@0.9.3
sanchitrk Sep 29, 2026
4006c2a
rm
sanchitrk Sep 29, 2026
9b9a34c
chore
sanchitrk Sep 30, 2026
2607ca6
feat(harness): accept image parts in prompts
sanchitrk Sep 30, 2026
eadbc08
feat(acp): accept image prompts
sanchitrk Sep 30, 2026
2b4deec
test(acp): run live cases against six providers
sanchitrk Sep 30, 2026
c6234e9
test(harness): run live cases against six providers
sanchitrk Sep 30, 2026
2b9d1a3
test(aikit): run OpenRouter suites on three models and Codex from the…
sanchitrk Sep 30, 2026
a661cf4
chore: update AGENTS.md and CLAUDE.md
sanchitrk Sep 30, 2026
2db10a3
refactor(acp): serve over the official ACP SDK
sanchitrk Sep 30, 2026
6d3c55f
test(cli): load .env.local for the ACP live suites
sanchitrk Sep 30, 2026
c1aa54d
test(cli): move the test env loader to test/utils like aikit
sanchitrk Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(harness): persist session model and thinking level
Revive the durable `session.config.changed` event and project it into a new
`session.config` JSON column, so a session's chosen model and thinking level
survive a restart. `Session.create` and `Session.attach` publish it; the
in-memory bindings keep only process-local options. Expose
`Session.configuration(id)`, resolved by the same composition a turn uses.

Refs COD-79

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Sep 29, 2026
commit 581ac9279755949206969164e5e280d178efc56d
9 changes: 9 additions & 0 deletions packages/harness/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,13 @@ export const migrations = {
// read. A session can be given one later without being re-created.
yield* sql`ALTER TABLE session ADD COLUMN host_dir TEXT`;
}),

"202609290001_session_config": Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

// The model and thinking level chosen for this session, as a JSON object
// (`SessionSchema.Config`). Projected from `session.config.changed`, which
// merges into it. NULL: nothing chosen, the session follows its settings.
yield* sql`ALTER TABLE session ADD COLUMN config TEXT`;
}),
};
2 changes: 2 additions & 0 deletions packages/harness/src/db/schema.sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ export class SessionRow extends Model.Class<SessionRow>("SessionRow")({
directory: AbsolutePath,
// The host directory anchor this session belongs to.
hostDir: Model.FieldOption(AbsolutePath),
// The model and thinking level chosen over settings; see `SessionSchema.Config`.
config: Model.FieldOption(Model.JsonFromString(SessionSchema.Config)),
title: Schema.String,
tag: Model.FieldOption(Schema.String),
metadata: Model.FieldOption(Model.JsonFromString(Metadata)),
Expand Down
37 changes: 33 additions & 4 deletions packages/harness/src/effect/session.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Model } from "@codeworksh/aikit";
import { Effect, Option, Stream } from "effect";
import { DateTime, Effect, Option, Stream } from "effect";
import * as Control from "../control.ts";
import * as Event from "../event/event.ts";
import { EventList } from "../event/list.ts";
import type { EventSchema } from "../event/schema.ts";
import { Location } from "../location/location.ts";
import * as SandboxController from "../sandbox/control.ts";
Expand All @@ -14,7 +15,7 @@ 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 { State } from "../state/state.ts";
import { State } from "../state/state.ts";
import { rooted } from "../util/path.ts";
import type { Info as SandboxInfo } from "./sandbox.ts";

Expand Down Expand Up @@ -103,15 +104,28 @@ export interface Handle {
readonly path: () => Effect.Effect<ReadonlyArray<SessionStore.HydratedEntry>>;
}

/** What stays in this process. The model and thinking level are the session's {@link chosen} config. */
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?.execution === undefined ? {} : { toolExecution: input.tools.execution }),
...(input.systemPrompt?.custom === undefined ? {} : { promptCustom: input.systemPrompt.custom }),
...(input.systemPrompt?.append === undefined ? {} : { promptSystemAppend: input.systemPrompt.append }),
});

/** The keys of the session's durable config this input names. */
const chosen = (input: RuntimeInput): SessionSchema.Config => ({
...(input.model === undefined ? {} : { model: { provider: input.model.provider, id: input.model.id } }),
...(input.thinkingLevel === undefined ? {} : { thinkingLevel: input.thinkingLevel }),
});

/** Records a config choice; the projector merges it into the session row in the same commit. */
const choose = Effect.fn("Session.choose")(function* (sessionId: SessionSchema.ID, input: RuntimeInput) {
const config = chosen(input);
if (Object.keys(config).length === 0) return;
const events = yield* Event.Service;
yield* events.publish(EventList.ConfigChanged, { sessionId, timestamp: yield* DateTime.now, ...config });
});

const promptInput = (input: PromptInput) => {
const value = typeof input === "string" ? { text: input } : input;
return {
Expand Down Expand Up @@ -191,6 +205,7 @@ export const create = Effect.fn("Session.create")(function* (input: CreateInput
...(input.hostDir === undefined ? {} : { hostDir: declaredHostDir(input.hostDir) }),
});
yield* runtime.set(id, runtimeBindings(input));
yield* choose(id, input);
return yield* makeHandle(id);
});

Expand Down Expand Up @@ -283,9 +298,23 @@ export const attach = Effect.fn("Session.attach")(function* (input: AttachInput)
* the only config layer, so there is nothing behind them to restore what a wipe took.
*/
yield* runtime.update(input.sessionId, runtimeBindings(input));
yield* choose(input.sessionId, input);
return found.value;
});

/**
* The model and thinking level the session's next exchange runs with. A choice made through
* {@link create} or {@link attach} is saved with the session and outlives the process; without
* one, the session follows its settings.
*/
export const configuration = Effect.fn("Session.configuration")(function* (sessionId: SessionSchema.ID) {
const sessions = yield* SessionStore.Service;
if (Option.isNone(yield* sessions.get(sessionId))) {
return yield* new SessionStore.SessionNotFoundError({ sessionId });
}
return yield* State.Service.use((state) => state.configuration(sessionId));
});

export { AbsolutePath, SessionMessageSchema, SessionSchema };

export * as Session from "./session.ts";
24 changes: 9 additions & 15 deletions packages/harness/src/event/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,21 +38,13 @@ export const Prompted = EventSchema.define({
});
export type Prompted = typeof Prompted.Type;

// export const ConfigChanged = EventSchema.define({
// type: "session.config.changed",
// ...durableOptions,
// schema: {
// ...baseOptions,
// model: optional(
// Schema.Struct({
// providerId: Schema.String,
// modelId: Schema.String,
// }),
// ),
// thinkingLevel: optional(Schema.Literals(["off", "minimal", "low", "medium", "high", "xhigh", "max"])),
// },
// });
// export type ConfigChanged = typeof ConfigChanged.Type;
/** A session's model or thinking level was chosen. Carries only the keys that changed. */
export const ConfigChanged = EventSchema.define({
type: "session.config.changed",
...durableOptions,
schema: { ...baseOptions, ...SessionSchema.Config.fields },
});
export type ConfigChanged = typeof ConfigChanged.Type;

const LLMFields = {
...baseOptions,
Expand Down Expand Up @@ -273,6 +265,7 @@ export const Definitions = EventSchema.inventory(
ExecutionInterrupted,
PromptAdmitted,
Prompted,
ConfigChanged,
SessionForked,
TurnStarted,
TurnEnded,
Expand Down Expand Up @@ -302,6 +295,7 @@ export const PublicDefinitions = Definitions;
export const DurableDefinitions = EventSchema.inventory(
PromptAdmitted,
Prompted,
ConfigChanged,
SessionForked,
TurnEnded,
LLMStarted,
Expand Down
5 changes: 5 additions & 0 deletions packages/harness/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ export const layer = Layer.effectDiscard(
}),
);

yield* events.project(EventList.ConfigChanged, (event) => {
const { sessionId, timestamp: _timestamp, ...config } = event.data;
return sessions.configure(sessionId, config);
});

yield* events.project(EventList.LLMStarted, (event) =>
Effect.gen(function* () {
if (event.durable === undefined) return yield* Effect.die("LLMStarted is missing its aggregate sequence");
Expand Down
3 changes: 2 additions & 1 deletion packages/harness/src/session/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import type { State } from "../state/state.ts";
* Per-session configuration, held in process.
*
* These bindings override file settings and the caller's `State.layer(options)` -- nothing here is written to the session log, so nothing
* survives a restart. `Session.attach` merges into it rather than replacing it.
* survives a restart. `Session.attach` merges into it rather than replacing it. The model and
* thinking level are not held here: they are the session's durable `SessionSchema.Config`.
*/
export type Bindings = State.Options;

Expand Down
16 changes: 15 additions & 1 deletion packages/harness/src/session/schema.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Model } from "@codeworksh/aikit";
import { Schema } from "effect";
import { NonNegativeCost, NonNegativeInt } from "../schema.ts";
import { NonNegativeCost, NonNegativeInt, optional } from "../schema.ts";

// Session identity lives in `@codeworksh/plugin`: it is on every plugin context and every
// tool call, so the brand a plugin holds has to be the one the harness mints.
Expand All @@ -26,6 +27,19 @@ export const titleFrom = (text: string): string | undefined => {
return `${(space > 0 ? cut.slice(0, space) : cut).trimEnd()}…`;
};

export const ThinkingLevel = Schema.Literals(Object.values(Model.ThinkingLevelEnum));

/**
* The model and thinking level chosen for a session, over its settings. Every key is optional:
* an absent one means the session follows its settings. `session.config.changed` carries only the
* keys it changes, and the `session.config` column holds them merged.
*/
export const Config = Schema.Struct({
model: optional(Schema.Struct({ provider: Schema.String, id: Schema.String })),
thinkingLevel: optional(ThinkingLevel),
});
export type Config = typeof Config.Type;

/**
* Why an execution stopped. Supplied by whoever asked for the interruption --
* a stop the user asked for and a stop the process imposed on shutdown are
Expand Down
21 changes: 21 additions & 0 deletions packages/harness/src/session/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ export interface Interface {
* so a title someone chose is never overwritten. Returns whether the title changed.
*/
readonly retitle: (sessionId: SessionSchema.ID, title: string) => Effect.Effect<boolean>;
/**
* Merge chosen keys into a session's {@link SessionSchema.Config}; keys not named keep their
* value. The projection of `session.config.changed`, so it runs inside that event's commit.
*/
readonly configure: (sessionId: SessionSchema.ID, config: SessionSchema.Config) => Effect.Effect<void>;
readonly get: (sessionId: SessionSchema.ID) => Effect.Effect<Option.Option<SessionRow>>;
/** The space a session attaches to — its env and absolute location. None when the session is unknown. */
readonly space: (sessionId: SessionSchema.ID) => Effect.Effect<Option.Option<SpaceSchema.Info>>;
Expand Down Expand Up @@ -499,6 +504,7 @@ export const layer = Layer.effect(
slug: input.slug,
directory: input.directory,
hostDir: Option.fromUndefinedOr(input.hostDir),
config: Option.none(),
title: input.title,
tag: Option.fromUndefinedOr(input.tag),
metadata: Option.fromUndefinedOr(input.metadata as Record<string, string> | undefined),
Expand Down Expand Up @@ -1021,6 +1027,8 @@ export const layer = Layer.effect(
directory: source.value.directory,
// A fork continues the same work, so it reads the same project.
hostDir: source.value.hostDir,
// And runs the same model, until the fork chooses another.
config: source.value.config,
title: input.title ?? source.value.title,
tag: input.tag === undefined ? source.value.tag : Option.some(input.tag),
metadata: source.value.metadata,
Expand Down Expand Up @@ -1164,6 +1172,18 @@ export const layer = Layer.effect(
return changed.length > 0;
});

const encodeConfig = Schema.encodeEffect(Schema.fromJsonString(SessionSchema.Config));
const configure = Effect.fn("Session.configure")(function* (
sessionId: SessionSchema.ID,
config: SessionSchema.Config,
) {
const patch = yield* encodeConfig(config);
yield* sql`
UPDATE session SET config = json_patch(COALESCE(config, '{}'), ${patch})
WHERE id = ${sessionId}
`;
}, Effect.orDie);

const relink = Effect.fn("Session.relink")(function* (input: RelinkInput) {
const reject = (reason: RelinkReason) =>
new RelinkError({ sessionId: input.sessionId, spaceId: input.spaceId, reason });
Expand Down Expand Up @@ -1248,6 +1268,7 @@ export const layer = Layer.effect(
create,
link,
retitle,
configure,
get,
space,
list,
Expand Down
54 changes: 42 additions & 12 deletions packages/harness/src/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ export interface Snapshot {
readonly toolExecution: ToolExecutionMode;
}

export interface Configuration {
readonly provider: string;
readonly model: string;
readonly thinkingLevel: Model.ThinkingLevel;
}

export interface Reloaded {
/** How many plugins the new set holds, when it swapped. */
readonly plugins: number;
Expand Down Expand Up @@ -150,6 +156,12 @@ export interface Interface {
* set once at its top.
*/
readonly reload: Effect.Effect<Reloaded>;
/**
* The model and thinking level a session's next exchange runs with: its runtime bindings over
* its chosen `SessionSchema.Config` over the caller's options over settings, resolved the
* way {@link snapshot} resolves them. Nothing is looked up in the catalog.
*/
readonly configuration: (sessionId: SessionId) => Effect.Effect<Configuration, Settings.SettingsError>;
/**
* Capture runtime state for one exchange. Called inside a session drain,
* where the mount it reads is already open.
Expand Down Expand Up @@ -310,6 +322,27 @@ export const layer = (
...("reference" in cause && typeof cause.reference === "string" ? { reference: cause.reference } : {}),
...(sessionId === undefined ? {} : { sessionId }),
});
/** Settings and composed configuration for a session's next exchange. */
const configure = Effect.fnUntraced(function* (sessionId: SessionId) {
// Read per exchange, not captured at creation, so `Session.link` takes effect at
// the next one. A session with none discovers no project layer: there is no
// fallback to the process's directory, which would hand it a stranger's project
// (it is a long-running server; one process serves sessions in many projects, or
// in none).
const session = Option.getOrUndefined(yield* sessions.get(sessionId));
const hostDir = session === undefined ? undefined : Option.getOrUndefined(session.hostDir);
// The chosen config survives restarts; runtime bindings, set in this process, sit over it.
const chosen = session === undefined ? {} : Option.getOrElse(session.config, () => ({}));
const sessionOptions = {
...(chosen.model === undefined ? {} : { provider: chosen.model.provider, model: chosen.model.id }),
...(chosen.thinkingLevel === undefined ? {} : { thinkingLevel: chosen.thinkingLevel }),
...Option.getOrElse(yield* runtime.get(sessionId), () => ({})),
};
// Not wrapped: a file the user can fix is more useful to a client as a settings
// failure carrying its path and key than as an anonymous snapshot failure.
const loadedSettings = yield* settings.load(hostDir);
return { hostDir, loadedSettings, configured: compose(loadedSettings, options, sessionOptions) };
});
return Service.of({
reload: Effect.gen(function* () {
// Swaps the module set of every view. Which of those a given session runs stays
Expand All @@ -334,19 +367,16 @@ export const layer = (
const [first] = failures.keys();
return first === undefined ? { plugins } : { plugins, failure: first };
}),
configuration: Effect.fn("State.configuration")(function* (sessionId: SessionId) {
const { configured } = yield* configure(sessionId);
return {
provider: configured.provider,
model: configured.model,
thinkingLevel: configured.thinkingLevel,
} satisfies Configuration;
}),
snapshot: Effect.fn("State.snapshot")(function* (sessionId: SessionId) {
const sessionOptions = Option.getOrElse(yield* runtime.get(sessionId), () => ({}));
// Read per exchange, not captured at creation, so `Session.link` takes effect at
// the next one. A session with none discovers no project layer: there is no
// fallback to the process's directory, which would hand it a stranger's project
// (it is a long-running server; one process serves sessions in many projects, or
// in none).
const session = yield* sessions.get(sessionId);
const hostDir = Option.isNone(session) ? undefined : Option.getOrUndefined(session.value.hostDir);
// Not wrapped: a file the user can fix is more useful to a client as a settings
// failure carrying its path and key than as an anonymous snapshot failure.
const loadedSettings = yield* settings.load(hostDir);
const configured = compose(loadedSettings, options, sessionOptions);
const { hostDir, loadedSettings, configured } = yield* configure(sessionId);
const {
promptCustom,
promptSystemAppend,
Expand Down
25 changes: 10 additions & 15 deletions packages/harness/test/sdk.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import "./utils/env.ts";
import { Settings } from "../src/settings/settings.ts";
import { Effect } from "effect";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
Expand Down Expand Up @@ -106,30 +105,26 @@ describe("Harness Effect SDK", () => {

yield* Effect.gen(function* () {
const session = yield* Session.attach({ sessionId });
expect(yield* Session.configuration(sessionId)).toEqual({
provider: "openai",
model: "gpt-4o",
thinkingLevel: "low",
});
yield* session.run("fourth");
}).pipe(Effect.provide(runtime()), Effect.scoped);

expect(contexts).toHaveLength(4);
/*
* Model config is process-local: it lives in SessionRuntime bindings and is
* not written to the session log. An attach that names a model applies it for
* that runtime's lifetime; an attach that does not falls back to the defaults,
* even on a session whose earlier runtime had one set.
* The model and thinking level are saved with the session: an attach that
* names none keeps what an earlier runtime chose, and one that names them
* replaces it for every runtime after.
*/
expect(inputs.map(({ provider, model, thinkingLevel }) => ({ provider, model, thinkingLevel }))).toEqual(
[
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{
provider: Settings.defaults.model.provider,
model: Settings.defaults.model.id,
thinkingLevel: Settings.defaults.model.thinkingLevel,
},
{ provider: "openai", model: "gpt-4o-mini", thinkingLevel: "max" },
{ provider: "openai", model: "gpt-4o", thinkingLevel: "low" },
{ provider: "openai", model: "gpt-4o", thinkingLevel: "low" },
{
provider: Settings.defaults.model.provider,
model: Settings.defaults.model.id,
thinkingLevel: Settings.defaults.model.thinkingLevel,
},
],
);
});
Expand Down
Loading