Skip to content

Commit f95a4d2

Browse files
committed
feat(cursor): surface shell command text and subagent work in timeline
Advertise `terminal: true` so Cursor issues `terminal/create` requests we can capture command text from, then merge that into the matching tool_call event via a new terminal-id hint lookup. Adds defense-in-depth fallbacks (content[] heuristic, `_meta.command`, alt rawInput keys) on both server and shared extractors. Wires a `cursor/task` notification handler that projects the single post-completion notification into paired `task.started` + `task.completed` events, with a turnId fallback to the last known turn so late notifications survive the latest-turn filter.
1 parent 6c18e10 commit f95a4d2

9 files changed

Lines changed: 867 additions & 20 deletions

File tree

apps/server/scripts/acp-mock-agent.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const emitInterleavedAssistantToolCalls =
1717
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
1818
const emitGenericToolPlaceholders = process.env.T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS === "1";
1919
const emitAskQuestion = process.env.T3_ACP_EMIT_ASK_QUESTION === "1";
20+
const emitTerminalCreate = process.env.MARCODE_ACP_EMIT_TERMINAL_CREATE === "1";
21+
const emitCursorTask = process.env.MARCODE_ACP_EMIT_CURSOR_TASK === "1";
2022
const failSetConfigOption = process.env.T3_ACP_FAIL_SET_CONFIG_OPTION === "1";
2123
const exitOnSetConfigOption = process.env.T3_ACP_EXIT_ON_SET_CONFIG_OPTION === "1";
2224
const promptResponseText = process.env.T3_ACP_PROMPT_RESPONSE_TEXT;
@@ -484,6 +486,52 @@ const program = Effect.gen(function* () {
484486
return { stopReason: "end_turn" };
485487
}
486488

489+
if (emitTerminalCreate) {
490+
// Mirror the terminal/create → session/update flow Cursor uses when
491+
// the client advertises `terminal: true`: spawn a terminal, then emit
492+
// a tool_call whose content[] references it by terminalId.
493+
const createResponse = yield* agent.client.createTerminal({
494+
sessionId: requestedSessionId,
495+
command: "cc",
496+
args: ["--version"],
497+
});
498+
yield* agent.client.sessionUpdate({
499+
sessionId: requestedSessionId,
500+
update: {
501+
sessionUpdate: "tool_call",
502+
toolCallId: "tool-call-terminal-1",
503+
title: "Terminal",
504+
kind: "execute",
505+
status: "pending",
506+
rawInput: {},
507+
content: [{ type: "terminal", terminalId: createResponse.terminalId }],
508+
},
509+
});
510+
yield* agent.client.sessionUpdate({
511+
sessionId: requestedSessionId,
512+
update: {
513+
sessionUpdate: "tool_call_update",
514+
toolCallId: "tool-call-terminal-1",
515+
status: "completed",
516+
},
517+
});
518+
return { stopReason: "end_turn" };
519+
}
520+
521+
if (emitCursorTask) {
522+
// Cursor fires `cursor/task` post-completion as a fire-and-forget
523+
// notification — the sole observable signal for subagent work.
524+
yield* agent.client.extNotification("cursor/task", {
525+
toolCallId: "task-subagent-1",
526+
description: "Explored BullMQ usages",
527+
prompt: "find all bullmq usages",
528+
subagentType: "explore",
529+
model: "default",
530+
durationMs: 1234,
531+
});
532+
return { stopReason: "end_turn" };
533+
}
534+
487535
yield* agent.client.sessionUpdate({
488536
sessionId: requestedSessionId,
489537
update: {

apps/server/src/provider/Layers/CursorAdapter.hints.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { describe, expect, it } from "vitest";
22

3-
import { applyToolCallHint } from "./CursorAdapter.ts";
3+
import { TurnId } from "@marcode/contracts";
4+
5+
import {
6+
applyToolCallHint,
7+
resolveEffectiveTurnId,
8+
resolveTerminalHintFromToolCall,
9+
} from "./CursorAdapter.ts";
410
import type { AcpToolCallState } from "../acp/AcpRuntimeModel.ts";
511

612
// Cursor ACP only emits the real command text in `session/request_permission`
@@ -58,3 +64,100 @@ describe("applyToolCallHint", () => {
5864
expect(merged.detail).toBe("existing output");
5965
});
6066
});
67+
68+
// When Cursor advertises `terminal: true`, it issues a `terminal/create`
69+
// request that carries the real command + args. The adapter stashes the
70+
// spawned terminal keyed by terminalId, and subsequent `session/update`
71+
// tool_call events reference that terminalId in `data.content` via
72+
// `{ type: "terminal", terminalId }`. `resolveTerminalHintFromToolCall`
73+
// bridges those two channels: it returns a hint with the captured command so
74+
// `applyToolCallHint` can merge it into the tool_call state.
75+
describe("resolveTerminalHintFromToolCall", () => {
76+
const terminalState: AcpToolCallState = {
77+
toolCallId: "tool-terminal-1",
78+
kind: "execute",
79+
title: "Ran command",
80+
status: "inProgress",
81+
data: {
82+
toolCallId: "tool-terminal-1",
83+
kind: "execute",
84+
toolName: "execute",
85+
content: [{ type: "terminal", terminalId: "t-1" }],
86+
},
87+
};
88+
89+
it("returns undefined when the tool_call has no terminal content ref", () => {
90+
expect(
91+
resolveTerminalHintFromToolCall(
92+
{ ...terminalState, data: { ...terminalState.data, content: [] } },
93+
new Map([["t-1", { command: "cc --version" }]]),
94+
),
95+
).toBeUndefined();
96+
});
97+
98+
it("returns undefined when the terminalId is not in the terminals map", () => {
99+
expect(resolveTerminalHintFromToolCall(terminalState, new Map())).toBeUndefined();
100+
});
101+
102+
it("returns the stored command when the tool_call references a known terminal", () => {
103+
const hint = resolveTerminalHintFromToolCall(
104+
terminalState,
105+
new Map([["t-1", { command: "cc --version" }]]),
106+
);
107+
expect(hint).toEqual({ command: "cc --version" });
108+
});
109+
110+
it("composes with applyToolCallHint to populate the command on the tool_call state", () => {
111+
const hint = resolveTerminalHintFromToolCall(
112+
terminalState,
113+
new Map([["t-1", { command: "bun run typecheck" }]]),
114+
);
115+
const merged = applyToolCallHint(terminalState, hint);
116+
expect(merged.command).toBe("bun run typecheck");
117+
expect(merged.data.command).toBe("bun run typecheck");
118+
});
119+
});
120+
121+
// Cursor fires `cursor/task` as a fire-and-forget notification after the
122+
// subagent run completes. Depending on timing, `ctx.activeTurnId` may still be
123+
// the turn that just ended, or may be undefined (e.g. if another code path
124+
// cleared it, or if the notification arrives between turns). Either way the
125+
// session-logic filter drops activities whose `turnId !== latestTurnId`, so
126+
// falling back to the last known turn prevents the subagent work from being
127+
// silently swallowed by the UI.
128+
describe("resolveEffectiveTurnId", () => {
129+
const turnA = TurnId.make("11111111-1111-1111-1111-111111111111");
130+
const turnB = TurnId.make("22222222-2222-2222-2222-222222222222");
131+
132+
it("returns undefined when no ctx is provided", () => {
133+
expect(resolveEffectiveTurnId(undefined)).toBeUndefined();
134+
});
135+
136+
it("prefers the active turn when one is set", () => {
137+
expect(resolveEffectiveTurnId({ activeTurnId: turnA, turns: [] })).toBe(turnA);
138+
expect(
139+
resolveEffectiveTurnId({
140+
activeTurnId: turnA,
141+
turns: [{ id: turnB }],
142+
}),
143+
).toBe(turnA);
144+
});
145+
146+
it("falls back to the last known turn when activeTurnId is undefined", () => {
147+
expect(
148+
resolveEffectiveTurnId({
149+
activeTurnId: undefined,
150+
turns: [{ id: turnA }, { id: turnB }],
151+
}),
152+
).toBe(turnB);
153+
});
154+
155+
it("returns undefined when there's no active turn and no known turns", () => {
156+
expect(
157+
resolveEffectiveTurnId({
158+
activeTurnId: undefined,
159+
turns: [],
160+
}),
161+
).toBeUndefined();
162+
});
163+
});

apps/server/src/provider/Layers/CursorAdapter.test.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1174,4 +1174,144 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
11741174
yield* adapter.stopSession(threadId);
11751175
}),
11761176
);
1177+
1178+
// When the client advertises `terminal: true`, Cursor issues a
1179+
// `terminal/create` request to run the command, then references the
1180+
// returned terminalId from the `session/update` tool_call via
1181+
// `content: [{ type: "terminal", terminalId }]`. The adapter merges the
1182+
// captured command onto the tool_call event so the CommandExecutionCard
1183+
// can render the command text instead of a blank "Ran command" pill.
1184+
it.effect("resolves command text from `terminal/create` into tool_call events", () =>
1185+
Effect.gen(function* () {
1186+
const adapter = yield* CursorAdapter;
1187+
const serverSettings = yield* ServerSettingsService;
1188+
const threadId = ThreadId.make("cursor-terminal-create-probe");
1189+
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
1190+
const itemCompletedReady = yield* Deferred.make<void>();
1191+
1192+
const wrapperPath = yield* Effect.promise(() =>
1193+
makeMockAgentWrapper({ MARCODE_ACP_EMIT_TERMINAL_CREATE: "1" }),
1194+
);
1195+
yield* serverSettings.updateSettings({
1196+
providers: { cursor: { binaryPath: wrapperPath } },
1197+
});
1198+
1199+
yield* Stream.runForEach(adapter.streamEvents, (event) =>
1200+
Effect.gen(function* () {
1201+
runtimeEvents.push(event);
1202+
if (String(event.threadId) !== String(threadId)) return;
1203+
if (event.type === "item.completed" && event.payload.itemType === "command_execution") {
1204+
yield* Deferred.succeed(itemCompletedReady, undefined).pipe(Effect.ignore);
1205+
}
1206+
}),
1207+
).pipe(Effect.forkChild);
1208+
1209+
yield* adapter.startSession({
1210+
threadId,
1211+
provider: "cursor",
1212+
cwd: process.cwd(),
1213+
runtimeMode: "full-access",
1214+
modelSelection: { provider: "cursor", model: "default" },
1215+
});
1216+
1217+
yield* adapter.sendTurn({
1218+
threadId,
1219+
input: "run cc --version",
1220+
attachments: [],
1221+
});
1222+
1223+
yield* Deferred.await(itemCompletedReady);
1224+
1225+
const threadEvents = runtimeEvents.filter(
1226+
(event) => String(event.threadId) === String(threadId),
1227+
);
1228+
const commandEvent = threadEvents.find(
1229+
(event) =>
1230+
(event.type === "item.updated" || event.type === "item.completed") &&
1231+
event.payload.itemType === "command_execution",
1232+
);
1233+
assert.isDefined(commandEvent);
1234+
if (
1235+
(commandEvent?.type === "item.updated" || commandEvent?.type === "item.completed") &&
1236+
commandEvent.payload.itemType === "command_execution"
1237+
) {
1238+
const data = commandEvent.payload.data as Record<string, unknown> | undefined;
1239+
assert.equal(data?.command, "cc --version");
1240+
}
1241+
1242+
yield* adapter.stopSession(threadId);
1243+
}),
1244+
);
1245+
1246+
// `cursor/task` is a fire-and-forget notification Cursor fires post-
1247+
// completion, so `ctx.activeTurnId` may be stale. `resolveEffectiveTurnId`
1248+
// falls back to `ctx.turns.at(-1).id` so the session-logic latest-turn
1249+
// filter doesn't drop the resulting subagent activity.
1250+
it.effect("emits task.started/task.completed runtime events for cursor/task", () =>
1251+
Effect.gen(function* () {
1252+
const adapter = yield* CursorAdapter;
1253+
const serverSettings = yield* ServerSettingsService;
1254+
const threadId = ThreadId.make("cursor-task-probe");
1255+
const runtimeEvents: Array<ProviderRuntimeEvent> = [];
1256+
const taskCompletedReady = yield* Deferred.make<void>();
1257+
1258+
const wrapperPath = yield* Effect.promise(() =>
1259+
makeMockAgentWrapper({ MARCODE_ACP_EMIT_CURSOR_TASK: "1" }),
1260+
);
1261+
yield* serverSettings.updateSettings({
1262+
providers: { cursor: { binaryPath: wrapperPath } },
1263+
});
1264+
1265+
yield* Stream.runForEach(adapter.streamEvents, (event) =>
1266+
Effect.gen(function* () {
1267+
runtimeEvents.push(event);
1268+
if (String(event.threadId) !== String(threadId)) return;
1269+
if (event.type === "task.completed") {
1270+
yield* Deferred.succeed(taskCompletedReady, undefined).pipe(Effect.ignore);
1271+
}
1272+
}),
1273+
).pipe(Effect.forkChild);
1274+
1275+
yield* adapter.startSession({
1276+
threadId,
1277+
provider: "cursor",
1278+
cwd: process.cwd(),
1279+
runtimeMode: "full-access",
1280+
modelSelection: { provider: "cursor", model: "default" },
1281+
});
1282+
1283+
const turn = yield* adapter.sendTurn({
1284+
threadId,
1285+
input: "use the explore subagent",
1286+
attachments: [],
1287+
});
1288+
1289+
yield* Deferred.await(taskCompletedReady);
1290+
1291+
const threadEvents = runtimeEvents.filter(
1292+
(event) => String(event.threadId) === String(threadId),
1293+
);
1294+
const taskStartedIndex = threadEvents.findIndex((event) => event.type === "task.started");
1295+
const taskCompletedIndex = threadEvents.findIndex((event) => event.type === "task.completed");
1296+
assert.isAtLeast(taskStartedIndex, 0);
1297+
assert.isAtLeast(taskCompletedIndex, 0);
1298+
// Effect.yieldNow between the two emissions guarantees the handler
1299+
// interleaves before offerRuntimeEvent#2 runs — which shows up as
1300+
// emission-order ordering on the PubSub stream.
1301+
assert.isBelow(taskStartedIndex, taskCompletedIndex);
1302+
1303+
const taskStarted = threadEvents[taskStartedIndex];
1304+
const taskCompleted = threadEvents[taskCompletedIndex];
1305+
if (taskStarted?.type === "task.started" && taskCompleted?.type === "task.completed") {
1306+
assert.equal(String(taskStarted.turnId), String(turn.turnId));
1307+
assert.equal(String(taskCompleted.turnId), String(turn.turnId));
1308+
assert.equal(taskStarted.payload.agentType, "explore");
1309+
assert.equal(taskStarted.payload.toolUseId, "task-subagent-1");
1310+
assert.equal(taskStarted.payload.prompt, "find all bullmq usages");
1311+
assert.equal(taskCompleted.payload.status, "completed");
1312+
}
1313+
1314+
yield* adapter.stopSession(threadId);
1315+
}),
1316+
);
11771317
});

0 commit comments

Comments
 (0)