Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
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();
});
});
13 changes: 12 additions & 1 deletion packages/core/src/sessions/cloudArtifactService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
type BundleLocalSkill,
CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL,
CLOUD_ARTIFACT_READ_FILE_AS_BASE64,
CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES,
type CloudArtifactClient,
type CloudArtifactUploadRequest,
type CloudSkillBundleRef,
type FinalizedCloudArtifact,
type PreparedCloudArtifact,
type ResolveSkillBundleDependencies,
} from "./cloudArtifactIdentifiers";

const ATTACHMENT_SOURCE = "posthog_code";
Expand Down Expand Up @@ -123,6 +125,8 @@ export class CloudArtifactService {
private readonly readFileAsBase64: ReadFileAsBase64,
@inject(CLOUD_ARTIFACT_BUNDLE_LOCAL_SKILL)
private readonly bundleLocalSkill: BundleLocalSkill,
@inject(CLOUD_ARTIFACT_RESOLVE_SKILL_DEPENDENCIES)
private readonly resolveSkillBundleDependencies: ResolveSkillBundleDependencies,
) {}

async uploadTaskStagedAttachments(
Expand Down Expand Up @@ -225,8 +229,15 @@ export class CloudArtifactService {
private async loadCloudSkillBundles(
skillBundleRefs: CloudSkillBundleRef[],
): Promise<LoadedCloudAttachment[]> {
if (skillBundleRefs.length === 0) {
return [];
}
// Pull in dependency skills the tagged ones declare, so a skill that needs
// another arrives in the sandbox together with it.
const expandedRefs =
await this.resolveSkillBundleDependencies(skillBundleRefs);
return Promise.all(
skillBundleRefs.map(async (skillBundleRef) => {
expandedRefs.map(async (skillBundleRef) => {
const bundle = await this.bundleLocalSkill(skillBundleRef);
const bytes = base64ToUint8Array(bundle.contentBase64);
if (bytes.byteLength !== bundle.size) {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/task-detail/taskCreationHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ export interface ITaskCreationHost {
prompt: string | ContentBlock[],
filePaths?: string[],
): CloudPromptTransport;
/**
* Rewrite a leading local-skill slash command (e.g. `/my-skill args`) into a
* `<skill .../>` tag so its bundle is uploaded on the first cloud message.
* Returns the prompt unchanged when it isn't a local-skill invocation. The
* follow-up message path already does this; the initial-creation path must
* too, or a typed `/my-skill` reaches the sandbox with no bundle attached.
*/
resolveLocalSkillCommandPrompt(prompt: string): Promise<string>;
uploadRunAttachments(
client: TaskCreationApiClient,
taskId: string,
Expand Down
101 changes: 101 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const mockHost = vi.hoisted(() => ({
getEnvironment: vi.fn(),
detectRepo: vi.fn(),
getCloudPromptTransport: vi.fn(),
resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt),
uploadRunAttachments: vi.fn(),
setProvisioningActive: vi.fn(),
clearProvisioning: vi.fn(),
Expand Down Expand Up @@ -414,6 +415,106 @@ describe("TaskCreationSaga", () => {
);
});

it("resolves a typed local-skill slash command before building the cloud transport", async () => {
const createdTask = createTask();
const startedTask = createTask({ latest_run: createRun() });
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);

const skillTag =
'<skill name="my-skill" source="user" path="/skills/my-skill" /> do it';
mockHost.resolveLocalSkillCommandPrompt.mockResolvedValue(skillTag);
mockHost.getCloudPromptTransport.mockReturnValue({
filePaths: [],
skillBundles: [
{ name: "my-skill", source: "user", path: "/skills/my-skill" },
],
messageText: "/my-skill do it",
promptText: "/my-skill do it",
});
mockHost.uploadRunAttachments.mockResolvedValue(["skill-artifact-1"]);

const saga = makeSaga({
createTask: createTaskMock,
createTaskRun: createTaskRunMock,
startTaskRun: startTaskRunMock,
});

const result = await saga.run({
content: "/my-skill do it",
repository: "posthog/posthog",
workspaceMode: "cloud",
branch: "main",
});

expect(result.success).toBe(true);
expect(mockHost.resolveLocalSkillCommandPrompt).toHaveBeenCalledWith(
"/my-skill do it",
);
// The resolved tag (not the raw slash command) must reach the transport so
// the bundle is collected and uploaded on the first message.
expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith(
skillTag,
undefined,
);
expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", {
pendingUserMessage: "/my-skill do it",
pendingUserArtifactIds: ["skill-artifact-1"],
});
});

it.each([
["a plain-text prompt that isn't a slash command", "just do the thing"],
["a slash command that isn't a local skill", "/good keep going"],
])(
"passes %s through to the transport unchanged",
async (_label, content) => {
const createdTask = createTask();
const startedTask = createTask({ latest_run: createRun() });
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);

// The host resolver is a no-op for non-local-skill prompts — it returns the
// original string unchanged (mirrors `resolveLocalSkillPrompt` yielding null
// and the host falling back to the prompt).
mockHost.resolveLocalSkillCommandPrompt.mockImplementation(
async (prompt: string) => prompt,
);
mockHost.getCloudPromptTransport.mockReturnValue({
filePaths: [],
skillBundles: [],
messageText: content,
promptText: content,
});
mockHost.uploadRunAttachments.mockResolvedValue([]);

const saga = makeSaga({
createTask: vi.fn().mockResolvedValue(createdTask),
createTaskRun: createTaskRunMock,
startTaskRun: startTaskRunMock,
});

const result = await saga.run({
content,
repository: "posthog/posthog",
workspaceMode: "cloud",
branch: "main",
});

expect(result.success).toBe(true);
expect(mockHost.resolveLocalSkillCommandPrompt).toHaveBeenCalledWith(
content,
);
// Resolution is a no-op, so the original content (not a rewritten skill tag)
// reaches the transport and no bundle is collected.
expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith(
content,
undefined,
);
},
);

it("uses the selected user GitHub integration for cloud task creation", async () => {
const createdTask = createTask({
github_user_integration: "user-integration-123",
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,11 +281,21 @@ export class TaskCreationSaga extends Saga<
execute: async () => {
const prAuthorshipMode = input.cloudPrAuthorshipMode ?? "user";

// Resolve a typed local-skill slash command (`/my-skill …`) into a
// `<skill .../>` tag before building the transport, so the skill
// bundle is collected and uploaded with the very first cloud message.
// Without this a first-message `/my-skill` reaches the sandbox with no
// bundle and is rejected as an unknown command (only a follow-up,
// which already resolves, would work).
const resolvedContent = input.content
? await this.deps.host.resolveLocalSkillCommandPrompt(input.content)
: input.content;

const transport =
(input.content || input.filePaths?.length) &&
workspaceMode === "cloud"
? this.deps.host.getCloudPromptTransport(
input.content ?? "",
resolvedContent ?? "",
input.filePaths,
)
: null;
Expand Down
Loading
Loading