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
188 changes: 188 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1738,6 +1738,194 @@ 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,
setPermissionMode: () => Effect.void,
stopTask: () => Effect.void,
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 @@ -1441,9 +1441,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. See ClaudeSkillDispatch.
// `/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 @@ -11,9 +11,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 @@ -29,15 +29,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 @@ -51,7 +51,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 @@ -64,6 +64,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
19 changes: 10 additions & 9 deletions apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@
* its Skill tool when it reads `/name` in the prompt, so earlier mentions
* are rewritten to `/name` inline.
*
* So one mention anywhere in the prompt becomes a guaranteed invocation, and
* the user's text on either side is kept in order.
* Expansion puts the command ahead of the earlier text blocks, so a block with
* only the words before a mid-prompt mention reads as a cut-off sentence. The
* earlier block therefore carries the whole request, and the last block
* repeats the invocation so the skill still loads.
*
* @module provider/Drivers/ClaudeSkillDispatch
*/
Expand All @@ -33,15 +35,15 @@ 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 {
/** Text before the dispatched mention, or `undefined` when it opens the prompt. */
/** 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;
readonly skillName: string;
}

/**
* Split `prompt` around the last `$skill` mention that names a known skill.
* Plan the text blocks that invoke the last `$skill` mention naming a known skill.
* Returns `undefined` when there is nothing to dispatch, in which case the
* prompt should go out unchanged. Mentions that do not match a discovered
* skill stay literal: a `$HOME` in prose must not become a command.
Expand All @@ -61,19 +63,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