Skip to content

Commit 30937e1

Browse files
committed
feat(mcp/plugin): render council scene in first response instructions (#1421)
Add buildCouncilSceneInstructions() to generate rendering instructions from council scene metadata and inject into the instructions field for PLAN/EVAL/AUTO modes. ACT mode remains compact with no council scene. MCP changes: - New pure function buildCouncilSceneInstructions() in council-scene.builder.ts - Wired into mode.handler.ts to append council rendering block to instructions - 7 new unit tests + 3 integration tests Plugin changes: - Updated mode_engine.py build_instructions() with matching rendering format - 3 new test cases for face+name+role format, moderator copy, EVAL rendering
1 parent 2fe5457 commit 30937e1

6 files changed

Lines changed: 194 additions & 7 deletions

File tree

apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, it, expect } from 'vitest';
2-
import { buildCouncilScene } from './council-scene.builder';
2+
import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder';
33
import type { CouncilPreset } from '../../agent/council-preset.types';
44
import type { VisualData } from '../../keyword/keyword.types';
5+
import type { CouncilScene } from './council-scene.types';
56

67
describe('buildCouncilScene', () => {
78
const planPreset: CouncilPreset = {
@@ -111,3 +112,87 @@ describe('buildCouncilScene', () => {
111112
expect(roundTripped).toEqual(scene);
112113
});
113114
});
115+
116+
describe('buildCouncilSceneInstructions', () => {
117+
it('returns undefined when councilScene is undefined', () => {
118+
expect(buildCouncilSceneInstructions(undefined)).toBeUndefined();
119+
});
120+
121+
it('returns undefined when councilScene has enabled=false', () => {
122+
const scene: CouncilScene = {
123+
enabled: false,
124+
cast: [{ name: 'test', role: 'primary', face: '●‿●' }],
125+
moderatorCopy: 'test copy',
126+
format: 'tiny-actor-grid',
127+
};
128+
expect(buildCouncilSceneInstructions(scene)).toBeUndefined();
129+
});
130+
131+
it('returns undefined when cast is empty', () => {
132+
const scene: CouncilScene = {
133+
enabled: true,
134+
cast: [],
135+
moderatorCopy: 'test copy',
136+
format: 'tiny-actor-grid',
137+
};
138+
expect(buildCouncilSceneInstructions(scene)).toBeUndefined();
139+
});
140+
141+
it('returns instruction text for a valid PLAN scene', () => {
142+
const scene: CouncilScene = {
143+
enabled: true,
144+
cast: [
145+
{ name: 'technical-planner', role: 'primary', face: '◇‿◇' },
146+
{ name: 'architecture-specialist', role: 'specialist', face: '⬡‿⬡' },
147+
{ name: 'security-specialist', role: 'specialist', face: '◮‿◮' },
148+
],
149+
moderatorCopy: 'Council assembled — let us design this together.',
150+
format: 'tiny-actor-grid',
151+
};
152+
153+
const result = buildCouncilSceneInstructions(scene);
154+
expect(result).toBeDefined();
155+
expect(result).toContain('COUNCIL SCENE');
156+
expect(result).toContain('Council assembled');
157+
});
158+
159+
it('includes all cast faces, names, and roles', () => {
160+
const scene: CouncilScene = {
161+
enabled: true,
162+
cast: [
163+
{ name: 'technical-planner', role: 'primary', face: '◇‿◇' },
164+
{ name: 'security-specialist', role: 'specialist', face: '◮‿◮' },
165+
],
166+
moderatorCopy: 'Council assembled.',
167+
format: 'tiny-actor-grid',
168+
};
169+
170+
const result = buildCouncilSceneInstructions(scene)!;
171+
expect(result).toContain('◇‿◇ technical-planner [primary]');
172+
expect(result).toContain('◮‿◮ security-specialist [specialist]');
173+
});
174+
175+
it('includes moderatorCopy in the output', () => {
176+
const scene: CouncilScene = {
177+
enabled: true,
178+
cast: [{ name: 'code-reviewer', role: 'primary', face: '●‿●' }],
179+
moderatorCopy: 'Review council convened — specialists are ready.',
180+
format: 'tiny-actor-grid',
181+
};
182+
183+
const result = buildCouncilSceneInstructions(scene)!;
184+
expect(result).toContain('Review council convened — specialists are ready.');
185+
});
186+
187+
it('includes format hint in the output', () => {
188+
const scene: CouncilScene = {
189+
enabled: true,
190+
cast: [{ name: 'test', role: 'primary', face: '●‿●' }],
191+
moderatorCopy: 'test copy',
192+
format: 'tiny-actor-grid',
193+
};
194+
195+
const result = buildCouncilSceneInstructions(scene)!;
196+
expect(result).toContain('tiny-actor-grid');
197+
});
198+
});

apps/mcp-server/src/mcp/handlers/council-scene.builder.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,25 @@ export interface CouncilSceneFallback {
2727
specialists?: string[];
2828
}
2929

30+
/**
31+
* Build first-response rendering instructions for the council scene.
32+
* Returns instruction text to append to the mode instructions, or undefined if no scene.
33+
*/
34+
export function buildCouncilSceneInstructions(councilScene?: CouncilScene): string | undefined {
35+
if (!councilScene?.enabled || !councilScene.cast?.length) {
36+
return undefined;
37+
}
38+
39+
const castLines = councilScene.cast.map(m => `${m.face} ${m.name} [${m.role}]`).join('\n');
40+
41+
return (
42+
`\n\n📋 COUNCIL SCENE — Render this at the start of your first response, ` +
43+
`right after the mode header:\n\n` +
44+
`${councilScene.moderatorCopy}\n${castLines}\n\n` +
45+
`Then proceed with your normal ${councilScene.format || 'standard'} mode response.`
46+
);
47+
}
48+
3049
/**
3150
* Build a CouncilScene for the given mode, or return undefined for ACT mode.
3251
*

apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2184,6 +2184,58 @@ describe('ModeHandler', () => {
21842184
const reserialized = JSON.stringify(parsed.councilScene);
21852185
expect(JSON.parse(reserialized)).toEqual(parsed.councilScene);
21862186
});
2187+
2188+
it('PLAN mode instructions should include council scene rendering block', async () => {
2189+
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
2190+
...mockParseModeResult,
2191+
mode: 'PLAN',
2192+
instructions: 'Base PLAN instructions',
2193+
originalPrompt:
2194+
'PLAN implement password reset endpoint that sends an email with a reset link',
2195+
});
2196+
2197+
const result = await handler.handle('parse_mode', {
2198+
prompt: 'PLAN implement password reset endpoint that sends an email with a reset link',
2199+
});
2200+
2201+
expect(result?.isError).toBeFalsy();
2202+
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
2203+
expect(parsed.instructions).toContain('COUNCIL SCENE');
2204+
});
2205+
2206+
it('ACT mode instructions should NOT include council scene rendering block', async () => {
2207+
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
2208+
...mockParseModeResult,
2209+
mode: 'ACT',
2210+
instructions: 'Base ACT instructions',
2211+
originalPrompt: 'implement feature',
2212+
});
2213+
2214+
const result = await handler.handle('parse_mode', {
2215+
prompt: 'ACT implement feature',
2216+
});
2217+
2218+
expect(result?.isError).toBeFalsy();
2219+
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
2220+
expect(parsed.instructions).not.toContain('COUNCIL SCENE');
2221+
});
2222+
2223+
it('EVAL mode instructions should include council scene rendering block', async () => {
2224+
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
2225+
...mockParseModeResult,
2226+
mode: 'EVAL',
2227+
instructions: 'Base EVAL instructions',
2228+
originalPrompt: 'evaluate implementation',
2229+
});
2230+
2231+
const result = await handler.handle('parse_mode', {
2232+
prompt: 'EVAL evaluate implementation',
2233+
});
2234+
2235+
expect(result?.isError).toBeFalsy();
2236+
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
2237+
expect(parsed.instructions).toContain('COUNCIL SCENE');
2238+
});
21872239
});
21882240

21892241
describe('reviewContext (#1411)', () => {

apps/mcp-server/src/mcp/handlers/mode.handler.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
suppressDispatchWhileGated,
5151
type ExecutionGate,
5252
} from './execution-gate';
53-
import { buildCouncilScene } from './council-scene.builder';
53+
import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder';
5454

5555
/** Maximum length for context title slug generation */
5656
const CONTEXT_TITLE_MAX_LENGTH = 50;
@@ -356,6 +356,15 @@ export class ModeHandler extends AbstractHandler {
356356
},
357357
);
358358

