Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 3 additions & 0 deletions apps/code/src/renderer/di/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ import {
type BundleLocalSkill,
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES,
type ResolveSkillBundleDependencies,
} from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
LOCAL_HANDOFF_DIALOG,
Expand Down Expand Up @@ -272,6 +274,7 @@ export interface RendererBindings {
[REVERT_HUNK_SERVICE]: RevertHunkService;
[SKILLS_WORKSPACE_CLIENT]: SkillsWorkspaceClient;
[CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL]: BundleLocalSkill;
[CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES]: ResolveSkillBundleDependencies;
[CLOUD_ARTIFACT_READ_FILE_AS_BASE64]: ReadFileAsBase64;
[LLM_GATEWAY_SERVICE]: LlmGatewayService;
[TITLE_GENERATOR_FILE_READ_CLIENT]: FileReadClient;
Expand Down
6 changes: 6 additions & 0 deletions apps/code/src/renderer/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import type { LlmMessage } from "@posthog/core/llm-gateway/schemas";
import {
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES,
} from "@posthog/core/sessions/cloudArtifactIdentifiers";
import {
LOCAL_HANDOFF_DIALOG,
Expand Down Expand Up @@ -393,6 +394,11 @@ container
.toConstantValue((skillBundleRef) =>
hostTrpcClient.skills.bundleLocal.query(skillBundleRef),
);
container
.bind(CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES)
.toConstantValue((skillBundleRefs) =>
hostTrpcClient.skills.resolveDependencies.query(skillBundleRefs),
);
container.bind(LLM_GATEWAY_SERVICE).toConstantValue({
prompt: (
messages: LlmMessage[],
Expand Down
13 changes: 1 addition & 12 deletions packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { PromptRequest } from "@agentclientprotocol/sdk";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
import { isClaudeImageMimeType, isImageFile } from "@posthog/shared";
import { isLocalSkillCommandChunk } from "../../local-skill";

const PDF_EXTENSIONS = new Set(["pdf"]);

Expand Down Expand Up @@ -80,18 +81,6 @@ 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
73 changes: 73 additions & 0 deletions packages/agent/src/adapters/codex/codex-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,79 @@ describe("CodexAcpAgent", () => {
});
});

it("applies a local-skill invocation: drops the /command chunk, injects the skill context, strips the meta", async () => {
const { agent, client } = createAgent();
mockCodexConnection.newSession.mockResolvedValue({
sessionId: "session-1",
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<NewSessionResponse>);
await agent.newSession({
cwd: process.cwd(),
} as never);

mockCodexConnection.prompt.mockResolvedValue({ stopReason: "end_turn" });

await agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "/depparent run the readiness check" }],
_meta: {
localSkillContext: "SKILL INSTRUCTIONS: run the readiness check",
localSkillName: "depparent",
},
} as never);

// codex-acp must receive the resolved skill instructions as plain text —
// NOT the bare `/depparent` slash command it would reject — and the
// local-skill meta must not be forwarded.
expect(mockCodexConnection.prompt).toHaveBeenCalledTimes(1);
const forwarded = mockCodexConnection.prompt.mock.calls[0][0];
expect(forwarded.prompt).toEqual([
{ type: "text", text: "SKILL INSTRUCTIONS: run the readiness check" },
]);
expect(forwarded._meta?.localSkillContext).toBeUndefined();
expect(forwarded._meta?.localSkillName).toBeUndefined();
// The broadcast still shows the real user turn (the typed command).
expect(client.sessionUpdate).toHaveBeenCalledWith({
sessionId: "session-1",
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text: "/depparent run the readiness check" },
},
});
});
Comment thread
tatoalo marked this conversation as resolved.

it.each([
[
"localSkillContext is set but localSkillName is missing",
{ localSkillContext: "SKILL INSTRUCTIONS" },
],
["there is no localSkillContext", {}],
])("forwards the prompt unchanged when %s", async (_label, meta) => {
const { agent } = createAgent();
mockCodexConnection.newSession.mockResolvedValue({
sessionId: "session-1",
modes: { currentModeId: "auto", availableModes: [] },
configOptions: [],
} satisfies Partial<NewSessionResponse>);
await agent.newSession({ cwd: process.cwd() } as never);
mockCodexConnection.prompt.mockResolvedValue({ stopReason: "end_turn" });

await agent.prompt({
sessionId: "session-1",
prompt: [{ type: "text", text: "/depparent run the readiness check" }],
_meta: meta,
} as never);

// Without both fields we never inject context nor drop the chunk — the
// prompt must reach codex-acp exactly as given (no context + stray-command
// mix), so the original chunk is preserved verbatim.
const forwarded = mockCodexConnection.prompt.mock.calls[0][0];
expect(forwarded.prompt).toEqual([
{ type: "text", text: "/depparent run the readiness check" },
]);
});

