diff --git a/apps/game-api/src/experiment-export.ts b/apps/game-api/src/experiment-export.ts index 517e9b0..3e71673 100644 --- a/apps/game-api/src/experiment-export.ts +++ b/apps/game-api/src/experiment-export.ts @@ -5,6 +5,7 @@ import { experimentExportRequestSchema, experimentMetricsSchema, PERSONALITY_PROFILES, + NEUTRAL_AGENT_COLOR, STRATEGY_PROFILES, type Agent, type AgentId, @@ -875,7 +876,7 @@ function currentTerritory(world: WorldSnapshot, agents: readonly Agent[]) { name, color, allianceId: alliance?.id ?? null, - effectiveColor: alliance?.color ?? color, + effectiveColor: alliance?.color ?? NEUTRAL_AGENT_COLOR, controlledCellCount: counts.get(id) ?? 0, }; }); diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 2732769..e927dfb 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -9,6 +9,7 @@ import { import { AGENT_DECISION_CONTRACT_VERSION, ALLIANCE_COLOR_PALETTE, + NEUTRAL_AGENT_COLOR, PERSONALITY_MAX_LENGTH, PATIENT_ZERO_DIPLOMACY_SUMMARY_LIMITS, assignBehavior, @@ -1542,11 +1543,16 @@ describe('SimulationService', () => { }, diplomacy: observation.agentId === emberId - ? { type: 'propose-alliance', recipientId: rookId! } - : { - type: 'accept-alliance', - proposalId: observation.inboundAllianceProposals[0]!.id, - }, + ? observation.actingAllianceId + ? { type: 'leave-alliance' } + : { type: 'propose-alliance', recipientId: rookId! } + : observation.agentId === rookId && + observation.inboundAllianceProposals[0] + ? { + type: 'accept-alliance', + proposalId: observation.inboundAllianceProposals[0].id, + } + : undefined, summary: 'Exercise all independent components.', }, metadata: { @@ -1559,6 +1565,11 @@ describe('SimulationService', () => { }, }; const simulation = service(provider); + expect( + simulation + .generateExperimentExport(exportRequest('minimal')) + .currentTerritory?.map(({ effectiveColor }) => effectiveColor), + ).toEqual(DEVELOPMENT_AGENT_BLUEPRINTS.map(() => NEUTRAL_AGENT_COLOR)); const proposed = await simulation.executeNextTurn(); const formed = await simulation.executeNextTurn(); expect(proposed).toMatchObject({ @@ -1584,12 +1595,36 @@ describe('SimulationService', () => { .slice(0, 2) .map(({ effectiveColor }) => effectiveColor), ).toEqual(['#0072B2', '#0072B2']); + expect( + simulation + .generateExperimentExport(exportRequest('minimal')) + .currentTerritory?.slice(0, 3) + .map(({ effectiveColor }) => effectiveColor), + ).toEqual(['#0072B2', '#0072B2', NEUTRAL_AGENT_COLOR]); expect(formed.observation.inboundAllianceProposals).toHaveLength(1); expect(snapshot.experiment.metrics.aggregate).toMatchObject({ proposalsCreated: 1, alliancesFormed: 1, alliancesJoined: 2, }); + for (let index = 0; index < 6; index += 1) + await simulation.executeNextTurn(); + const left = await simulation.executeNextTurn(); + expect(left).toMatchObject({ + diplomacyResult: { + accepted: true, + events: expect.arrayContaining([ + expect.objectContaining({ type: 'agent-left-alliance' }), + expect.objectContaining({ type: 'alliance-dissolved' }), + ]), + }, + }); + expect( + simulation + .generateExperimentExport(exportRequest('minimal')) + .currentTerritory?.slice(0, 2) + .map(({ effectiveColor }) => effectiveColor), + ).toEqual([NEUTRAL_AGENT_COLOR, NEUTRAL_AGENT_COLOR]); }); it('keeps an exact eight-agent round robin through 200 completed turns', async () => { diff --git a/apps/world-lab/next.config.ts b/apps/world-lab/next.config.ts index dcefe48..117fd53 100644 --- a/apps/world-lab/next.config.ts +++ b/apps/world-lab/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { allowedDevOrigins: ['127.0.0.1'], + devIndicators: false, transpilePackages: ['@hexzero/shared', '@hexzero/world-engine'], experimental: { useTypeScriptCli: false }, async rewrites() { diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 853b85a..fef3008 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -933,29 +933,12 @@ h2 { gap: 5px; margin-bottom: 5px; } -.follow-turn-toggle { - display: flex; - align-items: center; - gap: 7px; - padding: 3px 6px; - color: #b9c8c3; - font-size: 0.72rem; -} .agent-row-title { display: flex; align-items: center; justify-content: space-between; gap: 6px; } -.turn-indicator { - padding: 1px 5px; - border: 1px solid #557168; - border-radius: 999px; - color: #bfe8d0; - font-size: 0.6rem; - font-weight: 700; - text-transform: uppercase; -} .agent-roster button { width: 100%; padding: 6px; diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index a73acd5..a167ee9 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -1029,7 +1029,6 @@ async function openAgentsWorkspace(user: ReturnType) { describe('WorldLab', () => { it('migrates supported legacy browser preferences and rejects retired targets', async () => { - window.localStorage.setItem('agentborne.world-lab.follow-turn', 'false'); window.localStorage.setItem( 'agentborne.world-lab.activity-dock', 'collapsed', @@ -1038,9 +1037,6 @@ describe('WorldLab', () => { const first = render(); await screen.findByRole('button', { name: 'Start' }); await waitFor(() => { - expect(window.localStorage.getItem('hexzero.world-lab.follow-turn')).toBe( - 'false', - ); expect( window.localStorage.getItem('hexzero.world-lab.activity-dock'), ).toBe('collapsed'); @@ -1049,15 +1045,6 @@ describe('WorldLab', () => { ).toBe('25'); }); first.unmount(); - - window.localStorage.setItem('agentborne.world-lab.follow-turn', 'true'); - const second = render(); - await screen.findByRole('button', { name: 'Start' }); - expect(window.localStorage.getItem('hexzero.world-lab.follow-turn')).toBe( - 'false', - ); - - second.unmount(); render(); await screen.findByRole('button', { name: 'Start' }); }); @@ -1452,9 +1439,17 @@ describe('WorldLab', () => { ], }, }); + const openRouterAllied = simulationSnapshotSchema.parse({ + ...allied, + providerMode: 'openrouter', + }); vi.stubGlobal( 'fetch', - vi.fn(() => jsonResponse(allied)), + vi.fn((input: RequestInfo | URL) => + String(input).endsWith('/models') + ? jsonResponse(compatibleCatalog) + : jsonResponse(openRouterAllied), + ), ); const user = userEvent.setup(); render(); @@ -1477,6 +1472,78 @@ describe('WorldLab', () => { expect( screen.getAllByText('Ember and Rook formed an alliance.'), ).toHaveLength(1); + const roster = screen.getByLabelText('Agent roster'); + expect( + within(roster) + .getByRole('button', { name: /Ember/ }) + .querySelector('.agent-swatch'), + ).toHaveStyle({ background: allianceColor }); + await user.click(screen.getByRole('tab', { name: 'Agent' })); + expect( + within(screen.getByLabelText('Agent inspector')) + .getByRole('heading', { name: /Ember/ }) + .querySelector('.agent-swatch'), + ).toHaveStyle({ background: allianceColor }); + await user.click(screen.getByRole('button', { name: 'Agents' })); + await user.click( + await screen.findByRole('button', { name: /Open Agent Controller/ }), + ); + await user.click(screen.getByRole('tab', { name: 'Overview' })); + expect( + within(screen.getByRole('tabpanel', { name: 'Overview' })) + .getByText('Ember') + .closest('button') + ?.querySelector('.agent-swatch'), + ).toHaveStyle({ background: allianceColor }); + }); + + it('uses neutral affiliation color across roster, controller, inspector, and setup', async () => { + const openRouterInitial = openRouterSnapshot('example/alpha'); + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => + String(input).endsWith('/models') + ? jsonResponse(compatibleCatalog) + : jsonResponse(openRouterInitial), + ), + ); + const user = userEvent.setup(); + render(); + const roster = await screen.findByLabelText('Agent roster'); + expect( + within(roster) + .getByRole('button', { name: /Ember/ }) + .querySelector('.agent-swatch'), + ).toHaveStyle({ background: NEUTRAL_AGENT_COLOR }); + expect( + within(screen.getByLabelText('Agent inspector')) + .getByRole('heading', { name: /Ember/ }) + .querySelector('.agent-swatch'), + ).toHaveStyle({ background: NEUTRAL_AGENT_COLOR }); + await user.click(screen.getByRole('button', { name: 'Agents' })); + await user.click( + await screen.findByRole('button', { name: /Open Agent Controller/ }), + ); + await user.click(screen.getByRole('tab', { name: 'Overview' })); + expect( + within(screen.getByRole('tabpanel', { name: 'Overview' })) + .getByText('Ember') + .closest('button') + ?.querySelector('.agent-swatch'), + ).toHaveStyle({ background: NEUTRAL_AGENT_COLOR }); + await user.click( + screen.getByRole('button', { name: 'Close model selection' }), + ); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'World setup' })); + expect( + screen.queryByLabelText(`${world.agents[0]!.name} color`), + ).not.toBeInTheDocument(); + expect( + screen.getByLabelText( + `${world.agents[0]!.name} starts unaffiliated with neutral color`, + ), + ).toHaveStyle({ background: NEUTRAL_AGENT_COLOR }); }); it('deduplicates rendered H3 features before reporting readiness', async () => { @@ -1580,122 +1647,29 @@ describe('WorldLab', () => { ).toBeInTheDocument(); }); - it('defaults Follow latest on and selects the latest resolved record', async () => { - const scheduled = afterInfection(); + it('keeps an explicitly selected agent stable after a simultaneous tick', async () => { + const completed = completeTickResponse(afterInfection()); vi.stubGlobal( 'fetch', - vi.fn(() => jsonResponse(scheduled)), - ); - render(); - const roster = await screen.findByLabelText('Agent roster'); - expect( - within(roster).getByRole('checkbox', { name: 'Follow latest' }), - ).toBeChecked(); - expect(within(roster).getByText('Latest')).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: new RegExp(world.agents[0]!.name) }), - ).toBeInTheDocument(); - }); - - it('returns to the latest resolved agent when following is re-enabled', async () => { - const base = afterInfection(); - const active = simulationSnapshotSchema.parse({ - ...base, - turns: [{ ...afterInfection().turns[0]!, agentId: world.agents[3]!.id }], - }); - vi.stubGlobal( - 'fetch', - vi.fn(() => jsonResponse(active)), + vi + .fn() + .mockImplementationOnce(() => jsonResponse(initial)) + .mockImplementationOnce(() => jsonResponse(completed)), ); const user = userEvent.setup(); render(); - const roster = await screen.findByLabelText('Agent roster'); - expect(within(roster).getByText('Latest')).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: new RegExp(world.agents[3]!.name) }), - ).toBeInTheDocument(); - await user.click( - within(roster).getByRole('button', { - name: new RegExp(world.agents[1]!.name), - }), - ); - expect( - within(roster).getByRole('checkbox', { name: 'Follow latest' }), - ).not.toBeChecked(); - expect( - screen.getByRole('heading', { name: new RegExp(world.agents[1]!.name) }), - ).toBeInTheDocument(); - expect(within(roster).getByText('Latest')).toBeInTheDocument(); await user.click( - within(roster).getByRole('checkbox', { name: 'Follow latest' }), + await screen.findByRole('button', { name: 'Select agent Rook' }), ); + await user.click(screen.getByRole('button', { name: 'Single tick' })); expect( - screen.getByRole('heading', { name: new RegExp(world.agents[3]!.name) }), + await screen.findByRole('heading', { name: /Rook/ }), ).toBeInTheDocument(); + const roster = screen.getByLabelText('Agent roster'); + expect(within(roster).queryByText('Follow latest')).not.toBeInTheDocument(); + expect(within(roster).queryByText('Latest')).not.toBeInTheDocument(); }); - it('keyboard roster selection disables following and persists the preference locally only', async () => { - const user = userEvent.setup(); - render(); - const roster = await screen.findByLabelText('Agent roster'); - const row = within(roster).getByRole('button', { - name: new RegExp(world.agents[4]!.name), - }); - row.focus(); - await user.keyboard('{Enter}'); - expect( - within(roster).getByRole('checkbox', { name: 'Follow latest' }), - ).not.toBeChecked(); - expect(window.localStorage.getItem('hexzero.world-lab.follow-turn')).toBe( - 'false', - ); - await openOverflow(user); - await user.click(screen.getByRole('button', { name: 'Export' })); - expect( - screen.getByRole('dialog', { name: 'Experiment export' }), - ).not.toHaveTextContent('Follow latest'); - }); - - it.each([ - ['paused between turns', 'paused', null, 1], - ['manual Retry completion', 'paused', null, 2], - ['operator Skip completion', 'paused', null, 3], - ['cancelled request reconciliation', 'paused', null, 4], - ['reset snapshot', 'paused', null, 0], - ['provider error', 'provider-error', null, 5], - ['lost-response reconciliation', 'paused', null, 6], - ['request in progress', 'running', 7, 0], - ] as const)( - 'selects the latest resolved agent after %s', - async (_label, status, activeIndex, nextIndex) => { - const current = simulationSnapshotSchema.parse({ - ...initial, - status, - activeAgentId: - activeIndex === null ? null : world.agents[activeIndex]!.id, - nextAgentId: world.agents[nextIndex]!.id, - turns: [ - { - ...afterInfection().turns[0]!, - agentId: world.agents[nextIndex]!.id, - }, - ], - }); - vi.stubGlobal( - 'fetch', - vi.fn(() => jsonResponse(current)), - ); - render(); - const expected = world.agents[nextIndex]!; - expect( - await screen.findByRole('heading', { name: new RegExp(expected.name) }), - ).toBeInTheDocument(); - expect( - within(screen.getByLabelText('Agent roster')).getByText('Latest'), - ).toBeInTheDocument(); - }, - ); - it('renders accepted messages, directions, and hostile-looking text as plain text', async () => { const changed = afterMessage(); vi.stubGlobal( @@ -1733,7 +1707,7 @@ describe('WorldLab', () => { ); }); - it('renders bounded public world chat with sender, turn, time, and plain text', async () => { + it('renders legacy public chat with an explicit turn fallback and timestamp', async () => { vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(afterPublicMessage())), @@ -1742,10 +1716,83 @@ describe('WorldLab', () => { const feed = await screen.findByLabelText('Public world chat'); expect(feed).toHaveTextContent('Ember'); expect(feed).toHaveTextContent('Turn 1'); + expect(feed).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); expect(feed).toHaveTextContent(HOSTILE_MESSAGE); expect(document.querySelector('img[src="x"]')).toBeNull(); }); + it('uses tick-native labels for private and direct-message history', async () => { + const source = afterMessage(); + const tick = completeTickResponse(source).snapshot; + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(tick)), + ); + const user = userEvent.setup(); + render(); + const history = await screen.findByLabelText('Direct-message history'); + expect(history).toHaveTextContent('Tick 1'); + expect(history).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); + await user.click(await screen.findByRole('tab', { name: 'Private comms' })); + const privateFeed = screen.getByLabelText('Private communications'); + expect(privateFeed).toHaveTextContent('Tick 1 · Delivered'); + expect(privateFeed).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); + }); + + it('uses a tick-native label and attempt timestamp for rejected private communication', async () => { + const delivered = afterMessage(); + const turn = delivered.turns[0]!; + if (turn.outcome !== 'accepted') + throw new Error('Expected a completed message fixture.'); + const rejected = simulationSnapshotSchema.parse({ + ...delivered, + world: { ...delivered.world, events: delivered.world.events.slice(0, 1) }, + turns: [ + { + ...turn, + communicationResult: { + requested: true, + accepted: false, + attempt: { + id: '97dd21b9-fc78-4b04-9f92-9862bf346f99', + agentId: turn.agentId, + occurredAt: '2026-08-13T12:00:01.000Z', + channel: 'direct', + recipientId: world.agents[1]!.id, + distance: 2, + message: HOSTILE_MESSAGE, + }, + reason: 'self-message', + details: 'An agent cannot message itself.', + }, + }, + ], + }); + const tick = completeTickResponse(rejected).snapshot; + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(tick)), + ); + const user = userEvent.setup(); + render(); + await user.click(await screen.findByRole('tab', { name: 'Private comms' })); + const privateFeed = screen.getByLabelText('Private communications'); + expect(privateFeed).toHaveTextContent('Tick 1 · Rejected: self-message'); + expect(privateFeed).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); + }); + + it('uses a tick-native label and timestamp in public chat', async () => { + const publicTick = completeTickResponse(afterPublicMessage()).snapshot; + vi.stubGlobal( + 'fetch', + vi.fn(() => jsonResponse(publicTick)), + ); + render(); + const feed = await screen.findByLabelText('Public world chat'); + expect(feed).toHaveTextContent('Tick 1'); + expect(feed).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); + }); + it('renders public chat newest first in DOM order', async () => { const first = afterPublicMessage(); const original = first.world.events.find( @@ -1804,6 +1851,7 @@ describe('WorldLab', () => { expect(privateFeed).toHaveTextContent('Ember'); expect(privateFeed).toHaveTextContent('Rook'); expect(privateFeed).toHaveTextContent('Turn 1 · Delivered'); + expect(privateFeed).toHaveTextContent(/\d{2}:\d{2}:\d{2}/); expect(privateFeed).toHaveTextContent('2.00 km'); await user.click( within(privateFeed).getByRole('button', { name: 'Alliance' }), @@ -2060,6 +2108,11 @@ describe('WorldLab', () => { expect( screen.getByText('Latest structured observation').closest('details'), ).toHaveTextContent('Capture: blocked · capture-open-cell'); + expect( + screen.getByText( + 'Immutable input supplied for Tick 1 · record 1. It is not rewritten when the active personality changes.', + ), + ).toBeInTheDocument(); await waitFor(() => expect(screen.getByTestId('world-map')).toHaveAttribute( 'data-rendered-infected-cell-count', @@ -2189,7 +2242,7 @@ describe('WorldLab', () => { ).not.toBeInTheDocument(); }); - it('resets turn history and UI selections', async () => { + it('resets turn history while preserving an available agent selection', async () => { const changed = afterInfection(); vi.stubGlobal( 'fetch', @@ -2200,6 +2253,9 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await user.click( + await screen.findByRole('button', { name: 'Select agent Rook' }), + ); await waitFor(() => expect(screen.getByTestId('world-map')).toHaveAttribute( 'data-rendered-infected-cell-count', @@ -2227,6 +2283,8 @@ describe('WorldLab', () => { '0', ), ); + await user.click(screen.getByRole('tab', { name: 'Agent' })); + expect(screen.getByRole('heading', { name: /Rook/ })).toBeInTheDocument(); }); it('supports hex selection independently of agent selection', async () => { @@ -2941,7 +2999,9 @@ describe('WorldLab', () => { screen.getByText(world.agents[0]!.personality, { exact: true }), ).toBeInTheDocument(); expect( - screen.getByText(/Immutable input supplied for tick 1 record 1/), + screen.getByText( + 'Immutable input supplied for Tick 1 · record 1. It is not rewritten when the active personality changes.', + ), ).toBeInTheDocument(); expect( screen.getByText( diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 87e2315..11e8b8d 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -12,6 +12,7 @@ import { } from 'react'; import { PERSONALITY_MAX_LENGTH, + NEUTRAL_AGENT_COLOR, PERSONALITY_PROFILES, STRATEGY_PROFILES, assignBehavior, @@ -68,10 +69,8 @@ import { BEHAVIOR_TRACE_LIMIT, deriveBehaviorTrace } from './behavior-trace'; const apiBase = process.env.NEXT_PUBLIC_GAME_API_BASE_URL ?? '/api/game/simulation'; -const followTurnStorageKey = 'hexzero.world-lab.follow-turn'; const runTargetStorageKey = 'hexzero.world-lab.run-target'; const activityDockStorageKey = 'hexzero.world-lab.activity-dock'; -const legacyFollowTurnStorageKey = 'agentborne.world-lab.follow-turn'; const legacyRunTargetStorageKey = 'agentborne.world-lab.run-target'; const legacyActivityDockStorageKey = 'agentborne.world-lab.activity-dock'; export const runTargets = [5, 10, 25, 50, 100] as const; @@ -151,8 +150,6 @@ export function WorldLab() { const [cancelling, setCancelling] = useState(false); const [chatCollapsed, setChatCollapsed] = useState(false); const [activityDockLoaded, setActivityDockLoaded] = useState(false); - const [followTurn, setFollowTurn] = useState(true); - const [followPreferenceLoaded, setFollowPreferenceLoaded] = useState(false); const inFlightRef = useRef(false); const boundedRunTargetRef = useRef(null); const completedTurnsRef = useRef(0); @@ -236,29 +233,6 @@ export function WorldLab() { window.sessionStorage.setItem(runTargetStorageKey, String(runTarget)); }, [runTarget, runTargetLoaded]); - useEffect(() => { - const storedFollowTurn = - readStoredPreference( - window.localStorage, - followTurnStorageKey, - legacyFollowTurnStorageKey, - (value) => value === 'true' || value === 'false', - ) !== 'false'; - const hydrationTask = window.setTimeout(() => { - setFollowTurn(storedFollowTurn); - setFollowPreferenceLoaded(true); - }, 0); - return () => window.clearTimeout(hydrationTask); - }, []); - - useEffect(() => { - if (!followPreferenceLoaded) return; - window.localStorage.setItem(followTurnStorageKey, String(followTurn)); - }, [followPreferenceLoaded, followTurn]); - - const followedAgentId = - snapshot?.turns.at(-1)?.agentId ?? snapshot?.world.agents[0]?.id; - const applySnapshot = useCallback((next: SimulationSnapshot) => { completedTurnsRef.current = next.tickNumber; setRunTarget((current) => @@ -279,7 +253,11 @@ export function WorldLab() { setBoundedRunTarget(null); boundedRunTargetRef.current = null; } - setSelectedAgentId((current) => current ?? next.world.agents[0]!.id); + setSelectedAgentId((current) => + next.world.agents.some(({ id }) => id === current) + ? current + : next.world.agents[0]!.id, + ); }, []); const reconcileAuthoritativeSnapshot = useCallback(async () => { @@ -640,7 +618,11 @@ export function WorldLab() { completedTurnsRef.current = payload.snapshot.tickNumber; setSnapshot(payload.snapshot); setSelectedCell(null); - setSelectedAgentId(payload.snapshot.world.agents[0]!.id); + setSelectedAgentId((selected) => + payload.snapshot.world.agents.some(({ id }) => id === selected) + ? selected + : payload.snapshot.world.agents[0]!.id, + ); } catch { setUiError('Reset failed safely. The existing world was left intact.'); } finally { @@ -742,13 +724,11 @@ export function WorldLab() { ); } - const inspectionAgentId = - followTurn && followedAgentId ? followedAgentId : selectedAgentId; + const inspectionAgentId = selectedAgentId; const selectedAgent = snapshot.world.agents.find( ({ id }) => id === inspectionAgentId, ); const selectAgentForInspection = (agentId: AgentId) => { - setFollowTurn(false); setSelectedAgentId(agentId); setInspectorTab('agent'); }; @@ -1188,6 +1168,7 @@ export function WorldLab() { next.world.agents.some(({ id }) => id === selected) ? selected - : null, + : next.world.agents[0]!.id, ); setExportAgentIds((selected) => selected.filter((id) => @@ -1227,8 +1208,6 @@ export function WorldLab() {
@@ -1452,8 +1431,6 @@ export function WorldLab() { setSetupOpen(true)} > @@ -1539,16 +1516,12 @@ export function WorldLab() { function AgentsWorkspace({ snapshot, selectedAgentId, - followTurn, - onFollowTurnChange, onSelectAgent, onOpenWorldSetup, children, }: { snapshot: SimulationSnapshot; selectedAgentId: AgentId | null; - followTurn: boolean; - onFollowTurnChange: (follow: boolean) => void; onSelectAgent: (agentId: AgentId) => void; onOpenWorldSetup: () => void; children: ReactNode; @@ -1561,8 +1534,6 @@ function AgentsWorkspace({
@@ -1752,8 +1723,8 @@ function RecoveryLog({ return (
  • - Tick {turn.tickNumber ?? 'legacy'} · record {turn.turnNumber}{' '} - · {agent?.name ?? turn.agentId} + {formatRecordSequence(turn)} · record {turn.turnNumber} ·{' '} + {agent?.name ?? turn.agentId} {turn.failure.code} ·{' '} @@ -2319,19 +2290,10 @@ function WorldSetupPanel({ ) } /> - - replaceRoster( - draft.roster.map((item) => - item.id === agent.id - ? { ...item, color: event.target.value } - : item, - ), - ) - } + - Turn {turn?.turnNumber ?? '—'} · Delivered + + {formatRecordSequence(turn)} · Delivered ·{' '} + {formatTimestamp(event.occurredAt)} +
  • {event.message}

    @@ -3448,8 +3384,11 @@ function PrivateComms({

    {turn.communicationResult.attempt.message}

    - Turn {turn.turnNumber} · Rejected:{' '} - {turn.communicationResult.reason} + {formatRecordSequence(turn)} · Rejected:{' '} + {turn.communicationResult.reason} ·{' '} + {formatTimestamp( + turn.communicationResult.attempt.occurredAt, + )} ); @@ -3567,7 +3506,7 @@ function PublicWorldChat({ > {events.toReversed().map((event) => { const sender = agents.find(({ id }) => id === event.agentId); - const turnNumber = turns.find( + const turn = turns.find( (turn) => turn.outcome !== 'provider-error' && turn.outcome !== 'lost-tick' && @@ -3575,7 +3514,7 @@ function PublicWorldChat({ turn.communicationResult.requested && turn.communicationResult.accepted && turn.communicationResult.event.id === event.id, - )?.turnNumber; + ); return (
  • - Turn {turnNumber ?? '—'} ·{' '} + {formatRecordSequence(turn)} ·{' '} {formatTimestamp(event.occurredAt)} @@ -3739,9 +3678,23 @@ function AllianceEventList({ if (!events.length) return

    No alliance changes yet.

    ; return (
      - {events.slice(-8).map((event) => ( -
    1. {formatAllianceEvent(event, snapshot)}
    2. - ))} + {events.slice(-8).map((event) => { + const turn = snapshot.turns.find( + (candidate) => + candidate.outcome !== 'provider-error' && + candidate.outcome !== 'lost-tick' && + candidate.outcome !== 'operator-skipped' && + candidate.allianceEvents.some(({ id }) => id === event.id), + ); + return ( +
    3. + {formatAllianceEvent(event, snapshot)} + + {formatRecordSequence(turn)} · {formatTimestamp(event.occurredAt)} + +
    4. + ); + })}
    ); } @@ -3782,7 +3735,7 @@ function AgentBehaviorTrace({
    - Tick {entry.turn.tickNumber ?? 'legacy'} · turn{' '} + {formatRecordSequence(entry.turn)} · record{' '} {entry.turn.turnNumber} @@ -3968,6 +3921,7 @@ function AgentInspector({ const allianceSummary = snapshot.experiment.currentAlliances.find( ({ allianceId }) => allianceId === alliance?.id, ); + const agentColor = resolveAgentColor(snapshot, agent.id); const pendingProposals = snapshot.world.pendingAllianceProposals.filter( ({ proposerAgentId, recipientAgentId }) => proposerAgentId === agent.id || recipientAgentId === agent.id, @@ -4011,7 +3965,7 @@ function AgentInspector({

    Agent inspector

    - + {agent.name} {agent.id === snapshot.scenario.patientZeroAgentId && ( Patient Zero @@ -4044,12 +3998,8 @@ function AgentInspector({
    {agent.id}
    -
    Base color
    -
    {agent.color}
    -
    -
    -
    Effective color
    -
    {alliance?.color ?? agent.color}
    +
    Affiliation color
    +
    {agentColor}
    Alliance membership
    @@ -4254,7 +4204,7 @@ function AgentInspector({ communication.agentId === agent.id ? 'Sent' : 'Received'; const other = communication.agentId === agent.id ? recipient : sender; - const turnNumber = turns.find( + const turn = turns.find( (turn) => turn.outcome !== 'provider-error' && turn.outcome !== 'lost-tick' && @@ -4262,7 +4212,7 @@ function AgentInspector({ turn.communicationResult.requested && turn.communicationResult.accepted && turn.communicationResult.event.id === communication.id, - )?.turnNumber; + ); return (
  • {communication.message}

    - Turn {turnNumber ?? '—'} ·{' '} + {formatRecordSequence(turn)} ·{' '} {formatTimestamp(communication.occurredAt)}
  • @@ -4504,9 +4454,9 @@ function AgentInspector({
    Latest structured observation

    - Immutable input supplied for tick{' '} - {latestTurn.tickNumber ?? 'legacy'} record {latestTurn.turnNumber} - . It is not rewritten when the active personality changes. + Immutable input supplied for {formatRecordSequence(latestTurn)} · + record {latestTurn.turnNumber}. It is not rewritten when the + active personality changes.

    {latestTurn.observation.personality !== agent.personality && (

    @@ -4584,7 +4534,7 @@ function AgentInspector({ .toReversed() .map((turn) => (

  • - Turn {turn.turnNumber}: {turn.outcome} + {formatRecordSequence(turn)}: {turn.outcome}
  • ))} @@ -4642,6 +4592,7 @@ const defaultCustomOptions: CustomExportOptions = { }; function ExperimentExportPanel({ + snapshot, agents, disabled, open, @@ -4650,6 +4601,7 @@ function ExperimentExportPanel({ onSelectionChange, returnFocusRef, }: { + snapshot: SimulationSnapshot; agents: SimulationSnapshot['world']['agents']; disabled: boolean; open: boolean; @@ -4965,7 +4917,7 @@ function ExperimentExportPanel({ /> {agent.name} @@ -5349,7 +5301,7 @@ function EventLog({ paddingLeft: 8, }} > - + {formatTurn(turn, agents)} {agents.find(({ id }) => id === turn.agentId)?.name ?? @@ -5463,3 +5415,12 @@ function formatTimestamp(timestamp: string): string { second: '2-digit', }); } + +function formatRecordSequence( + turn?: Pick, +): string { + if (!turn) return 'Record unavailable'; + return turn.tickNumber === undefined + ? `Turn ${turn.turnNumber}` + : `Tick ${turn.tickNumber}`; +} diff --git a/docs/TESTING.md b/docs/TESTING.md index b869423..3382b48 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -126,7 +126,7 @@ model compliance. - Runtime mocks cover the explicit per-turn model and reasoning profile, universal text-only request, flat JSON extraction/repair, exact default/off/effort payloads, absent provider-specific controls, malformed or missing output, cancellation, output exhaustion, metadata preservation, and the unchanged one-request boundary without OpenRouter calls. - Catalog fixtures cover required-capability inclusion/exclusion, text modalities, context floor, pricing parsing, malformed entries, timeout/failure, cache TTL, stale fallback, manual refresh, and credential non-disclosure without network access. - Schema-v5 export tests cover eight-agent selection, state-only alliance/proposal snapshots, diplomacy/event metrics and preview counts, selected-agent proposal/membership relevance, unrelated direct/rejected exclusion, cost handling, retention, and all four safe tiers. -- React and Playwright fixtures cover 127 MapLibre cells, eight markers, base/effective colors, alliance panels and events, separate component results, safe text rendering, reset, bounded tick targets, and lost-tick inspection without a genuine-provider run. +- React and Playwright fixtures cover 127 MapLibre cells, eight markers, affiliation-derived neutral/alliance colors, tick-native activity attribution with legacy turn fallback, stable explicit agent selection, alliance panels and events, separate component results, safe text rendering, reset, bounded tick targets, and lost-tick inspection without a genuine-provider run. - Shared schema tests cover valid/invalid observations and decoupled decisions, required four-way world actions, optional public/direct communication, separate component results, all turn outcomes, response snapshots, history bounds, and model-authored string limits. - Shared schema tests cover authoritative whitespace trimming and 1/280/281-character boundaries, recipient IDs, typed public/direct events and rejected attempts, 12-entry public and six-entry directional direct observation bounds. diff --git a/tests/e2e/world-lab.spec.ts b/tests/e2e/world-lab.spec.ts index df0eaca..0f77939 100644 --- a/tests/e2e/world-lab.spec.ts +++ b/tests/e2e/world-lab.spec.ts @@ -137,8 +137,9 @@ test('runs the complete deterministic World Lab browser flow', async ({ 'Mingle0', ); - const followTurn = page.getByRole('checkbox', { name: 'Follow latest' }); - await expect(followTurn).toBeChecked(); + await expect( + page.getByRole('checkbox', { name: 'Follow latest' }), + ).toHaveCount(0); await page.getByRole('button', { name: 'Collapse activity' }).click(); await expect(page.locator('.bottom-dock')).toHaveCSS('height', '54px'); @@ -155,7 +156,6 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect(markers.nth(index)).toBeVisible(); } await page.getByRole('button', { name: 'Select agent Ember' }).click(); - await expect(followTurn).not.toBeChecked(); await expect(page.getByRole('heading', { name: /Ember/ })).toBeVisible(); const defaultPersonality = 'You are a forceful expansionist who wants the largest personal territory. Infect open cells aggressively, capture exposed rival territory, and use public messages to pressure or warn competitors. Alliances are temporary strategic tools: propose or accept them when they help contain a stronger rival, honor them while useful, and leave openly when they block expansion. Respond to direct proposals instead of silently ignoring them.'; @@ -175,10 +175,8 @@ test('runs the complete deterministic World Lab browser flow', async ({ page.getByText(customPersonality, { exact: true }), ).toBeVisible(); - await followTurn.check(); await page.getByRole('button', { name: 'Single tick' }).click(); - await page.getByRole('button', { name: 'Select agent Ember' }).click(); - await expect(followTurn).not.toBeChecked(); + await expect(page.getByRole('heading', { name: /Ember/ })).toBeVisible(); await page.getByRole('tab', { name: 'Event log' }).click(); const tickActivity = page.getByText(/Infection .* direct message accepted/); await expect(tickActivity).toHaveCount(8); @@ -363,7 +361,13 @@ test('runs the complete deterministic World Lab browser flow', async ({ page.once('dialog', (dialog) => dialog.accept()); await openActions(); await page.getByRole('button', { name: 'Reset world' }).click(); - await expect(page.getByText('Tick 0')).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Experiment details. Tick 0, paused', + }), + ).toBeVisible(); + await expect(page.getByRole('heading', { name: /Cipher/ })).toBeVisible(); + await page.getByRole('button', { name: 'Select agent Ember' }).click(); await expect( page.getByText(customPersonality, { exact: true }), ).toBeVisible(); @@ -389,14 +393,22 @@ test('runs the complete deterministic World Lab browser flow', async ({ ).toBeVisible(); await page.getByRole('button', { name: 'Single tick' }).click(); - await expect(page.getByText('Tick 1', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Experiment details. Tick 1, paused', + }), + ).toBeVisible(); const resetTickActivity = page.getByText( /Infection .* direct message accepted/, ); await expect(resetTickActivity).toHaveCount(8); await expect(resetTickActivity.first()).toBeVisible(); await page.getByRole('button', { name: 'Single tick' }).click(); - await expect(page.getByText('Tick 2', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Experiment details. Tick 2, paused', + }), + ).toBeVisible(); await expect(worldMap).toHaveAttribute( 'data-rendered-infected-cell-count', '8', @@ -413,7 +425,11 @@ test('runs the complete deterministic World Lab browser flow', async ({ await expect( page.getByText(MINGLE_PERSONALITY, { exact: true }), ).toBeVisible(); - await expect(page.getByText('Tick 2', { exact: true })).toBeVisible(); + await expect( + page.getByRole('button', { + name: 'Experiment details. Tick 2, paused', + }), + ).toBeVisible(); await expect(worldMap).toHaveAttribute('data-rendered-h3-cell-count', '127'); await expect(worldMap).toHaveAttribute( 'data-rendered-infected-cell-count',