Skip to content

Commit 7e5d3f3

Browse files
authored
fix: preserve structured composer intent (#270)
## Summary - preserve the user's exact authored message instead of encoding selected skills or connected apps into visible prompt text - carry non-app work intent, selected skill, and selected connected app through the validated run contract and durable Workflow input - make explicit Documents, Slides, Research, Data, and Media choices authoritative before keyword-based domain classification ## Architecture The browser sends an exact text message plus a small validated metadata envelope. The gateway includes that envelope in its idempotency body, the agent Worker checkpoints it in Workflow input, and agent-core converts it into request-scoped domain and capability guidance. App topology remains project mode; non-app work remains a general project. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | |---|---|---|---| | Preserve message identity | Keep user text byte-for-byte and send selections separately | Strip hidden prefixes at render time | Rendering would hide persisted corruption and leave routing ambiguous | | Route non-app surfaces | Validated run intent | More project modes or keyword-only classification | Intent is per run; project storage topology is unchanged | | Selected capabilities | Separate skill and connected-app fields | Internal prose or slash prefixes | Typed metadata survives retries without polluting transcript or model task text | | Classification precedence | App project mode, then explicit run intent, then keywords | Keywords first | Explicit user choices must win over incidental words such as “website” in a memo | ## Edge Cases Handled - signed-out and existing-project prompt handoffs retain intent and capability selections - custom user skills use the same bounded selected-skill contract as bundled skills - follow-up messages do not accidentally inherit first-run selection metadata - selected project or repository import keeps its existing app/general topology contract - invalid URL metadata is discarded before request submission and revalidated at the API boundary ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` ## Production QA after merge - submit an ordinary Documents request that mentions a website and confirm a document deliverable, exact visible prompt, and Files surface - submit ordinary Data and Research requests and confirm no internal skill prefix appears - submit a Slides request and confirm the deck skill is loaded from metadata - inspect browser console and production deployment health
1 parent 72da99b commit 7e5d3f3

25 files changed

Lines changed: 304 additions & 104 deletions

apps/agent-worker/README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ the existing account state and R2 lifecycle phases.
7373

7474
Run creation validates the gateway payload with the shared `CreateRunSchema` from
7575
`packages/types` before selecting the run-scoped `AgentRun` Durable Object. The
76-
database binds a gateway-hashed idempotency key to the exact body and thread. After the
76+
validated request keeps the user's exact message separate from explicit non-app run intent,
77+
selected skill, and selected connected-app metadata. Those selections remain in the
78+
checkpointed Workflow input and request context; they are never encoded into visible prompt text.
79+
The database binds a gateway-hashed idempotency key to the exact body and thread. After the
7780
pending run and thread pointer commit, start delivery is retried and then reconciled through
7881
an ordered run-key presence probe. A present object reconnects its stream (and finalizes a
7982
durable Workflow admission first); only an authoritative empty response fails the nonterminal database run

apps/agent-worker/src/agent-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ export async function startAgentRun(
190190
model: run.modelId,
191191
isModelExplicit,
192192
...(body.intent ? { runIntent: body.intent } : {}),
193+
...(body.selectedSkill ? { selectedSkill: body.selectedSkill } : {}),
194+
...(body.selectedTool ? { selectedTool: body.selectedTool } : {}),
193195
...(run.projectId ? { projectId: run.projectId } : {}),
194196
...(run.workspaceSlug ? { workspaceSlug: run.workspaceSlug } : {}),
195197
...(run.projectMode ? { projectMode: run.projectMode } : {}),

apps/agent-worker/src/durable-objects/agent-run-mastra-context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ export function createAgentRequestContext(
110110
openrouterApiKey: credential.transportProvider === "openrouter" ? credential.apiKey : undefined,
111111
projectMode: input.projectMode,
112112
runIntent: input.runIntent,
113+
selectedSkill: input.selectedSkill,
114+
selectedTool: input.selectedTool,
113115
runId: input.runId,
114116
taskMessage: input.messageText,
115117
...(isSkillCreator ? { userSkillCreator } : {}),

apps/agent-worker/src/durable-objects/agent-run-schemas.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import { CatalogModelIdSchema, LogicalModelIdSchema } from "@cheatcode/types";
2-
import { ProjectModeSchema, RunIntentSchema } from "@cheatcode/types/api";
1+
import {
2+
CatalogModelIdSchema,
3+
IntegrationNameSchema,
4+
LogicalModelIdSchema,
5+
} from "@cheatcode/types";
6+
import { ProjectModeSchema, RunIntentSchema, SelectedSkillSchema } from "@cheatcode/types/api";
37
import { z } from "zod";
48

59
export const StartRunInputSchema = z
@@ -16,6 +20,8 @@ export const StartRunInputSchema = z
1620
// automatic provider fallback so a pinned model is never silently replaced.
1721
isModelExplicit: z.boolean(),
1822
runIntent: RunIntentSchema.optional(),
23+
selectedSkill: SelectedSkillSchema.optional(),
24+
selectedTool: IntegrationNameSchema.optional(),
1925
projectMode: ProjectModeSchema.default("general"),
2026
isFirstRun: z.boolean().default(false),
2127
agentDisplayName: z.string().trim().min(1).max(80).optional(),

apps/web/README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,10 @@ The composer keeps three independent product concepts separate:
8585

8686
- `ComposerWorkIntentId` describes what the user wants to accomplish. Web app,
8787
mobile app, slides, research, data, documents, and media are discoverable
88-
composer choices. These choices guide prompt context; they are not all project
89-
modes. The generic Slides choice activates the general PPTX workflow, never the
88+
composer choices. Non-app choices cross the run API as validated intent metadata,
89+
so an explicit Documents choice cannot be reclassified as Web merely because the
90+
requested document discusses a website. These choices are not all project modes.
91+
The generic Slides choice activates the general PPTX workflow, never the
9092
fundraising-specific pitch-deck skill. Explicit PPTX and pitch-deck skill deep
9193
links both select the Slides intent while preserving the skill the user chose.
9294
- `AppBuildTarget` is only the runtime topology for generated applications:
@@ -100,9 +102,12 @@ The composer keeps three independent product concepts separate:
100102
focus from Files. Explicit browser takeover still selects Browser. The selected tab is not persisted
101103
across unrelated chats.
102104

105+
Composer selections are request metadata: `@`-selected skills and connected apps are
106+
validated separately from the exact user-authored text and cross the opaque chat handoff.
107+
The client never rewrites a visible message with a slash command or internal toolkit preamble.
103108
Signed-out launch handoff validates and restores `buildTarget`, model, and
104-
public GitHub repository state before chat creation. The opaque prompt and
105-
constrained run intent use the same one-shot handoff path. No `surface` query
109+
public GitHub repository state before chat creation. The opaque prompt,
110+
constrained run intent, skill, and connected-app selection use the same one-shot handoff path. No `surface` query
106111
parameter or persisted `app` tab alias is supported.
107112

108113
## Env

apps/web/src/components/chat/chat-panel-controller.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { ChatOnDataCallback, ChatStatus } from "ai";
99
import { useRouter } from "next/navigation";
1010
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
1111
import { toast } from "sonner";
12+
import type { ChatSubmissionSelection } from "@/components/chat/chat-panel-submission";
1213
import {
1314
mergeLoadedMessageHistory,
1415
type PendingSubmission,
@@ -44,6 +45,8 @@ export interface ChatPanelProps {
4445
hasOlderMessages: boolean;
4546
initialMessages?: CheatcodeUIMessage[] | undefined;
4647
initialRunIntent?: import("@cheatcode/types/api").RunIntent | null | undefined;
48+
initialSelectedSkill?: null | string | undefined;
49+
initialSelectedTool?: import("@cheatcode/types").IntegrationName | null | undefined;
4750
isLoadingOlderMessages: boolean;
4851
latestModelId: null | string;
4952
onLoadOlderMessages: () => Promise<CheatcodeUIMessage[]>;
@@ -161,7 +164,6 @@ function usePanelSubmission(
161164
getToken: runtime.getToken,
162165
hasReceivedStreamDataRef: runtime.hasReceivedStreamDataRef,
163166
hasSubmittedRef: runtime.hasSubmittedRef,
164-
initialRunIntent: input.initialRunIntent ?? null,
165167
onSubmitDraft: input.onSubmitDraft,
166168
pendingSubmissionRef: runtime.pendingSubmissionRef,
167169
project: input.project,
@@ -178,7 +180,6 @@ function usePanelSubmission(
178180
[
179181
input.activeRunId,
180182
input.onSubmitDraft,
181-
input.initialRunIntent,
182183
input.project,
183184
input.threadId,
184185
input.threadTitle,
@@ -213,11 +214,19 @@ function useAutoSubmitPrompt(
213214
if (runtime.store.draft.trim() !== prompt) {
214215
runtime.store.setDraft(input.threadId, prompt);
215216
}
216-
if (submitText(prompt, input.project)) {
217+
const selection: ChatSubmissionSelection = {
218+
intent: input.initialRunIntent ?? null,
219+
selectedSkill: input.initialSelectedSkill ?? null,
220+
selectedTool: input.initialSelectedTool ?? null,
221+
};
222+
if (submitText(prompt, input.project, selection)) {
217223
autoSubmittedPromptRef.current = prompt;
218224
}
219225
}, [
220226
input.autoSubmitPrompt,
227+
input.initialRunIntent,
228+
input.initialSelectedSkill,
229+
input.initialSelectedTool,
221230
input.project,
222231
input.threadId,
223232
runtime.store.draft,

apps/web/src/components/chat/chat-panel-submission.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { CheatcodeUIMessage } from "@cheatcode/types";
2-
import type { ProjectSummary, ThreadMessage } from "@cheatcode/types/api";
1+
import type { CheatcodeUIMessage, IntegrationName } from "@cheatcode/types";
2+
import type { ProjectSummary, RunIntent, ThreadMessage } from "@cheatcode/types/api";
33
import type { QueryClient } from "@tanstack/react-query";
44
import { toast } from "sonner";
55
import { buildExistingProjectParams, launchIntoProject } from "@/lib/api/home-launch";
@@ -12,6 +12,12 @@ import {
1212
} from "@/lib/api/project-thread";
1313
import { sidebarKeys, threadKeys } from "@/lib/api/query-keys";
1414

15+
export interface ChatSubmissionSelection {
16+
intent: RunIntent | null;
17+
selectedSkill: string | null;
18+
selectedTool: IntegrationName | null;
19+
}
20+
1521
export interface PendingSubmission {
1622
messageId: string;
1723
restoreToComposer: boolean;
@@ -70,6 +76,7 @@ export async function routePromptToProjectTarget(input: {
7076
queryClient: QueryClient;
7177
router: PromptRouter;
7278
selectedModel: null | string;
79+
selection: Partial<ChatSubmissionSelection>;
7380
setDraft: (threadId: string, value: string) => void;
7481
targetProject: ProjectSummary | null;
7582
threadId: string;
@@ -144,7 +151,12 @@ function completeProjectTargetNavigation(
144151
input: Parameters<typeof routePromptToProjectTarget>[0],
145152
targetThreadId: string,
146153
): void {
147-
const handoff = buildExistingProjectParams(input.prompt).toString();
154+
const handoff = buildExistingProjectParams({
155+
prompt: input.prompt,
156+
...(input.selection.intent ? { intent: input.selection.intent } : {}),
157+
...(input.selection.selectedSkill ? { selectedSkill: input.selection.selectedSkill } : {}),
158+
...(input.selection.selectedTool ? { selectedTool: input.selection.selectedTool } : {}),
159+
}).toString();
148160
input.setDraft(input.threadId, "");
149161
void input.queryClient.invalidateQueries({ queryKey: sidebarKeys.chats });
150162
void input.queryClient.invalidateQueries({ queryKey: sidebarKeys.projectThreads });

apps/web/src/components/chat/chat-transport.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export function createChatTransport(
4747
intent: body?.["intent"],
4848
message: runRequestMessage(latestMessage),
4949
model: body?.["model"],
50+
selectedSkill: body?.["selectedSkill"],
51+
selectedTool: body?.["selectedTool"],
5052
},
5153
headers: {
5254
...headers,

apps/web/src/components/chat/prompt-composer-controller.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import {
1313
useState,
1414
} from "react";
1515
import type { RunStatus } from "@/components/chat/status-pill";
16-
import { composePromptWithComposerContext } from "@/components/composer/composer-context-chips";
1716
import type { ComposerMenuItem } from "@/components/composer/composer-popover";
1817
import {
1918
type ComposerMenuController,
@@ -30,7 +29,11 @@ type ComposerControlMenu = "model";
3029
export interface PromptComposerProps {
3130
onChange: (value: string) => void;
3231
onStop: () => void;
33-
onSubmit: (value: string, project: ProjectSummary | null) => boolean;
32+
onSubmit: (
33+
value: string,
34+
project: ProjectSummary | null,
35+
selection: { selectedSkill: string | null; selectedTool: IntegrationName | null },
36+
) => boolean;
3437
project: ProjectSummary | null;
3538
resolvedModelId: null | string;
3639
status: RunStatus;
@@ -231,14 +234,10 @@ function createComposerSubmission({
231234
value,
232235
}: ComposerSubmissionOptions) {
233236
function submitComposerValue() {
234-
const wasAccepted = onSubmit(
235-
composePromptWithComposerContext({
236-
prompt: value,
237-
skill: selection.selectedSkill,
238-
tool: selection.selectedTool,
239-
}),
240-
project,
241-
);
237+
const wasAccepted = onSubmit(value.trim(), project, {
238+
selectedSkill: selection.selectedSkill,
239+
selectedTool: selection.selectedTool,
240+
});
242241
if (wasAccepted) {
243242
selection.setSelectedSkill(null);
244243
selection.setSelectedTool(null);

apps/web/src/components/chat/use-chat-submission.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import type { CheatcodeUIMessage } from "@cheatcode/types";
1+
import type { CheatcodeUIMessage, IntegrationName } from "@cheatcode/types";
22
import type { ProjectSummary, RunIntent } from "@cheatcode/types/api";
33
import type { QueryClient } from "@tanstack/react-query";
44
import type { ChatStatus } from "ai";
55
import { useCallback } from "react";
66
import { toast } from "sonner";
77
import {
8+
type ChatSubmissionSelection,
89
type PendingSubmission,
910
type PromptRouter,
1011
routePromptToProjectTarget,
@@ -23,7 +24,6 @@ interface ChatSubmissionInput {
2324
getToken: () => Promise<null | string>;
2425
hasReceivedStreamDataRef: { current: boolean };
2526
hasSubmittedRef: { current: boolean };
26-
initialRunIntent: RunIntent | null;
2727
onSubmitDraft?: (() => void) | undefined;
2828
pendingSubmissionRef: { current: PendingSubmission | null };
2929
project: ProjectSummary | null;
@@ -40,10 +40,18 @@ interface ChatSubmissionInput {
4040

4141
export function useChatSubmission(input: ChatSubmissionInput): {
4242
continueRun: () => void;
43-
submitText: (text: string, targetProject: ProjectSummary | null) => boolean;
43+
submitText: (
44+
text: string,
45+
targetProject: ProjectSummary | null,
46+
selection?: Partial<ChatSubmissionSelection>,
47+
) => boolean;
4448
} {
4549
const submitText = useCallback(
46-
(text: string, targetProject: ProjectSummary | null): boolean => {
50+
(
51+
text: string,
52+
targetProject: ProjectSummary | null,
53+
selection: Partial<ChatSubmissionSelection> = {},
54+
): boolean => {
4755
if (!isValidUserMessage(text)) {
4856
return false;
4957
}
@@ -52,10 +60,10 @@ export function useChatSubmission(input: ChatSubmissionInput): {
5260
}
5361
input.hasSubmittedRef.current = true;
5462
if (!isCurrentThreadTarget(input.project, targetProject)) {
55-
routeSubmissionToProject(input, text, targetProject);
63+
routeSubmissionToProject(input, text, targetProject, selection);
5664
return true;
5765
}
58-
submitInCurrentThread(input, text);
66+
submitInCurrentThread(input, text, selection);
5967
return true;
6068
},
6169
[input],
@@ -73,16 +81,17 @@ export function useChatSubmission(input: ChatSubmissionInput): {
7381
const pending = pendingSubmission(messageId, text, false);
7482
input.pendingSubmissionRef.current = pending;
7583
input.setRunStartedAt(pending.submittedAt);
76-
void input.sendMessage(
77-
userMessage(messageId, text, null),
78-
modelBody(input.selectedModel, null),
79-
);
84+
void input.sendMessage(userMessage(messageId, text, null), modelBody(input.selectedModel, {}));
8085
}, [input]);
8186

8287
return { continueRun, submitText };
8388
}
8489

85-
function submitInCurrentThread(input: ChatSubmissionInput, text: string): void {
90+
function submitInCurrentThread(
91+
input: ChatSubmissionInput,
92+
text: string,
93+
selection: Partial<ChatSubmissionSelection>,
94+
): void {
8695
if (!input.threadTitle?.trim() || input.threadTitle.trim() === "New chat") {
8796
void titleChatFromFirstPrompt(input.getToken, input.queryClient, input.threadId, text);
8897
}
@@ -92,8 +101,8 @@ function submitInCurrentThread(input: ChatSubmissionInput, text: string): void {
92101
input.pendingSubmissionRef.current = pending;
93102
input.setRunStartedAt(pending.submittedAt);
94103
void input.sendMessage(
95-
userMessage(messageId, text, input.initialRunIntent),
96-
modelBody(input.selectedModel, input.initialRunIntent),
104+
userMessage(messageId, text, selection.intent ?? null),
105+
modelBody(input.selectedModel, selection),
97106
);
98107
input.setDraft(input.threadId, "");
99108
input.onSubmitDraft?.();
@@ -103,13 +112,15 @@ function routeSubmissionToProject(
103112
input: ChatSubmissionInput,
104113
prompt: string,
105114
targetProject: ProjectSummary | null,
115+
selection: Partial<ChatSubmissionSelection>,
106116
): void {
107117
void routePromptToProjectTarget({
108118
getToken: input.getToken,
109119
prompt,
110120
queryClient: input.queryClient,
111121
router: input.router,
112122
selectedModel: input.selectedModel,
123+
selection,
113124
setDraft: input.setDraft,
114125
targetProject,
115126
threadId: input.threadId,
@@ -176,12 +187,21 @@ function userMessage(
176187

177188
function modelBody(
178189
selectedModel: null | string,
179-
intent: RunIntent | null,
180-
): { body: { intent?: RunIntent; model?: string } } {
190+
selection: Partial<ChatSubmissionSelection>,
191+
): {
192+
body: {
193+
intent?: RunIntent;
194+
model?: string;
195+
selectedSkill?: string;
196+
selectedTool?: IntegrationName;
197+
};
198+
} {
181199
return {
182200
body: {
183-
...(intent ? { intent } : {}),
201+
...(selection.intent ? { intent: selection.intent } : {}),
184202
...(selectedModel ? { model: selectedModel } : {}),
203+
...(selection.selectedSkill ? { selectedSkill: selection.selectedSkill } : {}),
204+
...(selection.selectedTool ? { selectedTool: selection.selectedTool } : {}),
185205
},
186206
};
187207
}

0 commit comments

Comments
 (0)