it("serializes concurrent prompts so usage accumulators are not wiped mid-turn", async () => {
const { agent } = createAgent();
mockCodexConnection.newSession.mockResolvedValue({
Expand Down
51 changes: 50 additions & 1 deletion packages/agent/src/adapters/codex/codex-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
estimateTokens,
} from "../claude/context-breakdown";
import { classifyAgentError } from "../error-classification";
import { isLocalSkillCommandChunk } from "../local-skill";
import {
enabledLocalTools,
LOCAL_TOOLS_MCP_NAME,
Expand Down Expand Up @@ -168,6 +169,52 @@ function prependPrContext(params: PromptRequest): PromptRequest {
};
}

/**
* Apply an installed local-skill invocation for codex. Claude consumes
* `_meta.localSkillContext` in `promptToClaude`; codex has no such seam, so
* without this the resolved skill instructions are dropped and the bare
* `/<skill>` slash command reaches codex-acp, which rejects it as an unknown
* command ("Internal error"). Mirror Claude: drop the leading `/<skill>` chunk
* (the context already carries the user's args), prepend the skill
* instructions as text, and strip the local-skill `_meta` before forwarding.
*/
function prependLocalSkillContext(params: PromptRequest): PromptRequest {
const meta = params._meta as Record<string, unknown> | undefined;
const localSkillContext = meta?.localSkillContext;
if (typeof localSkillContext !== "string" || localSkillContext.length === 0) {
return params;
}
const localSkillName =
typeof meta?.localSkillName === "string" ? meta.localSkillName : null;
// The agent-server always sets `localSkillContext` and `localSkillName`
// together. Without the name we can't identify the `/skill` chunk to drop, so
// injecting the context while leaving the bare command in place would forward
// exactly what codex-acp rejects — bail out rather than emit that broken mix.
if (!localSkillName) {
return params;
}

let skipped = false;
const rest = params.prompt.filter((chunk) => {
if (!skipped && isLocalSkillCommandChunk(chunk, localSkillName)) {
skipped = true;
return false;
}
return true;
});
Comment thread
tatoalo marked this conversation as resolved.

const {
localSkillContext: _ctx,
localSkillName: _name,
...restMeta
} = meta ?? {};
return {
...params,
prompt: [{ type: "text", text: localSkillContext }, ...rest],
_meta: restMeta,
};
}

function classifyPromptError(error: unknown): unknown {
const message = error instanceof Error ? error.message : String(error ?? "");
const classification = classifyAgentError(message);
Expand Down Expand Up @@ -762,7 +809,9 @@ export class CodexAcpAgent extends BaseAcpAgent {
this.session.promptRunning = true;
let response: PromptResponse;
try {
response = await this.codexConnection.prompt(prependPrContext(params));
response = await this.codexConnection.prompt(
prependPrContext(prependLocalSkillContext(params)),
);
} catch (error) {
throw classifyPromptError(error);
} finally {
Expand Down
13 changes: 13 additions & 0 deletions packages/agent/src/adapters/local-skill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { PromptRequest } from "@agentclientprotocol/sdk";

/** True when a prompt chunk is a bare `/<skillName> [args]` invocation. */
export 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;
}
11 changes: 11 additions & 0 deletions packages/core/src/sessions/cloudArtifactIdentifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ export type BundleLocalSkill = (
skillBundleRef: CloudSkillBundleRef,
) => Promise<LocalSkillBundle>;

/**
* Expand tagged skill refs to include their transitively-declared dependency
* skills so a cloud run gets every skill it needs, not just the tagged one.
*/
export type ResolveSkillBundleDependencies = (
skillBundleRefs: CloudSkillBundleRef[],
) => Promise<CloudSkillBundleRef[]>;

export const CLOUD_ARTIFACT_SERVICE = Symbol.for(
"posthog.core.sessions.cloudArtifactService",
);
Expand All @@ -76,3 +84,6 @@ export const CLOUD_ARTIFACT_READ_FILE_AS_BASE64 = Symbol.for(
export const CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL = Symbol.for(
"posthog.core.sessions.cloudArtifactBundleLocalSkill",
);
export const CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES = Symbol.for(
"posthog.core.sessions.cloudArtifactResolveSkillDependencies",
);
67 changes: 65 additions & 2 deletions packages/core/src/sessions/cloudArtifactService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type {
BundleLocalSkill,
CloudArtifactClient,
ResolveSkillBundleDependencies,
} from "./cloudArtifactIdentifiers";
import {
CLOUD_ATTACHMENT_MAX_SIZE_BYTES,
Expand All @@ -18,6 +19,8 @@ function makeClient(): CloudArtifactClient {
};
}

const passthroughDeps: ResolveSkillBundleDependencies = async (refs) => refs;

const bundleLocalSkill: BundleLocalSkill = vi.fn(async (skillBundleRef) => {
const contentBase64 = btoa("skill-bundle");
return {
Expand All @@ -34,7 +37,11 @@ const bundleLocalSkill: BundleLocalSkill = vi.fn(async (skillBundleRef) => {

describe("CloudArtifactService", () => {
it("returns empty ids when no file paths are provided", async () => {
const service = new CloudArtifactService(vi.fn(), bundleLocalSkill);
const service = new CloudArtifactService(
vi.fn(),
bundleLocalSkill,
passthroughDeps,
);
expect(
await service.uploadRunAttachments(makeClient(), "t", "r", []),
).toEqual([]);
Expand All @@ -46,6 +53,7 @@ describe("CloudArtifactService", () => {
const service = new CloudArtifactService(
vi.fn().mockResolvedValue(base64),
bundleLocalSkill,
passthroughDeps,
);

await expect(
Expand All @@ -61,6 +69,7 @@ describe("CloudArtifactService", () => {
const service = new CloudArtifactService(
vi.fn().mockResolvedValue(base64),
bundleLocalSkill,
passthroughDeps,
);

await expect(
Expand All @@ -76,6 +85,7 @@ describe("CloudArtifactService", () => {
const service = new CloudArtifactService(
vi.fn().mockResolvedValue(null),
bundleLocalSkill,
passthroughDeps,
);

await expect(
Expand All @@ -93,6 +103,7 @@ describe("CloudArtifactService", () => {
const service = new CloudArtifactService(
vi.fn().mockResolvedValue(base64),
bundleLocalSkill,
passthroughDeps,
);

const client = makeClient();
Expand Down Expand Up @@ -127,7 +138,11 @@ describe("CloudArtifactService", () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: true } as Response);
const service = new CloudArtifactService(vi.fn(), bundleLocalSkill);
const service = new CloudArtifactService(
vi.fn(),
bundleLocalSkill,
passthroughDeps,
);
const client = makeClient();

(
Expand Down Expand Up @@ -173,4 +188,52 @@ describe("CloudArtifactService", () => {
);
fetchMock.mockRestore();
});

it("uploads dependency skills the resolver adds to a tagged skill", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue({ ok: true } as Response);
const resolveDeps: ResolveSkillBundleDependencies = vi.fn(async (refs) => [
...refs,
{ name: "dep-skill", source: "user", path: "/tmp/dep-skill" },
]);
const service = new CloudArtifactService(
vi.fn(),
bundleLocalSkill,
resolveDeps,
);
const client = makeClient();

(
client.prepareTaskRunArtifactUploads as ReturnType<typeof vi.fn>
).mockImplementation((_taskId, _runId, uploads: unknown[]) =>
uploads.map((_upload, index) => ({
id: `prep-${index}`,
name: `skill-${index}.zip`,
type: "skill_bundle",
size: 12,
presigned_post: { url: "https://s3/upload", fields: { key: "k" } },
})),
);
(
client.finalizeTaskRunArtifactUploads as ReturnType<typeof vi.fn>
).mockResolvedValue([{ id: "primary-1" }, { id: "dep-1" }]);

const ids = await service.uploadRunAttachments(
client,
"task-1",
"run-1",
[],
[{ name: "primary-skill", source: "user", path: "/tmp/primary-skill" }],
);

expect(resolveDeps).toHaveBeenCalledWith([
{ name: "primary-skill", source: "user", path: "/tmp/primary-skill" },
]);
expect(bundleLocalSkill).toHaveBeenCalledWith(
expect.objectContaining({ name: "dep-skill" }),
);
expect(ids).toEqual(["primary-1", "dep-1"]);
fetchMock.mockRestore();
});
});
Loading
Loading