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
88 changes: 88 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,94 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.live("sends a slash command as the only ACP prompt block", () =>
Effect.gen(function* () {
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>();
const instanceId = ProviderInstanceId.make("acp-test-slash-single-block");
const threadId = ThreadId.make("thread-acp-slash-single-block");
yield* (yield* McpProviderSessions.McpProviderSessions).set({
environmentId: EnvironmentId.make("environment-acp-slash-single-block"),
threadId,
providerSessionId: "mcp-session-acp-slash-single-block",
providerInstanceId: instanceId,
endpoint: "http://127.0.0.1:43123/mcp",
authorizationHeader: "Bearer slash-single-block-token",
browserToolsAvailable: false,
});
const adapter = yield* makeAcpAdapterV2({
instanceId,
flavor: {
driver: ACP_TEST_DRIVER,
capabilities: AcpProviderCapabilitiesV2,
makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents }),
},
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-slash-single-block"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
const now = yield* DateTime.now;
const runTurn = Effect.fnUntraced(function* (ordinal: number, messageText: string) {
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now,
ordinal,
messageText,
}),
);
yield* runtime.events.pipe(
Stream.takeUntil((event) => event.type === "turn.terminal"),
Stream.runDrain,
);
while (true) {
const event = yield* Queue.take(protocolEvents);
if (event.direction !== "outgoing") continue;
if (rawProtocolMethod(event) === "session/prompt") {
return rawProtocolRequestParam(event, "prompt");
}
}
});

assert.deepStrictEqual(yield* runTurn(0, "/repro-skill"), [
{ type: "text", text: "/repro-skill" },
]);

const followUp = yield* runTurn(1, "Ordinary follow-up.");
assert.isArray(followUp);
const followUpTexts = (followUp as Array<{ readonly text?: unknown }>).map(
(block) => block.text,
);
assert.lengthOf(followUpTexts, 2);
assert.include(String(followUpTexts[0]), "T3 Code orchestration");
assert.include(String(followUpTexts[0]), "Ordinary follow-up.");
assert.include(String(followUpTexts[1]), "<runtime_info>");
}).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
18 changes: 11 additions & 7 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
import { ACP_EMPTY_OPTION_VALUE, ACP_SESSION_MODE_OPTION_ID } from "./sessionConfig.ts";
import * as AcpSessionRuntime from "./AcpSessionRuntime.ts";
import {
isSlashCommandPrompt,
t3AcpPromptWithInstructions,
type T3AcpInstructionState,
} from "@t3tools/provider-core/server/orchestrationInstructions";
Expand Down Expand Up @@ -6850,13 +6851,16 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
detail: "ACP turn requires non-empty text or attachments",
});
}
prompt.push({
type: "text",
text: buildRuntimeInstructions({
harness: flavor.runtimeHarness ?? driver,
model: turnInput.modelSelection.model,
}),
});
// ACP agents such as Copilot only expand a slash command when it is the only text block.
if (!isSlashCommandPrompt(messageText)) {
prompt.push({
type: "text",
text: buildRuntimeInstructions({
harness: flavor.runtimeHarness ?? driver,
model: turnInput.modelSelection.model,
}),
});
}
return { prompt, instructionState: text === messageText ? undefined : instructionState };
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export interface T3AcpInstructionState {
readonly hasT3Mcp: boolean;
}

/** Native slash commands must remain at the start of the prompt. */
export function isSlashCommandPrompt(prompt: string): boolean {
return prompt.trimStart().startsWith("/");
}

/**
* ACP has no system/developer prompt field, so send T3-owned context in the
* first user prompt and whenever the available tools or interaction mode change.
Expand All @@ -74,8 +79,7 @@ export function t3AcpPromptWithInstructions(input: {
readonly state: T3AcpInstructionState;
readonly previousState?: T3AcpInstructionState;
}): string {
// Native slash commands must remain at the start of the prompt.
if (input.prompt.trimStart().startsWith("/")) return input.prompt;
if (isSlashCommandPrompt(input.prompt)) return input.prompt;
if (
input.previousState?.interactionMode === input.state.interactionMode &&
input.previousState.hasT3Mcp === input.state.hasT3Mcp
Expand Down
Loading