Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
190 changes: 190 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1748,6 +1748,196 @@ describe("ClaudeAdapterV2 attachments", () => {
);
});

describe("ClaudeAdapterV2 skill dispatch", () => {
const makeSkillDispatchHarness = Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const claudeHome = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-v2-skills-home-",
});
yield* fileSystem.makeDirectory(path.join(claudeHome, "skills", "implement"), {
recursive: true,
});
yield* fileSystem.writeFileString(
path.join(claudeHome, "skills", "implement", "SKILL.md"),
"---\ndescription: Implement the requested change\n---\nImplement it.\n",
);
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-v2-skills-attachments-",
});
const offeredMessages: Array<SDKUserMessage> = [];
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath: claudeHome },
environment: {},
attachmentsDir,
fileSystem,
path,
crypto: yield* Crypto.Crypto,
idAllocator: yield* IdAllocator.IdAllocatorV2,
queryRunner: {
allocateSessionId: Effect.succeed("native-thread-claude-skills"),
open: () =>
Effect.succeed({
messages: Stream.never,
offer: (message) =>
Effect.sync(() => {
offeredMessages.push(message);
}),
setModel: () => Effect.void,
applyFlagSettings: () => Effect.void,
setPermissionMode: () => Effect.void,
stopTask: () => Effect.void,
backgroundTasks: () => Effect.succeed(false),
interrupt: Effect.void,
close: Effect.void,
}),
forkSession: () => Effect.die("unused forkSession"),
subagentLaunchToolUseId: () => Effect.succeed(null),
assertComplete: Effect.void,
},
});
const threadId = ThreadId.make("thread-claude-skills");
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-claude-skills"),
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY,
});
return { attachmentsDir, offeredMessages, providerThread, runtime, threadId };
});

