Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 86 additions & 1 deletion apps/mcp-server/src/mcp/handlers/council-scene.builder.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { buildCouncilScene } from './council-scene.builder';
import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder';
import type { CouncilPreset } from '../../agent/council-preset.types';
import type { VisualData } from '../../keyword/keyword.types';
import type { CouncilScene } from './council-scene.types';

describe('buildCouncilScene', () => {
const planPreset: CouncilPreset = {
Expand Down Expand Up @@ -111,3 +112,87 @@ describe('buildCouncilScene', () => {
expect(roundTripped).toEqual(scene);
});
});

describe('buildCouncilSceneInstructions', () => {
it('returns undefined when councilScene is undefined', () => {
expect(buildCouncilSceneInstructions(undefined)).toBeUndefined();
});

it('returns undefined when councilScene has enabled=false', () => {
const scene: CouncilScene = {
enabled: false,
cast: [{ name: 'test', role: 'primary', face: '●‿●' }],
moderatorCopy: 'test copy',
format: 'tiny-actor-grid',
};
expect(buildCouncilSceneInstructions(scene)).toBeUndefined();
});

it('returns undefined when cast is empty', () => {
const scene: CouncilScene = {
enabled: true,
cast: [],
moderatorCopy: 'test copy',
format: 'tiny-actor-grid',
};
expect(buildCouncilSceneInstructions(scene)).toBeUndefined();
});

it('returns instruction text for a valid PLAN scene', () => {
const scene: CouncilScene = {
enabled: true,
cast: [
{ name: 'technical-planner', role: 'primary', face: '◇‿◇' },
{ name: 'architecture-specialist', role: 'specialist', face: '⬡‿⬡' },
{ name: 'security-specialist', role: 'specialist', face: '◮‿◮' },
],
moderatorCopy: 'Council assembled — let us design this together.',
format: 'tiny-actor-grid',
};

const result = buildCouncilSceneInstructions(scene);
expect(result).toBeDefined();
expect(result).toContain('COUNCIL SCENE');
expect(result).toContain('Council assembled');
});

it('includes all cast faces, names, and roles', () => {
const scene: CouncilScene = {
enabled: true,
cast: [
{ name: 'technical-planner', role: 'primary', face: '◇‿◇' },
{ name: 'security-specialist', role: 'specialist', face: '◮‿◮' },
],
moderatorCopy: 'Council assembled.',
format: 'tiny-actor-grid',
};

const result = buildCouncilSceneInstructions(scene)!;
expect(result).toContain('◇‿◇ technical-planner [primary]');
expect(result).toContain('◮‿◮ security-specialist [specialist]');
});

it('includes moderatorCopy in the output', () => {
const scene: CouncilScene = {
enabled: true,
cast: [{ name: 'code-reviewer', role: 'primary', face: '●‿●' }],
moderatorCopy: 'Review council convened — specialists are ready.',
format: 'tiny-actor-grid',
};

const result = buildCouncilSceneInstructions(scene)!;
expect(result).toContain('Review council convened — specialists are ready.');
});

it('includes format hint in the output', () => {
const scene: CouncilScene = {
enabled: true,
cast: [{ name: 'test', role: 'primary', face: '●‿●' }],
moderatorCopy: 'test copy',
format: 'tiny-actor-grid',
};

const result = buildCouncilSceneInstructions(scene)!;
expect(result).toContain('tiny-actor-grid');
});
});
19 changes: 19 additions & 0 deletions apps/mcp-server/src/mcp/handlers/council-scene.builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,25 @@ export interface CouncilSceneFallback {
specialists?: string[];
}

/**
* Build first-response rendering instructions for the council scene.
* Returns instruction text to append to the mode instructions, or undefined if no scene.
*/
export function buildCouncilSceneInstructions(councilScene?: CouncilScene): string | undefined {
if (!councilScene?.enabled || !councilScene.cast?.length) {
return undefined;
}

const castLines = councilScene.cast.map(m => `${m.face} ${m.name} [${m.role}]`).join('\n');

return (
`\n\n📋 COUNCIL SCENE — Render this at the start of your first response, ` +
`right after the mode header:\n\n` +
`${councilScene.moderatorCopy}\n${castLines}\n\n` +
`Then proceed with your normal ${councilScene.format || 'standard'} mode response.`
);
}

/**
* Build a CouncilScene for the given mode, or return undefined for ACT mode.
*
Expand Down
52 changes: 52 additions & 0 deletions apps/mcp-server/src/mcp/handlers/mode.handler.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2184,6 +2184,58 @@ describe('ModeHandler', () => {
const reserialized = JSON.stringify(parsed.councilScene);
expect(JSON.parse(reserialized)).toEqual(parsed.councilScene);
});

it('PLAN mode instructions should include council scene rendering block', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'PLAN',
instructions: 'Base PLAN instructions',
originalPrompt:
'PLAN implement password reset endpoint that sends an email with a reset link',
});

const result = await handler.handle('parse_mode', {
prompt: 'PLAN implement password reset endpoint that sends an email with a reset link',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.instructions).toContain('COUNCIL SCENE');
});

it('ACT mode instructions should NOT include council scene rendering block', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'ACT',
instructions: 'Base ACT instructions',
originalPrompt: 'implement feature',
});

const result = await handler.handle('parse_mode', {
prompt: 'ACT implement feature',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.instructions).not.toContain('COUNCIL SCENE');
});

it('EVAL mode instructions should include council scene rendering block', async () => {
mockKeywordService.parseMode = vi.fn().mockResolvedValue({
...mockParseModeResult,
mode: 'EVAL',
instructions: 'Base EVAL instructions',
originalPrompt: 'evaluate implementation',
});

const result = await handler.handle('parse_mode', {
prompt: 'EVAL evaluate implementation',
});

expect(result?.isError).toBeFalsy();
const parsed = JSON.parse((result?.content[0] as { text: string }).text);
expect(parsed.instructions).toContain('COUNCIL SCENE');
});
});

describe('reviewContext (#1411)', () => {
Expand Down
11 changes: 10 additions & 1 deletion apps/mcp-server/src/mcp/handlers/mode.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ import {
suppressDispatchWhileGated,
type ExecutionGate,
} from './execution-gate';
import { buildCouncilScene } from './council-scene.builder';
import { buildCouncilScene, buildCouncilSceneInstructions } from './council-scene.builder';

/** Maximum length for context title slug generation */
const CONTEXT_TITLE_MAX_LENGTH = 50;
Expand Down Expand Up @@ -356,6 +356,15 @@ export class ModeHandler extends AbstractHandler {
},
);

