Skip to content
Merged
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
84 changes: 84 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import * as EffectAcpErrors from "effect-acp/errors";
import type * as EffectAcpProtocol from "effect-acp/protocol";
import type * as EffectAcpSchema from "effect-acp/compat";

import { writeFakeCli } from "@t3tools/provider-testing/fakeCli";
import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost";
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
Expand Down Expand Up @@ -3169,6 +3170,89 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

// Oracle: #13457. Grok expands only a leading `/name`, so a picked leading `$name`
// must reach it in that form; anything else keeps the user's text.
it.live.each([
{ text: "$deploy ship it", sent: "/deploy ship it", native: true },
{ text: "ship $deploy", sent: "ship $deploy", native: false },
{ text: "$other ship it", sent: "$other ship it", native: false },
])("Grok sends $text with a picked skill as $sent", ({ text, sent, native }) =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const mockAgentPath = yield* path.fromFileUrl(
new URL("../../../scripts/acp-mock-agent.ts", import.meta.url),
);
const hostPlatform = yield* HostProcess.Platform;
// Stands in for `grok inspect --json`, the catalog behind the `$` picker.
const catalog = { skills: [{ name: "deploy", source: { path: "/s/deploy/SKILL.md" } }] };
const grokPath = writeFakeCli({
directory: yield* fileSystem.makeTempDirectoryScoped(),
name: "grok",
source: `process.stdout.write(${JSON.stringify(`${JSON.stringify(catalog)}\n`)});\n`,
platform: hostPlatform,
});
const protocolEvents = yield* Queue.unbounded<EffectAcpProtocol.AcpProtocolLogEvent>();
const instanceId = ProviderInstanceId.make("grok-skill-mention");
const adapter = yield* makeGrokAdapterV2({
instanceId,
settings: { ...DEFAULT_GROK_SETTINGS, binaryPath: grokPath },
// The fake CLI needs `node` on its PATH.
environment: { ...process.env },
hostPlatform,
selfInvocation: yield* resolveSelfInvocation(),
makeRuntime: (input) =>
makeMockRuntime({ childProcessSpawner, mockAgentPath, protocolEvents })(input).pipe(
Effect.flatMap(makeXAiPromptCompletionRuntime),
),
});
const threadId = ThreadId.make("grok-skill-mention");
const modelSelection = { instanceId, model: "default" } as const;
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "full-access",
interactionMode: "default",
cwd: process.cwd(),
});
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("grok-skill-mention"),
modelSelection,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection,
runtimePolicy,
});
yield* runtime.startTurn(
makeTurnInput({
threadId,
providerThread,
instanceId,
runtimePolicy,
now: yield* DateTime.now,
messageText: text,
}),
);
const prompt = yield* Stream.fromQueue(protocolEvents).pipe(
Stream.filter(
(event) =>
event.direction === "outgoing" && rawProtocolMethod(event) === "session/prompt",
),
Stream.runHead,
);
const blocks = rawProtocolPromptTextBlocks(Option.getOrThrow(prompt));
if (native) {
// Same shape as a typed native command: one bare text block, no T3 instructions.
assert.deepEqual(blocks, [{ type: "text", text: sent }]);
} else {
assert.isTrue(blocks.some((block) => block.text.includes(sent)));
assert.isFalse(blocks.some((block) => block.text.trimStart().startsWith("/")));
}
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

