diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 7652b5636926..840e73e216a8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -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 = []; + 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( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 80eb47436534..cba665c17ff8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -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 diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts index acb7809c3a0d..eb11a661679e 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.test.ts @@ -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", }); @@ -29,7 +29,7 @@ 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", }); @@ -37,7 +37,7 @@ describe("planClaudeSkillDispatch", () => { 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", }); @@ -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", }); @@ -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(); }); diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 2dc106e85382..660692338b2b 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -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 */ @@ -33,7 +35,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 { - /** 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; @@ -41,7 +43,7 @@ export interface ClaudeSkillDispatch { } /** - * 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. @@ -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, };