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
fix(acp): offer models from the harness credential file
The model selector used its own credential checks and always read the
default OAuth file, so under `codework acp --home <dir>` it could hide a
provider the agent can call. It now uses ModelCatalog.available.

Adds an E2E test for the selector's providers and for the settings
model block setting a new session's model and thinking level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
  • Loading branch information
sanchitrk and claude committed Sep 29, 2026
commit 2d11b1a596a9219095c4e6d0f9617725f32c7bca
43 changes: 5 additions & 38 deletions packages/codework/src/acp/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import type * as Acp from "@codeworksh/acp/schema-v1";
import { Model } from "@codeworksh/aikit";
import { JsonGitHubCopilotAuthStorage } from "@codeworksh/aikit/oauth/github/copilot";
import { JsonOpenAICodexAuthStorage } from "@codeworksh/aikit/oauth/openai/codex";
import { ModelCatalog } from "@codeworksh/harness/effect";
import { Effect, Option } from "effect";

Expand Down Expand Up @@ -34,47 +32,16 @@ export const parseModel = (value: string): Option.Option<{ readonly provider: st
return Option.some({ provider: value.slice(0, slash), id: value.slice(slash + 1) });
};

const loggedIn = (provider: string, token: () => Promise<{ readonly access?: string } | undefined>) =>
Effect.tryPromise(token).pipe(
Effect.map((stored) => (stored?.access ? [provider] : [])),
Effect.orElseSucceed(() => []),
);

const oauthProviders = Effect.map(
Effect.all(
[
loggedIn("openai-codex", () => new JsonOpenAICodexAuthStorage({}).get()),
loggedIn("github-copilot", () => new JsonGitHubCopilotAuthStorage({}).get()),
],
{ concurrency: "unbounded" },
),
(providers) => providers.flat(),
);

/**
* Providers with credentials: an API key in the environment or an OAuth login.
* A harness "usable models" query replaces this (COD-83).
*/
const usableProviders = (catalog: Model.BuiltInModels) =>
Effect.map(oauthProviders, (oauth) => {
const withKeys = Object.entries(catalog).flatMap(([provider, models]) => {
const env = Object.values(models ?? {})[0]?.provider.env ?? [];
// oxlint-disable-next-line effecttsgo/process-env
return env.some((name) => Boolean(process.env[name])) ? [provider] : [];
});
return new Set([...withKeys, ...oauth]);
});

