Skip to content
Closed
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
3 changes: 3 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
154 changes: 88 additions & 66 deletions apps/server/src/provider/Layers/DevinAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down
39 changes: 21 additions & 18 deletions apps/server/src/provider/Layers/DevinAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/provider/acp/AcpSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
74 changes: 45 additions & 29 deletions apps/server/src/provider/acp/DevinAcpCliProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<void>();
let turnCompleted = yield* Deferred.make<void>();
yield* Stream.runForEach(adapter.streamEvents, (event) => {
if (event.threadId !== threadId) return Effect.void;
runtimeEvents.push(event);
Expand All @@ -386,43 +381,28 @@ 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);

const assistantText = runtimeEvents
.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(
Expand All @@ -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<void>();
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 },
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/provider/acp/DevinAcpSupport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ type DevinAcpRuntimeSettings = Pick<DevinSettings, "binaryPath">;

export const DEVIN_ACP_CLIENT_CAPABILITIES = {
_meta: {
"cognition.ai/requestDiagnostics": true,
"cognition.ai/mcp": true,
"cognition.ai/mcpWorkspaceDirs": true,
},
} satisfies NonNullable<EffectAcpSchema.InitializeRequest["clientCapabilities"]>;

Expand Down
Loading
Loading