// Council Scene rendering instructions (#1421) — append to instructions
// so the AI client renders the opening scene in its first response.
// Must come before clarification override since clarification replaces
// instructions entirely when the request is ambiguous.
const councilRenderInstructions = buildCouncilSceneInstructions(councilScene);
if (councilRenderInstructions) {
result.instructions += councilRenderInstructions;
}

// Clarification Gate (#1371) — only applies to PLAN/AUTO modes where the
// response might otherwise produce a plan. ACT and EVAL skip the gate
// because they assume PLAN has already set context.
Expand Down
14 changes: 11 additions & 3 deletions packages/claude-code-plugin/hooks/lib/mode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,11 +454,19 @@ def build_instructions(

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

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

# Enrich with .ai-rules data
enrichment = self._build_rules_snippet(mode_upper, agent["name"])
Expand Down
18 changes: 16 additions & 2 deletions packages/claude-code-plugin/hooks/lib/test_mode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,12 +430,26 @@ def test_case_insensitive(self):

def test_council_scene_in_build_instructions(self):
result = self.engine.build_instructions("PLAN")
self.assertIn("Council Scene:", result)
self.assertIn("COUNCIL SCENE", result)
self.assertIn("technical-planner", result)

def test_no_council_scene_in_act_instructions(self):
result = self.engine.build_instructions("ACT")
self.assertNotIn("Council Scene:", result)
self.assertNotIn("COUNCIL SCENE", result)

def test_council_scene_includes_face_name_role(self):
result = self.engine.build_instructions("PLAN")
self.assertIn("●‿● technical-planner [primary]", result)
self.assertIn("[specialist]", result)

def test_council_scene_includes_moderator_copy(self):
result = self.engine.build_instructions("PLAN")
self.assertIn("Council assembled", result)

def test_eval_council_scene_rendering(self):
result = self.engine.build_instructions("EVAL")
self.assertIn("COUNCIL SCENE", result)
self.assertIn("Review council convened", result)

def test_serializable_json(self):
scene = self.engine.build_council_scene("PLAN")
Expand Down
Loading