mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(contracts): pin API-mode override above discovery layer (#313) The old streamFormat='plain' rule was appended at the BOTTOM of the composed prompt, but DISCOVERY_AND_PHILOSOPHY is pinned at the TOP with its own 'these override anything later' header — so its hard rules ('TodoWrite on turn 3', 'brand-spec extraction via Bash + Read + WebFetch') still won precedence in API mode. With no real tools wired through to the Anthropic Messages path, the agent narrated pseudo-tool markup (<todo-list>...</todo-list>, [读取 X]) instead of emitting structured tool_use events the UI could render. Move the API-mode override to the absolute top of the prompt so it beats the discovery layer, name every unavailable tool, and explicitly forbid the pseudo-tool / fake-protocol markup observed in #313. <artifact> output and <question-form> discovery are still allowed — both are markup the UI parses, not tool calls. * fix(daemon): mirror API-mode override above discovery layer (#313) Address Codex + mrcfps review on #1207: the daemon has its own copy of composeSystemPrompt that is hit by any adapter declaring streamFormat: 'plain' (e.g. DeepSeek) via server.ts:6190. That copy still appended the obsolete bottom '## API mode rule', which loses the precedence war against DISCOVERY_AND_PHILOSOPHY's 'these override anything later' header — so plain-stream daemon agents could still narrate <todo-list> / [读取 X] pseudo-tool markup. Mirror the same top-anchored API_MODE_OVERRIDE here (byte-identical to the contracts copy) so both code paths produce the same behaviour. Adds 8 daemon-side tests including the indexOf-based positional assertion that pins the override above the discovery layer header.
This commit is contained in:
@@ -162,11 +162,26 @@ export function composeSystemPrompt({
|
||||
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
|
||||
// checklist + critique before <artifact>) win precedence over softer
|
||||
// wording later in the official base prompt.
|
||||
const parts: string[] = [
|
||||
const parts: string[] = [];
|
||||
|
||||
// API/BYOK mode (streamFormat === 'plain'): mirrors the same fix from
|
||||
// `@open-design/contracts`'s composer. The daemon hits this path for
|
||||
// any plain-stream adapter (e.g. DeepSeek), so without pinning the
|
||||
// override above DISCOVERY_AND_PHILOSOPHY here too, those daemon
|
||||
// agents still emit the `<todo-list>` / `[读取 X]` pseudo-tool
|
||||
// markup described in #313. Keep the wording byte-identical to the
|
||||
// contracts copy so both code paths produce the same observable
|
||||
// behaviour.
|
||||
if (streamFormat === 'plain') {
|
||||
parts.push(API_MODE_OVERRIDE);
|
||||
parts.push('\n\n---\n\n');
|
||||
}
|
||||
|
||||
parts.push(
|
||||
DISCOVERY_AND_PHILOSOPHY,
|
||||
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
|
||||
BASE_SYSTEM_PROMPT,
|
||||
];
|
||||
);
|
||||
|
||||
if (memoryBody && memoryBody.trim().length > 0) {
|
||||
parts.push(
|
||||
@@ -262,19 +277,36 @@ export function composeSystemPrompt({
|
||||
const mcpDirective = renderConnectedExternalMcpDirective(connectedExternalMcp);
|
||||
if (mcpDirective) parts.push(mcpDirective);
|
||||
|
||||
// Suppress tool_calls in API/BYOK mode (streamFormat === 'plain').
|
||||
// Only fires when the caller explicitly passes streamFormat='plain';
|
||||
// does NOT fire when streamFormat is omitted, so non-plain (tool-using)
|
||||
// adapters are unaffected and normal chat runs can still use tools.
|
||||
if (streamFormat === 'plain') {
|
||||
parts.push(
|
||||
'\n\n## API mode rule\n\nDo not emit tool_calls. Output only <artifact> HTML blocks. Any tool description in your internal reasoning must not appear in the response.',
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-anchored override for plain-stream daemon agents (#313). Mirrors
|
||||
* the contracts-package copy byte-for-byte; see that file for the full
|
||||
* rationale. Pinning it at the absolute top of the composed prompt is
|
||||
* what beats the discovery layer's own "these override anything later"
|
||||
* header — the old bottom-appended `## API mode rule` lost that
|
||||
* precedence war and let `<todo-list>` / `[读取 X]` pseudo-tool markup
|
||||
* leak into the chat.
|
||||
*/
|
||||
const API_MODE_OVERRIDE = `# API mode — no tools available (read first — overrides every rule below)
|
||||
|
||||
You are running through a plain Messages API. **No tools are wired through to you.** \`TodoWrite\`, \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, and \`WebFetch\` are unavailable — calls to them will not execute and will not render in the UI.
|
||||
|
||||
Every later instruction in this prompt that tells you to "call TodoWrite", "run Bash", "read via Read", or otherwise invoke a tool is describing the daemon-mode workflow. In this API run those instructions are **overridden** — do not attempt them and do not pretend you did.
|
||||
|
||||
**Forbidden output:**
|
||||
- Pseudo-tool markup such as \`<todo-list>...</todo-list>\`, \`<tool-call>\`, or invented XML wrappers around a plan.
|
||||
- Fake-protocol prose such as \`[读取 template.html ...]\`, \`[读取 layouts.md ...]\`, \`[正在调用 TodoWrite ...]\`, or any \`[doing X]\` placeholder narrating a tool you cannot run.
|
||||
- Statements like "I'll call TodoWrite to track this" or "let me read the skill file first" — there is no TodoWrite and no Read in this run.
|
||||
|
||||
**Allowed output:**
|
||||
- Plain chat prose to the user (in their language). State your plan as prose — a short numbered list in markdown is fine; it just must not be wrapped in \`<todo-list>\` or claim to be a tool call.
|
||||
- A final \`<artifact type="text/html">...</artifact>\` block containing a complete \`<!doctype html>\` document when the brief is ready to deliver.
|
||||
- \`<question-form>\` blocks for discovery on turn 1, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
|
||||
|
||||
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
||||
|
||||
// Defense-in-depth against Claude Code's synthetic OAuth tools.
|
||||
//
|
||||
// When Claude Code's built-in HTTP MCP transport gets a 401 on its first
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { composeSystemPrompt } from '../../src/prompts/system.js';
|
||||
|
||||
/**
|
||||
* Daemon-side mirror of the API-mode override fix for #313.
|
||||
*
|
||||
* The web-app/BYOK path goes through `@open-design/contracts`'s
|
||||
* `composeSystemPrompt`, which got the top-anchored fix first. But the
|
||||
* daemon has its own copy at `apps/daemon/src/prompts/system.ts`
|
||||
* (invoked by `apps/daemon/src/server.ts:6186-6193` for any agent whose
|
||||
* adapter declares `streamFormat: 'plain'` — e.g. DeepSeek). Without
|
||||
* mirroring the same fix here, plain-stream daemon agents still hit the
|
||||
* old bottom-appended `## API mode rule`, which sits BELOW
|
||||
* DISCOVERY_AND_PHILOSOPHY and therefore loses the precedence war
|
||||
* against the discovery layer's "TodoWrite on turn 3" hard rule.
|
||||
*/
|
||||
|
||||
describe('daemon composeSystemPrompt — API mode (#313)', () => {
|
||||
describe('non-plain stream (no streamFormat)', () => {
|
||||
it('keeps the discovery layer TodoWrite hard rule (control)', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
expect(prompt).toMatch(/TodoWrite/);
|
||||
});
|
||||
|
||||
it('does not inject the API-mode preamble', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
expect(prompt).not.toMatch(/API mode — no tools available/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('plain stream (streamFormat: plain)', () => {
|
||||
it('injects the API-mode override section', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/API mode — no tools available/i);
|
||||
});
|
||||
|
||||
it('pins the override above the discovery layer header', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
const overrideIdx = prompt.search(/API mode — no tools available/i);
|
||||
const discoveryIdx = prompt.indexOf('# OD core directives');
|
||||
expect(overrideIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(discoveryIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(overrideIdx).toBeLessThan(discoveryIdx);
|
||||
});
|
||||
|
||||
it('drops the obsolete bottom "## API mode rule" section', () => {
|
||||
// The old append-at-end section is the precedence bug. With the
|
||||
// top-anchored override in place, the trailing section is dead
|
||||
// weight and must be removed so we have a single source of truth.
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).not.toMatch(/## API mode rule\n\nDo not emit tool_calls/);
|
||||
});
|
||||
|
||||
it('names every tool the agent must not pretend to call', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/\bTodoWrite\b/);
|
||||
expect(prompt).toMatch(/\bRead\b/);
|
||||
expect(prompt).toMatch(/\bWrite\b/);
|
||||
expect(prompt).toMatch(/\bEdit\b/);
|
||||
expect(prompt).toMatch(/\bBash\b/);
|
||||
expect(prompt).toMatch(/\bWebFetch\b/);
|
||||
});
|
||||
|
||||
it('forbids the pseudo-tool markup observed in #313', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/<todo-list>/);
|
||||
expect(prompt).toMatch(/\[读取/);
|
||||
});
|
||||
|
||||
it('still allows <artifact> output', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/<artifact>/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -86,11 +86,26 @@ export function composeSystemPrompt({
|
||||
// turn 1", "branch on brand on turn 2", "TodoWrite on turn 3", run
|
||||
// checklist + critique before <artifact>) win precedence over softer
|
||||
// wording later in the official base prompt.
|
||||
const parts: string[] = [
|
||||
const parts: string[] = [];
|
||||
|
||||
// API/BYOK mode (streamFormat === 'plain'): no tools are wired through
|
||||
// to the model, but the discovery layer + base prompt below still tell
|
||||
// it to call TodoWrite/Read/Write/Edit/Bash/WebFetch. Without an
|
||||
// explicit top-anchored override, the model invents pseudo-tool markup
|
||||
// (`<todo-list>`, `[读取 X]`) instead of producing real progress
|
||||
// events — see #313. Pin this preamble ABOVE DISCOVERY_AND_PHILOSOPHY
|
||||
// so it beats the discovery layer's own "these override anything
|
||||
// later" header.
|
||||
if (streamFormat === 'plain') {
|
||||
parts.push(API_MODE_OVERRIDE);
|
||||
parts.push('\n\n---\n\n');
|
||||
}
|
||||
|
||||
parts.push(
|
||||
DISCOVERY_AND_PHILOSOPHY,
|
||||
'\n\n---\n\n# Identity and workflow charter (background)\n\n',
|
||||
BASE_SYSTEM_PROMPT,
|
||||
];
|
||||
);
|
||||
|
||||
// Mirrors the daemon-side composer in apps/daemon/src/prompts/system.ts —
|
||||
// keep both copies of this preamble in sync so a CLI chat and a BYOK
|
||||
@@ -155,19 +170,42 @@ export function composeSystemPrompt({
|
||||
parts.push(MEDIA_GENERATION_CONTRACT);
|
||||
}
|
||||
|
||||
// Suppress tool_calls in API/BYOK mode (streamFormat === 'plain').
|
||||
// Only fires when the caller explicitly passes streamFormat='plain';
|
||||
// does NOT fire when streamFormat is omitted, so non-plain (tool-using)
|
||||
// adapters are unaffected and normal chat runs can still use tools.
|
||||
if (streamFormat === 'plain') {
|
||||
parts.push(
|
||||
'\n\n## API mode rule\n\nDo not emit tool_calls. Output only <artifact> HTML blocks. Any tool description in your internal reasoning must not appear in the response.',
|
||||
);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-anchored override for API/BYOK mode (streamFormat === 'plain').
|
||||
*
|
||||
* Why it sits ABOVE DISCOVERY_AND_PHILOSOPHY: that layer starts with
|
||||
* "these override anything later in this prompt" and then mandates
|
||||
* TodoWrite / Bash / Read / WebFetch on turns 2–3. In daemon mode those
|
||||
* tools exist; in API mode they don't, so the agent narrates pseudo-tool
|
||||
* markup (`<todo-list>...`, `[读取 X]`) instead of producing structured
|
||||
* `tool_use` events the UI can render — bug #313. Pinning the override
|
||||
* at the absolute top is the cleanest way to beat the discovery layer's
|
||||
* precedence without restructuring its rules.
|
||||
*
|
||||
* The override does NOT block `<artifact>` blocks — those are how the
|
||||
* web UI receives finished HTML in API mode.
|
||||
*/
|
||||
const API_MODE_OVERRIDE = `# API mode — no tools available (read first — overrides every rule below)
|
||||
|
||||
You are running through a plain Messages API. **No tools are wired through to you.** \`TodoWrite\`, \`Read\`, \`Write\`, \`Edit\`, \`Bash\`, and \`WebFetch\` are unavailable — calls to them will not execute and will not render in the UI.
|
||||
|
||||
Every later instruction in this prompt that tells you to "call TodoWrite", "run Bash", "read via Read", or otherwise invoke a tool is describing the daemon-mode workflow. In this API run those instructions are **overridden** — do not attempt them and do not pretend you did.
|
||||
|
||||
**Forbidden output:**
|
||||
- Pseudo-tool markup such as \`<todo-list>...</todo-list>\`, \`<tool-call>\`, or invented XML wrappers around a plan.
|
||||
- Fake-protocol prose such as \`[读取 template.html ...]\`, \`[读取 layouts.md ...]\`, \`[正在调用 TodoWrite ...]\`, or any \`[doing X]\` placeholder narrating a tool you cannot run.
|
||||
- Statements like "I'll call TodoWrite to track this" or "let me read the skill file first" — there is no TodoWrite and no Read in this run.
|
||||
|
||||
**Allowed output:**
|
||||
- Plain chat prose to the user (in their language). State your plan as prose — a short numbered list in markdown is fine; it just must not be wrapped in \`<todo-list>\` or claim to be a tool call.
|
||||
- A final \`<artifact type="text/html">...</artifact>\` block containing a complete \`<!doctype html>\` document when the brief is ready to deliver.
|
||||
- \`<question-form>\` blocks for discovery on turn 1, exactly as the rules below describe — question-form is markup the UI parses, not a tool call.
|
||||
|
||||
If the rules below tell you to plan with TodoWrite, write the plan as prose instead. If they tell you to read skill side files before writing, describe in one sentence which patterns/conventions you're going to apply and proceed. If they tell you to run brand-spec extraction via Bash + Read + WebFetch, ask the user the missing brand questions in the discovery form instead.`;
|
||||
|
||||
function renderMetadataBlock(
|
||||
metadata: ProjectMetadata | undefined,
|
||||
template: ProjectTemplate | undefined,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { composeSystemPrompt } from '../src/prompts/system.js';
|
||||
|
||||
/**
|
||||
* Regression coverage for #313 — Anthropic API mode renders TodoWrite /
|
||||
* Read progress as raw text instead of tool UI cards.
|
||||
*
|
||||
* Root cause: `DISCOVERY_AND_PHILOSOPHY` (pinned at the TOP of the composed
|
||||
* prompt with an explicit "these override anything later" header) tells the
|
||||
* agent to call `TodoWrite`, `Bash`, `Read`, etc. on turn 3+. In API/BYOK
|
||||
* mode none of those tools are wired through to the model, so the agent
|
||||
* either narrates `<todo-list>` pseudo-markup or emits `[读取 X]`
|
||||
* fake-protocol prose. The old `streamFormat: 'plain'` rule was appended at
|
||||
* the BOTTOM of the prompt — lower precedence than the discovery layer —
|
||||
* which is why it was load-bearing-by-position-only and didn't actually
|
||||
* suppress the pseudo-tool output.
|
||||
*
|
||||
* Fix: the API-mode override must sit ABOVE the discovery layer and
|
||||
* explicitly invalidate any later "call TodoWrite / Read / Bash" rule.
|
||||
*/
|
||||
|
||||
describe('composeSystemPrompt — API mode (#313)', () => {
|
||||
describe('daemon mode (no streamFormat)', () => {
|
||||
it('keeps the TodoWrite hard rule from the discovery layer (control)', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
expect(prompt).toMatch(/TodoWrite/);
|
||||
});
|
||||
|
||||
it('does not inject the API-mode preamble', () => {
|
||||
const prompt = composeSystemPrompt({});
|
||||
expect(prompt).not.toMatch(/API mode — no tools available/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API mode (streamFormat: plain)', () => {
|
||||
it('injects the API-mode override section', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/API mode — no tools available/i);
|
||||
});
|
||||
|
||||
it('pins the override at the top so it overrides the discovery layer', () => {
|
||||
// The discovery layer (DISCOVERY_AND_PHILOSOPHY) starts with the
|
||||
// string `# OD core directives`. The API-mode override must appear
|
||||
// BEFORE that header — otherwise the discovery layer's own
|
||||
// "these override anything later" preamble wins precedence and
|
||||
// re-enables TodoWrite/Read/Write/Edit/Bash mentions later in the
|
||||
// prompt.
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
const overrideIdx = prompt.search(/API mode — no tools available/i);
|
||||
const discoveryIdx = prompt.indexOf('# OD core directives');
|
||||
expect(overrideIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(discoveryIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(overrideIdx).toBeLessThan(discoveryIdx);
|
||||
});
|
||||
|
||||
it('names every tool the agent must not pretend to call', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
// Each tool the discovery layer / base prompt assumes is available
|
||||
// must be explicitly listed as unavailable so the model knows the
|
||||
// later instructions are describing daemon-mode behavior.
|
||||
expect(prompt).toMatch(/\bTodoWrite\b/);
|
||||
expect(prompt).toMatch(/\bRead\b/);
|
||||
expect(prompt).toMatch(/\bWrite\b/);
|
||||
expect(prompt).toMatch(/\bEdit\b/);
|
||||
expect(prompt).toMatch(/\bBash\b/);
|
||||
expect(prompt).toMatch(/\bWebFetch\b/);
|
||||
});
|
||||
|
||||
it('forbids the pseudo-tool markup observed in #313 (`<todo-list>` and `[读取 ...]`)', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/<todo-list>/);
|
||||
expect(prompt).toMatch(/\[读取/);
|
||||
});
|
||||
|
||||
it('tells the agent to state its plan in prose instead of pretending to call TodoWrite', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/state.*plan.*prose|describe.*plan.*prose|plan.*as prose/i);
|
||||
});
|
||||
|
||||
it('explicitly invalidates later "call TodoWrite" / tool-use instructions', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
// The override must say "ignore later instructions that tell you to
|
||||
// call <tool>" — otherwise the discovery layer's RULE 3 "your first
|
||||
// tool call is TodoWrite" still applies.
|
||||
expect(prompt).toMatch(/override|ignore|do not follow/i);
|
||||
expect(prompt).toMatch(/later instructions|rules below|rest of this prompt|elsewhere/i);
|
||||
});
|
||||
|
||||
it('still allows <artifact> HTML output', () => {
|
||||
const prompt = composeSystemPrompt({ streamFormat: 'plain' });
|
||||
expect(prompt).toMatch(/<artifact>/);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user