mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 14:12:59 +03:00
feat: harden Devin ACP bridge contracts
This commit is contained in:
@@ -7,9 +7,10 @@ export const devin_cli_agenticProvider: RegistryEntry = {
|
||||
format: "claude",
|
||||
executor: "devin-cli-agentic",
|
||||
baseUrl: "devin://acp/stdio",
|
||||
authType: "oauth",
|
||||
authHeader: "Authorization",
|
||||
authPrefix: "Bearer ",
|
||||
// Authentication is owned exclusively by the official Devin CLI inside its
|
||||
// isolated volume. OmniRoute must not import or persist a host credential.
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
defaultContextLength: 200000,
|
||||
models: DEVIN_MODEL_CATALOG.map((model) => ({
|
||||
...model,
|
||||
@@ -18,4 +19,3 @@ export const devin_cli_agenticProvider: RegistryEntry = {
|
||||
supportsVision: false,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { estimateTokens, type ClaudeResponseArgs, type ClaudeToolUseArgs, type JsonRecord } from "./types.ts";
|
||||
import {
|
||||
estimateTokens,
|
||||
type ClaudeResponseArgs,
|
||||
type ClaudeToolUseArgs,
|
||||
type JsonRecord,
|
||||
} from "./types.ts";
|
||||
|
||||
function usage(inputTokens: number, outputTokens: number) {
|
||||
return {
|
||||
@@ -97,4 +102,3 @@ export function buildClaudeSseFrames(message: JsonRecord): string {
|
||||
out += frame("message_stop", { type: "message_stop" });
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
type AnthropicTool,
|
||||
type DevinPrompt,
|
||||
} from "./types.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const MAX_TOOL_RESULT_CHARS = 65536;
|
||||
|
||||
function stringifyContentValue(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
@@ -12,6 +15,13 @@ function stringifyContentValue(value: unknown): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function boundedToolResult(value: unknown): string {
|
||||
const text = stringifyContentValue(value);
|
||||
if (text.length <= MAX_TOOL_RESULT_CHARS) return text;
|
||||
const removed = text.length - MAX_TOOL_RESULT_CHARS;
|
||||
return `${text.slice(0, MAX_TOOL_RESULT_CHARS)}\n[TRUNCATED ${removed} CHARACTERS BY OMNIROUTE]`;
|
||||
}
|
||||
|
||||
function serializeSystem(system: unknown): string[] {
|
||||
if (typeof system === "string" && system.trim()) return [`[System]\n${system}`];
|
||||
if (!Array.isArray(system)) return [];
|
||||
@@ -31,7 +41,11 @@ function serializeSystem(system: unknown): string[] {
|
||||
return parts.length > 0 ? [`[System]\n${parts.join("\n")}`] : [];
|
||||
}
|
||||
|
||||
function serializeBlock(block: unknown): string {
|
||||
function serializeBlock(
|
||||
block: unknown,
|
||||
knownToolUses: Set<string>,
|
||||
tools: AnthropicTool[]
|
||||
): string {
|
||||
const record = asRecord(block);
|
||||
const type = String(record.type || "");
|
||||
|
||||
@@ -39,21 +53,44 @@ function serializeBlock(block: unknown): string {
|
||||
if (type === "thinking") return `[Thinking]\n${String(record.thinking || "")}`;
|
||||
if (type === "redacted_thinking") return "[Redacted Thinking]";
|
||||
if (type === "tool_use") {
|
||||
const id = String(record.id || "").trim();
|
||||
const name = String(record.name || "").trim();
|
||||
if (!id || knownToolUses.has(id)) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
id ? `Duplicate Anthropic tool_use id: ${id}` : "Anthropic tool_use is missing id",
|
||||
id ? "duplicate_tool_use_id" : "missing_tool_use_id"
|
||||
);
|
||||
}
|
||||
const declared = tools.find((tool) => tool.name === name);
|
||||
if (!declared) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
`Historical tool_use references undeclared tool: ${name || "unknown"}`,
|
||||
"undeclared_historical_tool"
|
||||
);
|
||||
}
|
||||
knownToolUses.add(id);
|
||||
return [
|
||||
"[Assistant Tool Use]",
|
||||
`id: ${String(record.id || "")}`,
|
||||
`name: ${String(record.name || "")}`,
|
||||
`id: ${id}`,
|
||||
`name: ${name}`,
|
||||
"arguments:",
|
||||
JSON.stringify(record.input || {}, null, 2),
|
||||
].join("\n");
|
||||
}
|
||||
if (type === "tool_result") {
|
||||
const toolUseId = String(record.tool_use_id || "").trim();
|
||||
if (!toolUseId || !knownToolUses.has(toolUseId)) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
`Anthropic tool_result references unknown tool_use id: ${toolUseId || "missing"}`,
|
||||
"orphan_tool_result"
|
||||
);
|
||||
}
|
||||
return [
|
||||
"[Tool Result]",
|
||||
`tool_use_id: ${String(record.tool_use_id || "")}`,
|
||||
`tool_use_id: ${toolUseId}`,
|
||||
`is_error: ${record.is_error === true ? "true" : "false"}`,
|
||||
"content:",
|
||||
stringifyContentValue(record.content),
|
||||
boundedToolResult(record.content),
|
||||
].join("\n");
|
||||
}
|
||||
if (type === "image") {
|
||||
@@ -69,16 +106,28 @@ function serializeBlock(block: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
function serializeMessage(message: unknown): string {
|
||||
function serializeMessage(
|
||||
message: unknown,
|
||||
knownToolUses: Set<string>,
|
||||
tools: AnthropicTool[]
|
||||
): string {
|
||||
const record = asRecord(message);
|
||||
const role = String(record.role || "user");
|
||||
if (role !== "user" && role !== "assistant") {
|
||||
throw new DevinAgenticBridgeError(
|
||||
`Unsupported Anthropic message role: ${role}`,
|
||||
"unsupported_role"
|
||||
);
|
||||
}
|
||||
const label = role === "assistant" ? "Assistant" : role === "system" ? "System" : "User";
|
||||
const content = record.content;
|
||||
|
||||
if (typeof content === "string") return `[${label}]\n${content}`;
|
||||
if (!Array.isArray(content)) return `[${label}]\n${stringifyContentValue(content)}`;
|
||||
|
||||
return `[${label}]\n${content.map((block) => serializeBlock(block)).join("\n\n")}`;
|
||||
return `[${label}]\n${content
|
||||
.map((block) => serializeBlock(block, knownToolUses, tools))
|
||||
.join("\n\n")}`;
|
||||
}
|
||||
|
||||
function normalizeTools(tools: unknown): AnthropicTool[] {
|
||||
@@ -121,14 +170,39 @@ function serializeToolCatalog(tools: AnthropicTool[]): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function serializeToolChoice(value: unknown, tools: AnthropicTool[]): string[] {
|
||||
if (value == null) return [];
|
||||
const choice = asRecord(value);
|
||||
const type = String(choice.type || "");
|
||||
if (type === "auto") return ["[Tool Choice]\nauto"];
|
||||
if (type === "any") return ["[Tool Choice]\nA tool call is required."];
|
||||
if (type === "none") return ["[Tool Choice]\nDo not call a tool."];
|
||||
if (type === "tool") {
|
||||
const name = String(choice.name || "").trim();
|
||||
if (!tools.some((tool) => tool.name === name)) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
`tool_choice references unknown tool: ${name}`,
|
||||
"invalid_tool_choice"
|
||||
);
|
||||
}
|
||||
return [`[Tool Choice]\nCall exactly this tool: ${name}`];
|
||||
}
|
||||
throw new DevinAgenticBridgeError(
|
||||
`Unsupported Anthropic tool_choice type: ${type || "missing"}`,
|
||||
"invalid_tool_choice"
|
||||
);
|
||||
}
|
||||
|
||||
export function serializeAnthropicForDevin(body: unknown): DevinPrompt {
|
||||
const record = asRecord(body);
|
||||
const messages = Array.isArray(record.messages) ? record.messages : [];
|
||||
const tools = normalizeTools(record.tools);
|
||||
const knownToolUses = new Set<string>();
|
||||
const sections: string[] = [
|
||||
...serializeSystem(record.system),
|
||||
...serializeToolCatalog(tools),
|
||||
...messages.map((message) => serializeMessage(message)),
|
||||
...serializeToolChoice(record.tool_choice, tools),
|
||||
...messages.map((message) => serializeMessage(message, knownToolUses, tools)),
|
||||
].filter((section) => section.trim().length > 0);
|
||||
|
||||
if (sections.length === 0) {
|
||||
@@ -136,5 +210,6 @@ export function serializeAnthropicForDevin(body: unknown): DevinPrompt {
|
||||
}
|
||||
|
||||
const text = sections.join("\n\n---\n\n");
|
||||
return { text, tools, inputTokensEstimate: estimateTokens(text) };
|
||||
const idSeed = createHash("sha256").update(text).digest("hex").slice(0, 24);
|
||||
return { text, tools, inputTokensEstimate: estimateTokens(text), idSeed };
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ function validateSchema(value: unknown, schema: JsonRecord, path: string): strin
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) {
|
||||
export function parseDevinToolRequest(text: string, tools: AnthropicTool[], idSeed = "") {
|
||||
const matches = [...text.matchAll(/<tool>\s*([\s\S]*?)\s*<\/tool>/g)];
|
||||
if (matches.length === 0) return null;
|
||||
if (matches.length > 1) {
|
||||
@@ -110,7 +110,7 @@ export function parseDevinToolRequest(text: string, tools: AnthropicTool[]) {
|
||||
}
|
||||
|
||||
const digest = createHash("sha256")
|
||||
.update(`${name}:${stableJson(input)}`)
|
||||
.update(`${idSeed}:${name}:${stableJson(input)}`)
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
return { id: `tool_devin_${digest}`, name, input };
|
||||
|
||||
@@ -10,6 +10,7 @@ export type DevinPrompt = {
|
||||
text: string;
|
||||
tools: AnthropicTool[];
|
||||
inputTokensEstimate: number;
|
||||
idSeed: string;
|
||||
};
|
||||
|
||||
export type ParsedToolRequest = {
|
||||
@@ -53,4 +54,3 @@ export function asRecord(value: unknown): JsonRecord {
|
||||
export function estimateTokens(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / 4));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,10 @@ import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import fs from "node:fs";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { BaseExecutor, type ExecuteInput } from "./base.ts";
|
||||
import { DEVIN_MODEL_CATALOG } from "../config/providers/registry/devin/catalog.ts";
|
||||
import { buildErrorBody, sanitizeErrorMessage } from "../utils/error.ts";
|
||||
import {
|
||||
buildClaudeSseFrames,
|
||||
buildClaudeTextResponse,
|
||||
@@ -21,6 +24,17 @@ type AcpMessage = {
|
||||
error?: { code: number; message: string };
|
||||
};
|
||||
|
||||
const ACP_PROTOCOL_VERSION = 1;
|
||||
const MAX_ACP_OUTPUT_CHARS = 1024 * 1024;
|
||||
const REPAIRABLE_TOOL_ERRORS = new Set([
|
||||
"invalid_tool_json",
|
||||
"missing_tool_name",
|
||||
"unknown_tool",
|
||||
"invalid_tool_arguments",
|
||||
"multiple_tool_requests",
|
||||
"mixed_tool_narrative",
|
||||
]);
|
||||
|
||||
const CLAUDE_ENV_BLOCKLIST = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
@@ -96,6 +110,9 @@ export function buildDevinChildEnv(
|
||||
};
|
||||
if (source.LC_ALL) env.LC_ALL = source.LC_ALL;
|
||||
if (source.TERM) env.TERM = source.TERM;
|
||||
if (source.DEVIN_BRIDGE_MOCK_LOG === "/evidence/mock-acp.jsonl") {
|
||||
env.DEVIN_BRIDGE_MOCK_LOG = source.DEVIN_BRIDGE_MOCK_LOG;
|
||||
}
|
||||
|
||||
for (const key of CLAUDE_ENV_BLOCKLIST) delete env[key];
|
||||
return env;
|
||||
@@ -103,16 +120,15 @@ export function buildDevinChildEnv(
|
||||
|
||||
function errorBody(error: unknown) {
|
||||
const bridge = error instanceof DevinAgenticBridgeError ? error : null;
|
||||
return {
|
||||
error: {
|
||||
message: bridge?.message || (error instanceof Error ? error.message : String(error)),
|
||||
type: "devin_agentic_error",
|
||||
code: bridge?.code || "devin_agentic_error",
|
||||
},
|
||||
};
|
||||
const status = bridge?.status || 500;
|
||||
const message = bridge?.message || (error instanceof Error ? error.message : String(error));
|
||||
return buildErrorBody(status, sanitizeErrorMessage(message), undefined, {
|
||||
type: "devin_agentic_error",
|
||||
code: bridge?.code || "devin_agentic_error",
|
||||
});
|
||||
}
|
||||
|
||||
async function runAcpTurn(args: {
|
||||
export async function runAcpTurn(args: {
|
||||
devinBin: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
model: string;
|
||||
@@ -121,8 +137,9 @@ async function runAcpTurn(args: {
|
||||
log?: ExecuteInput["log"];
|
||||
}) {
|
||||
const timeoutMs = Number(process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS || 120000);
|
||||
const child = spawn(args.devinBin, ["acp"], {
|
||||
const child = spawn(args.devinBin, ["acp", "--agent-type", "summarizer"], {
|
||||
env: args.env,
|
||||
cwd: args.env.HOME,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: false,
|
||||
});
|
||||
@@ -130,17 +147,23 @@ async function runAcpTurn(args: {
|
||||
let nextId = 1;
|
||||
let buffer = "";
|
||||
let text = "";
|
||||
let initialized = false;
|
||||
let sessionCreated = false;
|
||||
let phase: "initialize" | "session" | "prompt" = "initialize";
|
||||
let sessionId = "";
|
||||
let initializeRequestId = 0;
|
||||
let sessionRequestId = 0;
|
||||
let promptRequestId = 0;
|
||||
let settled = false;
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const abortHandler = () => {
|
||||
finish(new DevinAgenticBridgeError("Devin ACP request was cancelled", "acp_cancelled", 499));
|
||||
};
|
||||
|
||||
const finish = (err: Error | null, value = "") => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
args.signal?.removeEventListener("abort", abortHandler);
|
||||
try {
|
||||
child.stdin.end();
|
||||
} catch {}
|
||||
@@ -162,9 +185,8 @@ async function runAcpTurn(args: {
|
||||
return id;
|
||||
};
|
||||
|
||||
args.signal?.addEventListener("abort", () => {
|
||||
finish(new DevinAgenticBridgeError("Devin ACP request was cancelled", "acp_cancelled", 499));
|
||||
});
|
||||
if (args.signal?.aborted) return abortHandler();
|
||||
args.signal?.addEventListener("abort", abortHandler, { once: true });
|
||||
|
||||
child.on("error", (err) => {
|
||||
const message =
|
||||
@@ -180,6 +202,16 @@ async function runAcpTurn(args: {
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
if (buffer.length + text.length > MAX_ACP_OUTPUT_CHARS) {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
"Devin ACP output exceeded the bridge limit",
|
||||
"acp_output_too_large",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
let nl: number;
|
||||
while ((nl = buffer.indexOf("\n")) !== -1) {
|
||||
const line = buffer.slice(0, nl).trim();
|
||||
@@ -190,7 +222,14 @@ async function runAcpTurn(args: {
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
"Devin ACP emitted invalid JSON on stdout",
|
||||
"invalid_acp_frame",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.error) {
|
||||
@@ -204,17 +243,28 @@ async function runAcpTurn(args: {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialized && msg.result !== undefined && !msg.method) {
|
||||
initialized = true;
|
||||
send("session/new", {
|
||||
cwd: process.env.DEVIN_BRIDGE_WORKSPACE || process.cwd(),
|
||||
if (phase === "initialize" && msg.id === initializeRequestId && msg.result !== undefined) {
|
||||
const protocolVersion = Number(asRecord(msg.result).protocolVersion);
|
||||
if (protocolVersion !== ACP_PROTOCOL_VERSION) {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
`Devin ACP negotiated unsupported protocol version: ${String(protocolVersion)}`,
|
||||
"unsupported_acp_version",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
phase = "session";
|
||||
sessionRequestId = send("session/new", {
|
||||
cwd: args.env.HOME,
|
||||
mcpServers: [],
|
||||
model: args.model || undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (initialized && !sessionCreated && msg.result !== undefined && !msg.method) {
|
||||
if (phase === "session" && msg.id === sessionRequestId && msg.result !== undefined) {
|
||||
sessionId = String(asRecord(msg.result).sessionId || "");
|
||||
if (!sessionId) {
|
||||
finish(
|
||||
@@ -226,7 +276,7 @@ async function runAcpTurn(args: {
|
||||
);
|
||||
return;
|
||||
}
|
||||
sessionCreated = true;
|
||||
phase = "prompt";
|
||||
promptRequestId = send("session/prompt", {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text: args.promptText }],
|
||||
@@ -236,6 +286,17 @@ async function runAcpTurn(args: {
|
||||
|
||||
if (msg.method === "session/update" || msg.method === "$/update") {
|
||||
const params = asRecord(msg.params);
|
||||
const updateSessionId = String(params.sessionId || "");
|
||||
if (updateSessionId && sessionId && updateSessionId !== sessionId) {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
"Devin ACP update referenced a different session",
|
||||
"acp_session_mismatch",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const update = asRecord(params.update);
|
||||
const kind = String(update.sessionUpdate || params.type || "");
|
||||
if (kind === "agent_message_chunk") {
|
||||
@@ -250,10 +311,40 @@ async function runAcpTurn(args: {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sessionCreated && msg.id === promptRequestId && msg.result !== undefined) {
|
||||
if (phase === "prompt" && msg.id === promptRequestId && msg.result !== undefined) {
|
||||
const stopReason = String(asRecord(msg.result).stopReason || "");
|
||||
if (stopReason === "cancelled") {
|
||||
finish(
|
||||
new DevinAgenticBridgeError("Devin ACP cancelled the turn", "acp_cancelled", 502)
|
||||
);
|
||||
return;
|
||||
}
|
||||
const resultText =
|
||||
extractText(asRecord(msg.result).content) || extractText(asRecord(msg.result).message);
|
||||
finish(null, text || resultText);
|
||||
const finalText = text || resultText;
|
||||
if (!finalText) {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
`Devin ACP completed without model output (stopReason=${stopReason || "missing"})`,
|
||||
"empty_acp_output",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
finish(null, finalText);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.id !== undefined && msg.id !== null && !msg.method) {
|
||||
finish(
|
||||
new DevinAgenticBridgeError(
|
||||
`Devin ACP returned an unexpected response id: ${String(msg.id)}`,
|
||||
"unexpected_acp_response",
|
||||
502
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -271,14 +362,32 @@ async function runAcpTurn(args: {
|
||||
);
|
||||
});
|
||||
|
||||
send("initialize", {
|
||||
protocolVersion: "0.3",
|
||||
initializeRequestId = send("initialize", {
|
||||
protocolVersion: ACP_PROTOCOL_VERSION,
|
||||
clientInfo: { name: "omniroute-devin-cli-agentic", version: "1.0" },
|
||||
capabilities: {},
|
||||
clientCapabilities: {},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertKnownDevinModel(model: string): void {
|
||||
if (!DEVIN_MODEL_CATALOG.some((entry) => entry.id === model)) {
|
||||
throw new DevinAgenticBridgeError(
|
||||
`Model is not present in the current Devin catalog: ${model}`,
|
||||
"unknown_devin_model",
|
||||
400
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateAgenticOutput(
|
||||
args: Omit<Parameters<typeof runAcpTurn>[0], "promptText">,
|
||||
promptText: string
|
||||
) {
|
||||
const first = await runAcpTurn({ ...args, promptText });
|
||||
return first;
|
||||
}
|
||||
|
||||
function extractText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) return value.map((item) => extractText(item)).join("");
|
||||
@@ -309,21 +418,45 @@ export class DevinCliAgenticExecutor extends BaseExecutor {
|
||||
|
||||
async execute({ model, body, stream, credentials, signal, log }: ExecuteInput) {
|
||||
try {
|
||||
assertKnownDevinModel(model);
|
||||
const prompt = serializeAnthropicForDevin(body);
|
||||
const devinBin = resolveDevinBin();
|
||||
log?.info?.("DEVIN_AGENTIC", `devin acp → model=${model}, bin=${devinBin}`);
|
||||
|
||||
const text = await runAcpTurn({
|
||||
const turnArgs = {
|
||||
devinBin,
|
||||
env: buildDevinChildEnv(credentials),
|
||||
model,
|
||||
promptText: prompt.text,
|
||||
signal,
|
||||
log,
|
||||
});
|
||||
};
|
||||
|
||||
const tool = parseDevinToolRequest(text, prompt.tools);
|
||||
const id = `msg_devin_${Date.now()}`;
|
||||
let text = await generateAgenticOutput(turnArgs, prompt.text);
|
||||
let tool;
|
||||
try {
|
||||
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof DevinAgenticBridgeError) ||
|
||||
!REPAIRABLE_TOOL_ERRORS.has(error.code)
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
const repairPrompt = [
|
||||
prompt.text,
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"[Single Repair Attempt]",
|
||||
`The previous output was rejected: ${sanitizeErrorMessage(error.message)}`,
|
||||
"Return either plain final text or exactly one standalone <tool> JSON envelope.",
|
||||
"Do not narrate a tool action.",
|
||||
].join("\n");
|
||||
text = await generateAgenticOutput(turnArgs, repairPrompt);
|
||||
tool = parseDevinToolRequest(text, prompt.tools, prompt.idSeed);
|
||||
}
|
||||
|
||||
const id = `msg_devin_${randomUUID().replaceAll("-", "")}`;
|
||||
const outputTokens = estimateTokens(text);
|
||||
const message = tool
|
||||
? buildClaudeToolUseResponse({
|
||||
|
||||
@@ -3,6 +3,24 @@
|
||||
* Pure data literal; re-exported by the providers.ts barrel. No behavior change.
|
||||
*/
|
||||
export const NOAUTH_PROVIDERS = {
|
||||
"devin-cli-agentic": {
|
||||
id: "devin-cli-agentic",
|
||||
alias: "dva",
|
||||
name: "Devin CLI Agentic Bridge",
|
||||
icon: "terminal",
|
||||
color: "#635BFF",
|
||||
textIcon: "DV",
|
||||
website: "https://docs.devin.ai/work-with-devin/devin-cli",
|
||||
noAuth: true,
|
||||
hasFree: false,
|
||||
serviceKinds: ["llm"],
|
||||
isLocalCli: true,
|
||||
toolCalling: "emulated",
|
||||
authHint: "Authentication is owned by the official Devin CLI in its isolated bridge volume.",
|
||||
notice: {
|
||||
text: "This provider accepts only the official Devin CLI over local ACP stdio and never falls back to another provider.",
|
||||
},
|
||||
},
|
||||
opencode: {
|
||||
id: "opencode",
|
||||
alias: "oc",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
@@ -10,13 +9,22 @@ import {
|
||||
buildDevinChildEnv,
|
||||
DevinCliAgenticExecutor,
|
||||
} from "../../open-sse/executors/devin-cli-agentic.ts";
|
||||
import { devin_cli_agenticProvider } from "../../open-sse/config/providers/registry/devin-cli-agentic/index.ts";
|
||||
import { getProviderCredentials } from "../../src/sse/services/auth.ts";
|
||||
|
||||
process.env.DEVIN_AGENTIC_HOME = path.join(process.cwd(), ".sandbox", "unit-home");
|
||||
fs.mkdirSync(process.env.DEVIN_AGENTIC_HOME, { recursive: true });
|
||||
|
||||
async function readResponseText(response: Response) {
|
||||
return await response.text();
|
||||
}
|
||||
|
||||
function sandboxTmp(prefix: string) {
|
||||
const root = path.join(process.cwd(), ".sandbox", "unit-processes");
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
return fs.mkdtempSync(path.join(root, prefix));
|
||||
}
|
||||
|
||||
test("Devin child environment is allowlisted and requires an isolated home", () => {
|
||||
const isolatedHome = path.join(process.cwd(), ".sandbox", "unit-home");
|
||||
const env = buildDevinChildEnv(
|
||||
@@ -28,6 +36,7 @@ test("Devin child environment is allowlisted and requires an isolated home", ()
|
||||
AWS_ACCESS_KEY_ID: "must-not-leak",
|
||||
GITHUB_TOKEN: "must-not-leak",
|
||||
DEVIN_AGENTIC_HOME: isolatedHome,
|
||||
DEVIN_BRIDGE_MOCK_LOG: "/evidence/mock-acp.jsonl",
|
||||
}
|
||||
);
|
||||
|
||||
@@ -37,6 +46,18 @@ test("Devin child environment is allowlisted and requires an isolated home", ()
|
||||
assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined);
|
||||
assert.equal(env.AWS_ACCESS_KEY_ID, undefined);
|
||||
assert.equal(env.GITHUB_TOKEN, undefined);
|
||||
assert.equal(env.DEVIN_BRIDGE_MOCK_LOG, "/evidence/mock-acp.jsonl");
|
||||
assert.equal(
|
||||
buildDevinChildEnv(
|
||||
{},
|
||||
{
|
||||
PATH: "/usr/bin",
|
||||
DEVIN_AGENTIC_HOME: isolatedHome,
|
||||
DEVIN_BRIDGE_MOCK_LOG: "/tmp/unsafe.jsonl",
|
||||
}
|
||||
).DEVIN_BRIDGE_MOCK_LOG,
|
||||
undefined
|
||||
);
|
||||
assert.throws(
|
||||
() => buildDevinChildEnv({}, { PATH: "/usr/bin", DEVIN_AGENTIC_HOME: "/tmp/outside" }),
|
||||
/inside the bridge sandbox/
|
||||
@@ -49,13 +70,27 @@ test("Devin agentic upstream is fixed to local ACP stdio", () => {
|
||||
assert.throws(() => assertLocalAcpUrl("http://localhost:9999"), /ACP stdio/);
|
||||
});
|
||||
|
||||
test("Devin agentic provider delegates auth only to the isolated CLI", () => {
|
||||
assert.equal(devin_cli_agenticProvider.authType, "none");
|
||||
assert.equal(devin_cli_agenticProvider.baseUrl, "devin://acp/stdio");
|
||||
assert.equal(devin_cli_agenticProvider.baseUrls, undefined);
|
||||
});
|
||||
|
||||
test("Devin agentic provider resolves synthetic no-auth credentials without a DB row", async () => {
|
||||
const credentials = await getProviderCredentials("devin-cli-agentic");
|
||||
assert.equal(credentials?.connectionId, "noauth");
|
||||
assert.equal(credentials?.apiKey, null);
|
||||
});
|
||||
|
||||
function writeMockDevin(tmpDir: string, responseText: string) {
|
||||
const framesFile = path.join(tmpDir, "frames.json");
|
||||
const scriptFile = path.join(tmpDir, "mock-devin");
|
||||
const argsFile = path.join(tmpDir, "args.json");
|
||||
const scriptFile = path.join(tmpDir, "mock-devin.cjs");
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const readline = require("readline");
|
||||
const frames = [];
|
||||
fs.writeFileSync(${JSON.stringify(argsFile)}, JSON.stringify(process.argv.slice(2)));
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on("line", (line) => {
|
||||
if (!line.trim()) return;
|
||||
@@ -63,7 +98,7 @@ rl.on("line", (line) => {
|
||||
frames.push(msg);
|
||||
fs.writeFileSync(${JSON.stringify(framesFile)}, JSON.stringify(frames, null, 2));
|
||||
if (msg.method === "initialize") {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: {} }) + "\\n");
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } }) + "\\n");
|
||||
} else if (msg.method === "session/new") {
|
||||
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "sess_agentic" } }) + "\\n");
|
||||
} else if (msg.method === "session/prompt") {
|
||||
@@ -77,13 +112,45 @@ rl.on("line", (line) => {
|
||||
});
|
||||
`;
|
||||
writeFileSync(scriptFile, script, { mode: 0o755 });
|
||||
return { scriptFile, framesFile };
|
||||
return { scriptFile, framesFile, argsFile };
|
||||
}
|
||||
|
||||
function writeScenarioMock(tmpDir: string, body: string) {
|
||||
const scriptFile = path.join(tmpDir, "mock-devin.cjs");
|
||||
writeFileSync(
|
||||
scriptFile,
|
||||
`#!/usr/bin/env node
|
||||
const readline = require("readline");
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
const send = (value) => process.stdout.write(JSON.stringify(value) + "\\n");
|
||||
${body}
|
||||
`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
return scriptFile;
|
||||
}
|
||||
|
||||
async function executeTextRequest(scriptFile: string, signal?: AbortSignal) {
|
||||
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile;
|
||||
try {
|
||||
return await new DevinCliAgenticExecutor().execute({
|
||||
model: "swe-1-7",
|
||||
stream: false,
|
||||
credentials: {},
|
||||
signal,
|
||||
body: { messages: [{ role: "user", content: "Say hello" }] },
|
||||
});
|
||||
} finally {
|
||||
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
|
||||
}
|
||||
}
|
||||
|
||||
test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP frames", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-agentic-"));
|
||||
const tmpDir = sandboxTmp("devin-agentic-");
|
||||
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
const { scriptFile, framesFile } = writeMockDevin(
|
||||
const { scriptFile, framesFile, argsFile } = writeMockDevin(
|
||||
tmpDir,
|
||||
'<tool>{"name":"Read","arguments":{"file_path":"src/index.ts"}}</tool>'
|
||||
);
|
||||
@@ -111,7 +178,7 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.response.status, 200, await result.response.clone().text());
|
||||
const json = JSON.parse(await readResponseText(result.response));
|
||||
assert.equal(json.stop_reason, "tool_use");
|
||||
assert.equal(json.content[0].type, "tool_use");
|
||||
@@ -122,6 +189,14 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
|
||||
assert.ok(frames.some((frame: { method?: string }) => frame.method === "initialize"));
|
||||
assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/new"));
|
||||
assert.ok(frames.some((frame: { method?: string }) => frame.method === "session/prompt"));
|
||||
const initialize = frames.find((frame: { method?: string }) => frame.method === "initialize");
|
||||
assert.equal(initialize.params.protocolVersion, 1);
|
||||
assert.deepEqual(initialize.params.clientCapabilities, {});
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(argsFile, "utf8")), [
|
||||
"acp",
|
||||
"--agent-type",
|
||||
"summarizer",
|
||||
]);
|
||||
} finally {
|
||||
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
|
||||
@@ -130,7 +205,7 @@ test("DevinCliAgenticExecutor returns Anthropic tool_use JSON and sends ACP fram
|
||||
});
|
||||
|
||||
test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devin-agentic-sse-"));
|
||||
const tmpDir = sandboxTmp("devin-agentic-sse-");
|
||||
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
const { scriptFile } = writeMockDevin(tmpDir, "Done");
|
||||
process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile;
|
||||
@@ -144,7 +219,7 @@ test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients
|
||||
body: { messages: [{ role: "user", content: [{ type: "text", text: "Say done" }] }] },
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.response.status, 200, await result.response.clone().text());
|
||||
const sse = await readResponseText(result.response);
|
||||
assert.match(sse, /event: message_start/);
|
||||
assert.match(sse, /event: content_block_delta/);
|
||||
@@ -156,3 +231,141 @@ test("DevinCliAgenticExecutor returns Anthropic SSE for streaming Claude clients
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP client handles fragmented frames, multiple chunks, and stderr", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-fragmented-");
|
||||
const scriptFile = writeScenarioMock(
|
||||
tmpDir,
|
||||
`rl.on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "fragmented" } });
|
||||
if (msg.method === "session/prompt") {
|
||||
process.stderr.write("bounded diagnostic\\n");
|
||||
const first = JSON.stringify({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fragmented", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hel" } } } });
|
||||
process.stdout.write(first.slice(0, 13));
|
||||
setTimeout(() => {
|
||||
process.stdout.write(first.slice(13) + "\\n");
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "fragmented", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "lo" } } } });
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
|
||||
}, 10);
|
||||
}
|
||||
});`
|
||||
);
|
||||
try {
|
||||
const result = await executeTextRequest(scriptFile);
|
||||
assert.equal(result.response.status, 200, await result.response.clone().text());
|
||||
const body = JSON.parse(await result.response.text());
|
||||
assert.equal(body.content[0].text, "Hello");
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP client fails closed on protocol errors and early exit", async () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "invalid-frame",
|
||||
code: "invalid_acp_frame",
|
||||
body: `rl.on("line", () => process.stdout.write("not-json\\n"));`,
|
||||
},
|
||||
{
|
||||
name: "rpc-error",
|
||||
code: "acp_error",
|
||||
body: `rl.on("line", (line) => { const msg = JSON.parse(line); send({ jsonrpc: "2.0", id: msg.id, error: { code: -32602, message: "bad request" } }); });`,
|
||||
},
|
||||
{
|
||||
name: "early-exit",
|
||||
code: "acp_early_exit",
|
||||
body: `rl.on("line", () => process.exit(7));`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const scenario of cases) {
|
||||
const tmpDir = sandboxTmp(`devin-agentic-${scenario.name}-`);
|
||||
try {
|
||||
const result = await executeTextRequest(writeScenarioMock(tmpDir, scenario.body));
|
||||
assert.equal(result.response.status, 502, scenario.name);
|
||||
const body = JSON.parse(await result.response.text());
|
||||
assert.equal(body.error.code, scenario.code, scenario.name);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("ACP client times out, cancels, and terminates a stuck process", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-stuck-");
|
||||
const scriptFile = writeScenarioMock(tmpDir, `rl.on("line", () => {});`);
|
||||
const oldTimeout = process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS;
|
||||
try {
|
||||
process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = "80";
|
||||
const timeoutResult = await executeTextRequest(scriptFile);
|
||||
assert.equal(timeoutResult.response.status, 504);
|
||||
assert.equal(JSON.parse(await timeoutResult.response.text()).error.code, "acp_timeout");
|
||||
|
||||
process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = "1000";
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), 30);
|
||||
const cancelled = await executeTextRequest(scriptFile, controller.signal);
|
||||
assert.equal(cancelled.response.status, 499);
|
||||
assert.equal(JSON.parse(await cancelled.response.text()).error.code, "acp_cancelled");
|
||||
} finally {
|
||||
if (oldTimeout === undefined) delete process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS;
|
||||
else process.env.DEVIN_AGENTIC_ACP_TIMEOUT_MS = oldTimeout;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("tool repair is attempted once and produces a validated tool_use", async () => {
|
||||
const tmpDir = sandboxTmp("devin-agentic-repair-");
|
||||
const stateFile = path.join(tmpDir, "spawn-count");
|
||||
const scriptFile = writeScenarioMock(
|
||||
tmpDir,
|
||||
`const fs = require("fs");
|
||||
const stateFile = ${JSON.stringify(stateFile)};
|
||||
const count = Number(fs.existsSync(stateFile) ? fs.readFileSync(stateFile, "utf8") : "0") + 1;
|
||||
fs.writeFileSync(stateFile, String(count));
|
||||
rl.on("line", (line) => {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.method === "initialize") send({ jsonrpc: "2.0", id: msg.id, result: { protocolVersion: 1 } });
|
||||
if (msg.method === "session/new") send({ jsonrpc: "2.0", id: msg.id, result: { sessionId: "repair" } });
|
||||
if (msg.method === "session/prompt") {
|
||||
const text = count === 1
|
||||
? 'I will read it. <tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>'
|
||||
: '<tool>{"name":"Read","arguments":{"file_path":"a.ts"}}</tool>';
|
||||
send({ jsonrpc: "2.0", method: "session/update", params: { sessionId: "repair", update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } } } });
|
||||
send({ jsonrpc: "2.0", id: msg.id, result: { stopReason: "end_turn" } });
|
||||
}
|
||||
});`
|
||||
);
|
||||
const oldBin = process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
process.env.CLI_DEVIN_AGENTIC_BIN = scriptFile;
|
||||
try {
|
||||
const result = await new DevinCliAgenticExecutor().execute({
|
||||
model: "swe-1-7",
|
||||
stream: false,
|
||||
credentials: {},
|
||||
body: {
|
||||
tools: [
|
||||
{
|
||||
name: "Read",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
required: ["file_path"],
|
||||
properties: { file_path: { type: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: [{ role: "user", content: "Read a.ts" }],
|
||||
},
|
||||
});
|
||||
assert.equal(result.response.status, 200, await result.response.clone().text());
|
||||
assert.equal(JSON.parse(await result.response.text()).stop_reason, "tool_use");
|
||||
assert.equal(fs.readFileSync(stateFile, "utf8"), "2");
|
||||
} finally {
|
||||
if (oldBin === undefined) delete process.env.CLI_DEVIN_AGENTIC_BIN;
|
||||
else process.env.CLI_DEVIN_AGENTIC_BIN = oldBin;
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,10 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildClaudeSseFrames } from "../../open-sse/executors/devin-agentic/anthropicResponse.ts";
|
||||
import { serializeAnthropicForDevin } from "../../open-sse/executors/devin-agentic/serializer.ts";
|
||||
import {
|
||||
MAX_TOOL_RESULT_CHARS,
|
||||
serializeAnthropicForDevin,
|
||||
} from "../../open-sse/executors/devin-agentic/serializer.ts";
|
||||
import { parseDevinToolRequest } from "../../open-sse/executors/devin-agentic/toolParser.ts";
|
||||
|
||||
const readTool = {
|
||||
@@ -40,6 +43,52 @@ test("devin agentic serializer preserves Anthropic tool history and schemas", ()
|
||||
assert.match(prompt.text, /\[Assistant Tool Use\]/);
|
||||
assert.match(prompt.text, /\[Tool Result\]/);
|
||||
assert.equal(prompt.tools[0].name, "Read");
|
||||
assert.match(prompt.idSeed, /^[a-f0-9]{24}$/);
|
||||
});
|
||||
|
||||
test("devin agentic serializer preserves tool choice and validates tool-result association", () => {
|
||||
const prompt = serializeAnthropicForDevin({
|
||||
tools: [readTool],
|
||||
tool_choice: { type: "tool", name: "Read" },
|
||||
messages: [{ role: "user", content: "Read it" }],
|
||||
});
|
||||
assert.match(prompt.text, /Call exactly this tool: Read/);
|
||||
assert.throws(
|
||||
() =>
|
||||
serializeAnthropicForDevin({
|
||||
tools: [readTool],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_missing", content: "nope" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
/unknown tool_use id/
|
||||
);
|
||||
});
|
||||
|
||||
test("devin agentic serializer marks bounded tool-result truncation explicitly", () => {
|
||||
const prompt = serializeAnthropicForDevin({
|
||||
tools: [readTool],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_big", name: "Read", input: { file_path: "a" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_big",
|
||||
content: "x".repeat(MAX_TOOL_RESULT_CHARS + 9),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
assert.match(prompt.text, /\[TRUNCATED 9 CHARACTERS BY OMNIROUTE\]/);
|
||||
});
|
||||
|
||||
test("devin agentic serializer rejects images explicitly", () => {
|
||||
@@ -55,7 +104,8 @@ test("devin agentic serializer rejects images explicitly", () => {
|
||||
test("devin agentic parser validates known tool and arguments", () => {
|
||||
const parsed = parseDevinToolRequest(
|
||||
'<tool>{"name":"Read","arguments":{"file_path":"src/index.ts"}}</tool>',
|
||||
[readTool]
|
||||
[readTool],
|
||||
"request-a"
|
||||
);
|
||||
|
||||
assert.equal(parsed?.name, "Read");
|
||||
@@ -63,6 +113,15 @@ test("devin agentic parser validates known tool and arguments", () => {
|
||||
assert.match(parsed?.id || "", /^tool_devin_/);
|
||||
});
|
||||
|
||||
test("devin agentic tool ids are stable per request and distinct across turns", () => {
|
||||
const text = '<tool>{"name":"Read","arguments":{"file_path":"src/index.ts"}}</tool>';
|
||||
const first = parseDevinToolRequest(text, [readTool], "request-a");
|
||||
const retry = parseDevinToolRequest(text, [readTool], "request-a");
|
||||
const laterTurn = parseDevinToolRequest(text, [readTool], "request-b");
|
||||
assert.equal(first?.id, retry?.id);
|
||||
assert.notEqual(first?.id, laterTurn?.id);
|
||||
});
|
||||
|
||||
test("devin agentic parser rejects unknown tools and invalid arguments", () => {
|
||||
assert.throws(
|
||||
() => parseDevinToolRequest('<tool>{"name":"Write","arguments":{}}</tool>', [readTool]),
|
||||
|
||||
Reference in New Issue
Block a user