// Claude Code moves the expanded command ahead of earlier text blocks, so a
// leading block holding only the words before the chip reaches the model as
// a cut-off sentence (#13256).
it.effect("keeps the full request ahead of a mid-sentence skill command", () =>
Effect.scoped(
Effect.gen(function* () {
const { offeredMessages, providerThread, runtime, threadId } =
yield* makeSkillDispatchHarness;

yield* runtime.startTurn(
makeClaudeTestTurnInput({
threadId,
providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-claude-skills"),
text: "you will $implement fixes",
attachments: [],
}),
);

assert.deepEqual(offeredMessages[0]?.message.content, [
{ type: "text", text: "Ultrathink:\nyou will /implement fixes" },
{ type: "text", text: "/implement fixes" },
]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect("keeps the full request ahead of images and the skill command", () =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const { attachmentsDir, offeredMessages, providerThread, runtime, threadId } =
yield* makeSkillDispatchHarness;
const attachment = ChatImageAttachment.make({
type: "image",
id: ChatAttachmentId.make("thread-claude-skills-12345678-1234-1234-1234-123456789abc"),
name: "screen.png",
mimeType: "image/png",
sizeBytes: 4,
});
const attachmentPath = path.join(attachmentsDir, attachmentRelativePath(attachment)!);
yield* fileSystem.writeFile(attachmentPath, Uint8Array.from([1, 2, 3, 4]));

yield* runtime.startTurn(
makeClaudeTestTurnInput({
threadId,
providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("attempt-claude-skills-image"),
text: "please $implement this screenshot",
attachments: [attachment],
}),
);

const attachmentNote = `[Attached image "screen.png" is saved at: ${attachmentPath}]`;
assert.deepEqual(offeredMessages[0]?.message.content, [
{
type: "text",
text: `Ultrathink:\nplease /implement this screenshot\n\n${attachmentNote}`,
},
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AQIDBA==" } },
{ type: "text", text: `/implement this screenshot\n\n${attachmentNote}` },
]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);

it.effect("keeps the full request ahead of a skill command sent as steering", () =>
Effect.scoped(
Effect.gen(function* () {
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const { offeredMessages, providerThread, runtime, threadId } =
yield* makeSkillDispatchHarness;
const attemptId = RunAttemptId.make("attempt-claude-skills-steer");
yield* runtime.startTurn(
makeClaudeTestTurnInput({
threadId,
providerThread,
now: yield* DateTime.now,
attemptId,
text: "look at the failing tests",
attachments: [],
}),
);

yield* runtime.steerTurn({
threadId,
runId: RunId.make(`run-${attemptId}`),
providerThread,
providerTurnId: idAllocator.derive.providerTurn({
driver: ClaudeAdapterV2.CLAUDE_PROVIDER,
nativeTurnId: `turn:${attemptId}`,
}),
message: {
createdBy: "user",
creationSource: "web",
messageId: MessageId.make("message-claude-skills-steer"),
text: "then $implement the fixes",
attachments: [],
},
});

assert.equal(offeredMessages[1]?.priority, "now");
assert.deepEqual(offeredMessages[1]?.message.content, [
{ type: "text", text: "Ultrathink:\nthen /implement the fixes" },
{ type: "text", text: "/implement the fixes" },
]);
}).pipe(
Effect.provide(
Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
),
),
),
);
});

describe("ClaudeAdapterV2 native fork", () => {
it.effect("forks at the source assistant cursor and resumes the forked session", () =>
Effect.scoped(
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1546,9 +1546,10 @@ export function makeClaudeUserMessage(input: {
readonly uuid?: SDKUserMessage["uuid"];
}): SDKUserMessage {
// Claude Code expands a skill only from the LAST text block, and only when
// `/name` is its first character. A `$skill` chip anywhere in the prompt is
// therefore split into [leading text, "/name trailing text"] so the CLI runs
// it natively and the prose around it survives.
// `/name` is its first character. A `$skill` chip anywhere in the prompt
// therefore becomes [whole request, "/name trailing text"] so the CLI runs
// it natively and the model still reads the full request. See
// ClaudeSkillDispatch.
const dispatch =
input.skillNames === undefined
? undefined
Expand Down
27 changes: 22 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ describe("planClaudeSkillDispatch", () => {
expect(planClaudeSkillDispatch("echo $HOME then $unknown", SKILLS)).toBeUndefined();
});

it("moves a mid-prompt mention into a trailing slash command", () => {
it("keeps the whole request ahead of a mid-prompt slash command", () => {
expect(planClaudeSkillDispatch("ok, now $implement all the tickets", SKILLS)).toEqual({
leadingText: "ok, now",
leadingText: "ok, now /implement all the tickets",
commandText: "/implement all the tickets",
skillName: "implement",
});
Expand All @@ -28,15 +28,15 @@ describe("planClaudeSkillDispatch", () => {

it("dispatches a known skill whose name begins with a digit", () => {
expect(planClaudeSkillDispatch("use $2spec for this", SKILLS)).toEqual({
leadingText: "use",
leadingText: "use /2spec for this",
commandText: "/2spec for this",
skillName: "2spec",
});
});

it("dispatches the last mention and rewrites earlier ones inline", () => {
expect(planClaudeSkillDispatch("$review the diff, then $implement the fixes", SKILLS)).toEqual({
leadingText: "/review the diff, then",
leadingText: "/review the diff, then /implement the fixes",
commandText: "/implement the fixes",
skillName: "implement",
});
Expand All @@ -50,7 +50,7 @@ describe("planClaudeSkillDispatch", () => {
SKILLS,
),
).toEqual({
leadingText: "/review the diff, then",
leadingText: "/review the diff, then /implement the fixes",
commandText: "/implement the fixes",
skillName: "implement",
});
Expand All @@ -63,6 +63,23 @@ describe("planClaudeSkillDispatch", () => {
}
});

it.each([
["do not $implement fixes yet", "do not /implement fixes yet", "/implement fixes yet"],
["before $implement", "before /implement", "/implement"],
[
"please $implement the fixes\n\nThen report the result",
"please /implement the fixes\n\nThen report the result",
"/implement the fixes\n\nThen report the result",
],
["Ultrathink:\n$implement fixes", "Ultrathink:\n/implement fixes", "/implement fixes"],
])("keeps the request intact for %j", (prompt, leadingText, commandText) => {
expect(planClaudeSkillDispatch(prompt, SKILLS)).toEqual({
leadingText,
commandText,
skillName: "implement",
});
});

it("ignores a dollar token glued to other text", () => {
expect(planClaudeSkillDispatch("cost is 5$implement", SKILLS)).toBeUndefined();
});
Expand Down
10 changes: 5 additions & 5 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const SKILL_MENTION_PATTERN =
/(^|\s)\p{Sc}(?![0-9][0-9_]*(?:[kKmMbBtT]|[eE][0-9]+)?(?:\s|$))(?=[a-zA-Z0-9:_-]*[a-zA-Z])([a-zA-Z0-9][a-zA-Z0-9:_-]*)(?=\s|$)/gu;

export interface ClaudeSkillDispatch {
/** The whole prompt with mentions as `/name`, or `undefined` when the mention opens it. */
readonly leadingText: string | undefined;
/** `/name` plus the trailing text, ready to be the message's last text block. */
readonly commandText: string;
Expand All @@ -36,19 +37,18 @@ export function planClaudeSkillDispatch(
return undefined;
}

const leading = prompt.slice(0, last.start);
const trailing = prompt.slice(last.end);
const leadingWithInlineSlashes = mentions
.slice(0, -1)
const promptWithInlineSlashes = mentions
.reduceRight(
(text, mention) =>
`${text.slice(0, mention.start)}/${mention.name}${text.slice(mention.end)}`,
leading,
prompt,
)
.trimEnd();

return {
leadingText: leadingWithInlineSlashes.length > 0 ? leadingWithInlineSlashes : undefined,
leadingText:
prompt.slice(0, last.start).trim().length > 0 ? promptWithInlineSlashes : undefined,
commandText: `/${last.name}${trailing}`.trimEnd(),
skillName: last.name,
};
Expand Down
Loading