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
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"@opentelemetry/semantic-conventions": "^1.28.0",
"@types/jsonwebtoken": "^9.0.10",
"commander": "^14.0.2",
"fflate": "^0.8.2",
"hono": "^4.11.7",
"jsonwebtoken": "^9.0.2",
"minimatch": "^10.0.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,22 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
expectsUnsupportedChunk: false,
commandInMessage: null,
},
{
label:
"newly installed skill command is refreshed before unsupported check",
sessionId: "s-new-skill",
prompt: "/local-test-skill",
knownCommands: undefined,
supportedCommandsAfterReload: [
{
name: "local-test-skill",
description: "Local test skill",
argumentHint: "",
},
],
expectsUnsupportedChunk: false,
commandInMessage: null,
},
{
label:
"known plugin/skill command with early idle is not flagged as unsupported",
Expand All @@ -137,6 +153,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
tc.sessionId,
tc.knownCommands as Set<string> | undefined,
);
if ("supportedCommandsAfterReload" in tc) {
vi.mocked(query.supportedCommands).mockResolvedValue([
...tc.supportedCommandsAfterReload,
]);
}

const promptPromise = agent.prompt({
sessionId: tc.sessionId,
Expand Down Expand Up @@ -171,6 +192,10 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => {
expect(
findUnsupportedChunkText(client.sessionUpdate.mock.calls),
).toBeUndefined();
if ("supportedCommandsAfterReload" in tc) {
expect(query.reloadSkills).toHaveBeenCalled();
expect(query.supportedCommands).toHaveBeenCalled();
}
}
});
});
Expand Down
26 changes: 26 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
promptReplayed = true;
}

if (commandMatch && !isLocalOnlyCommand) {
await this.refreshSlashCommandsForPrompt(commandMatch[1]);
}
Comment thread
tatoalo marked this conversation as resolved.