/** The `model` and `thought_level` selectors for a session's current selection. */
export const options = Effect.fn("ACP.config.options")(function* (selection: Selection) {
const catalog = yield* ModelCatalog.models.pipe(Effect.orElseSucceed((): Model.BuiltInModels => ({})));
const providers = yield* usableProviders(catalog);
providers.add(selection.provider);
// Providers the harness has credentials for; the current model stays selectable even if not.
const providers = yield* ModelCatalog.available().pipe(Effect.orElseSucceed(() => []));

const current = `${selection.provider}/${selection.id}`;
const models = [...providers].flatMap((provider) =>
Object.entries(catalog[provider] ?? {}).map(([id, info]) => ({
value: `${provider}/${id}`,
const models = providers.flatMap((provider) =>
Object.entries(provider.models).map(([id, info]) => ({
value: `${provider.id}/${id}`,
name: `${info.provider.name}: ${info.name}`,
})),
);
Expand Down
19 changes: 19 additions & 0 deletions packages/codework/test/__artifacts__/acp.models.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"project": {
"current": "openrouter/anthropic/claude-haiku-4.5",
"providers": [
"github-copilot",
"openrouter"
],
"thinking": "low"
},
"withoutProjectSettings": {
"current": "openai/gpt-5.6-luna",
"providers": [
"github-copilot",
"openai",
"openrouter"
],
"thinking": "high"
}
}
77 changes: 74 additions & 3 deletions packages/codework/test/acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type * as AcpError from "@codeworksh/acp/errors";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { Effect, Exit, Fiber, Queue, type Scope } from "effect";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -19,6 +19,27 @@ type Client = AcpClient.AcpClient["Service"];
type Update = Parameters<Parameters<Client["handleSessionUpdate"]>[0]>[0]["update"];

const temps: string[] = [];
/** An environment with `undefined` entries removed, so an override can unset a variable. */
const environment = (env: Record<string, string | undefined>): Record<string, string> =>
Object.fromEntries(Object.entries(env).flatMap(([name, value]) => (value === undefined ? [] : [[name, value]])));

/**
* Every API key variable the catalog names, unset. A case that asserts which providers have
* credentials starts from this, so the developer's own keys can't change the answer.
*/
const withoutProviderKeys = (): Record<string, undefined> => {
const catalog: Record<string, Record<string, { provider: { env?: string[]; key?: string } }>> = JSON.parse(
readFileSync(models, "utf8"),
);
const names = Object.values(catalog).flatMap((entries) =>
Object.values(entries).flatMap(({ provider }) => [
...(provider.env ?? []),
...(provider.key ? [provider.key] : []),
]),
);
return Object.fromEntries(names.map((name) => [name, undefined]));
};

const workspace = () => {
const home = mkdtempSync(join(tmpdir(), "codework-acp-"));
temps.push(home);
Expand All @@ -35,14 +56,14 @@ const workspace = () => {
const withAgent = <A, E>(
home: string,
body: (acp: Client, updates: Queue.Queue<Update>) => Effect.Effect<A, E>,
env: Record<string, string> = {},
env: Record<string, string | undefined> = {},
) =>
Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const handle = yield* spawner.spawn(
ChildProcess.make(process.execPath, ["--conditions=development", cli, "--home", home, "acp"], {
cwd: home,
env: { ...process.env, HOME: home, CODEWORK_MODELS_FILE: models, ...env },
env: environment({ ...process.env, HOME: home, CODEWORK_MODELS_FILE: models, ...env }),
}),
);
return yield* Effect.gen(function* () {
Expand Down Expand Up @@ -145,6 +166,56 @@ describe("codework acp", () => {
);
});

it("offers the models its credentials reach, and starts from the project's model", () => {
const { home, project } = workspace();
// A Copilot login in the home's own credential file: `--home` must be where it is looked for.
mkdirSync(join(home, "aikit"));
writeFileSync(
join(home, "aikit", "auth.json"),
artifact({ "github-copilot": { access: "test-access", refresh: "test-refresh", expires: 4_102_444_800_000 } }),
);
// The project's settings pick the model and thinking level every new session starts with.
mkdirSync(join(project, ".codework"));
writeFileSync(
join(project, ".codework", "settings.jsonc"),
artifact({ model: { provider: "openrouter", id: "anthropic/claude-haiku-4.5", thinkingLevel: "low" } }),
);
const elsewhere = join(home, "elsewhere");
mkdirSync(elsewhere);
return run(
withAgent(
home,
(acp) =>
Effect.gen(function* () {
const selector = (
options: ReadonlyArray<{ id: string; currentValue: unknown; options?: unknown }>,
) => {
const model = options.find((option) => option.id === "model");
const values = (model?.options ?? []) as ReadonlyArray<{ value: string }>;
return {
current: model?.currentValue,
providers: [...new Set(values.map((option) => option.value.split("/")[0] ?? ""))].sort((a, b) =>
a.localeCompare(b),
),
thinking: options.find((option) => option.id === "thought_level")?.currentValue,
};
};
const inProject = yield* acp.agent.createSession({ cwd: project, mcpServers: [] });
const outside = yield* acp.agent.createSession({ cwd: elsewhere, mcpServers: [] });
yield* Effect.promise(() =>
expect(
artifact({
project: selector(inProject.configOptions ?? []),
withoutProjectSettings: selector(outside.configOptions ?? []),
}),
).toMatchFileSnapshot("./__artifacts__/acp.models.json"),
);
}),
{ ...withoutProviderKeys(), OPENROUTER_API_KEY: "sk-or-test" },
),
);
});

it.skipIf(!process.env.OPENROUTER_API_KEY)(
"streams, runs tools, cancels, fails and replays turns against OpenRouter",
() => {
Expand Down