Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ import * as IdAllocator from "../IdAllocator.ts";
import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts";
import * as ProviderContinuationRequests from "../ProviderContinuationRequests.ts";
import { OPENCODE_PROVIDER } from "./OpenCodeAdapterV2.ts";
import { OPENCODE_2_STILL_STOPPING } from "./OpenCode2AdapterV2.ts";
import { OPENCODE_2_STILL_STOPPING, t3McpServerName } from "./OpenCode2AdapterV2.ts";
import { openCode2ReplayRuntime } from "./OpenCode2AdapterV2.testkit.ts";

const SESSION = "ses_f148ca2deffeJcwCnRQtb0YFNX";
Expand Down Expand Up @@ -2702,6 +2702,86 @@ describe("OpenCode2 adapter", () => {
}).pipe(Effect.scoped),
);

it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () =>
Effect.gen(function* () {
// Spelled out, this delegated thread's server name would be 120 characters.
const child = ThreadId.make(
"thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Asubproject-b-round1",
);
const server = "t3-code-aa73fa1e03099934";
McpProviderSession.setMcpProviderSession({
environmentId: EnvironmentId.make("environment:opencode2-adapter"),
threadId: child,
providerSessionId: "mcp:opencode2-adapter",
providerInstanceId: instanceId,
endpoint: "http://127.0.0.1:3773/mcp",
authorizationHeader: "Bearer thread-credential",
browserToolsAvailable: false,
});
yield* Effect.addFinalizer(() =>
Effect.sync(() => McpProviderSession.clearMcpProviderSession(child)),
);
const runtime = yield* openCode2ReplayRuntimeWithInstructions([
...opening,
out("session.get", { sessionID: SESSION }),
// The session already has this thread's rules, so they are not rewritten.
replyData(
"session.get",
sessionInfo({
permissions: [
{ action: "*", resource: "*", effect: "allow" },
{ action: "t3-code-*", resource: "*", effect: "deny" },
{ action: `${server}_*`, resource: "*", effect: "allow" },
],
}),
),
...noOpenRequests,
out("mcp.add", {
server,
"location[directory]": WORK,
config: {
type: "remote",
url: "http://127.0.0.1:3773/mcp",
headers: { Authorization: "Bearer thread-credential" },
oauth: false,
},
}),
reply("mcp.add", null),
out("session.prompt", { sessionID: SESSION, text: "<any>" }),
promptAccepted,
event("session.execution.succeeded", { sessionID: SESSION }),
out("mcp.remove", { server, "location[directory]": WORK }),
reply("mcp.remove", null),
]);
const thread = yield* runtime.resumeThread({
providerThread: { ...providerThread(yield* DateTime.now), appThreadId: child },
threadId: child,
modelSelection: bigPickle,
runtimePolicy: policy(),
});
const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
yield* runtime.startTurn({ ...turnInput(thread), threadId: child });
assert.equal((yield* Fiber.join(terminal))?.status, "completed");
yield* runtime.unloadThread!({ providerThread: thread });
}).pipe(Effect.scoped),
);

it("names each thread's MCP server within OpenCode's limits, one name per thread", () => {
const project = "thread:project:ce04e4e2-6c29-4ff0-a1d7-b089dd63e258";
const ids = [
`${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c4`,
`${project}:d3b2d715-c4a1-4b63-bb65-1634c3a3a8c5`,
"thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around1",
"thread:delegated-task:command%3Amcp%3A48bef2bf-6d0e-4f7a-9c3b-2e5d8a1f7c40%3Adelegate-task%3Around2",
];
const names = ids.map(t3McpServerName);
for (const name of names) assert.match(name, /^t3-code-[A-Za-z0-9_-]{1,56}$/);
assert.equal(new Set(names).size, ids.length);
assert.deepEqual(ids.map(t3McpServerName), names);
// A name that already fits stays readable.
assert.equal(t3McpServerName(threadId), "t3-code-thread_opencode2-adapter");
});

it.effect("reads user and assistant text from the session's message list", () =>
Effect.gen(function* () {
const runtime = yield* openCode2ReplayRuntimeWithInstructions([
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
*
* @module orchestration-v2/Adapters/OpenCode2AdapterV2
*/
import * as NodeCrypto from "node:crypto";

import {
AbsolutePath,
Agent,
Expand Down Expand Up @@ -447,9 +449,16 @@ const rule = (action: string, effect: Rule["effect"]): Rule => ({ action, resour
* T3's MCP server is registered per directory, not per session, so each thread
* gets its own `t3-code-<thread>` entry with its own credential. OpenCode names
* an MCP tool's permission `<server>_<tool>` (non-alphanumerics become `_`).
* OpenCode skips every tool of a server whose name is over 64 characters (its
* tool namespace limit), and its router rejects adding one over 100, so a
* thread id that does not fit is replaced by a digest of it.
*/
const t3McpServerName = (threadId: string) =>
`t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
export const t3McpServerName = (threadId: string) => {
const name = `t3-code-${threadId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}`;
if (name.length <= 64) return name;
const digest = NodeCrypto.createHash("sha256").update(threadId).digest("hex");
return `t3-code-${digest.slice(0, 16)}`;
};

/**
* The rules that keep T3's MCP servers to their own thread, after the mode's:
Expand Down
Loading