Skip to content

Commit 1d60fb7

Browse files
committed
feat(mcp/plugin): add opening council-scene contract to parse_mode (#1366)
Add councilScene field to parse_mode response for PLAN/EVAL/AUTO modes, enabling the assistant to open its first response with a Tiny Actor council scene. ACT mode omits the field entirely. MCP server: - council-scene.types.ts: CouncilScene and CouncilSceneCastMember types - council-scene.builder.ts: pure function building scene from preset/visual/fallback - mode.handler.ts: integrate councilScene into response - 8 new builder unit tests, 7 new handler integration tests Standalone plugin: - mode_engine.py: COUNCIL_PRESETS, MODERATOR_COPY, build_council_scene() - 12 new Python tests covering all modes and serialization Closes #1366
1 parent 24685c1 commit 1d60fb7

8 files changed

Lines changed: 615 additions & 2 deletions

File tree

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { buildCouncilScene } from './council-scene.builder';
3+
import type { CouncilPreset } from '../../agent/council-preset.types';
4+
import type { VisualData } from '../../keyword/keyword.types';
5+
6+
describe('buildCouncilScene', () => {
7+
const planPreset: CouncilPreset = {
8+
mode: 'PLAN',
9+
primary: 'technical-planner',
10+
specialists: ['architecture-specialist', 'security-specialist'],
11+
};
12+
13+
const evalPreset: CouncilPreset = {
14+
mode: 'EVAL',
15+
primary: 'code-reviewer',
16+
specialists: ['security-specialist', 'performance-specialist'],
17+
};
18+
19+
const visual: VisualData = {
20+
banner: '╭━━━━━╮\n┃ ◊‿◊ ┃ PLAN mode!\n╰━━┳━━╯',
21+
agents: [
22+
{ name: 'Technical Planner', face: '◇‿◇', color: 'magenta', status: 'analyzing' },
23+
{ name: 'Architecture Specialist', face: '⬡‿⬡', color: 'blue', status: 'waiting' },
24+
{ name: 'Security Specialist', face: '◮‿◮', color: 'red', status: 'waiting' },
25+
],
26+
collaboration: { format: 'minimal', renderHint: 'Display agent collaboration' },
27+
};
28+
29+
it('returns undefined for ACT mode', () => {
30+
expect(buildCouncilScene('ACT', planPreset, visual)).toBeUndefined();
31+
});
32+
33+
it('returns councilScene for PLAN mode with councilPreset', () => {
34+
const scene = buildCouncilScene('PLAN', planPreset, undefined);
35+
expect(scene).toBeDefined();
36+
expect(scene!.enabled).toBe(true);
37+
expect(scene!.format).toBe('tiny-actor-grid');
38+
expect(scene!.moderatorCopy).toContain('design this together');
39+
expect(scene!.cast).toHaveLength(3);
40+
expect(scene!.cast[0]).toEqual({
41+
name: 'technical-planner',
42+
role: 'primary',
43+
face: '●‿●',
44+
});
45+
expect(scene!.cast[1].role).toBe('specialist');
46+
});
47+
48+
it('returns councilScene for EVAL mode with councilPreset', () => {
49+
const scene = buildCouncilScene('EVAL', evalPreset, undefined);
50+
expect(scene).toBeDefined();
51+
expect(scene!.enabled).toBe(true);
52+
expect(scene!.moderatorCopy).toContain('Review council');
53+
expect(scene!.cast[0].name).toBe('code-reviewer');
54+
expect(scene!.cast[0].role).toBe('primary');
55+
});
56+
57+
it('returns councilScene for AUTO mode from visual agents', () => {
58+
const scene = buildCouncilScene('AUTO', undefined, visual);
59+
expect(scene).toBeDefined();
60+
expect(scene!.enabled).toBe(true);
61+
expect(scene!.moderatorCopy).toContain('Autonomous council');
62+
expect(scene!.cast[0].role).toBe('primary');
63+
expect(scene!.cast[0].name).toBe('Technical Planner');
64+
expect(scene!.cast[0].face).toBe('◇‿◇');
65+
expect(scene!.cast.slice(1).every(m => m.role === 'specialist')).toBe(true);
66+
});
67+
68+
it('returns councilScene for AUTO mode from fallback when visual is undefined', () => {
69+
const scene = buildCouncilScene('AUTO', undefined, undefined, {
70+
delegatesTo: 'agent-architect',
71+
specialists: ['security-specialist'],
72+
});
73+
expect(scene).toBeDefined();
74+
expect(scene!.cast[0]).toEqual({
75+
name: 'agent-architect',
76+
role: 'primary',
77+
face: '●‿●',
78+
});
79+
expect(scene!.cast[1]).toEqual({
80+
name: 'security-specialist',
81+
role: 'specialist',
82+
face: '●‿●',
83+
});
84+
});
85+
86+
it('returns undefined when no data is available', () => {
87+
const scene = buildCouncilScene('AUTO', undefined, undefined);
88+
expect(scene).toBeUndefined();
89+
});
90+
91+
it('cross-references faces from visual data for councilPreset agents', () => {
92+
const scene = buildCouncilScene('PLAN', planPreset, visual);
93+
expect(scene).toBeDefined();
94+
// "technical-planner" slug matches "Technical Planner" → face "◇‿◇"
95+
expect(scene!.cast[0].face).toBe('◇‿◇');
96+
// "architecture-specialist" slug matches "Architecture Specialist" → face "⬡‿⬡"
97+
expect(scene!.cast[1].face).toBe('⬡‿⬡');
98+
});
99+
100+
it('cast has exactly one primary member', () => {
101+
const scene = buildCouncilScene('PLAN', planPreset, visual);
102+
expect(scene).toBeDefined();
103+
const primaries = scene!.cast.filter(m => m.role === 'primary');
104+
expect(primaries).toHaveLength(1);
105+
});
106+
107+
it('is serializable JSON', () => {
108+
const scene = buildCouncilScene('PLAN', planPreset, visual);
109+
expect(scene).toBeDefined();
110+
const roundTripped = JSON.parse(JSON.stringify(scene));
111+
expect(roundTripped).toEqual(scene);
112+
});
113+
});
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* Council Scene Builder — constructs the opening council-scene contract (#1366).
3+
*
4+
* Pure function: no I/O, no side-effects. Depends only on data already
5+
* resolved by the handler (councilPreset, visual data, agent IDs).
6+
*/
7+
8+
import type { CouncilScene, CouncilSceneCastMember } from './council-scene.types';
9+
import type { CouncilPreset } from '../../agent/council-preset.types';
10+
import type { Mode, VisualData, AgentVisualInfo } from '../../keyword/keyword.types';
11+
12+
/** Default face when no visual data is available */
13+
const DEFAULT_FACE = '●‿●';
14+
15+
/** Mode-specific moderator opening lines (deterministic, testable) */
16+
const MODERATOR_COPY: Partial<Record<Mode, string>> = {
17+
PLAN: 'Council assembled — let us design this together.',
18+
EVAL: 'Review council convened — specialists are ready.',
19+
AUTO: 'Autonomous council activated — full cycle begins.',
20+
};
21+
22+
/** Optional agent ID fallback for modes without a council preset */
23+
export interface CouncilSceneFallback {
24+
/** Primary agent ID (e.g. "agent-architect") */
25+
delegatesTo?: string;
26+
/** Specialist agent IDs (e.g. ["security-specialist", ...]) */
27+
specialists?: string[];
28+
}
29+
30+
/**
31+
* Build a CouncilScene for the given mode, or return undefined for ACT mode.
32+
*
33+
* Resolution strategy:
34+
* - PLAN/EVAL → uses councilPreset (primary + specialists), faces from visual
35+
* - AUTO → visual.agents first, then agent ID fallback
36+
* - ACT → undefined (no council scene)
37+
*/
38+
export function buildCouncilScene(
39+
mode: Mode,
40+
councilPreset: CouncilPreset | undefined,
41+
visual: VisualData | undefined,
42+
fallback?: CouncilSceneFallback,
43+
): CouncilScene | undefined {
44+
if (mode === 'ACT') {
45+
return undefined;
46+
}
47+
48+
const moderatorCopy = MODERATOR_COPY[mode];
49+
if (!moderatorCopy) {
50+
return undefined;
51+
}
52+
53+
const cast = buildCast(councilPreset, visual, fallback);
54+
if (cast.length === 0) {
55+
return undefined;
56+
}
57+
58+
return {
59+
enabled: true,
60+
cast,
61+
moderatorCopy,
62+
format: 'tiny-actor-grid',
63+
};
64+
}
65+
66+
/**
67+
* Build the cast list for the council scene.
68+
*/
69+
function buildCast(
70+
councilPreset: CouncilPreset | undefined,
71+
visual: VisualData | undefined,
72+
fallback?: CouncilSceneFallback,
73+
): CouncilSceneCastMember[] {
74+
// Build a face lookup from visual agents (keyed by display name and slug)
75+
const faceLookup = new Map<string, string>();
76+
if (visual?.agents) {
77+
for (const agent of visual.agents) {
78+
faceLookup.set(agent.name, agent.face);
79+
const slug = agent.name.toLowerCase().replace(/\s+/g, '-');
80+
faceLookup.set(slug, agent.face);
81+
}
82+
}
83+
84+
// Priority 1: councilPreset (PLAN/EVAL)
85+
if (councilPreset) {
86+
return buildCastFromPreset(councilPreset, faceLookup);
87+
}
88+
89+
// Priority 2: visual agents (when loaded)
90+
if (visual?.agents?.length) {
91+
return buildCastFromVisual(visual.agents);
92+
}
93+
94+
// Priority 3: agent ID fallback (AUTO mode when visual loading fails)
95+
if (fallback?.delegatesTo) {
96+
return buildCastFromFallback(fallback, faceLookup);
97+
}
98+
99+
return [];
100+
}
101+
102+
/**
103+
* Build cast from a council preset, cross-referencing faces from visual data.
104+
*/
105+
function buildCastFromPreset(
106+
preset: CouncilPreset,
107+
faceLookup: Map<string, string>,
108+
): CouncilSceneCastMember[] {
109+
const cast: CouncilSceneCastMember[] = [];
110+
111+
cast.push({
112+
name: preset.primary,
113+
role: 'primary',
114+
face: faceLookup.get(preset.primary) ?? DEFAULT_FACE,
115+
});
116+
117+
for (const specialist of preset.specialists) {
118+
cast.push({
119+
name: specialist,
120+
role: 'specialist',
121+
face: faceLookup.get(specialist) ?? DEFAULT_FACE,
122+
});
123+
}
124+
125+
return cast;
126+
}
127+
128+
/**
129+
* Build cast from visual agent info.
130+
* First agent is tagged as primary, rest as specialists.
131+
*/
132+
function buildCastFromVisual(agents: AgentVisualInfo[]): CouncilSceneCastMember[] {
133+
const [first, ...rest] = agents;
134+
const cast: CouncilSceneCastMember[] = [];
135+
136+
if (first) {
137+
cast.push({
138+
name: first.name,
139+
role: 'primary',
140+
face: first.face || DEFAULT_FACE,
141+
});
142+
}
143+
144+
for (const agent of rest) {
145+
cast.push({
146+
name: agent.name,
147+
role: 'specialist',
148+
face: agent.face || DEFAULT_FACE,
149+
});
150+
}
151+
152+
return cast;
153+
}
154+
155+
/**
156+
* Build cast from agent ID fallback (when visual data is unavailable).
157+
*/
158+
function buildCastFromFallback(
159+
fallback: CouncilSceneFallback,
160+
faceLookup: Map<string, string>,
161+
): CouncilSceneCastMember[] {
162+
const cast: CouncilSceneCastMember[] = [];
163+
164+
if (fallback.delegatesTo) {
165+
cast.push({
166+
name: fallback.delegatesTo,
167+
role: 'primary',
168+
face: faceLookup.get(fallback.delegatesTo) ?? DEFAULT_FACE,
169+
});
170+
}
171+
172+
if (fallback.specialists) {
173+
for (const specialist of fallback.specialists) {
174+
cast.push({
175+
name: specialist,
176+
role: 'specialist',
177+
face: faceLookup.get(specialist) ?? DEFAULT_FACE,
178+
});
179+
}
180+
}
181+
182+
return cast;
183+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Council Scene Types — first-response council-scene contract (#1366).
3+
*
4+
* When present in the parse_mode response, the consuming assistant should
5+
* open its first visible response with a Tiny Actor council scene followed
6+
* by structured consensus output (risks / disagreements / next step).
7+
*/
8+
9+
/** A single cast member in the council scene */
10+
export interface CouncilSceneCastMember {
11+
/** Agent identifier (kebab-case slug, e.g. "technical-planner") */
12+
name: string;
13+
/** Role in the council */
14+
role: 'primary' | 'specialist';
15+
/** ASCII face expression (e.g. "◇‿◇") */
16+
face: string;
17+
}
18+
19+
/**
20+
* Opening council-scene contract for eligible workflow modes.
21+
*
22+
* Scoped to PLAN, EVAL, and AUTO modes. ACT mode and unrelated
23+
* prompts must NOT include this field.
24+
*/
25+
export interface CouncilScene {
26+
/** Whether the council scene should be rendered */
27+
enabled: boolean;
28+
/** Ordered list of council members (primary first) */
29+
cast: CouncilSceneCastMember[];
30+
/** Opening moderator line for the scene */
31+
moderatorCopy: string;
32+
/** Rendering format hint */
33+
format: 'tiny-actor-grid';
34+
}

0 commit comments

Comments
 (0)