Skip to content
Closed
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
7 changes: 6 additions & 1 deletion apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,11 @@ import {
GITHUB_CONNECT_CLIENT,
type GithubConnectClient,
} from "@posthog/core/onboarding/identifiers";
import { CLOUD_ARTIFACT_READ_FILE_AS_BASE64 } from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
type BundleLocalSkill,
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
} from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
LOCAL_HANDOFF_DIALOG,
LOCAL_HANDOFF_HOST,
Expand Down Expand Up @@ -262,6 +266,7 @@ export interface RendererBindings {
[CODE_REVIEW_WORKSPACE_CLIENT]: CodeReviewWorkspaceClient;
[REVERT_HUNK_SERVICE]: RevertHunkService;
[SKILLS_WORKSPACE_CLIENT]: SkillsWorkspaceClient;
[CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL]: BundleLocalSkill;
[CLOUD_ARTIFACT_READ_FILE_AS_BASE64]: ReadFileAsBase64;
[LLM_GATEWAY_SERVICE]: LlmGatewayService;
[TITLE_GENERATOR_FILE_READ_CLIENT]: FileReadClient;
Expand Down
10 changes: 9 additions & 1 deletion apps/code/src/renderer/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import {
import { LLM_GATEWAY_SERVICE } from "@posthog/core/llm-gateway/identifiers";
import type { LlmGatewayService } from "@posthog/core/llm-gateway/llm-gateway";
import type { LlmMessage } from "@posthog/core/llm-gateway/schemas";
import { CLOUD_ARTIFACT_READ_FILE_AS_BASE64 } from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
} from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
LOCAL_HANDOFF_DIALOG,
LOCAL_HANDOFF_HOST,
Expand Down Expand Up @@ -365,6 +368,11 @@ container
.toConstantValue((filePath: string) =>
trpcClient.fs.readFileAsBase64.query({ filePath }),
);
container
.bind(CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL)
.toConstantValue((skillBundleRef) =>
hostTrpcClient.skills.bundleLocal.query(skillBundleRef),
);
container.bind(LLM_GATEWAY_SERVICE).toConstantValue({
prompt: (
messages: LlmMessage[],
Expand Down
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
23 changes: 23 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]);
}

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,24 @@ 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;
}

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(
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 @@ -225,6 +228,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 @@ -944,6 +953,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