Skip to content

Commit 9dbf01e

Browse files
authored
fix(cloud-task): bundle a local skill tagged on the first message (#3056)
1 parent 116a80c commit 9dbf01e

4 files changed

Lines changed: 129 additions & 1 deletion

File tree

packages/core/src/task-detail/taskCreationHost.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ export interface ITaskCreationHost {
104104
prompt: string | ContentBlock[],
105105
filePaths?: string[],
106106
): CloudPromptTransport;
107+
/**
108+
* Rewrite a leading local-skill slash command (e.g. `/my-skill args`) into a
109+
* `<skill .../>` tag so its bundle is uploaded on the first cloud message.
110+
* Returns the prompt unchanged when it isn't a local-skill invocation. The
111+
* follow-up message path already does this; the initial-creation path must
112+
* too, or a typed `/my-skill` reaches the sandbox with no bundle attached.
113+
*/
114+
resolveLocalSkillCommandPrompt(prompt: string): Promise<string>;
107115
uploadRunAttachments(
108116
client: TaskCreationApiClient,
109117
taskId: string,

packages/core/src/task-detail/taskCreationSaga.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const mockHost = vi.hoisted(() => ({
2020
getEnvironment: vi.fn(),
2121
detectRepo: vi.fn(),
2222
getCloudPromptTransport: vi.fn(),
23+
resolveLocalSkillCommandPrompt: vi.fn(async (prompt: string) => prompt),
2324
uploadRunAttachments: vi.fn(),
2425
setProvisioningActive: vi.fn(),
2526
clearProvisioning: vi.fn(),
@@ -414,6 +415,106 @@ describe("TaskCreationSaga", () => {
414415
);
415416
});
416417

418+
it("resolves a typed local-skill slash command before building the cloud transport", async () => {
419+
const createdTask = createTask();
420+
const startedTask = createTask({ latest_run: createRun() });
421+
const createTaskMock = vi.fn().mockResolvedValue(createdTask);
422+
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
423+
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);
424+
425+
const skillTag =
426+
'<skill name="my-skill" source="user" path="/skills/my-skill" /> do it';
427+
mockHost.resolveLocalSkillCommandPrompt.mockResolvedValue(skillTag);
428+
mockHost.getCloudPromptTransport.mockReturnValue({
429+
filePaths: [],
430+
skillBundles: [
431+
{ name: "my-skill", source: "user", path: "/skills/my-skill" },
432+
],
433+
messageText: "/my-skill do it",
434+
promptText: "/my-skill do it",
435+
});
436+
mockHost.uploadRunAttachments.mockResolvedValue(["skill-artifact-1"]);
437+
438+
const saga = makeSaga({
439+
createTask: createTaskMock,
440+
createTaskRun: createTaskRunMock,
441+
startTaskRun: startTaskRunMock,
442+
});
443+
444+
const result = await saga.run({
445+
content: "/my-skill do it",
446+
repository: "posthog/posthog",
447+
workspaceMode: "cloud",
448+
branch: "main",
449+
});
450+
451+
expect(result.success).toBe(true);
452+
expect(mockHost.resolveLocalSkillCommandPrompt).toHaveBeenCalledWith(
453+
"/my-skill do it",
454+
);
455+
// The resolved tag (not the raw slash command) must reach the transport so
456+
// the bundle is collected and uploaded on the first message.
457+
expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith(
458+
skillTag,
459+
undefined,
460+
);
461+
expect(startTaskRunMock).toHaveBeenCalledWith("task-123", "run-123", {
462+
pendingUserMessage: "/my-skill do it",
463+
pendingUserArtifactIds: ["skill-artifact-1"],
464+
});
465+
});
466+
467+
it.each([
468+
["a plain-text prompt that isn't a slash command", "just do the thing"],
469+
["a slash command that isn't a local skill", "/good keep going"],
470+
])(
471+
"passes %s through to the transport unchanged",
472+
async (_label, content) => {
473+
const createdTask = createTask();
474+
const startedTask = createTask({ latest_run: createRun() });
475+
const createTaskRunMock = vi.fn().mockResolvedValue(createRun());
476+
const startTaskRunMock = vi.fn().mockResolvedValue(startedTask);
477+
478+
// The host resolver is a no-op for non-local-skill prompts — it returns the
479+
// original string unchanged (mirrors `resolveLocalSkillPrompt` yielding null
480+
// and the host falling back to the prompt).
481+
mockHost.resolveLocalSkillCommandPrompt.mockImplementation(
482+
async (prompt: string) => prompt,
483+
);
484+
mockHost.getCloudPromptTransport.mockReturnValue({
485+
filePaths: [],
486+
skillBundles: [],
487+
messageText: content,
488+
promptText: content,
489+
});
490+
mockHost.uploadRunAttachments.mockResolvedValue([]);
491+
492+
const saga = makeSaga({
493+
createTask: vi.fn().mockResolvedValue(createdTask),
494+
createTaskRun: createTaskRunMock,
495+
startTaskRun: startTaskRunMock,
496+
});
497+
498+
const result = await saga.run({
499+
content,
500+
repository: "posthog/posthog",
501+
workspaceMode: "cloud",
502+
branch: "main",
503+
});
504+
505+
expect(result.success).toBe(true);
506+
expect(mockHost.resolveLocalSkillCommandPrompt).toHaveBeenCalledWith(
507+
content,
508+
);
509+
// Resolution is a no-op, so the original content (not a rewritten skill tag)
510+
// reaches the transport and no bundle is collected.
511+
expect(mockHost.getCloudPromptTransport).toHaveBeenCalledWith(
512+
content,
513+
undefined,
514+
);
515+
},
516+
);
517+
417518
it("uses the selected user GitHub integration for cloud task creation", async () => {
418519
const createdTask = createTask({
419520
github_user_integration: "user-integration-123",

packages/core/src/task-detail/taskCreationSaga.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,21 @@ export class TaskCreationSaga extends Saga<
281281
execute: async () => {
282282
const prAuthorshipMode = input.cloudPrAuthorshipMode ?? "user";
283283

284+
// Resolve a typed local-skill slash command (`/my-skill …`) into a
285+
// `<skill .../>` tag before building the transport, so the skill
286+
// bundle is collected and uploaded with the very first cloud message.
287+
// Without this a first-message `/my-skill` reaches the sandbox with no
288+
// bundle and is rejected as an unknown command (only a follow-up,
289+
// which already resolves, would work).
290+
const resolvedContent = input.content
291+
? await this.deps.host.resolveLocalSkillCommandPrompt(input.content)
292+
: input.content;
293+
284294
const transport =
285295
(input.content || input.filePaths?.length) &&
286296
workspaceMode === "cloud"
287297
? this.deps.host.getCloudPromptTransport(
288-
input.content ?? "",
298+
resolvedContent ?? "",
289299
input.filePaths,
290300
)
291301
: null;

packages/ui/src/features/task-detail/taskCreationHostImpl.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { injectable } from "inversify";
2929
import { track } from "../../shell/analytics";
3030
import { getAuthenticatedClient } from "../auth/authClientImperative";
3131
import { assertCloudUsageAvailable } from "../billing/preflightCloudUsage";
32+
import { resolveLocalSkillPrompt } from "../message-editor/commands";
3233
import { DEFAULT_PANEL_IDS } from "../panels/panelConstants";
3334
import { usePanelLayoutStore } from "../panels/panelLayoutStore";
3435
import { useProvisioningStore } from "../provisioning/store";
@@ -146,6 +147,14 @@ export class TrpcTaskCreationHost implements ITaskCreationHost {
146147
return getCloudPromptTransport(prompt, filePaths);
147148
}
148149

150+
async resolveLocalSkillCommandPrompt(prompt: string): Promise<string> {
151+
return (
152+
(await resolveLocalSkillPrompt(prompt, () =>
153+
hostClient().skills.list.query(),
154+
)) ?? prompt
155+
);
156+
}
157+
149158
uploadRunAttachments(
150159
client: TaskCreationApiClient,
151160
taskId: string,

0 commit comments

Comments
 (0)