Skip to content
Merged
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
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"],
});
});
Comment thread
tatoalo marked this conversation as resolved.

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
9 changes: 9 additions & 0 deletions packages/ui/src/features/task-detail/taskCreationHostImpl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { injectable } from "inversify";
import { track } from "../../shell/analytics";
import { getAuthenticatedClient } from "../auth/authClientImperative";
import { assertCloudUsageAvailable } from "../billing/preflightCloudUsage";
import { resolveLocalSkillPrompt } from "../message-editor/commands";
import { DEFAULT_PANEL_IDS } from "../panels/panelConstants";
import { usePanelLayoutStore } from "../panels/panelLayoutStore";
import { useProvisioningStore } from "../provisioning/store";
Expand Down Expand Up @@ -146,6 +147,14 @@ export class TrpcTaskCreationHost implements ITaskCreationHost {
return getCloudPromptTransport(prompt, filePaths);
}

async resolveLocalSkillCommandPrompt(prompt: string): Promise<string> {
return (
(await resolveLocalSkillPrompt(prompt, () =>
hostClient().skills.list.query(),
)) ?? prompt
);
}

uploadRunAttachments(
client: TaskCreationApiClient,
taskId: string,
Expand Down
Loading