From 1e8b1b02d29984554231dd4f3cd2e04d06be5216 Mon Sep 17 00:00:00 2001 From: ProtonDev-sys Date: Thu, 10 Sep 2026 19:42:07 +0100 Subject: [PATCH] fix(devin): connect T3 tools for new and resumed sessions --- apps/server/scripts/acp-mock-agent.ts | 3 + .../src/provider/Layers/DevinAdapter.test.ts | 154 ++++++++++-------- .../src/provider/Layers/DevinAdapter.ts | 39 +++-- .../src/provider/acp/AcpSessionRuntime.ts | 3 + .../src/provider/acp/DevinAcpCliProbe.test.ts | 74 +++++---- .../src/provider/acp/DevinAcpSupport.ts | 3 +- apps/server/src/provider/acp/DevinMcp.test.ts | 85 ++++++++++ apps/server/src/provider/acp/DevinMcp.ts | 53 ++++++ packages/effect-acp/src/client.test.ts | 37 +++++ packages/effect-acp/src/client.ts | 2 +- packages/effect-acp/src/rpc.ts | 2 +- packages/effect-acp/src/schema.ts | 22 +++ 12 files changed, 361 insertions(+), 116 deletions(-) create mode 100644 apps/server/src/provider/acp/DevinMcp.test.ts create mode 100644 apps/server/src/provider/acp/DevinMcp.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 82e3b23f1225..5cc772d5a9af 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1354,6 +1354,9 @@ const program = Effect.gen(function* () { ); yield* agent.handleUnknownExtRequest((method, params) => { + if (method === "_cognition.ai/mcp/connectServer") { + return Effect.succeed({ connectionStatus: "connected" }); + } if (method === "_test/environment") { return Effect.succeed({ inherited: process.env.T3_ACP_RUNTIME_AMBIENT === "sentinel", diff --git a/apps/server/src/provider/Layers/DevinAdapter.test.ts b/apps/server/src/provider/Layers/DevinAdapter.test.ts index 6bcd80cb4522..3c4e631143da 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.test.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.test.ts @@ -565,75 +565,97 @@ it.layer(devinAdapterTestLayer, { excludeTestServices: true })("DevinAdapterLive }), ); - it.effect("passes the current T3 MCP server to a new Devin ACP session", () => - Effect.gen(function* () { - const requestLogDir = yield* Effect.promise(() => - NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mcp-")), - ); - const requestLogPath = NodePath.join(requestLogDir, "requests.log"); - const wrapperPath = yield* makeMockDevinWrapper({ - T3_ACP_REQUEST_LOG_PATH: requestLogPath, - }); - const adapter = yield* makeTestAdapter(wrapperPath); - const threadId = ThreadId.make("devin-t3-mcp"); - const endpoint = "http://127.0.0.1:43123/mcp"; - const authorizationHeader = "Bearer devin-mcp-test-token"; - - yield* Effect.sync(() => - McpProviderSession.setMcpProviderSession({ - environmentId: EnvironmentId.make("devin-mcp-test-environment"), - threadId, - providerSessionId: "devin-mcp-test-session", - providerInstanceId: ProviderInstanceId.make("devin"), - endpoint, - authorizationHeader, - }), - ); - - yield* Effect.gen(function* () { - yield* adapter.startSession({ - threadId, - provider: ProviderDriverKind.make("devin"), - cwd: process.cwd(), - runtimeMode: "full-access", - modelSelection: devinModelSelection("default"), + it.effect( + "connects T3 tools from a private workspace and removes it when the session stops", + () => + Effect.gen(function* () { + const requestLogDir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "devin-acp-mcp-")), + ); + const requestLogPath = NodePath.join(requestLogDir, "requests.log"); + const wrapperPath = yield* makeMockDevinWrapper({ + T3_ACP_REQUEST_LOG_PATH: requestLogPath, }); + const adapter = yield* makeTestAdapter(wrapperPath); + const threadId = ThreadId.make("devin-t3-mcp"); + const endpoint = "http://127.0.0.1:43123/mcp"; + const authorizationHeader = "Bearer devin-mcp-test-token"; + + yield* Effect.sync(() => + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("devin-mcp-test-environment"), + threadId, + providerSessionId: "devin-mcp-test-session", + providerInstanceId: ProviderInstanceId.make("devin"), + endpoint, + authorizationHeader, + }), + ); - const logContents = yield* Effect.promise(() => NodeFSP.readFile(requestLogPath, "utf8")); - const requests = logContents - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line): { method?: string; params?: unknown } | undefined => { - try { - return JSON.parse(line) as { method?: string; params?: unknown }; - } catch { - return undefined; - } - }) - .filter( - (value): value is { method: string; params: unknown } => - value !== undefined && typeof value.method === "string", + yield* Effect.gen(function* () { + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: process.cwd(), + runtimeMode: "full-access", + modelSelection: devinModelSelection("default"), + }); + + yield* adapter.sendTurn({ threadId, input: "Use the test tool." }); + + const logContents = yield* Effect.promise(() => NodeFSP.readFile(requestLogPath, "utf8")); + const requests = logContents + .split("\n") + .filter((line) => line.trim().length > 0) + .map((line): { method?: string; params?: unknown } | undefined => { + try { + return JSON.parse(line) as { method?: string; params?: unknown }; + } catch { + return undefined; + } + }) + .filter( + (value): value is { method: string; params: unknown } => + value !== undefined && typeof value.method === "string", + ); + const newSessionRequest = requests.find((request) => request.method === "session/new"); + assert.isDefined(newSessionRequest); + const params = newSessionRequest!.params as { + cwd: string; + mcpServers: unknown[]; + additionalDirectories: string[]; + }; + assert.equal(params.cwd, process.cwd()); + assert.isEmpty(params.mcpServers); + assert.lengthOf(params.additionalDirectories, 1); + const directory = params.additionalDirectories[0]!; + const connection = requests.find( + (request) => request.method === "_cognition.ai/mcp/connectServer", ); - const newSessionRequest = requests.find((request) => request.method === "session/new"); - assert.isDefined(newSessionRequest); - const params = newSessionRequest!.params as { mcpServers?: unknown }; - assert.deepEqual(params.mcpServers, [ - { - type: "http", - name: "t3-code", - url: endpoint, - headers: [{ name: "Authorization", value: authorizationHeader }], - }, - ]); - }).pipe( - Effect.ensuring( - Effect.gen(function* () { - yield* adapter.stopSession(threadId).pipe(Effect.ignore); - yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); - }), - ), - ); - }), + assert.deepEqual(connection?.params, { serverId: "t3-code", workspaceDirs: [directory] }); + const config = yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(directory, ".devin/mcp_config.local.json"), "utf8"), + ); + assert.include(config, authorizationHeader); + assert.include(config, endpoint); + assert.notInclude(logContents, authorizationHeader); + yield* adapter.stopSession(threadId); + const exists = yield* Effect.promise(() => + NodeFSP.stat(directory).then( + () => true, + () => false, + ), + ); + assert.isFalse(exists); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* adapter.stopSession(threadId).pipe(Effect.ignore); + yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId)); + }), + ), + ); + }), ); it.effect("keeps the session ready after rejecting an empty prompt", () => diff --git a/apps/server/src/provider/Layers/DevinAdapter.ts b/apps/server/src/provider/Layers/DevinAdapter.ts index 44306aef0a0e..f1f53f2b1ed4 100644 --- a/apps/server/src/provider/Layers/DevinAdapter.ts +++ b/apps/server/src/provider/Layers/DevinAdapter.ts @@ -46,6 +46,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { prepareDevinMcp } from "../acp/DevinMcp.ts"; import { type ProviderAdapterError, ProviderAdapterProcessError, @@ -954,31 +955,31 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd ? yield* options.resolveSettings : devinSettings; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const connectMcp = mcpSession + ? yield* prepareDevinMcp(mcpSession).pipe( + Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: input.threadId, + detail: "Failed to prepare Devin's T3 Code tool connection.", + cause, + }), + ), + ) + : undefined; const acp = yield* makeDevinAcpRuntime({ devinSettings: effectiveDevinSettings, ...(options?.environment ? { environment: options.environment } : {}), childProcessSpawner, cwd: input.cwd, + ...(connectMcp ? { additionalDirectories: [connectMcp.directory] } : {}), ...(input.resumeSessionId ? { resumeSessionId: input.resumeSessionId } : {}), clientInfo: { name: "t3-code", version: "0.0.0" }, - ...(mcpSession - ? { - mcpServers: [ - { - type: "http" as const, - name: "t3-code", - url: mcpSession.endpoint, - headers: [ - { - name: "Authorization", - value: mcpSession.authorizationHeader, - }, - ], - }, - ], - } - : {}), ...acpNativeLoggers, }).pipe( Effect.provideService(Crypto.Crypto, crypto), @@ -1077,7 +1078,9 @@ export function makeDevinAdapter(devinSettings: DevinSettings, options?: DevinAd }), ), ); - return yield* acp.start(); + const result = yield* acp.start(); + if (connectMcp) yield* connectMcp.connect(acp); + return result; }).pipe( Effect.mapError((error) => mapAcpToAdapterError(PROVIDER, input.threadId, "session/start", error), diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index 22aa4398e7dc..8d63c3916182 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -762,6 +762,9 @@ export const make = ( sessionId: options.resumeSessionId, cwd: options.cwd, mcpServers: options.mcpServers ?? [], + ...(options.additionalDirectories && options.additionalDirectories.length > 0 + ? { additionalDirectories: options.additionalDirectories } + : {}), } satisfies EffectAcpSchema.LoadSessionRequest; const sessionLoadTimeout = Duration.fromInputUnsafe( options.sessionLoadTimeout ?? defaultSessionLoadTimeout, diff --git a/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts index 96d0b855f66f..d63782691353 100644 --- a/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts +++ b/apps/server/src/provider/acp/DevinAcpCliProbe.test.ts @@ -5,6 +5,7 @@ * * Set T3_DEVIN_LIVE_TURN=1 to send a real prompt. This consumes Devin usage. * Set T3_DEVIN_MCP_SMOKE=1 to drive a real turn through the local T3 MCP server. + * T3_DEVIN_TEST_MODEL selects the model used by the MCP and resume checks. * The regular Devin adapter tests use the local ACP fixture for permissions, * cancellation, image input, and failure recovery; these checks validate the * installed CLI's command and ACP compatibility at the opt-in boundary. @@ -364,19 +365,13 @@ describe.runIf(process.env.T3_DEVIN_MCP_SMOKE === "1")("Devin MCP smoke", () => }).pipe(Effect.forkScoped); yield* Effect.yieldNow; - const nativeAcpEvents: unknown[] = []; const adapter = yield* makeDevinAdapter(makeProbeSettings(), { environment: process.env, promptTimeout: Duration.seconds(180), - nativeEventLogger: { - filePath: "devin-mcp-smoke-native-events", - write: (event) => Effect.sync(() => nativeAcpEvents.push(event)), - close: () => Effect.void, - }, }); yield* Effect.addFinalizer(() => adapter.stopSession(threadId).pipe(Effect.ignore)); const runtimeEvents: ProviderRuntimeEvent[] = []; - const turnCompleted = yield* Deferred.make(); + let turnCompleted = yield* Deferred.make(); yield* Stream.runForEach(adapter.streamEvents, (event) => { if (event.threadId !== threadId) return Effect.void; runtimeEvents.push(event); @@ -386,35 +381,20 @@ describe.runIf(process.env.T3_DEVIN_MCP_SMOKE === "1")("Devin MCP smoke", () => }).pipe(Effect.forkScoped); yield* Effect.yieldNow; - yield* adapter.startSession({ + const session = yield* adapter.startSession({ threadId, provider: ProviderDriverKind.make("devin"), cwd: workspace, runtimeMode: "full-access", - modelSelection: { instanceId: providerInstanceId, model: "adaptive" }, + modelSelection: { + instanceId: providerInstanceId, + model: process.env.T3_DEVIN_TEST_MODEL ?? "adaptive", + }, }); - const sessionNewRequest = nativeAcpEvents - .map( - (record) => - record as { - readonly event?: { - readonly kind?: unknown; - readonly payload?: { - readonly method?: unknown; - readonly request?: { readonly fieldCount?: unknown }; - }; - }; - }, - ) - .find( - (record) => - record.event?.kind === "request" && record.event.payload?.method === "session/new", - ); - expect(sessionNewRequest?.event?.payload?.request?.fieldCount).toBe(2); yield* adapter.sendTurn({ threadId, input: - "Use the T3 Code MCP tool preview_status exactly once. After the tool succeeds, reply exactly T3_DEVIN_MCP_OK and do not use any other tool.", + "Call the MCP tool preview_status on t3-code exactly once. After the tool succeeds, reply exactly T3_DEVIN_MCP_OK and do not use any other tool.", }); yield* Deferred.await(turnCompleted); @@ -422,7 +402,7 @@ describe.runIf(process.env.T3_DEVIN_MCP_SMOKE === "1")("Devin MCP smoke", () => .filter((event) => event.type === "content.delta") .map((event) => event.payload.delta) .join(""); - expect(requests).toHaveLength(1); + expect(requests, `Devin reply: ${assistantText}`).toHaveLength(1); expect(assistantText).toContain("T3_DEVIN_MCP_OK"); expect( requests.some( @@ -434,6 +414,42 @@ describe.runIf(process.env.T3_DEVIN_MCP_SMOKE === "1")("Devin MCP smoke", () => (request) => request.threadId === threadId && request.operation === "status", ), ).toBe(true); + + yield* adapter.stopSession(threadId); + yield* registry.revokeProviderSession(issued.config.providerSessionId); + expect(yield* registry.resolve(issuedToken)).toBeUndefined(); + const renewed = yield* registry.issue({ threadId, providerInstanceId }); + yield* Effect.addFinalizer(() => + registry.revokeProviderSession(renewed.config.providerSessionId), + ); + McpProviderSession.setMcpProviderSession(renewed.config); + turnCompleted = yield* Deferred.make(); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(workspace, "input.txt"), "T3_WORKSPACE_OK"), + ); + yield* adapter.startSession({ + threadId, + provider: ProviderDriverKind.make("devin"), + cwd: workspace, + runtimeMode: "full-access", + resumeCursor: session.resumeCursor, + modelSelection: { + instanceId: providerInstanceId, + model: process.env.T3_DEVIN_TEST_MODEL ?? "adaptive", + }, + }); + yield* adapter.sendTurn({ + threadId, + input: + "Read input.txt in the project and write its text into output.txt. Call preview_status on t3-code once again using the live MCP tool, even though you called it earlier. Then reply T3_DEVIN_RESUMED_OK.", + }); + yield* Deferred.await(turnCompleted); + expect(requests).toHaveLength(2); + expect( + (yield* Effect.promise(() => + NodeFSP.readFile(NodePath.join(workspace, "output.txt"), "utf8"), + )).trimEnd(), + ).toBe("T3_WORKSPACE_OK"); }), ).pipe(Effect.provide(DevinMcpSmokeLayer)), { timeout: 190_000 }, diff --git a/apps/server/src/provider/acp/DevinAcpSupport.ts b/apps/server/src/provider/acp/DevinAcpSupport.ts index 7773dba054f6..5c8435d1f4e1 100644 --- a/apps/server/src/provider/acp/DevinAcpSupport.ts +++ b/apps/server/src/provider/acp/DevinAcpSupport.ts @@ -31,7 +31,8 @@ type DevinAcpRuntimeSettings = Pick; export const DEVIN_ACP_CLIENT_CAPABILITIES = { _meta: { - "cognition.ai/requestDiagnostics": true, + "cognition.ai/mcp": true, + "cognition.ai/mcpWorkspaceDirs": true, }, } satisfies NonNullable; diff --git a/apps/server/src/provider/acp/DevinMcp.test.ts b/apps/server/src/provider/acp/DevinMcp.test.ts new file mode 100644 index 000000000000..994e4e1a54b9 --- /dev/null +++ b/apps/server/src/provider/acp/DevinMcp.test.ts @@ -0,0 +1,85 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import { prepareDevinMcp } from "./DevinMcp.ts"; + +const decodeConfig = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + mcpServers: Schema.Struct({ + "t3-code": Schema.Struct({ + serverUrl: Schema.String, + headers: Schema.Struct({ Authorization: Schema.String }), + }), + }), + }), + ), +); + +it.layer(NodeServices.layer)("Devin MCP configuration", (it) => { + it.effect( + "isolates simultaneous sessions and removes only the closed session's credentials", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const firstScope = yield* Scope.make(); + const secondScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(firstScope, Exit.void)); + yield* Effect.addFinalizer(() => Scope.close(secondScope, Exit.void)); + const first = yield* prepareDevinMcp({ + endpoint: "http://127.0.0.1:10001/mcp", + authorizationHeader: "Bearer first-test-token", + }).pipe(Effect.provideService(Scope.Scope, firstScope)); + const second = yield* prepareDevinMcp({ + endpoint: "http://127.0.0.1:10002/mcp", + authorizationHeader: "Bearer second-test-token", + }).pipe(Effect.provideService(Scope.Scope, secondScope)); + assert.notEqual(first.directory, second.directory); + const readConfig = (directory: string) => + fs + .readFileString(path.join(directory, ".devin/mcp_config.local.json")) + .pipe(Effect.map(decodeConfig)); + assert.equal( + (yield* readConfig(first.directory)).mcpServers["t3-code"].headers.Authorization, + "Bearer first-test-token", + ); + assert.equal( + (yield* readConfig(second.directory)).mcpServers["t3-code"].headers.Authorization, + "Bearer second-test-token", + ); + yield* Scope.close(firstScope, Exit.void); + assert.isFalse(yield* fs.exists(first.directory)); + assert.isTrue(yield* fs.exists(second.directory)); + yield* Scope.close(secondScope, Exit.void); + assert.isFalse(yield* fs.exists(second.directory)); + }), + ); + + it.effect( + "fails startup when Devin does not confirm the connection and cleans up credentials", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + let directory = ""; + const failure = yield* Effect.gen(function* () { + const prepared = yield* prepareDevinMcp({ + endpoint: "http://127.0.0.1:10001/mcp", + authorizationHeader: "Bearer rejected-test-token", + }); + directory = prepared.directory; + yield* prepared.connect({ + request: () => Effect.succeed({ connectionStatus: "auth_required" }), + }); + }).pipe(Effect.scoped, Effect.flip); + assert.equal(failure._tag, "AcpTransportError"); + assert.isFalse(yield* fs.exists(directory)); + }), + ); +}); diff --git a/apps/server/src/provider/acp/DevinMcp.ts b/apps/server/src/provider/acp/DevinMcp.ts new file mode 100644 index 000000000000..44644436d900 --- /dev/null +++ b/apps/server/src/provider/acp/DevinMcp.ts @@ -0,0 +1,53 @@ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import { AcpTransportError } from "effect-acp/errors"; + +import type { McpProviderSessionConfig } from "../../mcp/McpProviderSession.ts"; +import type { AcpSessionRuntime } from "./AcpSessionRuntime.ts"; + +const encodeConfig = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeConnected = Schema.decodeUnknownEffect( + Schema.Struct({ connectionStatus: Schema.Literal("connected") }), +); + +// Devin ignores session/new.mcpServers. Its MCP extension can instead load a +// private configuration directory, without changing user or workspace settings. +export const prepareDevinMcp = Effect.fn("prepareDevinMcp")(function* ( + session: Pick, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-devin-mcp-" }); + const configDirectory = path.join(directory, ".devin"); + yield* fs.makeDirectory(configDirectory, { mode: 0o700 }); + yield* fs.writeFileString( + path.join(configDirectory, "mcp_config.local.json"), + encodeConfig({ + mcpServers: { + "t3-code": { + serverUrl: session.endpoint, + headers: { Authorization: session.authorizationHeader }, + }, + }, + }), + { mode: 0o600 }, + ); + const connect = (runtime: Pick) => + runtime + .request("_cognition.ai/mcp/connectServer", { + serverId: "t3-code", + workspaceDirs: [directory], + }) + .pipe( + Effect.flatMap(decodeConnected), + Effect.timeout("20 seconds"), + Effect.asVoid, + Effect.mapError( + (cause) => + new AcpTransportError({ detail: "Devin could not connect to T3 Code tools.", cause }), + ), + ); + return { directory, connect }; +}); diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 3a0b262e6e22..3042196ca1d7 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -620,6 +620,43 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect( + "transmits additional workspace roots when creating, loading, and resuming sessions", + () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + const directories = ["/workspace/shared files", "C:\\Projects\\shared"]; + const payload = { cwd: "/workspace", mcpServers: [], additionalDirectories: directories }; + const decodeRequest = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.Number, + params: Schema.Struct({ additionalDirectories: Schema.Array(Schema.String) }), + }), + ), + ); + for (const call of [ + acp.agent.createSession(payload), + acp.agent.loadSession({ ...payload, sessionId: "session-1" }), + acp.agent.resumeSession({ ...payload, sessionId: "session-1" }), + ]) { + const fiber = yield* call.pipe(Effect.forkScoped); + const request = yield* decodeRequest(yield* Queue.take(output)); + assert.deepEqual(request.params.additionalDirectories, directories); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), { + jsonrpc: "2.0", + id: request.id, + result: { sessionId: "session-1" }, + }), + ); + yield* Fiber.join(fiber); + } + }), + ); + it.effect("uses distinct ids for RPC calls and extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index b3bcf3cd2433..108ae8ef3187 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -12,7 +12,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as AcpError from "./errors.ts"; import * as AcpProtocol from "./protocol.ts"; import * as AcpRpcs from "./rpc.ts"; -import * as AcpSchema from "./_generated/schema.gen.ts"; +import * as AcpSchema from "./schema.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; import { callRpc, diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 5026645374eb..6a93d2d15c29 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -1,7 +1,7 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import * as AcpSchema from "./_generated/schema.gen.ts"; +import * as AcpSchema from "./schema.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { diff --git a/packages/effect-acp/src/schema.ts b/packages/effect-acp/src/schema.ts index 8e354aca3701..53d62b434e88 100644 --- a/packages/effect-acp/src/schema.ts +++ b/packages/effect-acp/src/schema.ts @@ -1,2 +1,24 @@ +import * as Schema from "effect/Schema"; +import * as Generated from "./_generated/schema.gen.ts"; + export * from "./_generated/schema.gen.ts"; export * from "./_generated/meta.gen.ts"; + +// Some agents advertise additionalDirectories before it reaches the generated +// protocol schema. Keep these explicit extensions in the RPC encoder too. +const additionalDirectories = Schema.optionalKey(Schema.Array(Schema.String)); +export const NewSessionRequest = Schema.Struct({ + ...Generated.NewSessionRequest.fields, + additionalDirectories, +}); +export type NewSessionRequest = typeof NewSessionRequest.Type; +export const LoadSessionRequest = Schema.Struct({ + ...Generated.LoadSessionRequest.fields, + additionalDirectories, +}); +export type LoadSessionRequest = typeof LoadSessionRequest.Type; +export const ResumeSessionRequest = Schema.Struct({ + ...Generated.ResumeSessionRequest.fields, + additionalDirectories, +}); +export type ResumeSessionRequest = typeof ResumeSessionRequest.Type;