if (this.session.promptRunning) {
const isSteer = isSteerMeta(params._meta);
if (isSteer) {
Expand Down Expand Up @@ -2164,6 +2168,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {

private async sendAvailableCommandsUpdate(): Promise<void> {
const commands = await this.session.query.supportedCommands();
this.session.knownSlashCommands = collectKnownSlashCommands(commands);
const available = getAvailableSlashCommands(commands);
await this.client.sessionUpdate({
sessionId: this.sessionId,
Expand All @@ -2175,6 +2180,27 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
this.updateBreakdownCategory("skills", estimateSkillsTokens(available));
}

private async refreshSlashCommandsForPrompt(command: string): Promise<void> {
const commandName = command.slice(1);
if (this.session.knownSlashCommands?.has(commandName)) {
return;
}
if (commandName.includes(":") || commandName.includes("__")) {
return;
}

Comment thread
tatoalo marked this conversation as resolved.
try {
await this.session.query.reloadSkills();
await this.sendAvailableCommandsUpdate();
} catch (error) {
this.logger.warn("Failed to refresh slash commands before prompt", {
sessionId: this.sessionId,
command,
error: error instanceof Error ? error.message : String(error),
});
}
}

/** Update one category of the context-breakdown baseline so the next
* `_posthog/usage_update` carries fresher numbers. No-op when the baseline
* hasn't been initialized yet (e.g. in a unit-test session). */
Expand Down
18 changes: 18 additions & 0 deletions packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ describe("promptToClaude", () => {
expect(result.priority).toBe("next");
});

it("adds local skill context before the visible slash command", () => {
const result = promptToClaude({
sessionId: "session-1",
prompt: [{ type: "text", text: "/mimi" }],
_meta: {
localSkillContext:
"Apply the local skill instructions and include LOCAL_SKILL_MARKER.",
localSkillName: "mimi",
},
});

expect(result.message.content).toHaveLength(1);
expect(result.message.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("LOCAL_SKILL_MARKER"),
});
});

it("leaves priority and shouldQuery unset for a normal message", () => {
const result = promptToClaude({
sessionId: "session-1",
Expand Down
28 changes: 28 additions & 0 deletions packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,18 @@ function transformMcpCommand(text: string): string {
return text;
}

function isLocalSkillCommandChunk(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels kinda risky to me what if a the string is "hello please run /skill_name - thank you" could the tokens split the skill name?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunk is an ACP prompt content block, not model tokens, and the matcher is anchored to a slash command at the start of the trimmed block so that ^ example would not fly

It also can't reach this as a "skill" at all since localSkillName is only set when the leading text block parses as / and exactly equals an installed skill_bundle artifact's skill_name. The name is captured and compared by full equality so ther;s no tokenization, no prefix matches, and the skill name never feeds the regex

chunk: PromptRequest["prompt"][number],
skillName: string,
): boolean {
if (chunk.type !== "text") {
return false;
}

const match = chunk.text.trim().match(/^\/([^\s]+)(?:\s+[\s\S]*)?$/);
return match?.[1] === skillName;
}

function processPromptChunk(
chunk: PromptRequest["prompt"][number],
content: ContentBlockParam[],
Expand Down Expand Up @@ -168,8 +180,24 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
if (typeof prContext === "string") {
content.push(sdkText(prContext));
}
const localSkillContext = meta?.localSkillContext;
if (typeof localSkillContext === "string") {
content.push(sdkText(localSkillContext));
}
const localSkillName =
typeof meta?.localSkillName === "string" ? meta.localSkillName : null;
let skippedLocalSkillCommand = false;

for (const chunk of prompt.prompt) {
if (
localSkillContext &&
localSkillName &&
!skippedLocalSkillCommand &&
isLocalSkillCommandChunk(chunk, localSkillName)
) {
skippedLocalSkillCommand = true;
continue;
}
processPromptChunk(chunk, content, context);
}

Expand Down
103 changes: 103 additions & 0 deletions packages/agent/src/server/agent-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createHash } from "node:crypto";
import type { ContentBlock } from "@agentclientprotocol/sdk";
import { zipSync } from "fflate";
import jwt from "jsonwebtoken";
import { type SetupServerApi, setupServer } from "msw/node";
import {
Expand Down Expand Up @@ -227,6 +230,12 @@ function getNextTestPort(): number {
return port;
}

function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy.buffer;
}

// The Claude Agent SDK has an internal readMessages() loop that rejects with
// "Query closed before response received" during cleanup. The SDK starts this
// promise in the constructor without a .catch() handler, so the rejection is
Expand Down Expand Up @@ -946,6 +955,100 @@ describe("AgentServer HTTP Mode", () => {
const body = await response.json();
expect(body.error).toBe("No active session for this run");
}, 20000);

it("rewrites a bundled local skill slash command before sending the prompt", async () => {
const skillDefinition = [
"---",
"name: local-test-skill",
"description: Test skill",
"---",
"",
"Reply with LOCAL_SKILL_MARKER from the bundled skill.",
].join("\n");
const bundle = zipSync({
"SKILL.md": new TextEncoder().encode(skillDefinition),
});
const checksum = createHash("sha256")
.update(Buffer.from(bundle))
.digest("hex");

const s = createServer();
await s.start();
const prompt = vi.fn(
async (_params: {
prompt: ContentBlock[];
_meta?: Record<string, unknown>;
}) => ({ stopReason: "cancelled" }) as { stopReason: string },
);
const downloadArtifact = vi.fn(async () => exactArrayBuffer(bundle));
const serverInternals = s as unknown as {
session: { clientConnection: { prompt: typeof prompt } };
posthogAPI: { downloadArtifact: typeof downloadArtifact };
};
serverInternals.session.clientConnection.prompt = prompt;
serverInternals.posthogAPI.downloadArtifact = downloadArtifact;

const token = createToken();
const response = await fetch(`http://localhost:${port}/command`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "skill-command",
method: "user_message",
params: {
content: "/local-test-skill with context",
artifacts: [
{
id: "skill-artifact-1",
name: "local-test-skill.zip",
type: "skill_bundle",
source: "posthog_code_skill",
storage_path: "tasks/artifacts/local-test-skill.zip",
content_type: "application/zip",
metadata: {
skill_name: "local-test-skill",
skill_source: "user",
content_sha256: checksum,
bundle_format: "zip",
schema_version: 1,
},
},
],
},
}),
});

expect(response.status).toBe(200);
const body = (await response.json()) as {
result?: { stopReason?: string };
};
expect(body.result?.stopReason).toBe("cancelled");
expect(downloadArtifact).toHaveBeenCalledWith(
"test-task-id",
"test-run-id",
"tasks/artifacts/local-test-skill.zip",
);
expect(prompt).toHaveBeenCalledOnce();

const sentPrompt = prompt.mock.calls[0]?.[0].prompt;
const sentMeta = prompt.mock.calls[0]?.[0]._meta;
const sentText = sentPrompt?.find(
(block): block is Extract<ContentBlock, { type: "text" }> =>
block.type === "text",
)?.text;

expect(sentText).toBe("/local-test-skill with context");
expect(sentMeta?.localSkillContext).toContain(
'local skill "/local-test-skill"',
);
expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER");
expect(sentMeta?.localSkillContext).toContain("with context");
expect(sentMeta?.localSkillName).toBe("local-test-skill");
}, 20000);
});

describe("404 handling", () => {
Expand Down
Loading
Loading