// Oracle: #15888 KillTask reports. Command completion must release the turn even
// when the CLI keeps the prompt RPC and process alive. Kill calls stay visible.
it.live.each([
Expand Down
23 changes: 21 additions & 2 deletions packages/provider-acp/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,14 @@ class AcpModelSelectionError extends Schema.TaggedError<AcpModelSelectionError>(

export interface AcpAdapterV2Flavor {
readonly promptFailure?: (cause: unknown) => OrchestrationV2ProviderFailure;
/**
* Runs before attachment paths, T3 instructions and native slash-command detection,
* so a rewrite can turn the user's text into a native command.
*/
readonly rewriteUserText?: (
text: string,
turnInput: ProviderAdapter.ProviderAdapterV2TurnInput,
) => Effect.Effect<string, never, ChildProcessSpawner.ChildProcessSpawner>;
readonly driver: ProviderDriverKind;
readonly capabilities: OrchestrationV2ProviderCapabilities;
readonly clientCapabilitiesMeta?: NonNullable<
Expand Down Expand Up @@ -7162,12 +7170,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
} satisfies T3AcpInstructionState;
const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId);
const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId);
const userText =
flavor.rewriteUserText === undefined
? turnInput.message.text
: yield* flavor
.rewriteUserText(turnInput.message.text, turnInput)
.pipe(
Effect.provideService(
ChildProcessSpawner.ChildProcessSpawner,
childProcessSpawner,
),
);
const messageText = providerMessageTextWithAttachmentPaths({
text: turnInput.message.text,
text: userText,
attachments: turnInput.message.attachments,
resolveAttachmentPath: host.resolveAttachmentPath,
});
const isNativeSlashCommand = turnInput.message.text.trimStart().startsWith("/");
const isNativeSlashCommand = userText.trimStart().startsWith("/");
const text = t3AcpPromptWithInstructions({
prompt: messageText,
state: instructionState,
Expand Down
19 changes: 19 additions & 0 deletions packages/provider-grok/src/server/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isXAiTaskCompletedWakeNotification,
xAiRateLimitedErrorCode,
} from "./xaiAcpExtension.ts";
import { discoverGrokSkills, leadingGrokSkillMention } from "./skills.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { resolveSelfInvocation, type SelfInvocation } from "@t3tools/shared/nodeRuntime";
import {
Expand Down Expand Up @@ -251,6 +252,24 @@ export function makeGrokAcpAdapterFlavor(
// still accepts image content blocks (verified with real screenshots).
supportsImagePrompts: true,
supportsCompaction: true,
// Grok expands only a leading `/name`, while the `$` picker inserts `$name` from the
// `grok inspect` catalog. Ask that catalog again so other `$` text is left alone.
rewriteUserText: (text, turnInput) => {
const mention = leadingGrokSkillMention(text);
if (mention === undefined) return Effect.succeed(text);
return discoverGrokSkills(
options.settings,
options.environment,
turnInput.runtimePolicy.cwd ?? undefined,
).pipe(
Effect.map((skills) =>
skills.some((skill) => skill.enabled && skill.name === mention.name)
? `/${mention.name}${mention.rest}`
: text,
),
Effect.orElseSucceed(() => text),
);
},
resolveModelId: (selection) => resolveGrokAcpBaseModelId(selection.model),
getModelContextWindow: (selection, session) => {
if (selection.instanceId !== options.instanceId) return undefined;
Expand Down
15 changes: 13 additions & 2 deletions packages/provider-grok/src/server/skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { describe, expect, it } from "@effect/vitest";
import { assert, describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
import type * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";

import { discoverGrokSkills } from "./skills.ts";
import { discoverGrokSkills, leadingGrokSkillMention } from "./skills.ts";

const inspectPayload = (skills: ReadonlyArray<unknown>) => JSON.stringify({ skills });

Expand Down Expand Up @@ -199,3 +199,14 @@ describe("discoverGrokSkills", () => {
});
});
});

describe("leadingGrokSkillMention", () => {
it.each([
{ text: " €deploy\nship it", mention: { name: "deploy", rest: "\nship it" } },
{ text: "$deploy", mention: { name: "deploy", rest: "" } },
{ text: "$25 off", mention: undefined },
{ text: "$deploy, then ship", mention: undefined },
])("reads $text", ({ text, mention }) => {
assert.deepEqual(leadingGrokSkillMention(text), mention);
});
});
12 changes: 12 additions & 0 deletions packages/provider-grok/src/server/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,24 @@ import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as ChildProcess from "effect/process/ChildProcess";
import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens";
import { resolveSpawnCommand } from "@t3tools/shared/shell";

import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe";
import { grokProbeEnvironment } from "./probeEnvironment.ts";

const GROK_SKILLS_PROBE_TIMEOUT_MS = 4_000;
// The composer's chip rule, anchored to the message start, so `$25` never names a skill.
const LEADING_SKILL_MENTION = new RegExp(`^\\s*${SKILL_MENTION_PATTERN.source}`, "u");

/** A message opening with a mention, such as `deploy` and ` ship it` for `$deploy ship it`. */
export function leadingGrokSkillMention(
text: string,
): { readonly name: string; readonly rest: string } | undefined {
const match = LEADING_SKILL_MENTION.exec(text);
if (match?.[2] === undefined) return undefined;
return { name: match[2], rest: text.slice(match[0].length) };
}

class GrokSkillsProbeError extends Schema.TaggedError<GrokSkillsProbeError>()(
"GrokSkillsProbeError",
Expand Down
Loading