359+
// Council Scene rendering instructions (#1421) — append to instructions
360+
// so the AI client renders the opening scene in its first response.
361+
// Must come before clarification override since clarification replaces
362+
// instructions entirely when the request is ambiguous.
363+
const councilRenderInstructions = buildCouncilSceneInstructions(councilScene);
364+
if (councilRenderInstructions) {
365+
result.instructions += councilRenderInstructions;
366+
}
367+
359368
// Clarification Gate (#1371) — only applies to PLAN/AUTO modes where the
360369
// response might otherwise produce a plan. ACT and EVAL skip the gate
361370
// because they assume PLAN has already set context.

packages/claude-code-plugin/hooks/lib/mode_engine.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -454,11 +454,19 @@ def build_instructions(
454454

455455
instructions = template.format(agent_name=agent["name"])
456456

457-
# Council scene contract for eligible modes (#1366)
457+
# Council scene rendering instructions for eligible modes (#1366, #1421)
458458
council = self.build_council_scene(mode_upper)
459459
if council:
460-
names = ", ".join(m["name"] for m in council["cast"])
461-
instructions += f"\n\nCouncil Scene: {council['moderatorCopy']}\nCast: {names}"
460+
cast_lines = "\n".join(
461+
f"{m.get('face', '●‿●')} {m['name']} [{m['role']}]"
462+
for m in council["cast"]
463+
)
464+
instructions += (
465+
f"\n\n📋 COUNCIL SCENE — Render this at the start of your "
466+
f"first response, right after the mode header:\n\n"
467+
f"{council['moderatorCopy']}\n{cast_lines}\n\n"
468+
f"Then proceed with your normal mode response."
469+
)
462470

463471
# Enrich with .ai-rules data
464472
enrichment = self._build_rules_snippet(mode_upper, agent["name"])

packages/claude-code-plugin/hooks/lib/test_mode_engine.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,12 +430,26 @@ def test_case_insensitive(self):
430430

431431
def test_council_scene_in_build_instructions(self):
432432
result = self.engine.build_instructions("PLAN")
433-
self.assertIn("Council Scene:", result)
433+
self.assertIn("COUNCIL SCENE", result)
434434
self.assertIn("technical-planner", result)
435435

436436
def test_no_council_scene_in_act_instructions(self):
437437
result = self.engine.build_instructions("ACT")
438-
self.assertNotIn("Council Scene:", result)
438+
self.assertNotIn("COUNCIL SCENE", result)
439+
440+
def test_council_scene_includes_face_name_role(self):
441+
result = self.engine.build_instructions("PLAN")
442+
self.assertIn("●‿● technical-planner [primary]", result)
443+
self.assertIn("[specialist]", result)
444+
445+
def test_council_scene_includes_moderator_copy(self):
446+
result = self.engine.build_instructions("PLAN")
447+
self.assertIn("Council assembled", result)
448+
449+
def test_eval_council_scene_rendering(self):
450+
result = self.engine.build_instructions("EVAL")
451+
self.assertIn("COUNCIL SCENE", result)
452+
self.assertIn("Review council convened", result)
439453

440454
def test_serializable_json(self):
441455
scene = self.engine.build_council_scene("PLAN")

0 commit comments

Comments
 (0)