Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ npm install -g subturn
claude mcp add subturn -- subturn serve
```

Requires Node 22.13 or newer, and OpenCode 2 or newer for that harness. Until the package is published, `npx github:eshatalov/subturn` works the same way. From a checkout, `npm install` builds `dist/` and the server is `node dist/faces/mcp.js`.
Requires Node 22.13 or newer, and OpenCode 2.0.25 or newer for that harness. Until the package is published, `npx github:eshatalov/subturn` works the same way. From a checkout, `npm install` builds `dist/` and the server is `node dist/faces/mcp.js`.

## The caller's whole manual

Expand Down
42 changes: 2 additions & 40 deletions src/core/acp-turn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,25 +20,14 @@ import path from "node:path";
const INITIALIZE_TIMEOUT_MS = 30_000;
const SESSION_NEW_TIMEOUT_MS = 60_000;
const SESSION_LOAD_TIMEOUT_MS = 120_000; // replay of a long history takes time
const CONFIG_SETTLE_MS = 5_000;

/** Runs `attempt`; when the harness refuses it with -32602, runs it again
* after each config_option_update, giving up CONFIG_SETTLE_MS after the
* first try. For a harness that opens a session before its option list has
* loaded and announces the rest. */
export type RetryOnConfigUpdate = (attempt: () => Promise<unknown>) => Promise<unknown>;

export interface AcpTurnPlan {
argv: string[];
/** Applied after session/new or session/load; returns the
* acknowledged-bundle record for ack.json, or null when the harness
* echoes nothing. Throw to fail. Runs on resumed turns too — this is
* where model+effort are re-applied explicitly. */
configure?: (
client: AcpClient,
sessionId: string,
retryOnConfigUpdate: RetryOnConfigUpdate,
) => Promise<Record<string, unknown> | null>;
configure?: (client: AcpClient, sessionId: string) => Promise<Record<string, unknown> | null>;
/** Extract the acknowledged bundle from the session/new response (grok:
* models.currentModelId + reasoningEffort meta). */
ackFromSessionNew?: (result: Record<string, unknown>) => Record<string, unknown> | null;
Expand Down Expand Up @@ -113,8 +102,6 @@ export async function runAcpTurn(ctx: LaunchContext, plan: AcpTurnPlan): Promise
const usageCandidates: unknown[] = [];
let activeSessionId = "";
let collecting = false; // off until the prompt goes out (replay isn't ours)
let configUpdates = 0;
let onConfigUpdate: (() => void) | null = null;

const client = new AcpClient(spawned.child, {
emit: (e) => ctx.emit(e),
Expand All @@ -124,10 +111,6 @@ export async function runAcpTurn(ctx: LaunchContext, plan: AcpTurnPlan): Promise
return;
}
ctx.emit({ type: collecting ? "session_update" : "session_update_replay", update });
if (update["sessionUpdate"] === "config_option_update") {
configUpdates++;
onConfigUpdate?.();
}
if (!collecting) return;
if (update["sessionUpdate"] === "agent_message_chunk") {
const content = update["content"];
Expand Down Expand Up @@ -164,27 +147,6 @@ export async function runAcpTurn(ctx: LaunchContext, plan: AcpTurnPlan): Promise
client.onExtNotification(method, (params) => handler(params, sink));
}

const nextConfigUpdate = (timeoutMs: number): Promise<boolean> =>
new Promise((resolve) => {
const timer = setTimeout(() => { onConfigUpdate = null; resolve(false); }, timeoutMs);
onConfigUpdate = () => { clearTimeout(timer); onConfigUpdate = null; resolve(true); };
});
const retryOnConfigUpdate: RetryOnConfigUpdate = async (attempt) => {
const deadline = Date.now() + CONFIG_SETTLE_MS;
for (;;) {
const seen = configUpdates; // an update landing mid-attempt counts
try {
return await attempt();
} catch (e) {
if (!(e instanceof RpcError) || e.code !== -32602) throw e;
const left = deadline - Date.now();
if (left <= 0) throw e;
if (configUpdates === seen && !(await nextConfigUpdate(left))) throw e;
ctx.emit({ type: "configure_retry", refused: e.message });
}
}
};

try {
const initResult = await client.request("initialize", {
protocolVersion: 1,
Expand Down Expand Up @@ -228,7 +190,7 @@ export async function runAcpTurn(ctx: LaunchContext, plan: AcpTurnPlan): Promise
ackBase = plan.ackFromSessionNew(sessionNewResult);
}
if (plan.configure !== undefined) {
const configureAck = await plan.configure(client, activeSessionId, retryOnConfigUpdate);
const configureAck = await plan.configure(client, activeSessionId);
if (configureAck !== null) ackBase = { ...(ackBase ?? {}), ...configureAck };
}
if (currentAck() !== null) {
Expand Down
12 changes: 5 additions & 7 deletions src/plugins/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,11 @@ export const fakePlugin: Plugin = {
}
return runAcpTurn(ctx, {
argv: [process.execPath, script],
configure: async (client, sessionId, retryOnConfigUpdate) => {
const modelRes = await retryOnConfigUpdate(() =>
client.request(
"session/set_config_option",
{ sessionId, configId: "model", value: ctx.model },
15_000,
),
configure: async (client, sessionId) => {
const modelRes = await client.request(
"session/set_config_option",
{ sessionId, configId: "model", value: ctx.model },
15_000,
);
const effortRes = await client.request(
"session/set_config_option",
Expand Down
10 changes: 2 additions & 8 deletions src/plugins/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@
// session/load, handled in acp-turn) runs the same configure step, so the
// bundle is re-applied explicitly on every turn — never server defaults.
//
// The server lists models before its plugins have added theirs (the
// OpenCode Console providers, providers from the user's config) and
// announces the rest with config_option_update about a second later. A
// model refused right after the session opens is therefore set again after
// each such update (retryOnConfigUpdate in acp-turn).
//
// Posture: `opencode acp` has no --auto flag; the permissive posture is the
// Subturn's ACP client itself, which answers every session/request_permission
// with the most permissive option (core/acp.ts). No prompt can fire
Expand Down Expand Up @@ -184,9 +178,9 @@ export const opencodePlugin: Plugin = {
launch(ctx: LaunchContext): Promise<LaunchOutcome> {
return runAcpTurn(ctx, {
argv: [ctx.binPath, "acp"],
configure: async (client, sessionId, retryOnConfigUpdate) => {
configure: async (client, sessionId) => {
// Model FIRST — the effort option appears only after the model set.
const modelRes = await retryOnConfigUpdate(() => setConfig(client, sessionId, "model", ctx.model));
const modelRes = await setConfig(client, sessionId, "model", ctx.model);
Comment thread
eshatalov marked this conversation as resolved.
const effortRes = await setConfig(client, sessionId, "effort", ctx.effort);
// The effort response is the later echo of the whole option list, so
// it is the authoritative currentValue for both.
Expand Down
31 changes: 0 additions & 31 deletions test/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,37 +194,6 @@ test("posture: a permission request is auto-answered with the most permissive op
}
});

test("a model the harness announces after the session opens is set once announced", async () => {
process.env["FAKE_MODE"] = "latemodel";
try {
const res = await spawn(baseInput());
const done = await awaitTurn(res.session_id, 30);
assert.equal(done.status, "completed");
assert.deepEqual(done.bundle, { model: "fake-model-1", effort: "high" });
const events = inspect(res.session_id).events.parsed as Array<Record<string, unknown>>;
assert.deepEqual(
events.filter((e) => e["type"] === "configure_retry").map((e) => e["refused"]),
["model not found: fake-model-1"],
);
} finally {
delete process.env["FAKE_MODE"];
}
});

test("a model the harness never announces fails with the harness's refusal", async () => {
process.env["FAKE_MODE"] = "nomodel";
try {
const res = await spawn(baseInput());
const done = await awaitTurn(res.session_id, 30);
assert.equal(done.status, "failed");
const evidence = inspect(res.session_id);
assert.ok(evidence.error.text?.includes("-32602"));
assert.ok(evidence.error.text?.includes("model not found: fake-model-1"));
} finally {
delete process.env["FAKE_MODE"];
}
});

test("configure failure after the id exists: failed turn with raw rpc evidence", async () => {
process.env["FAKE_MODE"] = "badeffort";
try {
Expand Down
18 changes: 0 additions & 18 deletions test/fake-agent.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
// permission — asks for permission mid-prompt; completes only when allowed
// hang — accepts the prompt request and never answers (deadline test)
// badeffort — refuses the "effort" set_config with -32602
// latemodel — refuses the "model" set_config with -32602 until it has
// announced its models with a config_option_update
// nomodel — refuses the "model" set_config with -32602, announces nothing
// grok — never answers the prompt request; completes via the private
// _x.ai/session/prompt_complete notification (race test)
// die — exits immediately (launch-failure-before-session-id test)
Expand All @@ -27,7 +24,6 @@ if (mode === "die") {
let buf = "";
const config = { model: null, effort: null };
let loadedSessionId = null;
let modelsAnnounced = false;
let pendingPermissionId = null;
let pendingPromptId = null;
let promptSessionId = null;
Expand Down Expand Up @@ -94,20 +90,6 @@ function handle(msg) {
send({ jsonrpc: "2.0", id, error: { code: -32602, message: `invalid effort: ${params.value}` } });
return;
}
if ((mode === "latemodel" || mode === "nomodel") && params.configId === "model" && !modelsAnnounced) {
send({ jsonrpc: "2.0", id, error: { code: -32602, message: `model not found: ${params.value}` } });
if (mode === "latemodel") {
setTimeout(() => {
modelsAnnounced = true;
send({
jsonrpc: "2.0",
method: "session/update",
params: { sessionId: params.sessionId, update: { sessionUpdate: "config_option_update", configOptions: [] } },
});
}, 50);
}
return;
}
config[params.configId] = params.value;
result(id, { configId: params.configId, currentValue: params.value });
return;
Expand Down
Loading