Skip to content
Open
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
27 changes: 27 additions & 0 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ const copilotModesProfile = process.env.T3_ACP_COPILOT_MODES === "1";
const copilotModeId = (name: string) =>
`https://agentclientprotocol.com/protocol/session-modes#${name}`;
const modeOptionCategory = process.env.T3_ACP_MODE_OPTION_CATEGORY ?? "mode";
// Comma-separated command names the mock advertises, so `/compact` support is testable.
const advertisedCommandNames = (process.env.T3_ACP_AVAILABLE_COMMANDS ?? "")
.split(",")
.map((name) => name.trim())
.filter((name) => name.length > 0);
const loadedCommandNames = process.env.T3_ACP_LOADED_COMMANDS?.split(",").filter(Boolean);
const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1";
const emitInterleavedAssistantToolCalls =
process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1";
Expand Down Expand Up @@ -608,6 +614,18 @@ const program = Effect.gen(function* () {
if (antigravityProfile) {
yield* publishAntigravityCommands(sessionId);
}
if (advertisedCommandNames.length > 0) {
yield* agent.client.sessionUpdate({
sessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: advertisedCommandNames.map((name) => ({
name,
description: `${name} command`,
})),
},
});
}
return {
sessionId,
configOptions: configOptions(),
Expand Down Expand Up @@ -663,6 +681,15 @@ const program = Effect.gen(function* () {
yield* agent.handleLoadSession((request) =>
Effect.gen(function* () {
const requestedSessionId = String(request.sessionId ?? sessionId);
if (loadedCommandNames !== undefined) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
update: {
sessionUpdate: "available_commands_update",
availableCommands: loadedCommandNames.map((name) => ({ name, description: name })),
},
});
}
if (failLoadSession) {
return yield* AcpError.AcpRequestError.internalError("Mock load session failure");
}
Expand Down
172 changes: 172 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,6 +760,94 @@ function assertReinjectedInstructions(
assert.include(text, `<user_request>\n${request}\n</user_request>`);
}

const makeAdvertisedCompactionHarness = Effect.fnUntraced(function* (
environment: Readonly<Record<string, string>>,
) {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const path = yield* Path.Path;
const selfInvocation = yield* resolveSelfInvocation();
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
// Resolves after the adapter records what the agent advertised, so the refusal is deterministic.
const commandsPublished = yield* Deferred.make<void>();
const instanceId = ProviderInstanceId.make("acp-test-advertised-compaction");
const threadId = ThreadId.make("thread-acp-advertised-compaction");
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
compactCommand: "compact",
onAvailableCommandsUpdate: () => Deferred.succeed(commandsPublished, undefined),
makeRuntime: makeMockRuntime({
childProcessSpawner,
mockAgentPath,
protocolEvents,
environment,
}),
},
selfInvocation,
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const modelSelection = { instanceId, model: "default" } as const;
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-acp-advertised-compaction"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({ threadId, modelSelection, runtimePolicy });
yield* Deferred.await(commandsPublished);
const now = yield* DateTime.now;
let ordinal = 0;
const nextPromptTexts = () =>
Effect.gen(function* () {
while (true) {
const event = yield* Queue.take(protocolEvents);
if (event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt") {
return rawProtocolPromptTextBlocks(event);
}
}
});
return {
runtime,
compact: (otherSession = false) =>
Effect.gen(function* () {
const compactThread = runtime.compactThread;
assert.isDefined(compactThread, "compactThread must be wired for compactCommand");
return yield* Effect.exit(
compactThread!(
makeTurnInput({
threadId,
providerThread: otherSession
? {
...providerThread,
nativeThreadRef: {
driver: ACP_TEST_DRIVER,
nativeId: "other-session",
strength: "strong",
},
}
: { ...providerThread, contextUsage: { usedTokens: 1234 } },
instanceId,
runtimePolicy,
now,
ordinal: ++ordinal,
messageText: "Compact the conversation.",
}),
),
);
}),
nextPromptTexts,
};
});

const runFormElicitation = (input: {
readonly name: string;
readonly requestedSchema: unknown;
Expand Down Expand Up @@ -2323,6 +2411,90 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live.each([false, true])(
"sends a bare /compact prompt with protocol compaction %s",
(structured) =>
Effect.gen(function* () {
const harness = yield* makeAdvertisedCompactionHarness({
T3_ACP_AVAILABLE_COMMANDS: "compact,plan",
T3_ACP_COMPACTION_PROMPT_ORDINALS: structured ? "1" : "",
});
const compacted = yield* harness.compact();
assert.isTrue(Exit.isSuccess(compacted));
assert.deepEqual(yield* harness.nextPromptTexts(), [{ type: "text", text: "/compact" }]);
const events = yield* harness.runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runCollect,
);
assert.isTrue(
events.some(
(event) =>
event.type === "provider_thread.updated" &&
event.providerThread.contextUsage === null,
),
);
assert.isTrue(
events.some(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "compaction" &&
event.turnItem.status === "completed" &&
event.turnItem.title === (structured ? "Compact context" : "Context compacted"),
),
);
assert.lengthOf(
events.filter(
(event) =>
event.type === "turn_item.updated" &&
event.turnItem.type === "compaction" &&
event.turnItem.status === "completed",
),
1,
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live("refuses compaction when the agent does not advertise the compact command", () =>
Effect.gen(function* () {
const harness = yield* makeAdvertisedCompactionHarness({
T3_ACP_AVAILABLE_COMMANDS: "plan",
});
const compacted = yield* harness.compact();
const error = Exit.isFailure(compacted) ? Cause.squash(compacted.cause) : undefined;
assert.instanceOf(error, ProviderAdapter.ProviderAdapterTurnStartError);
assert.include(
(error as ProviderAdapter.ProviderAdapterTurnStartError).cause,
"does not advertise a /compact command",
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live.each([
{ active: "plan", target: "compact", allowed: true },
{ active: "compact", target: "plan", allowed: false },
])(
"validates the target session's compact command ($active -> $target)",
({ active, target, allowed }) =>
Effect.gen(function* () {
const harness = yield* makeAdvertisedCompactionHarness({
T3_ACP_AVAILABLE_COMMANDS: active,
T3_ACP_LOADED_COMMANDS: target,
});
const result = yield* harness.compact(true);
assert.equal(Exit.isSuccess(result), allowed);
if (allowed) {
assert.deepEqual(yield* harness.nextPromptTexts(), [{ type: "text", text: "/compact" }]);
yield* harness.runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runDrain,
);
} else {
const error = Exit.isFailure(result) ? Cause.squash(result.cause) : undefined;
assert.instanceOf(error, ProviderAdapter.ProviderAdapterTurnStartError);
}
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live("reapplies a selection after a switch away from it failed partway", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
3 changes: 3 additions & 0 deletions packages/provider-acp-registry/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu
}
: AcpProviderCapabilitiesV2,
customModels: readCustomModelEntries(options.settings.customModels).map((entry) => entry.slug),
// Agents that implement `/compact` as a prompt command (omp, Claude, Codex) compact natively;
// agents that do not advertise it are rejected instead of receiving `/compact` as chat.
compactCommand: "compact",
promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause),
...(registryAgentId === "factory-droid"
? { extractElicitationForm: extractDroidElicitationForm }
Expand Down
70 changes: 58 additions & 12 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,12 @@ export interface AcpAdapterV2Flavor {
) => Effect.Effect<void>;
readonly subagentsIdleOnTurnCompletion?: boolean;
readonly supportsCompaction?: boolean;
/**
* Name of the slash command an agent runs natively to compact its own context
* (`compact` for agents that implement `/compact` as a prompt command).
* Turns `/compact` into an agent turn; rejected unless the agent advertises it.
*/
readonly compactCommand?: string;
/**
* Native steering: `send` queues a message into the running turn and returns the
* agent's id for it, which `injectedSteerId` reads from the update that delivers it.
Expand Down Expand Up @@ -1935,6 +1941,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
const promptInstructionStates = yield* Ref.make(new Map<string, T3AcpInstructionState>());
// Unique markers keep delayed prompt completions from clearing later compactions.
const compactionMarkers = yield* Ref.make(new Map<string, symbol>());
// Commands each agent session advertised; `/compact` is only sent to agents that list it.
const advertisedCommandNames = yield* Ref.make(new Map<string, ReadonlySet<string>>());
// Agents that run `/compact` themselves advertise it as a command, so they take part in
// T3 compaction even though ACP has no dedicated compact method.
const compactCommand = flavor.compactCommand?.trim().replace(/^\/+/u, "").toLowerCase();
const compactPromptText = `/${compactCommand ?? "compact"}`;
const compactsContext =
flavor.supportsCompaction === true ||
flavor.compactSession !== undefined ||
compactCommand !== undefined;
const runtimeRestartRequired = yield* Ref.make(false);
const runtimeTeardownState = yield* Ref.make<AcpRuntimeTeardownState>({ _tag: "Idle" });
const runtimeCallbackGeneration = yield* Ref.make(0);
Expand Down Expand Up @@ -6318,10 +6334,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
clientTerminals.readOutputSnapshot,
);
if (notification.update.sessionUpdate === "available_commands_update") {
yield* (
flavor.onAvailableCommandsUpdate?.(notification.update.availableCommands) ??
Effect.void
);
const sessionId = rawNotification.sessionId;
const advertisedCommands = notification.update.availableCommands;
yield* Ref.update(advertisedCommandNames, (current) => {
const updated = new Map(current);
updated.set(
sessionId,
new Set(advertisedCommands.map((command) => command.name.trim().toLowerCase())),
);
return updated;
});
yield* flavor.onAvailableCommandsUpdate?.(advertisedCommands) ?? Effect.void;
}
if (
notification.update.sessionUpdate === "config_option_update" ||
Expand Down Expand Up @@ -7646,10 +7669,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
}
const now = yield* DateTime.now;
if (
(flavor.supportsCompaction === true || flavor.compactSession !== undefined) &&
isCompactionMessage(context.input.message) &&
settledStatus === "completed"
compactsContext &&
context.input.message.text.trim().toLowerCase() === compactPromptText &&
Comment thread
maria-rcks marked this conversation as resolved.
Resolved
context.input.message.attachments.length === 0 &&
settledStatus === "completed" &&
!context.tools.keys().some((id) => id.startsWith("acp-compaction:"))
) {
context.contextUsage = null;
yield* Ref.update(contextUsageBySessionId, (current) => {
const updated = new Map(current);
updated.delete(context.nativeThreadId);
return updated;
});
const nativeItemId = `${context.nativeTurnId}:compaction`;
yield* Ref.update(compactionMarkers, (current) => {
const updated = new Map(current);
Expand Down Expand Up @@ -8110,6 +8141,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
yield* Ref.set(activeInteractionMode, turnInput.runtimePolicy.interactionMode);
}
}
if (
compactCommand !== undefined &&
turnInput.message.text.trim().toLowerCase() === compactPromptText &&
turnInput.message.attachments.length === 0
) {
const advertised = (yield* Ref.get(advertisedCommandNames)).get(requestedSessionId);
if (advertised !== undefined && !advertised.has(compactCommand)) {
return yield* Effect.fail(
`This agent does not advertise a /${compactCommand} command.`,
);
}
}
yield* validateModelSelection(turnInput.modelSelection);
yield* Ref.set(lastTurnRoute, {
threadId: turnInput.threadId,
Expand Down Expand Up @@ -8775,13 +8818,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
),
),
startTurn,
...(flavor.supportsCompaction === true || flavor.compactSession !== undefined
...(compactsContext
? {
compactThread: (turnInput: ProviderAdapter.ProviderAdapterV2TurnInput) =>
startTurn({
compactThread: Effect.fn("AcpAdapterV2.compactThread")(function* (
turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
) {
return yield* startTurn({
...turnInput,
message: { ...turnInput.message, text: "/compact" },
}),
message: { ...turnInput.message, text: compactPromptText },
});
}),
}
: {}),
steerTurn: Effect.fn("AcpAdapterV2.steerTurn")(
Expand Down
Loading