Skip to content

Commit 94282d2

Browse files
committed
feat(desktop): route run preferences semantically
1 parent ad04297 commit 94282d2

9 files changed

Lines changed: 648 additions & 156 deletions

File tree

apps/desktop/src/main/ipc/generate.test.ts

Lines changed: 10 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ vi.mock('../electron-runtime', () => ({
88
import {
99
buildRunPreferenceAskInput,
1010
contextWindowForContextPack,
11-
mergeRunPreferenceAnswers,
12-
shouldRequestRunPreferencePreflight,
1311
shouldRunUserMemoryCandidateCapture,
1412
} from './generate';
1513

@@ -49,55 +47,17 @@ describe('generate IPC memory preference helpers', () => {
4947
});
5048

5149
describe('generate IPC run preference preflight helpers', () => {
52-
it('asks fresh designs for tweak preferences', () => {
53-
expect(
54-
shouldRequestRunPreferencePreflight({
55-
hasSource: false,
56-
existingPreferences: null,
57-
prompt: 'make a dashboard',
58-
}),
59-
).toBe(true);
60-
});
61-
62-
it('does not ask again once run preferences are stored', () => {
63-
expect(
64-
shouldRequestRunPreferencePreflight({
65-
hasSource: false,
66-
existingPreferences: {
67-
schemaVersion: 1,
68-
tweaks: 'auto',
69-
bitmapAssets: 'auto',
70-
reusableSystem: 'auto',
71-
},
72-
prompt: 'make another screen',
73-
}),
74-
).toBe(false);
75-
});
76-
77-
it('updates explicit prompt overrides without preflight', () => {
78-
expect(
79-
mergeRunPreferenceAnswers(
80-
{
81-
schemaVersion: 1,
82-
tweaks: 'auto',
83-
bitmapAssets: 'auto',
84-
reusableSystem: 'auto',
85-
},
86-
[],
87-
'不要加微调,也不要生成图片',
88-
),
89-
).toEqual({
90-
schemaVersion: 1,
91-
tweaks: 'no',
92-
bitmapAssets: 'no',
93-
reusableSystem: 'auto',
94-
});
95-
});
96-
97-
it('builds a required tweaks question for fresh preflight', () => {
98-
const input = buildRunPreferenceAskInput('make a landing page');
50+
it('builds clarification input from semantic router questions', () => {
51+
const input = buildRunPreferenceAskInput([
52+
{
53+
id: 'bitmapAssets',
54+
type: 'text-options',
55+
prompt: 'Generate bitmap assets?',
56+
options: ['auto', 'no', 'yes'],
57+
},
58+
]);
9959
expect(input.questions[0]).toMatchObject({
100-
id: 'tweaks',
60+
id: 'bitmapAssets',
10161
type: 'text-options',
10262
options: ['auto', 'no', 'yes'],
10363
});

apps/desktop/src/main/ipc/generate.ts

Lines changed: 49 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import path_module from 'node:path';
22
import {
33
type AgentEvent,
4-
type AskAnswer,
54
type AskInput,
5+
applyRunPreferenceAnswers,
66
buildApplyCommentUserPrompt,
77
buildDesignContextPack,
88
type CoreLogger,
@@ -15,14 +15,14 @@ import {
1515
inspectWorkspaceFiles,
1616
loadDesignSkills,
1717
loadFrameTemplates,
18+
routeRunPreferences,
1819
updateDesignSessionBrief,
1920
} from '@open-codesign/core';
2021
import { detectProviderFromKey, generateImage } from '@open-codesign/providers';
2122
import {
2223
ApplyCommentPayload,
2324
CancelGenerationPayloadV1,
2425
CodesignError,
25-
type DesignRunPreferencesV1,
2626
deriveResourceStateFromChatRows,
2727
GeneratePayloadV1,
2828
} from '@open-codesign/shared';
@@ -88,65 +88,28 @@ export function shouldRunUserMemoryCandidateCapture(prefs: {
8888
return prefs.memoryEnabled === true && prefs.userMemoryAutoUpdate === true;
8989
}
9090

91-
const DEFAULT_RUN_PREFERENCES: DesignRunPreferencesV1 = {
92-
schemaVersion: 1,
93-
tweaks: 'auto',
94-
bitmapAssets: 'auto',
95-
reusableSystem: 'auto',
96-
};
97-
98-
export function shouldRequestRunPreferencePreflight(input: {
99-
hasSource: boolean;
100-
existingPreferences: DesignRunPreferencesV1 | null;
101-
prompt: string;
102-
}): boolean {
103-
if (input.existingPreferences !== null) return false;
104-
if (input.hasSource) return false;
105-
return input.prompt.trim().length > 0;
106-
}
107-
108-
export function buildRunPreferenceAskInput(_prompt: string): AskInput {
91+
export function buildRunPreferenceAskInput(questions: AskInput['questions']): AskInput {
10992
return {
11093
rationale: 'A few setup choices help Open CoDesign avoid unnecessary work.',
111-
questions: [
112-
{
113-
id: 'tweaks',
114-
type: 'text-options',
115-
prompt: 'Do you want tweak controls for this design?',
116-
options: ['auto', 'no', 'yes'],
117-
},
118-
],
94+
questions,
11995
};
12096
}
12197

122-
function answerValue(answers: AskAnswer[], questionId: string): string | null {
123-
const value = answers.find((answer) => answer.questionId === questionId)?.value;
124-
return typeof value === 'string' ? value : null;
125-
}
126-
127-
export function mergeRunPreferenceAnswers(
128-
base: DesignRunPreferencesV1 | null,
129-
answers: AskAnswer[],
130-
prompt: string,
131-
): DesignRunPreferencesV1 {
132-
const next: DesignRunPreferencesV1 = { ...(base ?? DEFAULT_RUN_PREFERENCES) };
133-
const tweakAnswer = answerValue(answers, 'tweaks');
134-
if (tweakAnswer === 'yes' || tweakAnswer === 'no' || tweakAnswer === 'auto') {
135-
next.tweaks = tweakAnswer;
136-
}
137-
const lower = prompt.toLowerCase();
138-
const disablesTweaks = /.*|.*|no tweaks?|without tweaks?/.test(lower);
139-
if (disablesTweaks) {
140-
next.tweaks = 'no';
141-
} else if (/.*|.*|add tweaks?|with tweaks?/.test(lower)) {
142-
next.tweaks = 'yes';
143-
}
144-
if (/.*(|)|.*.*(|)|no images?|without images?/.test(lower)) {
145-
next.bitmapAssets = 'no';
146-
} else if (/.*(|)|.*(|)|generate images?|with images?/.test(lower)) {
147-
next.bitmapAssets = 'yes';
148-
}
149-
return next;
98+
function recentHistoryForRunPreferenceRouter(
99+
chatRows: ReturnType<typeof listSessionChatMessages>,
100+
): string {
101+
return chatRows
102+
.slice(-12)
103+
.map((row) => {
104+
if (row.kind !== 'user' && row.kind !== 'assistant_text') return null;
105+
const text =
106+
typeof (row.payload as { text?: unknown }).text === 'string'
107+
? (row.payload as { text: string }).text
108+
: '';
109+
return text.trim().length > 0 ? `[${row.kind}] ${text.trim().slice(0, 800)}` : null;
110+
})
111+
.filter((line): line is string => line !== null)
112+
.join('\n');
150113
}
151114

152115
function designMdSummaryForMemory(
@@ -797,27 +760,42 @@ export function registerGenerateIpc({ db, getMainWindow }: RegisterGenerateIpcDe
797760
runPreferenceStoreOptions !== null
798761
? readSessionRunPreferences(runPreferenceStoreOptions, designId)
799762
: null;
800-
let runPreferences = mergeRunPreferenceAnswers(
801-
existingRunPreferences,
802-
[],
803-
payload.prompt,
804-
);
805-
if (
806-
shouldRequestRunPreferencePreflight({
807-
hasSource: Boolean(payload.previousSource?.trim()),
808-
existingPreferences: existingRunPreferences,
809-
prompt: payload.prompt,
810-
})
811-
) {
763+
const workspaceState = {
764+
sourcePath: payload.previousSource ? 'App.jsx' : null,
765+
hasSource: Boolean(payload.previousSource?.trim()),
766+
hasDesignMd: Boolean(promptContext.projectContext.designMd?.trim()),
767+
hasAgentsMd: Boolean(promptContext.projectContext.agentsMd?.trim()),
768+
hasSettingsJson: Boolean(promptContext.projectContext.settingsJson?.trim()),
769+
};
770+
const routedPreferences = await routeRunPreferences({
771+
prompt: payload.prompt,
772+
existingPreferences: existingRunPreferences,
773+
recentHistory: recentHistoryForRunPreferenceRouter(chatRows),
774+
workspaceState,
775+
designBrief: existingBrief ? JSON.stringify(existingBrief) : null,
776+
userMemory: memoryContext?.userMemory?.content ?? null,
777+
workspaceMemory: memoryContext?.workspaceMemory?.content ?? null,
778+
model: active.model,
779+
apiKey,
780+
...(baseUrl !== undefined ? { baseUrl } : {}),
781+
wire: active.wire,
782+
...(active.httpHeaders !== undefined ? { httpHeaders: active.httpHeaders } : {}),
783+
...(active.reasoningLevel !== undefined
784+
? { reasoningLevel: active.reasoningLevel }
785+
: {}),
786+
...(allowKeyless ? { allowKeyless: true } : {}),
787+
logger: coreLogger,
788+
});
789+
let runPreferences = routedPreferences.preferences;
790+
if (routedPreferences.needsClarification && routedPreferences.clarificationQuestions) {
812791
const askResult = await requestAsk(
813792
id,
814-
buildRunPreferenceAskInput(payload.prompt),
793+
buildRunPreferenceAskInput(routedPreferences.clarificationQuestions),
815794
() => getMainWindow(),
816795
);
817-
runPreferences = mergeRunPreferenceAnswers(
796+
runPreferences = applyRunPreferenceAnswers(
818797
runPreferences,
819798
askResult.status === 'answered' ? askResult.answers : [],
820-
payload.prompt,
821799
);
822800
}
823801
if (runPreferenceStoreOptions !== null) {

packages/core/src/agent.test.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,15 +1466,17 @@ describe('generateViaAgent()', () => {
14661466
expect(sys).toContain('Prefer progressive generation');
14671467
expect(sys).toContain('coherent first pass');
14681468
expect(sys).toContain('complete first pass');
1469-
expect(sys).toContain('Do not call `preview` while the artifact is still only a scaffold');
1470-
expect(sys).toContain('A complete `create App.jsx` is acceptable');
1469+
expect(sys).toContain(
1470+
'Do not call `preview` while a previewable artifact is still only a scaffold',
1471+
);
1472+
expect(sys).toContain('A complete first `create` is acceptable');
14711473
expect(sys).toContain('Interleave major tool groups');
14721474
expect(sys).toContain('under 18 words');
14731475
expect(sys).toContain('`str_replace`, or `insert`');
14741476
expect(sys).toContain('Do not emit `<artifact>`');
14751477
expect(sys).toContain('design source to `App.jsx`');
14761478
expect(sys).toContain('Local workspace assets and scaffolded files are allowed');
1477-
expect(sys).toContain('Call `done(path)` after the final mutation');
1479+
expect(sys).toContain('`done(path)` after the final mutation');
14781480
expect(sys).toContain('stop after 3 error rounds');
14791481
expect(sys).not.toContain('text_editor.create(');
14801482
expect(sys).not.toContain('view("index.html"');
@@ -1541,7 +1543,7 @@ describe('generateViaAgent()', () => {
15411543
expect(names).not.toContain('verify_html');
15421544
});
15431545

1544-
it('hides tweaks and image tools when the feature profile disables them', async () => {
1546+
it('hides tweaks and image tools only for explicit high-confidence refusals', async () => {
15451547
scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT };
15461548
await generateViaAgent(
15471549
{
@@ -1554,6 +1556,10 @@ describe('generateViaAgent()', () => {
15541556
tweaks: 'no',
15551557
bitmapAssets: 'no',
15561558
reusableSystem: 'auto',
1559+
routing: {
1560+
tweaks: { provenance: 'explicit', confidence: 'high' },
1561+
bitmapAssets: { provenance: 'explicit', confidence: 'high' },
1562+
},
15571563
},
15581564
runPreview: async () => ({
15591565
ok: true,
@@ -1584,6 +1590,47 @@ describe('generateViaAgent()', () => {
15841590
expect(sys).not.toContain('Bitmap asset generation');
15851591
});
15861592

1593+
it('keeps semantic tools available for inferred low-confidence refusals', async () => {
1594+
scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT };
1595+
await generateViaAgent(
1596+
{
1597+
prompt: 'design a landing page',
1598+
history: [],
1599+
model: MODEL,
1600+
apiKey: 'sk-test',
1601+
runPreferences: {
1602+
schemaVersion: 1,
1603+
tweaks: 'no',
1604+
bitmapAssets: 'no',
1605+
reusableSystem: 'auto',
1606+
routing: {
1607+
tweaks: { provenance: 'inferred', confidence: 'low' },
1608+
bitmapAssets: { provenance: 'inferred', confidence: 'low' },
1609+
},
1610+
},
1611+
readWorkspaceFiles: async () => [],
1612+
},
1613+
{
1614+
fs: makeStubFs({ 'App.jsx': SAMPLE_HTML }),
1615+
generateImageAsset: async () => ({
1616+
path: 'assets/hero.png',
1617+
dataUrl: 'data:image/png;base64,aW1n',
1618+
mimeType: 'image/png',
1619+
model: 'gpt-image-2',
1620+
provider: 'openai',
1621+
}),
1622+
},
1623+
);
1624+
1625+
const tools = (agentCalls[0]?.options.initialState?.tools ?? []) as Array<{ name?: string }>;
1626+
const names = tools.map((tool) => tool.name);
1627+
expect(names).toContain('tweaks');
1628+
expect(names).toContain('generate_image_asset');
1629+
const sys = agentCalls[0]?.options.initialState?.systemPrompt as string;
1630+
expect(sys).toContain('User-routed preferences');
1631+
expect(sys).not.toContain('The user explicitly declined');
1632+
});
1633+
15871634
it('keeps selective tweaks guidance in auto mode', async () => {
15881635
scriptedAgent = { assistantText: RESPONSE_WITH_ARTIFACT };
15891636
await generateViaAgent({
@@ -1599,7 +1646,7 @@ describe('generateViaAgent()', () => {
15991646
},
16001647
});
16011648
const sys = agentCalls[0]?.options.initialState?.systemPrompt as string;
1602-
expect(sys).toContain('Call `tweaks()` only when user preference allows');
1649+
expect(sys).toContain('decide agentically whether tweak controls improve iteration');
16031650
});
16041651

16051652
it('injects apply-comment supporting context only once through the agent boundary', async () => {

0 commit comments

Comments
 (0)