From a40b110c89832abb6d0d9f83f9d92498878f8804 Mon Sep 17 00:00:00 2001
From: Smartnewb <159137930+Smartnewb@users.noreply.github.com>
Date: Sun, 13 Sep 2026 00:00:07 +0900
Subject: [PATCH 1/3] feat(swe2): relay client tools through the devin cli
---
.env.example | 6 +
README.en.md | 8 +
README.md | 8 +
docs/ENV-SWITCHES.md | 10 +
docs/SWE2-ACP.md | 107 ++++
src/handlers/chat.js | 9 +
src/swe2-acp/bridge.mjs | 1051 ++++++++++++++++++++++++++++++++
src/swe2-acp/mcp-relay.mjs | 73 +++
src/swe2-acp/protocol.mjs | 160 +++++
test/fixtures/swe2-acp-cli.mjs | 83 +++
test/swe2-acp-route.test.js | 172 ++++++
test/swe2-acp.test.js | 472 ++++++++++++++
12 files changed, 2159 insertions(+)
create mode 100644 docs/SWE2-ACP.md
create mode 100644 src/swe2-acp/bridge.mjs
create mode 100644 src/swe2-acp/mcp-relay.mjs
create mode 100644 src/swe2-acp/protocol.mjs
create mode 100755 test/fixtures/swe2-acp-cli.mjs
create mode 100644 test/swe2-acp-route.test.js
create mode 100644 test/swe2-acp.test.js
diff --git a/.env.example b/.env.example
index c70fe981..3ef2d038 100644
--- a/.env.example
+++ b/.env.example
@@ -671,3 +671,9 @@ ALLOW_PRIVATE_PROXY_HOSTS=
# 量级(实测):逐事件记录 —— 一个 2000 token 的回答(每 token 一个事件)
# 产生 2000 行、约 215 KiB(约 110 B/行)。生产上打开抓现场时按"每个长回答
# 几百到几千行"预估,抓到后改回 0 并重启即可关闭。
+
+# Optional local SWE-2 agent transport. Disabled by default; other models keep their existing transport.
+# Requires the official Devin CLI and `devin auth login`. See docs/SWE2-ACP.md.
+# DEVIN_SWE2_TRANSPORT=acp
+# DEVIN_CLI_PATH=/absolute/path/to/devin
+# DEVIN_SWE2_ACP_DATA=/private/directory/for/acp-sessions
diff --git a/README.en.md b/README.en.md
index c61a3e61..c78c6361 100644
--- a/README.en.md
+++ b/README.en.md
@@ -627,3 +627,11 @@ Release automation may publish Docker images and GitHub Releases. Keep tokens, A
Star History · 点击查看完整星图
+
+## Optional SWE-2 CLI transport
+
+`DEVIN_SWE2_TRANSPORT=acp` connects SWE-2 requests to the official Devin CLI and
+relays tool execution to the calling client. Other model routes are unchanged.
+Use `DEVIN_CLI_PATH` to select the executable and `DEVIN_SWE2_ACP_DATA` to select
+the private session directory. See [SWE-2 ACP setup and client recipes](docs/SWE2-ACP.md)
+for requirements, effort selection, permissions, and current limitations.
diff --git a/README.md b/README.md
index 422136c6..e8922c37 100644
--- a/README.md
+++ b/README.md
@@ -712,3 +712,11 @@ MIT License. See [LICENSE](LICENSE).
Star History · 点击查看完整星图
+
+## Optional SWE-2 CLI transport
+
+`DEVIN_SWE2_TRANSPORT=acp` connects SWE-2 requests to the official Devin CLI and
+relays tool execution to the calling client. Other model routes are unchanged.
+Use `DEVIN_CLI_PATH` to select the executable and `DEVIN_SWE2_ACP_DATA` to select
+the private session directory. See [SWE-2 ACP setup and client recipes](docs/SWE2-ACP.md)
+for requirements, effort selection, permissions, and current limitations.
diff --git a/docs/ENV-SWITCHES.md b/docs/ENV-SWITCHES.md
index bbcbe01d..43c87b4f 100644
--- a/docs/ENV-SWITCHES.md
+++ b/docs/ENV-SWITCHES.md
@@ -241,3 +241,13 @@ PY
[`test/docs-consistency-guard.test.js`](../test/docs-consistency-guard.test.js) 的
「every switch read in src/ is findable in some reader-facing doc」守着 ——
**加新开关不写文档会直接让测试变红**,不再依赖人记得。
+
+## Optional SWE-2 ACP transport
+
+| Variable | Default | Purpose |
+| --- | --- | --- |
+| `DEVIN_SWE2_TRANSPORT` | unset (disabled) | Set to `acp` to route SWE-2 Chat Completions through the installed Devin CLI. |
+| `DEVIN_CLI_PATH` | `~/.local/bin/devin` | Absolute path to the official CLI executable. |
+| `DEVIN_SWE2_ACP_DATA` | `~/.local/share/windsurfapi/swe2-acp` | Private temporary session/config directory. |
+
+See [SWE-2 ACP](SWE2-ACP.md). These settings do not rewrite other model routes.
diff --git a/docs/SWE2-ACP.md b/docs/SWE2-ACP.md
new file mode 100644
index 00000000..4cb21de2
--- /dev/null
+++ b/docs/SWE2-ACP.md
@@ -0,0 +1,107 @@
+# SWE-2 through the official Devin CLI
+
+This opt-in Chat Completions transport runs `devin acp` and relays tool calls back
+to the calling client. It supports clients such as Aside, OMO, and OpenClaw without
+editing their shared prompts or tool implementations. It is an experimental local
+adapter, not an official Cognition API integration.
+
+## Setup
+
+Use a source or npm installation with Node.js. Standalone packaged executables
+are not supported by this transport because the MCP relay runs as a Node child.
+
+1. Install the official Devin CLI and sign in with `devin auth login`.
+2. Check `devin models list --format json` for the models available to your account.
+3. Set `DEVIN_SWE2_TRANSPORT=acp` in the proxy environment and restart the proxy.
+ Set `DEVIN_CLI_PATH` if the executable is not at `~/.local/bin/devin`.
+4. Send a text request to the proxy's `/v1/chat/completions` endpoint:
+
+```json
+{
+ "model": "swe-2-high",
+ "reasoning_effort": "medium",
+ "messages": [{ "role": "user", "content": "Explain how a binary search works." }]
+}
+```
+
+This starts `devin acp --model swe-2-medium`. An explicit `reasoning_effort`
+overrides the suffix in the requested model. Without an effort, the suffix is
+used; bare `swe-2` selects High. Medium, High, and Max are native CLI model
+variants, not three unrelated model families. `low`, `minimal`, and `off` select
+Medium; `xhigh` selects Max. Check your CLI catalog before depending on a variant.
+
+The session acknowledgement must match the selected model before the adapter
+sends the caller's prompt. The official CLI model guide is
+.
+
+Keep this endpoint local to a trusted operator. It uses that operator's CLI
+account, not the proxy's cloud account pool. Configure the proxy's existing
+authentication before exposing it to other clients. HTTP authentication and model
+access checks still run; cloud-account failover does not apply to this transport.
+
+## Client configuration
+
+Configure an OpenAI-compatible Chat Completions provider pointing at your proxy.
+Use an existing custom-provider facility; avoid changing a client's global
+system prompt, output-length limit, or tool schema to accommodate SWE-2.
+
+| Client | SWE-2 configuration |
+| --- | --- |
+| OpenCodex | Use a custom-named `openai-chat` provider. Use explicit model variants or send `reasoning_effort`; a picker default alone does not prove a raw Chat request sent that value. |
+| Aside | Add a custom model with text input and reasoning support. Select Medium for that model if desired; do not add image input just to make it appear in a vision picker. Restart a running conversation or reopen the app if it retains an older explicit choice. |
+| OMO | Select the custom provider's `swe-2-high` model and High effort. Keep existing `read`, `eval`, and Python tool schemas; the model retrieves their full descriptions before calling them. |
+| OpenClaw | For a custom `opencodex-chat` provider exposing `devin/swe-2-high`, set `agents.defaults.models["opencodex-chat/devin/swe-2-high"].params.thinking` to `"high"`. An agent or session thinking override can take precedence. |
+
+The `opencodex-chat` name above is an example custom provider, not an official
+OpenCodex provider. Match the name and model path in your own configuration.
+Installing a newer OpenCodex version may also expose its separate built-in Devin
+providers; those do not automatically select this proxy transport.
+
+## Tool calls and session continuity
+
+The CLI sees a small MCP catalog with three functions: list client tools,
+retrieve their complete descriptions and schemas, and request a client tool call.
+Large schemas are paged without removing content. The adapter returns a normal
+OpenAI `tool_calls` response and waits for the client's actual tool result.
+It never executes a caller tool itself or invents its output.
+
+Tool call IDs are 28 alphanumeric characters, preserving identity across clients
+that truncate long IDs or strip punctuation. Continue with the returned ID and
+the same server-derived caller identity. A lazily expanded client tool catalog
+can be refreshed while completing an existing tool batch.
+
+If system/developer instructions change or a user adds steering while a tool is
+pending, the old ACP prompt is closed before receiving the result. A fresh session
+receives the full updated transcript, including the real client tool result.
+This preserves the new instructions instead of resuming an outdated prompt.
+
+The client continues to own its permissions. Native CLI tools are denied in a
+dedicated session configuration; unexpected ACP permission requests are rejected.
+An explicit OMO tool-permission rejection ends the turn. Provider content-policy
+errors are returned as errors without stripping instructions or retrying through
+another model. System/developer messages and memories are preserved in full.
+
+If the model ends with a short announcement of work but no tool call, the adapter
+asks it once to finish that work or explain a concrete blocker. This is bounded
+and does not retry policy refusals.
+
+## Limits and verification
+
+- Text only. Image content is rejected explicitly.
+- One CLI account and at most 12 in-memory sessions per proxy process.
+- Sessions expire after 30 idle minutes and are lost on restart. Do not replay
+ pending tool IDs after a restart as though their CLI session still existed.
+- Each response wait is bounded at ten minutes. An active ACP prompt may span
+ several client tool turns without a ten-minute total-work cutoff.
+- `DEVIN_SWE2_ACP_DATA` selects the private session/config directory. Session
+ cleanup stops the CLI process group and removes its generated configuration.
+- The proxy does not manage CLI-account billing, quota, or failover.
+
+For a useful smoke test, give the client a read-only tool and ask it to read a
+new fixture containing a unique value, then verify that it returns that value.
+A 200 health response or a model-picker entry alone does not verify tool execution
+or effort selection. Tests in `test/swe2-acp.test.js` cover protocol handling and
+`test/swe2-acp-route.test.js` exercise the Chat handler with a local fake ACP child.
+
+Unset `DEVIN_SWE2_TRANSPORT` and restart to restore the previous transport. Other
+model requests retain their existing routes while ACP is enabled.
diff --git a/src/handlers/chat.js b/src/handlers/chat.js
index c4937fde..86f33406 100644
--- a/src/handlers/chat.js
+++ b/src/handlers/chat.js
@@ -4,6 +4,8 @@
*/
import { createHash, randomUUID } from 'crypto';
+import { handleSwe2AcpChat } from '../swe2-acp/bridge.mjs';
+import { sweModel } from '../swe2-acp/protocol.mjs';
import { WindsurfClient, contentToString, isCascadeTransportError } from '../client.js';
import { getApiKey, acquireAccountByKey, releaseAccountById, currentApiKeyForId, getAccountAvailability, reportError, reportSuccess, markRateLimited, markQuotaExhausted, reportInternalError, reportDeadToken, updateCapability, getAccountList, isAllRateLimited, isAllTemporarilyUnavailable, refundReservation, looksLikeBanSignal, reportBanSignal, clearBanSignals, isModelBlockedByDrought, isConnectSelectorBlockedByDrought, getDroughtSummary, reLoginAccount, getAccountCount, hasConnectEntitledAccount, recordAccountSpend, ensureDeviceSeed } from '../auth.js';
import { isStickyEnabled, setStickyBinding, peekStickyBinding } from '../account/sticky-session.js';
@@ -2764,6 +2766,13 @@ function connectSpendOpts(billing) {
}
export async function handleChatCompletions(body, context = {}) {
+ // Opt-in local CLI transport; the existing routes own every other request.
+ // Admission still respects the operator's model access policy. Do not apply
+ // cloud-account fallback or instruction rewriting to an ACP session.
+ if (process.env.DEVIN_SWE2_TRANSPORT === 'acp' && sweModel(body?.model)) {
+ return handleSwe2AcpChat(body, context);
+ }
+
// Full-chain trace (gated WINDSURFAPI_TRACE=1): one traceId stitches client
// request → routing → Devin wire bytes → client response. Reused as reqId so
// logs and the trace dir share the same id. No-op when tracing is off.
diff --git a/src/swe2-acp/bridge.mjs b/src/swe2-acp/bridge.mjs
new file mode 100644
index 00000000..d52c0822
--- /dev/null
+++ b/src/swe2-acp/bridge.mjs
@@ -0,0 +1,1051 @@
+import { spawn } from 'node:child_process';
+import readline from 'node:readline';
+import http from 'node:http';
+import { mkdtemp, readFile, writeFile, mkdir, rm } from 'node:fs/promises';
+import { homedir } from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { randomUUID, randomBytes } from 'node:crypto';
+import { isModelAllowed } from '../dashboard/model-access.js';
+import {
+ sweModel,
+ hash,
+ conversationKey,
+ isPrefix,
+ toolDefinitions,
+ buildPrompt,
+ contentText,
+ errorInfo,
+ newToolCallId,
+ isPendingAction,
+ clientPermissionDenial,
+} from './protocol.mjs';
+const here = path.dirname(fileURLToPath(import.meta.url));
+const sessions = new Set(),
+ pendingCalls = new Map();
+const TTL = 30 * 60_000,
+ MAX_SESSIONS = 12;
+function diagnostic(s, event, extra = {}) {
+ console.log(
+ '[INFO] SWE2_ACP ' +
+ JSON.stringify({
+ session: s?.id?.slice(0, 12),
+ model: s?.model,
+ event,
+ ...extra,
+ }),
+ );
+}
+class Session {
+ constructor(body, context) {
+ this.model = sweModel(body.model, body.reasoning_effort);
+ this.owner = context.callerKey || '';
+ this.defs = toolDefinitions(body);
+ this.schemaHash = hash(this.defs);
+ this.instructionHash = hash(
+ body.messages.filter((m) => ['system', 'developer'].includes(m.role)),
+ );
+ this.calls = new Map();
+ this.events = [];
+ this.waiter = null;
+ this.rpc = new Map();
+ this.nextId = 0;
+ this.closed = false;
+ this.busy = false;
+ this.lastUsed = Date.now();
+ this.active = false;
+ this.lastConversation = [];
+ this.pendingInput = null;
+ }
+ push(event) {
+ if (this.closed) return;
+ this.events.push(event);
+ this.waiter?.();
+ this.waiter = null;
+ }
+ request(method, params, timeout = 45000) {
+ return new Promise((resolve, reject) => {
+ const id = ++this.nextId,
+ timer =
+ timeout > 0
+ ? setTimeout(() => {
+ this.rpc.delete(id);
+ reject(
+ Object.assign(new Error(`Devin ACP ${method} timed out`), {
+ code: 'ACP_TIMEOUT',
+ }),
+ );
+ }, timeout)
+ : null;
+ this.rpc.set(id, {
+ resolve: (v) => {
+ clearTimeout(timer);
+ resolve(v);
+ },
+ reject: (e) => {
+ clearTimeout(timer);
+ reject(e);
+ },
+ });
+ this.child.stdin.write(
+ JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n',
+ (e) => {
+ if (e) {
+ this.rpc.get(id)?.reject(e);
+ this.rpc.delete(id);
+ }
+ },
+ );
+ });
+ }
+ async start() {
+ const ensureOpen = () => {
+ if (this.closed) throw new Error('ACP session closed during startup');
+ };
+ ensureOpen();
+ const data =
+ process.env.DEVIN_SWE2_ACP_DATA ||
+ path.join(homedir(), '.local/share/windsurfapi/swe2-acp');
+ await mkdir(data, { recursive: true, mode: 0o700 });
+ ensureOpen();
+ this.dir = await mkdtemp(path.join(data, 'session-'));
+ ensureOpen();
+ await mkdir(path.join(this.dir, '.devin'), { mode: 0o700 });
+ ensureOpen();
+ let userConfig = {};
+ try {
+ userConfig = JSON.parse(
+ await readFile(
+ path.join(homedir(), '.config/devin/config.json'),
+ 'utf8',
+ ),
+ );
+ } catch {}
+ ensureOpen();
+ const cfg = {
+ version: userConfig.version || 1,
+ devin: userConfig.devin || {},
+ agent: { model: this.model },
+ auto_update: false,
+ subagents_enabled: false,
+ read_config_from: { cursor: false, windsurf: false, claude: false },
+ permissions: {
+ deny: [
+ 'read',
+ 'edit',
+ 'grep',
+ 'glob',
+ 'exec',
+ 'Read(**)',
+ 'Write(**)',
+ 'Bash(*)',
+ 'WebFetch(*)',
+ ],
+ allow: ['mcp__client__*'],
+ },
+ };
+ const cfgPath = path.join(this.dir, 'adapter-config.json');
+ await writeFile(cfgPath, JSON.stringify(cfg), { mode: 0o600 });
+ ensureOpen();
+ this.token = randomBytes(32).toString('hex');
+ this.server = http.createServer((req, res) =>
+ this.mcp(req, res).catch((e) => {
+ if (!res.writableEnded) {
+ res.writeHead(500);
+ res.end('MCP relay error');
+ }
+ }),
+ );
+ await new Promise((resolve, reject) => {
+ this.server.once('error', reject);
+ this.server.listen(0, '127.0.0.1', resolve);
+ });
+ ensureOpen();
+ const mcp = {
+ mcpServers: {
+ client: {
+ command: process.execPath,
+ args: [path.join(here, 'mcp-relay.mjs')],
+ env: {
+ SWE2_RELAY_PORT: String(this.server.address().port),
+ SWE2_RELAY_TOKEN: this.token,
+ },
+ },
+ },
+ };
+ await writeFile(
+ path.join(this.dir, '.devin/mcp_config.local.json'),
+ JSON.stringify(mcp),
+ { mode: 0o600 },
+ );
+ ensureOpen();
+ // The CLI owns its account credentials. Do not inherit the proxy's API keys.
+ const env = {};
+ for (const key of [
+ 'HOME',
+ 'USERPROFILE',
+ 'PATH',
+ 'TMPDIR',
+ 'TMP',
+ 'TEMP',
+ 'LANG',
+ 'LC_ALL',
+ 'XDG_CONFIG_HOME',
+ 'XDG_DATA_HOME',
+ 'XDG_CACHE_HOME',
+ 'APPDATA',
+ 'LOCALAPPDATA',
+ 'SystemRoot',
+ 'COMSPEC',
+ 'PATHEXT',
+ 'HTTP_PROXY',
+ 'HTTPS_PROXY',
+ 'ALL_PROXY',
+ 'NO_PROXY',
+ 'SSL_CERT_FILE',
+ 'SSL_CERT_DIR',
+ 'NODE_EXTRA_CA_CERTS',
+ ]) {
+ if (process.env[key] != null) env[key] = process.env[key];
+ }
+ env.NO_COLOR = '1';
+ this.child = spawn(
+ process.env.DEVIN_CLI_PATH || path.join(homedir(), '.local/bin/devin'),
+ ['--config', cfgPath, 'acp', '--model', this.model],
+ {
+ cwd: this.dir,
+ env,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ detached: process.platform !== 'win32',
+ windowsHide: true,
+ },
+ );
+ this.child.stdin.on('error', (e) => this.fail(e));
+ this.child.on('error', (e) => this.fail(e));
+ this.child.stderr.on('data', () => {});
+ this.child.once('close', (code) => {
+ if (!this.closed) this.fail(new Error(`Devin ACP exited (${code})`));
+ });
+ readline
+ .createInterface({ input: this.child.stdout })
+ .on('line', (line) => this.receive(line));
+ const init = await this.request('initialize', {
+ protocolVersion: 1,
+ clientCapabilities: {
+ fs: { readTextFile: false, writeTextFile: false },
+ terminal: false,
+ },
+ clientInfo: { name: 'windsurfapi-client-tool-bridge', version: '1.0.0' },
+ });
+ ensureOpen();
+ if (init.protocolVersion !== 1)
+ throw new Error('Unsupported Devin ACP protocol version');
+ const session = await this.request('session/new', {
+ cwd: this.dir,
+ mcpServers: [],
+ });
+ ensureOpen();
+ this.id = session.sessionId;
+ const actual =
+ session.configOptions?.find((o) => o.id === 'model')?.currentValue ||
+ session.models?.currentModelId;
+ if (!this.id || actual !== this.model)
+ throw new Error(
+ `Devin ACP model mismatch: expected ${this.model}, received ${actual || 'unknown'}`,
+ );
+ diagnostic(this, 'ready', {
+ tools: this.defs.length,
+ instructionsPreserved: true,
+ });
+ }
+ receive(line) {
+ let d;
+ try {
+ d = JSON.parse(line);
+ } catch {
+ return;
+ }
+ if (d.id != null && !d.method) {
+ const p = this.rpc.get(d.id);
+ this.rpc.delete(d.id);
+ if (d.error)
+ p?.reject(
+ Object.assign(new Error(d.error.message || 'ACP error'), {
+ code: d.error.code,
+ }),
+ );
+ else p?.resolve(d.result);
+ return;
+ }
+ if (d.method && d.id != null) {
+ let result = null;
+ if (d.method === 'session/request_permission') {
+ // MCP execution remains pending until the caller actually executes and
+ // returns its tool result. All native permission requests are denied.
+ // The dedicated config allows the relay only. Never approve an unexpected
+ // native or third-party tool by inspecting free-form request prose.
+ const option = d.params?.options?.find((o) => o.kind === 'reject_once');
+ result = {
+ outcome: option
+ ? { outcome: 'selected', optionId: option.optionId }
+ : { outcome: 'cancelled' },
+ };
+ diagnostic(this, 'unexpected_permission_denied');
+ }
+ this.child.stdin.write(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ id: d.id,
+ ...(result
+ ? { result }
+ : {
+ error: {
+ code: -32601,
+ message:
+ 'Native execution is unavailable in this client tool adapter.',
+ },
+ }),
+ }) + '\n',
+ );
+ return;
+ }
+ if (d.method === 'session/update') {
+ const u = d.params?.update;
+ if (
+ u?.sessionUpdate === 'agent_message_chunk' &&
+ u.content?.type === 'text'
+ )
+ this.push({ type: 'text', text: u.content.text });
+ // Thought chunks are not mixed into the answer or required to continue.
+ }
+ }
+ async mcp(req, res) {
+ if (
+ req.method !== 'POST' ||
+ req.url !== '/mcp' ||
+ req.headers.authorization !== `Bearer ${this.token}`
+ ) {
+ res.writeHead(403);
+ res.end();
+ return;
+ }
+ let raw = '',
+ size = 0;
+ for await (const part of req) {
+ size += part.length;
+ if (size > 16 * 1024 * 1024) {
+ res.writeHead(413);
+ res.end();
+ return;
+ }
+ raw += part;
+ }
+ let rpc;
+ try {
+ rpc = JSON.parse(raw);
+ } catch {
+ res.writeHead(400);
+ res.end();
+ return;
+ }
+ const send = (result) => {
+ if (!res.writableEnded) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: rpc.id, result }));
+ }
+ };
+ if (rpc.method === 'initialize') {
+ send({
+ protocolVersion: '2024-11-05',
+ capabilities: { tools: {} },
+ serverInfo: { name: 'client', version: '1.0.0' },
+ });
+ return;
+ }
+ if (rpc.method === 'tools/list') {
+ send({
+ tools: [
+ {
+ name: 'list_client_tools',
+ description:
+ 'List names and short summaries of all functions available in the calling client. Use get_client_tools to retrieve complete descriptions and parameter schemas before calling a function.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ query: {
+ type: 'string',
+ description:
+ 'Optional case-insensitive substring to filter names and descriptions.',
+ },
+ offset: { type: 'integer', minimum: 0 },
+ },
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true },
+ },
+ {
+ name: 'get_client_tools',
+ description:
+ 'Retrieve the complete original descriptions and JSON parameter schemas for selected client functions. No fields or instructions are removed.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ names: {
+ type: 'array',
+ items: { type: 'string' },
+ maxItems: 5,
+ },
+ offset: {
+ type: 'integer',
+ minimum: 0,
+ description:
+ 'For a large schema, continue the JSON fragment at the returned nextOffset.',
+ },
+ },
+ required: ['names'],
+ additionalProperties: false,
+ },
+ annotations: { readOnlyHint: true },
+ },
+ {
+ name: 'call_client_tool',
+ description:
+ 'Call an existing client function with arguments matching its original schema. Execution and approval happen in the client. Returns its actual result.',
+ inputSchema: {
+ type: 'object',
+ properties: {
+ name: { type: 'string' },
+ arguments: { type: 'object', additionalProperties: true },
+ },
+ required: ['name', 'arguments'],
+ additionalProperties: false,
+ },
+ },
+ ],
+ });
+ return;
+ }
+ if (rpc.method === 'ping') {
+ send({});
+ return;
+ }
+ if (rpc.method === 'tools/call') {
+ const input = rpc.params?.arguments || {};
+ if (rpc.params?.name === 'list_client_tools') {
+ const q = String(input.query || '').toLowerCase();
+ const matches = this.defs.filter(
+ (x) =>
+ !q ||
+ (x.originalName + ' ' + x.description).toLowerCase().includes(q),
+ );
+ const offset = Math.max(0, Math.trunc(Number(input.offset) || 0));
+ send({
+ content: [
+ {
+ type: 'text',
+ text: JSON.stringify({
+ count: matches.length,
+ nextOffset: offset + 40 < matches.length ? offset + 40 : null,
+ tools: matches.slice(offset, offset + 40).map((x) => ({
+ name: x.originalName,
+ summary: x.description.slice(0, 160),
+ fullSchemaAvailable: true,
+ })),
+ }),
+ },
+ ],
+ });
+ return;
+ }
+ if (rpc.params?.name === 'get_client_tools') {
+ const names = Array.isArray(input.names) ? input.names.slice(0, 5) : [];
+ const schemaText = JSON.stringify(
+ names.map((name) => {
+ const d = this.defs.find((x) => x.originalName === name);
+ return d
+ ? { name, description: d.description, inputSchema: d.inputSchema }
+ : { name, error: 'Unknown client function' };
+ }),
+ );
+ const offset = Math.max(0, Math.trunc(Number(input.offset) || 0));
+ const text =
+ Buffer.byteLength(schemaText) <= 16000 && !offset
+ ? schemaText
+ : JSON.stringify({
+ format: 'json_fragment',
+ offset,
+ totalChars: schemaText.length,
+ nextOffset:
+ offset + 4000 < schemaText.length ? offset + 4000 : null,
+ fragment: schemaText.slice(offset, offset + 4000),
+ instruction:
+ 'Concatenate fragments in offset order to recover the complete original schema. Request the same names with nextOffset until null.',
+ });
+ send({ content: [{ type: 'text', text }] });
+ return;
+ }
+ const def =
+ rpc.params?.name === 'call_client_tool'
+ ? this.defs.find((x) => x.originalName === input.name)
+ : null;
+ if (!def) {
+ send({
+ isError: true,
+ content: [{ type: 'text', text: 'Unknown client function.' }],
+ });
+ return;
+ }
+ // Keep IDs below client normalization limits (OmO rewrites long IDs).
+ const id = newToolCallId();
+ const call = {
+ id,
+ type: 'function',
+ function: {
+ name: def.originalName,
+ arguments: JSON.stringify(input.arguments || {}),
+ },
+ };
+ const pending = { session: this, call, send };
+ this.calls.set(id, pending);
+ pendingCalls.set(id, pending);
+ this.push({ type: 'tool', call });
+ diagnostic(this, 'client_tool', { name: def.originalName, id });
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ id: rpc.id,
+ error: { code: -32601, message: 'Unsupported MCP method' },
+ }),
+ );
+ }
+ begin(messages, body, initial) {
+ if (this.active) throw new Error('A Devin prompt is already active');
+ this.active = true;
+ // One ACP prompt spans many client tool turns. Bound each response wait in
+ // take() and idle sessions in the reaper, not the total useful work duration.
+ this.request(
+ 'session/prompt',
+ {
+ sessionId: this.id,
+ prompt: [
+ { type: 'text', text: buildPrompt(messages, body, { initial }) },
+ ],
+ },
+ 0,
+ ).then(
+ (result) => {
+ this.active = false;
+ this.push({ type: 'done', result });
+ },
+ (e) => {
+ this.active = false;
+ this.push({ type: 'error', error: e });
+ },
+ );
+ }
+ fail(e) {
+ for (const p of this.rpc.values()) p.reject(e);
+ this.rpc.clear();
+ this.push({ type: 'error', error: e });
+ }
+ async take(signal) {
+ if (signal?.aborted) throw new Error('Client disconnected');
+ while (!this.events.length) {
+ if (this.closed || signal?.aborted)
+ throw new Error('Client disconnected');
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ this.waiter = null;
+ reject(new Error('Devin response timed out'));
+ }, 10 * 60_000);
+ const abort = () => {
+ clearTimeout(timer);
+ this.waiter = null;
+ reject(new Error('Client disconnected'));
+ };
+ this.waiter = () => {
+ clearTimeout(timer);
+ signal?.removeEventListener('abort', abort);
+ resolve();
+ };
+ signal?.addEventListener('abort', abort, { once: true });
+ });
+ }
+ return this.events.shift();
+ }
+ async close() {
+ // Cleanup may run again after an interrupted startup finishes an awaited
+ // allocation. No resource created after the first close may be orphaned.
+ this.closed = true;
+ sessions.delete(this);
+ this.waiter?.();
+ for (const [id, p] of this.calls) {
+ pendingCalls.delete(id);
+ p.send({
+ isError: true,
+ content: [
+ {
+ type: 'text',
+ text: 'Client session ended before a tool result was returned.',
+ },
+ ],
+ });
+ }
+ this.calls.clear();
+ for (const p of this.rpc.values())
+ p.reject(new Error('ACP session closed'));
+ this.rpc.clear();
+ const child = this.child;
+ const signalTree = (signal) => {
+ try {
+ if (child?.pid && process.platform !== 'win32') {
+ process.kill(-child.pid, signal);
+ return;
+ }
+ } catch {}
+ try {
+ child?.kill(signal);
+ } catch {}
+ };
+ if (child && child.exitCode === null && child.signalCode === null) {
+ await new Promise((resolve) => {
+ const killTimer = setTimeout(() => signalTree('SIGKILL'), 2000);
+ const endTimer = setTimeout(resolve, 5000);
+ child.once('close', () => {
+ clearTimeout(killTimer);
+ clearTimeout(endTimer);
+ resolve();
+ });
+ signalTree('SIGTERM');
+ });
+ }
+ this.server?.closeAllConnections();
+ this.server?.close();
+ if (this.dir) await rm(this.dir, { recursive: true, force: true });
+ }
+}
+async function acquire(body, context) {
+ if (!Array.isArray(body.messages) || !body.messages.length)
+ throw Object.assign(new Error('messages must be a nonempty array'), {
+ status: 400,
+ });
+ const owner = context.callerKey || '',
+ model = sweModel(body.model, body.reasoning_effort),
+ defs = toolDefinitions(body),
+ schemaHash = hash(defs);
+ const toolMessages = body.messages.filter(
+ (m) => m.role === 'tool' && pendingCalls.has(m.tool_call_id),
+ );
+ if (
+ new Set(toolMessages.map((m) => m.tool_call_id)).size !==
+ toolMessages.length
+ )
+ throw Object.assign(new Error('Duplicate pending tool result'), {
+ status: 400,
+ });
+ if (!toolMessages.length && pendingCalls.size) {
+ const pending = [...pendingCalls.values()].filter(
+ (p) => p.session.owner === owner && p.session.model === model,
+ );
+ if (pending.length)
+ diagnostic(null, 'pending_result_mismatch', {
+ roles: body.messages.slice(-4).map((m) => m.role),
+ incomingIds: body.messages
+ .filter((m) => m.role === 'tool')
+ .slice(-3)
+ .map((m) => m.tool_call_id),
+ pendingIds: pending.map((p) => p.call.id),
+ });
+ }
+ const denied = clientPermissionDenial(body.messages);
+ const denialError = () =>
+ Object.assign(
+ new Error(
+ 'Client tool permission was rejected. This SWE-2 turn has stopped; no alternative tool will execute. ' +
+ denied,
+ ),
+ {
+ status: 403,
+ type: 'permission_error',
+ code: 'CLIENT_TOOL_PERMISSION_DENIED',
+ },
+ );
+ let s;
+ if (toolMessages.length) {
+ const found = new Set(
+ toolMessages.map((m) => pendingCalls.get(m.tool_call_id).session),
+ );
+ if (found.size !== 1)
+ throw Object.assign(
+ new Error('Tool results span different client sessions'),
+ { status: 400 },
+ );
+ s = [...found][0];
+ if (s.owner !== owner || s.model !== model) {
+ diagnostic(s, 'tool_context_changed', {
+ sameOwner: s.owner === owner,
+ nextModel: model,
+ sameSchema: s.schemaHash === schemaHash,
+ oldToolCount: s.defs?.length,
+ newToolCount: defs.length,
+ });
+ throw Object.assign(
+ new Error('Tool result does not match its client session/model/schema'),
+ { status: 409 },
+ );
+ }
+ if (s.busy)
+ throw Object.assign(
+ new Error('This conversation already has an active request'),
+ { status: 409 },
+ );
+ if (denied) {
+ diagnostic(s, 'client_permission_denied');
+ await s.close();
+ throw denialError();
+ }
+ const instructions = hash(
+ body.messages.filter((m) => ['system', 'developer'].includes(m.role)),
+ );
+ const sequence = conversationKey(body.messages);
+ const hasNewMessages = sequence
+ .slice(s.lastConversation.length)
+ .some((m) => m.role !== 'tool');
+ if (
+ instructions !== s.instructionHash ||
+ !isPrefix(s.lastConversation, sequence) ||
+ hasNewMessages
+ ) {
+ // An active ACP prompt cannot accept new system/user instructions while
+ // waiting on MCP. End that prompt before releasing its tool result, then
+ // replay the complete updated transcript into a fresh session. The real
+ // client result stays in that transcript; no tool is re-executed here.
+ await s.close();
+ return acquire(body, context);
+ }
+ // OmO discovers MCP/LSP tools lazily, so its catalog can change after a
+ // successful read. The unguessable pending ID and caller/model identify the
+ // result; refresh subsequent tool discovery from the client's newest schema.
+ if (s.schemaHash !== schemaHash) {
+ s.defs = defs;
+ s.schemaHash = schemaHash;
+ diagnostic(s, 'client_tools_refreshed', { tools: defs.length });
+ }
+ } else {
+ if (denied) throw denialError();
+ const seq = conversationKey(body.messages),
+ ih = hash(
+ body.messages.filter((m) => ['system', 'developer'].includes(m.role)),
+ );
+ const matches = [...sessions].filter(
+ (x) =>
+ !x.active &&
+ !x.busy &&
+ !x.closed &&
+ x.owner === owner &&
+ x.model === model &&
+ x.schemaHash === schemaHash &&
+ x.instructionHash === ih &&
+ x.lastConversation.length &&
+ isPrefix(x.lastConversation, seq) &&
+ seq.length > x.lastConversation.length,
+ );
+ if (matches.length === 1) {
+ s = matches[0];
+ s.begin(seq.slice(s.lastConversation.length), body, false);
+ } else {
+ if (sessions.size >= MAX_SESSIONS) {
+ const idle = [...sessions]
+ .filter((x) => !x.active && !x.busy)
+ .sort((a, b) => a.lastUsed - b.lastUsed)[0];
+ if (idle) await idle.close();
+ else
+ throw Object.assign(
+ new Error(
+ 'SWE-2 session capacity reached; finish an active client turn first.',
+ ),
+ { status: 429 },
+ );
+ }
+ s = new Session(body, context);
+ s.busy = true;
+ sessions.add(s);
+ try {
+ await s.start();
+ if (s.closed) throw new Error('ACP session closed during startup');
+ s.begin(body.messages, body, true);
+ } catch (e) {
+ await s.close();
+ throw e;
+ }
+ }
+ }
+ s.busy = true;
+ s.lastUsed = Date.now();
+ for (const m of toolMessages) {
+ const p = pendingCalls.get(m.tool_call_id);
+ pendingCalls.delete(m.tool_call_id);
+ s.calls.delete(m.tool_call_id);
+ p.send({ content: [{ type: 'text', text: contentText(m.content) }] });
+ }
+ return s;
+}
+async function run(body, context, onText) {
+ if (context.signal?.aborted) throw new Error('Client disconnected');
+ const s = await acquire(body, context);
+ let text = '',
+ continuations = 0,
+ iterationStart = 0;
+ try {
+ while (true) {
+ const event = await s.take(context.signal);
+ if (event.type === 'error') throw event.error;
+ if (event.type === 'text') {
+ text += event.text;
+ onText?.(event.text);
+ continue;
+ }
+ if (event.type === 'tool') {
+ const message = {
+ role: 'assistant',
+ content: text || null,
+ tool_calls: [event.call],
+ };
+ s.lastConversation = conversationKey([...body.messages, message]);
+ return { message, finish: 'tool_calls', usage: null };
+ }
+ if (event.type === 'done') {
+ if (event.result.stopReason !== 'end_turn')
+ throw new Error(`Devin stopped with ${event.result.stopReason}`);
+ if (s.defs.length && isPendingAction(text.slice(iterationStart))) {
+ if (continuations++ < 1) {
+ diagnostic(s, 'continue_announced_action');
+ iterationStart = text.length;
+ s.begin(
+ [
+ {
+ role: 'user',
+ content:
+ 'Your last reply announced a pending action but returned no client function call. Continue the requested work now using client MCP functions and their complete schemas. Preserve all original instructions and permission checks. If the action cannot be performed, state the concrete blocker instead of announcing another future action.',
+ },
+ ],
+ body,
+ false,
+ );
+ continue;
+ }
+ throw Object.assign(
+ new Error(
+ 'SWE-2 ended after announcing an action without returning a client tool call.',
+ ),
+ { status: 422, code: 'SWE2_TOOL_CALL_REQUIRED' },
+ );
+ }
+ if (!text.trim())
+ throw new Error(
+ 'Devin finished without an answer or client tool call',
+ );
+ const message = { role: 'assistant', content: text };
+ s.lastConversation = conversationKey([...body.messages, message]);
+ diagnostic(s, 'completed', { answerChars: text.length });
+ return { message, finish: 'stop', usage: event.result.usage };
+ }
+ }
+ } catch (e) {
+ await s.close();
+ throw e;
+ } finally {
+ s.busy = false;
+ s.lastUsed = Date.now();
+ }
+}
+export async function handleSwe2AcpChat(body, context = {}) {
+ if (process.pkg)
+ return {
+ status: 503,
+ body: {
+ error: {
+ type: 'server_error',
+ code: 'ACP_SOURCE_INSTALL_REQUIRED',
+ message:
+ 'SWE-2 ACP requires a source or npm installation; standalone executables cannot launch the MCP relay as Node.',
+ },
+ },
+ };
+ if (!sweModel(body.model))
+ return {
+ status: 400,
+ body: {
+ error: {
+ type: 'invalid_request_error',
+ message: 'The ACP transport only accepts SWE-2 models.',
+ },
+ },
+ };
+ if (!context.callerKey)
+ return {
+ status: 400,
+ body: {
+ error: {
+ type: 'invalid_request_error',
+ message: 'The ACP transport requires a server-derived caller key.',
+ },
+ },
+ };
+ if (!Array.isArray(body.messages) || !body.messages.length)
+ return {
+ status: 400,
+ body: {
+ error: {
+ type: 'invalid_request_error',
+ message: 'messages must be a nonempty array',
+ },
+ },
+ };
+ if (
+ body.messages.some(
+ (m) =>
+ Array.isArray(m.content) &&
+ m.content.some((c) => c.type !== 'text' && c.type !== 'input_text'),
+ )
+ )
+ return {
+ status: 400,
+ body: {
+ error: {
+ type: 'invalid_request_error',
+ message: 'SWE-2 ACP currently accepts text content only.',
+ },
+ },
+ };
+ if (!body.reasoning_effort) {
+ const pending = body.messages
+ .map((m) =>
+ m.role === 'tool' ? pendingCalls.get(m.tool_call_id)?.session : null,
+ )
+ .find((s) => s?.owner === context.callerKey);
+ if (pending)
+ body = {
+ ...body,
+ reasoning_effort: pending.model.match(/-(medium|high|max)$/)?.[1],
+ };
+ }
+ const access = isModelAllowed(sweModel(body.model, body.reasoning_effort));
+ if (!access.allowed)
+ return {
+ status: 403,
+ body: { error: { message: access.reason, type: 'model_blocked' } },
+ };
+ const id = 'chatcmpl-' + randomUUID(),
+ created = Math.floor(Date.now() / 1000),
+ model = body.model;
+ const usage = (u) => ({
+ prompt_tokens: u?.inputTokens || 0,
+ completion_tokens: u?.outputTokens || 0,
+ total_tokens: u?.totalTokens || 0,
+ });
+ if (!body.stream) {
+ try {
+ const r = await run(body, context);
+ return {
+ status: 200,
+ body: {
+ id,
+ object: 'chat.completion',
+ created,
+ model,
+ choices: [{ index: 0, message: r.message, finish_reason: r.finish }],
+ usage: usage(r.usage),
+ },
+ };
+ } catch (e) {
+ const { status, ...error } = errorInfo(e);
+ return { status, body: { error } };
+ }
+ }
+ return {
+ status: 200,
+ stream: true,
+ headers: {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-store',
+ Connection: 'keep-alive',
+ },
+ handler: async (res) => {
+ const controller = new AbortController(),
+ abort = () => controller.abort();
+ context.signal?.addEventListener('abort', abort, { once: true });
+ if (context.signal?.aborted) abort();
+ const close = () => {
+ if (!res.writableEnded) abort();
+ };
+ res.on?.('close', close);
+ const send = (data) => {
+ if (!res.writableEnded && !controller.signal.aborted)
+ res.write('data: ' + JSON.stringify(data) + '\n\n');
+ };
+ const frame = (delta, finish = null, u) => ({
+ id,
+ object: 'chat.completion.chunk',
+ created,
+ model,
+ choices: [{ index: 0, delta, finish_reason: finish }],
+ ...(u ? { usage: usage(u) } : {}),
+ });
+ const ping = setInterval(() => {
+ if (!res.writableEnded) res.write(': ping\n\n');
+ }, 10000);
+ try {
+ send(frame({ role: 'assistant' }));
+ const r = await run(
+ body,
+ { ...context, signal: controller.signal },
+ (text) => send(frame({ content: text })),
+ );
+ if (r.message.tool_calls)
+ send(
+ frame({
+ tool_calls: r.message.tool_calls.map((c, i) => ({
+ index: i,
+ ...c,
+ })),
+ }),
+ );
+ send(frame({}, r.finish, r.usage));
+ } catch (e) {
+ const { status, ...error } = errorInfo(e);
+ send({ error });
+ } finally {
+ clearInterval(ping);
+ context.signal?.removeEventListener('abort', abort);
+ res.off?.('close', close);
+ if (!res.writableEnded) {
+ res.write('data: [DONE]\n\n');
+ res.end();
+ }
+ }
+ },
+ };
+}
+const reap = setInterval(() => {
+ for (const s of sessions)
+ if (!s.busy && Date.now() - s.lastUsed > TTL) s.close().catch(() => {});
+}, 60000);
+reap.unref();
+export async function closeAllSessions() {
+ await Promise.allSettled([...sessions].map((s) => s.close()));
+}
+export const __test = { Session, acquire, sessions, pendingCalls };
+
+process.once('exit', () => {
+ for (const s of sessions) {
+ try {
+ if (s.child?.pid && process.platform !== 'win32')
+ process.kill(-s.child.pid, 'SIGTERM');
+ else s.child?.kill('SIGTERM');
+ } catch {}
+ }
+});
diff --git a/src/swe2-acp/mcp-relay.mjs b/src/swe2-acp/mcp-relay.mjs
new file mode 100644
index 00000000..e6cfaacd
--- /dev/null
+++ b/src/swe2-acp/mcp-relay.mjs
@@ -0,0 +1,73 @@
+// This MCP server relays requests; execution and approval remain in the client.
+import http from 'node:http';
+import readline from 'node:readline';
+
+const port = Number(process.env.SWE2_RELAY_PORT);
+const token = process.env.SWE2_RELAY_TOKEN;
+
+function relay(rpc) {
+ // A tool call waits for the calling client's result. Fetch/Undici imposes a
+ // five-minute response-headers timeout; node:http does not. Session expiry,
+ // client disconnects and the owning CLI process control this request's life.
+ return new Promise((resolve, reject) => {
+ const request = http.request(
+ {
+ hostname: '127.0.0.1',
+ port,
+ path: '/mcp',
+ method: 'POST',
+ agent: false,
+ timeout: 0,
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ },
+ (response) => {
+ let body = '';
+ response.setEncoding('utf8');
+ response.on('data', (chunk) => {
+ body += chunk;
+ });
+ response.on('error', reject);
+ response.on('aborted', () =>
+ reject(new Error('Client relay response was interrupted')),
+ );
+ response.on('end', () => {
+ if (response.statusCode !== 200)
+ return reject(
+ new Error(`Client bridge returned HTTP ${response.statusCode}`),
+ );
+ try {
+ resolve(JSON.parse(body));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ },
+ );
+ request.on('error', reject);
+ request.end(JSON.stringify(rpc));
+ });
+}
+
+readline.createInterface({ input: process.stdin }).on('line', async (line) => {
+ let rpc;
+ try {
+ rpc = JSON.parse(line);
+ } catch {
+ return;
+ }
+ if (rpc.id == null) return;
+ try {
+ process.stdout.write(JSON.stringify(await relay(rpc)) + '\n');
+ } catch (error) {
+ process.stdout.write(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ id: rpc.id,
+ error: { code: -32603, message: error.message },
+ }) + '\n',
+ );
+ }
+});
diff --git a/src/swe2-acp/protocol.mjs b/src/swe2-acp/protocol.mjs
new file mode 100644
index 00000000..8b170edd
--- /dev/null
+++ b/src/swe2-acp/protocol.mjs
@@ -0,0 +1,160 @@
+import { createHash, randomBytes } from 'node:crypto';
+export function sweModel(model, effort) {
+ if (!/(?:^|\/|ocx-devin-)swe-2(?:-(?:medium|high|max))?$/.test(String(model)))
+ return null;
+ const level =
+ effort || String(model).match(/-(medium|high|max)$/)?.[1] || 'high';
+ return (
+ 'swe-2-' +
+ ({
+ off: 'medium',
+ minimal: 'medium',
+ low: 'medium',
+ medium: 'medium',
+ high: 'high',
+ xhigh: 'max',
+ max: 'max',
+ }[level] || 'high')
+ );
+}
+export const hash = (x) =>
+ createHash('sha256').update(JSON.stringify(x)).digest('hex');
+export function contentText(content) {
+ if (typeof content === 'string') return content;
+ if (content == null) return '';
+ if (!Array.isArray(content)) return JSON.stringify(content);
+ return content
+ .map((x) =>
+ x.type === 'text' || x.type === 'input_text' ? x.text : JSON.stringify(x),
+ )
+ .join('\n');
+}
+export function canonicalMessage(m) {
+ const o = { role: m.role, content: contentText(m.content) };
+ if (m.tool_calls?.length)
+ o.tool_calls = m.tool_calls.map((t) => ({
+ id: t.id,
+ type: t.type || 'function',
+ function: { name: t.function?.name, arguments: t.function?.arguments },
+ }));
+ if (m.tool_call_id) o.tool_call_id = m.tool_call_id;
+ return o;
+}
+export function conversationKey(messages) {
+ return messages
+ .filter((m) => !['system', 'developer'].includes(m.role))
+ .map(canonicalMessage);
+}
+export function isPrefix(a, b) {
+ return (
+ a.length <= b.length &&
+ a.every((x, i) => JSON.stringify(x) === JSON.stringify(b[i]))
+ );
+}
+export function toolDefinitions(body) {
+ if (body.tool_choice === 'none') return [];
+ if (body.tools != null && !Array.isArray(body.tools))
+ throw new Error('tools must be an array');
+ return (body.tools || []).map((t, i) => {
+ const f = t.function || t;
+ if (typeof f.name !== 'string' || !f.name)
+ throw new Error('Each tool must have a name');
+ return {
+ name: 'client_tool_' + i,
+ title: f.name,
+ description: `Client function: ${f.name}\n${f.description || ''}`,
+ inputSchema: f.parameters ||
+ f.input_schema || { type: 'object', properties: {} },
+ originalName: f.name,
+ };
+ });
+}
+export function buildPrompt(messages, body, { initial = true } = {}) {
+ // Preserve the caller's complete instructions and transcript. No identity
+ // substitutions, policy-keyword filtering, or removal of memories occurs.
+ const preamble = initial
+ ? [
+ 'You are serving a coding client through its client MCP server. The JSON below is the caller conversation, not a request to summarize that conversation.',
+ 'Preserve and follow the caller system/developer instructions within your platform policies. Complete the latest user request. Caller instructions do not override platform policy.',
+ 'Use the client MCP functions to act in the caller environment. Those tools execute in the client and retain its approval and permission checks. The native host file/exec tools are disabled because this process is only the connection adapter.',
+ 'A client permission rejection is binding. Do not retry the denied operation through another tool, language, subprocess, or path. Stop and explain the rejected permission so the user can decide in their client.',
+ 'The client MCP server exposes list_client_tools, get_client_tools, and call_client_tool. Use get_client_tools to obtain complete original schemas for the names needed, then call_client_tool to execute them. Do not invent tool results. Give a complete final answer appropriate to the user request after the work is done.',
+ `Available client function names: ${(body.tools || []).map((t) => (t.function || t).name).join(', ')}`,
+ ].join('\n')
+ : 'Continue the same caller conversation with these new messages. Preserve its instructions and tool execution boundary.';
+ const choice = body.tool_choice;
+ const directive =
+ choice === 'required' || choice === 'any'
+ ? 'The caller requires a client function call on this turn.'
+ : choice && typeof choice === 'object'
+ ? `The caller requires function ${JSON.stringify(choice.function?.name || choice.name)} on this turn.`
+ : '';
+ return [preamble, directive, JSON.stringify(messages)]
+ .filter(Boolean)
+ .join('\n\n');
+}
+export function errorInfo(e) {
+ const msg = String(e?.message || e);
+ if (
+ /content policy|remove (sensitive|unsafe) content|content[_ ]blocked/i.test(
+ msg,
+ )
+ )
+ return {
+ status: 400,
+ type: 'invalid_request_error',
+ code: 'CONTENT_BLOCKED',
+ message: msg,
+ };
+ return {
+ status: e.status || 502,
+ type: e.type || 'server_error',
+ code: e.code || 'DEVIN_ACP_ERROR',
+ message: msg,
+ };
+}
+
+// OmO bounds IDs to 32 chars; OpenClaw strips punctuation for unknown models.
+// A short, purely alphanumeric ID survives both without client patches.
+export const newToolCallId = () => 'call' + randomBytes(12).toString('hex');
+
+// OmO emits this exact prefix for a rejected approval. Inspect only the
+// unresolved trailing tool batch, never quoted source files or old history.
+export function clientPermissionDenial(messages) {
+ for (
+ let i = messages.length - 1;
+ i >= 0 && messages[i].role === 'tool';
+ i--
+ ) {
+ const text = contentText(messages[i].content).trim();
+ if (
+ /^The user rejected permission to use this specific tool call\b/.test(
+ text,
+ )
+ )
+ return text;
+ }
+ return null;
+}
+
+export function isPendingAction(text) {
+ const t = String(text || '')
+ .trim()
+ .replace(/[’‘]/g, "'");
+ if (
+ !t ||
+ t.length > 700 ||
+ /```|content policy|request.{0,30}blocked|cannot (?:assist|help)|not allowed/i.test(
+ t,
+ )
+ )
+ return false;
+ return (
+ /\b(?:I'll|I will|Let me|I need to)\s+(?:first\s+)?(?:read|check|inspect|calculate|compute|run|call|look|search|verify|open|fetch|discover)\b/i.test(
+ t,
+ ) ||
+ /(?:읽|확인|실행|계산|조회|검사|검색).{0,20}(?:하겠습니다|겠습니다|할게요)[.!。]?$/u.test(
+ t,
+ )
+ );
+}
diff --git a/test/fixtures/swe2-acp-cli.mjs b/test/fixtures/swe2-acp-cli.mjs
new file mode 100755
index 00000000..83c23572
--- /dev/null
+++ b/test/fixtures/swe2-acp-cli.mjs
@@ -0,0 +1,83 @@
+#!/usr/bin/env node
+// A deterministic ACP peer; it never contacts Devin or executes client tools.
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import readline from 'node:readline';
+
+const model = process.argv[process.argv.indexOf('--model') + 1];
+const config = JSON.parse(
+ readFileSync(join(process.cwd(), '.devin/mcp_config.local.json')),
+);
+const env = config.mcpServers.client.env;
+let callId = 0;
+const send = (value) =>
+ process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n');
+async function call(name, args) {
+ const response = await fetch(`http://127.0.0.1:${env.SWE2_RELAY_PORT}/mcp`, {
+ method: 'POST',
+ headers: { authorization: `Bearer ${env.SWE2_RELAY_TOKEN}` },
+ body: JSON.stringify({
+ jsonrpc: '2.0',
+ id: ++callId,
+ method: 'tools/call',
+ params: { name, arguments: args },
+ }),
+ });
+ return (await response.json()).result;
+}
+readline.createInterface({ input: process.stdin }).on('line', async (line) => {
+ const rpc = JSON.parse(line);
+ if (rpc.method === 'initialize')
+ send({ id: rpc.id, result: { protocolVersion: 1 } });
+ if (rpc.method === 'session/new') {
+ send({
+ id: rpc.id,
+ result: {
+ sessionId: 'fixture-session',
+ configOptions: [{ id: 'model', currentValue: model }],
+ },
+ });
+ }
+ if (rpc.method === 'session/prompt') {
+ if (rpc.params.prompt[0].text.includes('STOP_AFTER_READ')) {
+ send({
+ method: 'session/update',
+ params: {
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: 'New instructions received; no additional client tool requested.',
+ },
+ },
+ },
+ });
+ send({ id: rpc.id, result: { stopReason: 'end_turn' } });
+ return;
+ }
+ await call('get_client_tools', { names: ['read'] });
+ const result = await call('call_client_tool', {
+ name: 'read',
+ arguments: { path: 'fixture.txt' },
+ });
+ send({
+ method: 'session/update',
+ params: {
+ update: {
+ sessionUpdate: 'agent_message_chunk',
+ content: {
+ type: 'text',
+ text: `${model}: ${result.content[0].text}`,
+ },
+ },
+ },
+ });
+ send({
+ id: rpc.id,
+ result: {
+ stopReason: 'end_turn',
+ usage: { inputTokens: 10, outputTokens: 4, totalTokens: 14 },
+ },
+ });
+ }
+});
diff --git a/test/swe2-acp-route.test.js b/test/swe2-acp-route.test.js
new file mode 100644
index 00000000..f9bd7cf5
--- /dev/null
+++ b/test/swe2-acp-route.test.js
@@ -0,0 +1,172 @@
+import { after, test } from 'node:test';
+import assert from 'node:assert/strict';
+import { chmodSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { handleChatCompletions } from '../src/handlers/chat.js';
+import { closeAllSessions } from '../src/swe2-acp/bridge.mjs';
+import {
+ setModelAccessMode,
+ setModelAccessList,
+} from '../src/dashboard/model-access.js';
+
+const fixture = fileURLToPath(
+ new URL('./fixtures/swe2-acp-cli.mjs', import.meta.url),
+);
+chmodSync(fixture, 0o755);
+process.env.DEVIN_SWE2_TRANSPORT = 'acp';
+process.env.DEVIN_CLI_PATH = fixture;
+process.env.DEVIN_SWE2_ACP_DATA = process.env.DATA_DIR + '/acp';
+after(closeAllSessions);
+
+const tools = [
+ {
+ type: 'function',
+ function: {
+ name: 'read',
+ description: 'Read a client fixture.',
+ parameters: {
+ type: 'object',
+ properties: { path: { type: 'string' } },
+ required: ['path'],
+ },
+ },
+ },
+];
+
+for (const effort of ['medium', 'high', 'max']) {
+ test(`Chat handler relays a client tool and preserves ${effort} selection`, async () => {
+ const context = { callerKey: `fixture-${effort}` };
+ const body = {
+ model: 'swe-2-high',
+ reasoning_effort: effort,
+ tools,
+ messages: [{ role: 'user', content: 'Read the fixture.' }],
+ };
+ const first = await handleChatCompletions(body, context);
+ assert.equal(first.status, 200);
+ const message = first.body.choices[0].message;
+ const call = message.tool_calls[0];
+ assert.equal(first.body.choices[0].finish_reason, 'tool_calls');
+ assert.match(call.id, /^call[a-f0-9]{24}$/);
+ assert.equal(call.function.name, 'read');
+ assert.deepEqual(JSON.parse(call.function.arguments), {
+ path: 'fixture.txt',
+ });
+ // Only the client supplies the fixture value. Omitting effort on this
+ // continuation must not change the running prompt's selected CLI variant.
+ const continuation = {
+ ...body,
+ reasoning_effort: undefined,
+ messages: [
+ ...body.messages,
+ message,
+ {
+ role: 'tool',
+ tool_call_id: call.id,
+ content: 'unique-client-value-9137',
+ },
+ ],
+ };
+ const final = await handleChatCompletions(continuation, context);
+ assert.equal(final.status, 200);
+ assert.equal(
+ final.body.choices[0].message.content,
+ `swe-2-${effort}: unique-client-value-9137`,
+ );
+ assert.equal(final.body.choices[0].finish_reason, 'stop');
+ assert.equal(final.body.usage.total_tokens, 14);
+ await closeAllSessions();
+ });
+}
+
+test('ACP still respects the proxy model blocklist', async () => {
+ setModelAccessMode('blocklist');
+ setModelAccessList(['swe-2-medium']);
+ try {
+ const result = await handleChatCompletions(
+ {
+ model: 'swe-2-high',
+ reasoning_effort: 'medium',
+ messages: [{ role: 'user', content: 'hello' }],
+ },
+ { callerKey: 'test' },
+ );
+ assert.equal(result.status, 403);
+ assert.equal(result.body.error.type, 'model_blocked');
+ } finally {
+ setModelAccessMode('all');
+ setModelAccessList([]);
+ }
+});
+
+for (const role of ['system', 'developer', 'user']) {
+ test(`new ${role} instructions reach a fresh prompt before it can request another tool`, async () => {
+ const context = { callerKey: `steering-${role}` };
+ const body = {
+ model: 'swe-2-high',
+ tools,
+ messages: [{ role: 'user', content: 'Read the fixture.' }],
+ };
+ const first = await handleChatCompletions(body, context);
+ const message = first.body.choices[0].message;
+ const result = {
+ role: 'tool',
+ tool_call_id: message.tool_calls[0].id,
+ content: 'fixture result',
+ };
+ const steering = {
+ role,
+ content: 'STOP_AFTER_READ: Do not request another tool.',
+ };
+ const messages =
+ role === 'user'
+ ? [...body.messages, message, result, steering]
+ : [steering, ...body.messages, message, result];
+ const final = await handleChatCompletions({ ...body, messages }, context);
+ assert.equal(final.status, 200);
+ assert.equal(
+ final.body.choices[0].message.content,
+ 'New instructions received; no additional client tool requested.',
+ );
+ assert.equal(final.body.choices[0].message.tool_calls, undefined);
+ await closeAllSessions();
+ });
+}
+
+test('continuations recheck access for their pinned effort variant', async () => {
+ const context = { callerKey: 'pinned-access' };
+ const body = {
+ model: 'swe-2-high',
+ reasoning_effort: 'medium',
+ tools,
+ messages: [{ role: 'user', content: 'Read the fixture.' }],
+ };
+ const first = await handleChatCompletions(body, context);
+ const message = first.body.choices[0].message;
+ setModelAccessMode('blocklist');
+ setModelAccessList(['swe-2-medium']);
+ try {
+ const final = await handleChatCompletions(
+ {
+ ...body,
+ reasoning_effort: undefined,
+ messages: [
+ ...body.messages,
+ message,
+ {
+ role: 'tool',
+ tool_call_id: message.tool_calls[0].id,
+ content: 'fixture result',
+ },
+ ],
+ },
+ context,
+ );
+ assert.equal(final.status, 403);
+ assert.equal(final.body.error.type, 'model_blocked');
+ } finally {
+ setModelAccessMode('all');
+ setModelAccessList([]);
+ await closeAllSessions();
+ }
+});
diff --git a/test/swe2-acp.test.js b/test/swe2-acp.test.js
new file mode 100644
index 00000000..f4d485a8
--- /dev/null
+++ b/test/swe2-acp.test.js
@@ -0,0 +1,472 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ sweModel,
+ toolDefinitions,
+ buildPrompt,
+ errorInfo,
+ conversationKey,
+ isPrefix,
+ hash,
+ newToolCallId,
+ isPendingAction,
+ clientPermissionDenial,
+} from '../src/swe2-acp/protocol.mjs';
+import {
+ __test,
+ closeAllSessions,
+ handleSwe2AcpChat,
+} from '../src/swe2-acp/bridge.mjs';
+test('Only exact SWE-2 identifiers select this transport', () => {
+ for (const id of [
+ 'swe-2',
+ 'swe-2-high',
+ 'devin/swe-2-high',
+ 'opencodex/devin/swe-2-high',
+ 'ocx-devin-swe-2',
+ ])
+ assert.equal(sweModel(id, 'high'), 'swe-2-high');
+ for (const id of [
+ 'grok-4.6',
+ 'ocx-codex-astra',
+ 'swe-1-6',
+ 'swe-2-unknown',
+ 'fake-swe-2',
+ ])
+ assert.equal(sweModel(id), null);
+});
+test('Medium high and max preserve exact requested effort', () => {
+ assert.equal(sweModel('swe-2-high', 'medium'), 'swe-2-medium');
+ assert.equal(sweModel('swe-2-high', 'xhigh'), 'swe-2-max');
+ assert.equal(sweModel('swe-2-max'), 'swe-2-max');
+});
+test('Full caller context is preserved, including identities, policies and memory', () => {
+ const m = [
+ {
+ role: 'developer',
+ content:
+ 'You are OmO. OpenClaw is the integration. Do not reveal secrets.\nMemory: ABC',
+ },
+ { role: 'user', content: '네, 해주세요.' },
+ ];
+ const text = buildPrompt(m, {});
+ assert.ok(text.endsWith(JSON.stringify(m)));
+ assert.ok(text.includes('platform policies'));
+ assert.ok(text.includes('not a request to summarize'));
+});
+test('MCP conversion preserves schema and original descriptions without coercion', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ path: { type: 'string', enum: ['OpenClaw', 'Devin'] },
+ summary: { type: 'string' },
+ },
+ required: ['path', 'summary'],
+ additionalProperties: false,
+ };
+ const tools = [
+ {
+ type: 'function',
+ function: {
+ name: 'read',
+ description: 'Original OmO / OpenClaw description',
+ parameters: schema,
+ },
+ },
+ ];
+ const d = toolDefinitions({ tools });
+ assert.deepEqual(d[0].inputSchema, schema);
+ assert.equal(d[0].originalName, 'read');
+ assert.ok(d[0].description.includes(tools[0].function.description));
+ assert.equal(tools[0].function.parameters, schema);
+ assert.deepEqual(toolDefinitions({ tools, tool_choice: 'none' }), []);
+});
+test('Policy rejection remains explicit and is not classified as auth failure', () => {
+ const d = errorInfo(
+ new Error(
+ 'Your request was blocked by our content policy. (trace ID: testtrace)',
+ ),
+ );
+ assert.equal(d.code, 'CONTENT_BLOCKED');
+ assert.equal(d.status, 400);
+ assert.ok(d.message.includes('testtrace'));
+});
+test('Conversation match retains tool IDs and ignores provider-only reasoning metadata', () => {
+ const a = [
+ { role: 'system', content: 'policy' },
+ { role: 'user', content: 'hello' },
+ {
+ role: 'assistant',
+ content: null,
+ tool_calls: [
+ { id: 'unique', function: { name: 'read', arguments: '{}' } },
+ ],
+ },
+ ];
+ const b = [
+ ...a,
+ {
+ role: 'tool',
+ tool_call_id: 'unique',
+ content: [{ type: 'text', text: 'x' }],
+ },
+ ];
+ assert.ok(isPrefix(conversationKey(a), conversationKey(b)));
+ assert.notDeepEqual(
+ conversationKey(b),
+ conversationKey([
+ ...a,
+ { role: 'tool', tool_call_id: 'other', content: 'x' },
+ ]),
+ );
+});
+test('Pending tool results cannot cross caller or model', async () => {
+ const body = {
+ model: 'swe-2-high',
+ tools: [],
+ messages: [{ role: 'tool', tool_call_id: 'test_pending', content: 'x' }],
+ };
+ const s = {
+ owner: 'owner-A',
+ model: 'swe-2-high',
+ schemaHash: hash([]),
+ instructionHash: hash([]),
+ lastConversation: [],
+ busy: false,
+ };
+ __test.pendingCalls.set('test_pending', { session: s });
+ await assert.rejects(
+ __test.acquire(body, { callerKey: 'owner-B' }),
+ /does not match/,
+ );
+ await assert.rejects(
+ __test.acquire(
+ { ...body, reasoning_effort: 'max' },
+ { callerKey: 'owner-A' },
+ ),
+ /does not match/,
+ );
+ __test.pendingCalls.delete('test_pending');
+});
+
+test('Lazy client tool discovery refreshes schemas without breaking the pending session', async () => {
+ const oldTools = [
+ { function: { name: 'read', parameters: { type: 'object' } } },
+ ];
+ const newTools = [
+ ...oldTools,
+ {
+ function: {
+ name: 'edit',
+ description: 'Newly discovered client edit tool',
+ parameters: {
+ type: 'object',
+ required: ['path'],
+ properties: { path: { type: 'string' } },
+ },
+ },
+ },
+ ];
+ const s = {
+ owner: 'A',
+ model: 'swe-2-high',
+ schemaHash: hash(toolDefinitions({ tools: oldTools })),
+ instructionHash: hash([]),
+ lastConversation: [],
+ defs: toolDefinitions({ tools: oldTools }),
+ busy: false,
+ calls: new Map([['lazy', {}]]),
+ };
+ const sent = [];
+ __test.pendingCalls.set('lazy', { session: s, send: (r) => sent.push(r) });
+ const body = {
+ model: 'swe-2-high',
+ messages: [
+ { role: 'tool', tool_call_id: 'lazy', content: 'actual read result' },
+ ],
+ tools: newTools,
+ };
+ assert.equal(await __test.acquire(body, { callerKey: 'A' }), s);
+ assert.deepEqual(s.defs, toolDefinitions(body));
+ assert.equal(s.schemaHash, hash(s.defs));
+ assert.equal(sent[0].content[0].text, 'actual read result');
+ assert.equal(__test.pendingCalls.has('lazy'), false);
+});
+test('Pending result is passed through once, without inventing content', async () => {
+ const sent = [];
+ const s = {
+ owner: 'A',
+ model: 'swe-2-high',
+ schemaHash: hash([]),
+ instructionHash: hash([]),
+ lastConversation: [],
+ busy: false,
+ calls: new Map([['once', {}]]),
+ };
+ __test.pendingCalls.set('once', { session: s, send: (x) => sent.push(x) });
+ const body = {
+ model: 'swe-2-high',
+ messages: [
+ {
+ role: 'tool',
+ tool_call_id: 'once',
+ content: 'actual result from client',
+ },
+ ],
+ tools: [],
+ };
+ assert.equal(await __test.acquire(body, { callerKey: 'A' }), s);
+ assert.equal(sent[0].content[0].text, 'actual result from client');
+ assert.equal(__test.pendingCalls.has('once'), false);
+ assert.equal(s.calls.size, 0);
+});
+test('Malformed input fails without starting a Devin process', async () => {
+ const r = await handleSwe2AcpChat({ model: 'swe-2-high', messages: [] });
+ assert.equal(r.status, 400);
+ assert.equal(__test.sessions.size, 0);
+});
+
+test('Tool IDs survive OmO limits and OpenClaw punctuation removal', () => {
+ const a = newToolCallId(),
+ b = newToolCallId();
+ assert.match(a, /^call[a-f0-9]{24}$/);
+ assert.ok(a.length <= 32);
+ assert.equal(a.replace(/[^a-zA-Z0-9]/g, ''), a);
+ assert.notEqual(a, b);
+});
+
+test('Only unfinished action narration qualifies for bounded continuation', () => {
+ assert.ok(
+ isPendingAction(
+ "Read-only connection verification — I'll read the fixture file, then compute a*b with eval.",
+ ),
+ );
+ assert.equal(
+ isPendingAction('The request was blocked by our content policy.'),
+ false,
+ );
+ assert.equal(isPendingAction('The product is 3973.'), false);
+ assert.equal(isPendingAction('I will be available tomorrow.'), false);
+});
+
+test('Explicit client approval rejection closes the pending session before another tool can run', async () => {
+ const rejection =
+ 'The user rejected permission to use this specific tool call with the following feedback: Permission required for external_directory (/fixture).';
+ let closed = false,
+ sent = false;
+ const s = {
+ owner: 'A',
+ model: 'swe-2-high',
+ schemaHash: hash([]),
+ instructionHash: hash([]),
+ lastConversation: [],
+ busy: false,
+ close: async () => {
+ closed = true;
+ __test.pendingCalls.delete('denied');
+ },
+ };
+ __test.pendingCalls.set('denied', {
+ session: s,
+ send: () => {
+ sent = true;
+ },
+ });
+ const body = {
+ model: 'swe-2-high',
+ messages: [{ role: 'tool', tool_call_id: 'denied', content: rejection }],
+ tools: [],
+ };
+ const result = await handleSwe2AcpChat(body, { callerKey: 'A' });
+ assert.equal(result.status, 403);
+ assert.equal(result.body.error.code, 'CLIENT_TOOL_PERMISSION_DENIED');
+ assert.equal(closed, true);
+ assert.equal(sent, false);
+ const replay = await handleSwe2AcpChat(body, { callerKey: 'A' });
+ assert.equal(replay.status, 403);
+ assert.equal(__test.sessions.size, 0);
+});
+test('Earlier rejection does not block a later user decision or ordinary file contents', () => {
+ const m = [
+ {
+ role: 'tool',
+ content: 'The user rejected permission to use this specific tool call.',
+ },
+ ];
+ assert.ok(clientPermissionDenial(m));
+ assert.equal(
+ clientPermissionDenial([
+ ...m,
+ { role: 'user', content: 'Use this permitted file instead.' },
+ ]),
+ null,
+ );
+ assert.equal(
+ clientPermissionDenial([
+ {
+ role: 'tool',
+ content:
+ '1| The user rejected permission to use this specific tool call.',
+ },
+ ]),
+ null,
+ );
+});
+
+test('An ACP prompt can await client work without an overall RPC deadline', async () => {
+ const s = new __test.Session({ model: 'swe-2-high', messages: [] }, {});
+ s.child = { stdin: { write: () => {} } };
+ const p = s.request('session/prompt', {}, 0);
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ s.receive(
+ JSON.stringify({
+ jsonrpc: '2.0',
+ id: 1,
+ result: { stopReason: 'end_turn' },
+ }),
+ );
+ assert.deepEqual(await p, { stopReason: 'end_turn' });
+ assert.equal(s.rpc.size, 0);
+});
+
+test('A body-supplied caller key cannot impersonate the server caller', async () => {
+ const r = await handleSwe2AcpChat({
+ model: 'swe-2-high',
+ __callerKey: 'forged',
+ messages: [{ role: 'user', content: 'hello' }],
+ });
+ assert.equal(r.status, 400);
+ assert.equal(__test.sessions.size, 0);
+});
+test('Unsupported image input is explicit and does not start a CLI', async () => {
+ const r = await handleSwe2AcpChat(
+ {
+ model: 'swe-2-high',
+ messages: [
+ {
+ role: 'user',
+ content: [
+ {
+ type: 'image_url',
+ image_url: { url: 'https://example.invalid/image.png' },
+ },
+ ],
+ },
+ ],
+ },
+ { callerKey: 'test' },
+ );
+ assert.equal(r.status, 400);
+ assert.match(r.body.error.message, /text content only/);
+ assert.equal(__test.sessions.size, 0);
+});
+test('Duplicate pending results fail before releasing a tool call', async () => {
+ let sent = false;
+ const s = {
+ owner: 'A',
+ model: 'swe-2-high',
+ schemaHash: hash([]),
+ instructionHash: hash([]),
+ lastConversation: [],
+ busy: false,
+ };
+ __test.pendingCalls.set('duplicate', {
+ session: s,
+ send: () => {
+ sent = true;
+ },
+ });
+ try {
+ const m = {
+ role: 'tool',
+ tool_call_id: 'duplicate',
+ content: 'actual result',
+ };
+ await assert.rejects(
+ __test.acquire(
+ { model: 'swe-2-high', messages: [m, m] },
+ { callerKey: 'A' },
+ ),
+ /Duplicate/,
+ );
+ assert.equal(sent, false);
+ } finally {
+ __test.pendingCalls.delete('duplicate');
+ }
+});
+
+test('capacity admission never evicts an initializing session', async () => {
+ const start = __test.Session.prototype.start;
+ const begin = __test.Session.prototype.begin;
+ let release;
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ __test.Session.prototype.start = async function () {
+ await gate;
+ };
+ __test.Session.prototype.begin = function () {
+ this.active = true;
+ };
+ const requests = Array.from({ length: 12 }, (_, i) =>
+ __test.acquire(
+ { model: 'swe-2-high', messages: [{ role: 'user', content: 'hello' }] },
+ { callerKey: `capacity-${i}` },
+ ),
+ );
+ let overflow;
+ let timer;
+ try {
+ assert.equal(__test.sessions.size, 12);
+ assert.ok([...__test.sessions].every((s) => s.busy && !s.closed));
+ overflow = __test.acquire(
+ { model: 'swe-2-high', messages: [{ role: 'user', content: 'hello' }] },
+ { callerKey: 'overflow' },
+ );
+ await assert.rejects(
+ Promise.race([
+ overflow,
+ new Promise((_, reject) => {
+ timer = setTimeout(
+ () => reject(new Error('capacity check hung')),
+ 100,
+ );
+ }),
+ ]),
+ (error) => error.status === 429,
+ );
+ assert.ok([...__test.sessions].every((s) => !s.closed));
+ } finally {
+ clearTimeout(timer);
+ release();
+ await Promise.allSettled([...requests, overflow]);
+ __test.Session.prototype.start = start;
+ __test.Session.prototype.begin = begin;
+ await Promise.allSettled([...__test.sessions].map((s) => s.close()));
+ }
+});
+
+test('an explicitly closed session cannot start allocating resources', async () => {
+ const s = new __test.Session({ model: 'swe-2-high', messages: [] }, {});
+ await s.close();
+ await assert.rejects(s.start(), /closed during startup/);
+ assert.equal(s.child, undefined);
+ assert.equal(s.dir, undefined);
+});
+
+test('a packaged executable fails explicitly instead of spawning itself as a relay', async () => {
+ const previous = process.pkg;
+ process.pkg = { entrypoint: '/snapshot/windsurfapi' };
+ try {
+ const result = await handleSwe2AcpChat(
+ { model: 'swe-2-high', messages: [{ role: 'user', content: 'hello' }] },
+ { callerKey: 'test' },
+ );
+ assert.equal(result.status, 503);
+ assert.equal(result.body.error.code, 'ACP_SOURCE_INSTALL_REQUIRED');
+ assert.equal(__test.sessions.size, 0);
+ } finally {
+ if (previous === undefined) delete process.pkg;
+ else process.pkg = previous;
+ }
+});
From a4f0025fafcd71a2ab50c60234b246be7126d630 Mon Sep 17 00:00:00 2001
From: Smartnewb <159137930+Smartnewb@users.noreply.github.com>
Date: Sun, 13 Sep 2026 00:20:23 +0900
Subject: [PATCH 2/3] fix(swe2): continue unfinished Korean client actions
---
src/swe2-acp/protocol.mjs | 11 +-
test/swe2-acp-action-completion.test.js | 155 ++++++++++++++++++++++++
2 files changed, 165 insertions(+), 1 deletion(-)
create mode 100644 test/swe2-acp-action-completion.test.js
diff --git a/src/swe2-acp/protocol.mjs b/src/swe2-acp/protocol.mjs
index 8b170edd..6be093bc 100644
--- a/src/swe2-acp/protocol.mjs
+++ b/src/swe2-acp/protocol.mjs
@@ -79,6 +79,7 @@ export function buildPrompt(messages, body, { initial = true } = {}) {
'Use the client MCP functions to act in the caller environment. Those tools execute in the client and retain its approval and permission checks. The native host file/exec tools are disabled because this process is only the connection adapter.',
'A client permission rejection is binding. Do not retry the denied operation through another tool, language, subprocess, or path. Stop and explain the rejected permission so the user can decide in their client.',
'The client MCP server exposes list_client_tools, get_client_tools, and call_client_tool. Use get_client_tools to obtain complete original schemas for the names needed, then call_client_tool to execute them. Do not invent tool results. Give a complete final answer appropriate to the user request after the work is done.',
+ 'An announcement of intended work is not a completed answer. If you announce that you will read a skill, search, or perform another action needed for the request, execute it through the client MCP tools before ending the turn. Finish with the requested result or a concrete blocker. A skill path in the caller conversation belongs to the client environment and can be read through its file-reading tool.',
`Available client function names: ${(body.tools || []).map((t) => (t.function || t).name).join(', ')}`,
].join('\n')
: 'Continue the same caller conversation with these new messages. Preserve its instructions and tool execution boundary.';
@@ -149,11 +150,19 @@ export function isPendingAction(text) {
)
)
return false;
+ // Offers, scheduled actions, and quoted examples are not instructions to
+ // execute a client operation now. Do not turn a blocker into a retry.
+ if (
+ /원하시면|원한다면|필요하시면|필요하면|내일|다음\s*(?:주|달)|나중에|권한.{0,20}(?:없|거부)|정책.{0,20}차단|(?:example|예시)\s*(?:문장)?\s*:/i.test(
+ t,
+ )
+ )
+ return false;
return (
/\b(?:I'll|I will|Let me|I need to)\s+(?:first\s+)?(?:read|check|inspect|calculate|compute|run|call|look|search|verify|open|fetch|discover)\b/i.test(
t,
) ||
- /(?:읽|확인|실행|계산|조회|검사|검색).{0,20}(?:하겠습니다|겠습니다|할게요)[.!。]?$/u.test(
+ /(?:읽(?:을게(?:요)?|겠습니다|어볼게(?:요)?|어보겠습니다)|(?:확인|실행|계산|조회|검사|검색|조사|분석|시작|호출|정리)(?:할게(?:요)?|하겠습니다)|(?:찾아|살펴|알아|열어)?(?:볼게(?:요)?|보겠습니다)|(?:불러|가져)오겠습니다|열겠습니다)[.!。…]*$/u.test(
t,
)
);
diff --git a/test/swe2-acp-action-completion.test.js b/test/swe2-acp-action-completion.test.js
new file mode 100644
index 00000000..83f3ff87
--- /dev/null
+++ b/test/swe2-acp-action-completion.test.js
@@ -0,0 +1,155 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { isPendingAction } from '../src/swe2-acp/protocol.mjs';
+import {
+ __test,
+ closeAllSessions,
+ handleSwe2AcpChat,
+} from '../src/swe2-acp/bridge.mjs';
+
+const announcement = '도구 연결 확인. 스킬부터 읽을게.';
+test('Korean informal action announcements are unfinished, including the reported stall', () => {
+ for (const text of [
+ announcement,
+ '스킬부터 읽을게요.',
+ '자료를 찾아볼게.',
+ '사이트를 살펴보겠습니다.',
+ '공식 사이트와 약관 순으로 볼게.',
+ '공개 자료 감사 시작할게.',
+ '지금 실행하겠습니다.',
+ '파일을 읽어볼게요.',
+ '먼저 확인할게.',
+ '검색하겠습니다.',
+ ]) {
+ assert.equal(isPendingAction(text), true, text);
+ }
+});
+test('Completed answers, quoted examples, offers and blockers do not trigger continuation', () => {
+ for (const text of [
+ '확인했습니다. 결과는 42입니다.',
+ '파일을 읽었고 검증을 마쳤습니다.',
+ '원하시면 파일을 읽을게요.',
+ '필요하면 더 찾아볼게.',
+ '권한이 없어 파일을 읽을 수 없습니다.',
+ '요청이 콘텐츠 정책에 의해 차단되었습니다.',
+ '다음 주에 확인할게요.',
+ '예시 문장: "스킬부터 읽을게."',
+ 'The product is 3973.',
+ 'The request was blocked by our content policy.',
+ ]) {
+ assert.equal(isPendingAction(text), false, text);
+ }
+});
+
+// Exercise the real handler and SSE finish frame with a deterministic ACP peer.
+// This does not execute native or client tools or make a provider request.
+for (const stream of [false, true]) {
+ test(`A premature Korean end_turn continues to a client tool (stream=${stream})`, async () => {
+ const start = __test.Session.prototype.start;
+ const begin = __test.Session.prototype.begin;
+ let prompts = 0;
+ __test.Session.prototype.start = async function () {
+ this.id = 'korean-action-fixture';
+ };
+ __test.Session.prototype.begin = function () {
+ prompts++;
+ if (prompts === 1) {
+ this.push({ type: 'text', text: announcement });
+ this.push({ type: 'done', result: { stopReason: 'end_turn' } });
+ } else {
+ this.push({
+ type: 'tool',
+ call: {
+ id: 'callfixture',
+ type: 'function',
+ function: { name: 'read_file', arguments: '{"path":"fixture.md"}' },
+ },
+ });
+ }
+ };
+ try {
+ const result = await handleSwe2AcpChat(
+ {
+ stream,
+ model: 'swe-2-high',
+ reasoning_effort: 'medium',
+ tools: [{ function: { name: 'read_file' } }],
+ messages: [
+ { role: 'user', content: 'Read the requested skill and continue.' },
+ ],
+ },
+ { callerKey: 'korean-regression' },
+ );
+ assert.equal(result.status, 200);
+ if (stream) {
+ let wire = '';
+ await result.handler({
+ writableEnded: false,
+ write(chunk) {
+ wire += chunk;
+ },
+ end() {
+ this.writableEnded = true;
+ },
+ });
+ const frames = wire
+ .split('\n')
+ .filter((line) => line.startsWith('data: {'))
+ .map((line) => JSON.parse(line.slice(6)));
+ assert.equal(frames.at(-1).choices[0].finish_reason, 'tool_calls');
+ assert.equal(
+ frames.some((f) => f.choices?.[0].finish_reason === 'stop'),
+ false,
+ );
+ assert.equal(
+ frames.flatMap((f) => f.choices?.[0].delta.tool_calls || [])[0]
+ .function.name,
+ 'read_file',
+ );
+ } else {
+ assert.equal(result.body.choices[0].finish_reason, 'tool_calls');
+ assert.equal(
+ result.body.choices[0].message.tool_calls[0].function.name,
+ 'read_file',
+ );
+ }
+ assert.equal(prompts, 2);
+ } finally {
+ __test.Session.prototype.start = start;
+ __test.Session.prototype.begin = begin;
+ await closeAllSessions();
+ }
+ });
+}
+
+test('A second unfinished announcement fails explicitly after one continuation', async () => {
+ const start = __test.Session.prototype.start;
+ const begin = __test.Session.prototype.begin;
+ let prompts = 0;
+ __test.Session.prototype.start = async function () {
+ this.id = 'bounded-fixture';
+ };
+ __test.Session.prototype.begin = function () {
+ prompts++;
+ this.push({ type: 'text', text: announcement });
+ this.push({ type: 'done', result: { stopReason: 'end_turn' } });
+ };
+ try {
+ const result = await handleSwe2AcpChat(
+ {
+ model: 'swe-2-high',
+ reasoning_effort: 'medium',
+ tools: [{ function: { name: 'read_file' } }],
+ messages: [{ role: 'user', content: 'Read the skill.' }],
+ },
+ { callerKey: 'bounded-regression' },
+ );
+ assert.equal(result.status, 422);
+ assert.equal(result.body.error.code, 'SWE2_TOOL_CALL_REQUIRED');
+ assert.equal(prompts, 2);
+ } finally {
+ __test.Session.prototype.start = start;
+ __test.Session.prototype.begin = begin;
+ await closeAllSessions();
+ }
+});
From 626467551ec68fada343241fe2dc06ed4c01d2a9 Mon Sep 17 00:00:00 2001
From: Smartnewb <159137930+Smartnewb@users.noreply.github.com>
Date: Sun, 13 Sep 2026 01:24:22 +0900
Subject: [PATCH 3/3] fix(swe2): recover structured incomplete turns
---
docs/SWE2-ACP.md | 9 +-
src/swe2-acp/bridge.mjs | 46 +++++--
src/swe2-acp/protocol.mjs | 126 +++++++++++++++++++
test/swe2-acp-action-completion.test.js | 153 +++++++++++++++++++++++-
4 files changed, 319 insertions(+), 15 deletions(-)
diff --git a/docs/SWE2-ACP.md b/docs/SWE2-ACP.md
index 4cb21de2..0d29b8f2 100644
--- a/docs/SWE2-ACP.md
+++ b/docs/SWE2-ACP.md
@@ -81,9 +81,12 @@ An explicit OMO tool-permission rejection ends the turn. Provider content-policy
errors are returned as errors without stripping instructions or retrying through
another model. System/developer messages and memories are preserved in full.
-If the model ends with a short announcement of work but no tool call, the adapter
-asks it once to finish that work or explain a concrete blocker. This is bounded
-and does not retry policy refusals.
+An explicitly selected `[$Skill](.../SKILL.md)` is incomplete until the matching
+client file-reading call returns. Required `tool_choice` values are enforced from
+the request field rather than inferred from prose. If a turn ends empty after a
+real client tool result, the adapter asks once for the final answer without
+repeating completed side effects. A short announcement of unfinished work remains
+a bounded fallback signal. None of these paths retries policy refusals.
## Limits and verification
diff --git a/src/swe2-acp/bridge.mjs b/src/swe2-acp/bridge.mjs
index d52c0822..c810dea0 100644
--- a/src/swe2-acp/bridge.mjs
+++ b/src/swe2-acp/bridge.mjs
@@ -17,7 +17,8 @@ import {
contentText,
errorInfo,
newToolCallId,
- isPendingAction,
+ completionIssue,
+ completionNudge,
clientPermissionDenial,
} from './protocol.mjs';
const here = path.dirname(fileURLToPath(import.meta.url));
@@ -795,6 +796,9 @@ async function acquire(body, context) {
}
async function run(body, context, onText) {
if (context.signal?.aborted) throw new Error('Client disconnected');
+ const receivedToolResults = (body.messages || []).filter(
+ (m) => m.role === 'tool' && pendingCalls.has(m.tool_call_id),
+ ).length;
const s = await acquire(body, context);
let text = '',
continuations = 0,
@@ -820,16 +824,25 @@ async function run(body, context, onText) {
if (event.type === 'done') {
if (event.result.stopReason !== 'end_turn')
throw new Error(`Devin stopped with ${event.result.stopReason}`);
- if (s.defs.length && isPendingAction(text.slice(iterationStart))) {
+ const issue = completionIssue({
+ attemptText: text.slice(iterationStart),
+ messages: body.messages,
+ body,
+ defs: s.defs,
+ receivedToolResults: receivedToolResults > 0,
+ });
+ if (issue) {
if (continuations++ < 1) {
- diagnostic(s, 'continue_announced_action');
+ diagnostic(s, 'completion_retry', {
+ reason: issue.kind,
+ receivedToolResults,
+ });
iterationStart = text.length;
s.begin(
[
{
role: 'user',
- content:
- 'Your last reply announced a pending action but returned no client function call. Continue the requested work now using client MCP functions and their complete schemas. Preserve all original instructions and permission checks. If the action cannot be performed, state the concrete blocker instead of announcing another future action.',
+ content: completionNudge(issue),
},
],
body,
@@ -837,17 +850,28 @@ async function run(body, context, onText) {
);
continue;
}
+ diagnostic(s, 'completion_failed', {
+ reason: issue.kind,
+ receivedToolResults,
+ });
+ if (
+ ['skill_read_required', 'required_tool', 'announced_action'].includes(
+ issue.kind,
+ )
+ )
+ throw Object.assign(
+ new Error(
+ 'SWE-2 ended without the required client tool call after one corrective continuation.',
+ ),
+ { status: 422, code: 'SWE2_TOOL_CALL_REQUIRED' },
+ );
throw Object.assign(
new Error(
- 'SWE-2 ended after announcing an action without returning a client tool call.',
+ 'SWE-2 ended without an answer or client tool call after one corrective continuation.',
),
- { status: 422, code: 'SWE2_TOOL_CALL_REQUIRED' },
+ { status: 502, code: 'SWE2_EMPTY_RESPONSE' },
);
}
- if (!text.trim())
- throw new Error(
- 'Devin finished without an answer or client tool call',
- );
const message = { role: 'assistant', content: text };
s.lastConversation = conversationKey([...body.messages, message]);
diagnostic(s, 'completed', { answerChars: text.length });
diff --git a/src/swe2-acp/protocol.mjs b/src/swe2-acp/protocol.mjs
index 6be093bc..bb0d28d6 100644
--- a/src/swe2-acp/protocol.mjs
+++ b/src/swe2-acp/protocol.mjs
@@ -138,6 +138,99 @@ export function clientPermissionDenial(messages) {
return null;
}
+function normalizeSkillPath(value) {
+ const raw = String(value || '')
+ .trim()
+ .replace(/^<|>$/g, '')
+ .replace(/#.*$/, '');
+ try {
+ return decodeURIComponent(raw);
+ } catch {
+ return raw;
+ }
+}
+
+function readToolNames(defs) {
+ return new Set(
+ (defs || [])
+ .filter((def) => {
+ const name = String(def.originalName || def.name || '');
+ const props = def.inputSchema?.properties || {};
+ return (
+ /^(?:read|read_file|read_text_file|fs_read)$/i.test(name) &&
+ ['path', 'file_path', 'filePath', 'filename'].some((key) =>
+ Object.hasOwn(props, key),
+ )
+ );
+ })
+ .map((def) => def.originalName || def.name),
+ );
+}
+
+function callPath(call) {
+ let args = call?.function?.arguments;
+ if (typeof args === 'string') {
+ try {
+ args = JSON.parse(args);
+ } catch {
+ return '';
+ }
+ }
+ if (!args || typeof args !== 'object') return '';
+ return normalizeSkillPath(
+ args.path || args.file_path || args.filePath || args.filename,
+ );
+}
+
+// Aside and Pi encode an explicitly selected skill as a Markdown link to its
+// SKILL.md. The matching client read and result are the completion contract;
+// free-form promises such as "I'll read it" are only a fallback signal.
+export function pendingSkillRead(messages, defs) {
+ const names = readToolNames(defs);
+ if (!names.size) return null;
+ let ref = null;
+ for (let i = 0; i < (messages || []).length; i++) {
+ if (messages[i]?.role !== 'user') continue;
+ const text = contentText(messages[i].content);
+ const re = /\[\$([^\]\r\n]+)\]\(([^)\r\n]*\/SKILL\.md(?:#[^)\r\n]*)?)\)/giu;
+ for (const match of text.matchAll(re)) {
+ ref = {
+ index: i,
+ name: match[1].trim(),
+ path: normalizeSkillPath(match[2]),
+ };
+ }
+ }
+ if (!ref) return null;
+ const calls = new Map();
+ for (let i = ref.index + 1; i < (messages || []).length; i++) {
+ const message = messages[i];
+ if (message?.role === 'assistant') {
+ for (const call of message.tool_calls || []) {
+ if (
+ names.has(call?.function?.name) &&
+ callPath(call) === ref.path
+ )
+ calls.set(call.id, true);
+ }
+ }
+ if (message?.role === 'tool' && calls.has(message.tool_call_id)) return null;
+ }
+ return { ...ref, toolNames: [...names] };
+}
+
+export function requiredToolChoice(body) {
+ const choice = body?.tool_choice;
+ if (choice === 'required' || choice === 'any')
+ return { kind: 'required_tool' };
+ if (choice && typeof choice === 'object')
+ return {
+ kind: 'required_tool',
+ name: choice.function?.name || choice.name || null,
+ };
+ return null;
+}
+
export function isPendingAction(text) {
const t = String(text || '')
.trim()
@@ -167,3 +260,36 @@ export function isPendingAction(text) {
)
);
}
+
+export function completionIssue({
+ attemptText,
+ messages,
+ body,
+ defs,
+ receivedToolResults = false,
+}) {
+ const text = String(attemptText || '').trim();
+ if (!text)
+ return { kind: receivedToolResults ? 'empty_post_tool' : 'empty_turn' };
+ const skill = pendingSkillRead(messages, defs);
+ if (skill) return { kind: 'skill_read_required', ...skill };
+ const required = requiredToolChoice(body);
+ if (required) return required;
+ if ((defs || []).length && isPendingAction(text))
+ return { kind: 'announced_action' };
+ return null;
+}
+
+export function completionNudge(issue) {
+ if (issue?.kind === 'empty_post_tool')
+ return 'The client tool results were delivered, but your last turn contained no final answer. Continue from the existing session state and give the user the complete final answer now. Do not repeat completed side effects. Use another client function only if more evidence is actually required.';
+ if (issue?.kind === 'empty_turn')
+ return 'Your last turn contained neither an answer nor a client function call. Continue the requested work now. Use the client MCP functions when action is required, or give the complete final answer if no function is needed.';
+ if (issue?.kind === 'skill_read_required')
+ return `The caller explicitly selected skill ${JSON.stringify(issue.name)}. Before completing, call one of ${JSON.stringify(issue.toolNames)} through client MCP to read exactly ${JSON.stringify(issue.path)}. Preserve the client's approval checks, then follow the loaded skill.`;
+ if (issue?.kind === 'required_tool')
+ return issue.name
+ ? `The caller requires client function ${JSON.stringify(issue.name)} on this turn. Call it through client MCP with its complete schema before completing.`
+ : 'The caller requires at least one client function call on this turn. Call the appropriate function through client MCP before completing.';
+ return 'Your last reply announced a pending action but returned no client function call. Continue the requested work now using client MCP functions and their complete schemas. Preserve all original instructions and permission checks. If the action cannot be performed, state the concrete blocker instead of announcing another future action.';
+}
diff --git a/test/swe2-acp-action-completion.test.js b/test/swe2-acp-action-completion.test.js
index 83f3ff87..2e2dfaa0 100644
--- a/test/swe2-acp-action-completion.test.js
+++ b/test/swe2-acp-action-completion.test.js
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
-import { isPendingAction } from '../src/swe2-acp/protocol.mjs';
+import {
+ completionIssue,
+ completionNudge,
+ isPendingAction,
+ pendingSkillRead,
+} from '../src/swe2-acp/protocol.mjs';
import {
__test,
closeAllSessions,
@@ -8,6 +13,89 @@ import {
} from '../src/swe2-acp/bridge.mjs';
const announcement = '도구 연결 확인. 스킬부터 읽을게.';
+const skillPath = '/Users/test/.agents/skills/ultraresearch/SKILL.md';
+const readTool = {
+ function: {
+ name: 'read_file',
+ description: 'Read a client file.',
+ parameters: {
+ type: 'object',
+ properties: { path: { type: 'string' } },
+ required: ['path'],
+ additionalProperties: false,
+ },
+ },
+};
+
+test('Explicit Aside skill links require the matching client read result', () => {
+ const messages = [
+ {
+ role: 'user',
+ content: `Use [$Ultraresearch](${skillPath}) for this task.`,
+ },
+ ];
+ const defs = [
+ {
+ originalName: 'read_file',
+ inputSchema: readTool.function.parameters,
+ },
+ ];
+ assert.deepEqual(pendingSkillRead(messages, defs), {
+ index: 0,
+ name: 'Ultraresearch',
+ path: skillPath,
+ toolNames: ['read_file'],
+ });
+ assert.equal(
+ completionIssue({ attemptText: 'I can help.', messages, body: {}, defs })
+ .kind,
+ 'skill_read_required',
+ );
+});
+
+test('A matching read result satisfies the explicit skill contract', () => {
+ const messages = [
+ {
+ role: 'user',
+ content: `Use [$Ultraresearch](${skillPath}) for this task.`,
+ },
+ {
+ role: 'assistant',
+ content: null,
+ tool_calls: [
+ {
+ id: 'callskill',
+ type: 'function',
+ function: {
+ name: 'read_file',
+ arguments: JSON.stringify({ path: skillPath }),
+ },
+ },
+ ],
+ },
+ { role: 'tool', tool_call_id: 'callskill', content: 'skill contents' },
+ ];
+ const defs = [
+ {
+ originalName: 'read_file',
+ inputSchema: readTool.function.parameters,
+ },
+ ];
+ assert.equal(pendingSkillRead(messages, defs), null);
+});
+
+test('An empty post-tool turn gets a replay-safe final-answer continuation', () => {
+ const issue = completionIssue({
+ attemptText: '',
+ messages: [],
+ body: {},
+ defs: [],
+ receivedToolResults: true,
+ });
+ assert.deepEqual(issue, { kind: 'empty_post_tool' });
+ assert.match(completionNudge(issue), /complete final answer now/);
+ assert.match(completionNudge(issue), /Do not repeat completed side effects/);
+});
test('Korean informal action announcements are unfinished, including the reported stall', () => {
for (const text of [
announcement,
@@ -153,3 +241,66 @@ test('A second unfinished announcement fails explicitly after one continuation',
await closeAllSessions();
}
});
+
+test('An empty turn after a real client tool result continues to a final answer', async () => {
+ const begin = __test.Session.prototype.begin;
+ let continuations = 0;
+ const call = {
+ id: 'callposttool',
+ type: 'function',
+ function: {
+ name: 'read_file',
+ arguments: JSON.stringify({ path: skillPath }),
+ },
+ };
+ const body = {
+ model: 'swe-2-high',
+ reasoning_effort: 'high',
+ tools: [readTool],
+ messages: [
+ {
+ role: 'user',
+ content: `Use [$Ultraresearch](${skillPath}) for this task.`,
+ },
+ { role: 'assistant', content: null, tool_calls: [call] },
+ {
+ role: 'tool',
+ tool_call_id: call.id,
+ content: 'PROBE_VALUE=post-tool-recovered',
+ },
+ ],
+ };
+ const context = { callerKey: 'post-tool-regression' };
+ const session = new __test.Session(body, context);
+ session.id = 'post-tool-fixture';
+ __test.sessions.add(session);
+ const pending = {
+ session,
+ call,
+ send() {
+ session.push({ type: 'done', result: { stopReason: 'end_turn' } });
+ },
+ };
+ session.calls.set(call.id, pending);
+ __test.pendingCalls.set(call.id, pending);
+ __test.Session.prototype.begin = function () {
+ continuations++;
+ this.push({
+ type: 'text',
+ text: 'Final answer: post-tool-recovered',
+ });
+ this.push({ type: 'done', result: { stopReason: 'end_turn' } });
+ };
+ try {
+ const result = await handleSwe2AcpChat(body, context);
+ assert.equal(result.status, 200);
+ assert.equal(
+ result.body.choices[0].message.content,
+ 'Final answer: post-tool-recovered',
+ );
+ assert.equal(continuations, 1);
+ } finally {
+ __test.Session.prototype.begin = begin;
+ await closeAllSessions();
+ }
+});