diff --git a/apps/game-api/src/app.test.ts b/apps/game-api/src/app.test.ts index 9e55b90..9400d74 100644 --- a/apps/game-api/src/app.test.ts +++ b/apps/game-api/src/app.test.ts @@ -1031,6 +1031,68 @@ describe('game API simulation boundary', () => { ); }); + it('exports a Full Safe simultaneous tick containing a lost record', async () => { + let failingAgentId: string | undefined; + const app = createApp({ + provider: { + mode: 'scripted-test', + configured: true, + async decide(observation): Promise { + failingAgentId ??= observation.agentId; + if (observation.agentId === failingAgentId) + throw new AgentProviderError({ + code: 'timeout', + message: 'Deadline exhausted.', + retryable: false, + }); + return { + decision: { worldAction: { type: 'wait' }, summary: 'Wait.' }, + metadata: { + provider: 'scripted-test', + model: 'lost-tick-export-test', + latencyMs: 0, + }, + }; + }, + }, + }); + const tickResponse = await app.request('/api/simulation/tick', { + method: 'POST', + }); + expect(tickResponse.status).toBe(200); + + const response = await app.request('/api/simulation/experiment/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + agents: { mode: 'all' }, + turns: { mode: 'entire-retained' }, + outcomes: [ + 'accepted', + 'rejected', + 'provider-error', + 'operator-skipped', + 'lost-tick', + ], + actions: ['move', 'infect', 'capture', 'wait'], + communications: { channel: 'all', status: 'all' }, + level: 'full-safe', + }), + }); + expect(response.status).toBe(200); + const { document } = experimentExportResponseSchema.parse( + await response.json(), + ); + expect(document.schemaVersion).toBe(10); + const lostTick = document.turns.find( + ({ outcome }) => outcome === 'lost-tick', + ); + expect(lostTick).toBeDefined(); + expect(lostTick).not.toHaveProperty('worldActionResult'); + expect(lostTick).not.toHaveProperty('communicationResult'); + expect(lostTick).not.toHaveProperty('diplomacyResult'); + }); + it('archives the exact supplied generated artifact through an injected writer', async () => { const archiveExperimentExport = vi.fn( (document: ExperimentExportDocument) => ({ diff --git a/apps/game-api/src/simulation-service.test.ts b/apps/game-api/src/simulation-service.test.ts index 0e86b6d..2732769 100644 --- a/apps/game-api/src/simulation-service.test.ts +++ b/apps/game-api/src/simulation-service.test.ts @@ -818,17 +818,74 @@ describe('SimulationService', () => { .getSnapshot() .agentMemories.filter(({ entries }) => entries.length > 0), ).toHaveLength(7); - const exported = simulation.generateExperimentExport({ - ...exportRequest('minimal'), - outcomes: [ - 'accepted', - 'rejected', - 'lost-tick', - 'provider-error', - 'operator-skipped', - ], - }); - expect(exported.tickSummaries).toEqual([ + const lostTickOutcomes = [ + 'accepted', + 'rejected', + 'lost-tick', + 'provider-error', + 'operator-skipped', + ] as const; + const customWithResults = { + turnObservations: false, + personalityTextHistory: false, + nearbyAgents: false, + recentEvents: false, + recentPublicMessages: false, + recentDirectMessages: false, + recentControlChanges: false, + validationDetails: true, + resultingEvents: true, + providerUsageMetadata: false, + initialWorldState: false, + currentWorldState: false, + computedMetrics: false, + communications: false, + controlChanges: false, + }; + const exports = [ + simulation.generateExperimentExport({ + ...exportRequest('minimal'), + outcomes: lostTickOutcomes, + }), + simulation.generateExperimentExport({ + ...exportRequest('standard'), + outcomes: lostTickOutcomes, + }), + simulation.generateExperimentExport({ + ...exportRequest('full-safe'), + outcomes: lostTickOutcomes, + }), + simulation.generateExperimentExport({ + ...exportRequest('custom'), + outcomes: lostTickOutcomes, + custom: customWithResults, + }), + ]; + for (const exported of exports) { + expect(experimentExportDocumentSchema.safeParse(exported).success).toBe( + true, + ); + const lostTick = exported.turns.find( + ({ outcome }) => outcome === 'lost-tick', + ); + expect(lostTick).toBeDefined(); + expect(lostTick).not.toHaveProperty('worldActionResult'); + expect(lostTick).not.toHaveProperty('communicationResult'); + expect(lostTick).not.toHaveProperty('diplomacyResult'); + } + const minimalAccepted = exports[0]!.turns.find( + ({ outcome }) => outcome === 'accepted', + ); + expect(minimalAccepted).not.toHaveProperty('worldActionResult'); + for (const exported of exports.slice(1)) { + const accepted = exported.turns.find( + ({ outcome }) => outcome === 'accepted', + ); + expect(accepted).toHaveProperty('worldActionResult'); + expect(accepted).toHaveProperty('communicationResult'); + expect(accepted).toHaveProperty('diplomacyResult'); + } + expect(exports[0]!.tickSummaries).toEqual([ expect.objectContaining({ providerCallCount: 8, aggregateDecisionLatencyMs: 37, diff --git a/docs/TESTING.md b/docs/TESTING.md index f374153..b869423 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -132,7 +132,7 @@ model compliance. - 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. - Shared schema tests cover open/uncontrolled and infected/controlled hex unions, controller validity, alliance-aware capture eligibility, territory scoreboards, gain/loss bounds, independent communication/diplomacy metrics, and export schema version 5. - Shared schema tests cover trimmed personality updates, empty/oversized/malformed values, update/restore response contracts, and typed mutation errors. -- Shared schema tests cover optional provider usage and tiny costs, experiment identities/manifests/retention/configuration events, all export levels and filters, Custom dependencies, invalid empty/range/agent selections, previews, generated documents, and level-specific omissions. +- Shared and service export tests cover optional provider usage and tiny costs, experiment identities/manifests/retention/configuration events, all export levels and filters, Custom dependencies, invalid empty/range/agent selections, previews, generated documents, level-specific omissions, and result-free lost-tick records in Minimal, Standard, Full Safe, and Custom exports. - World-engine tests cover all existing movement/infection/capture behavior plus public delivery, direct distances zero/one/three, distance-four/self/unknown rejection, message trimming, independent application, pre-action proximity, event ordering, and deterministic construction with unchanged personalities. - Agent-runtime tests inspect the model-agnostic OpenRouter text request, fixed trust instructions, flat sentinel normalization, fenced/prose-wrapped/trailing-comma JSON recovery, exact metadata-selected reasoning payloads, explicit model overrides, body-inclusive 75-second timeouts, cancellation, bounded diagnostics, secret non-leakage, and provider-name-independent mocked output. - Agent-runtime tests normalize successful/missing reasoning/cache/token/cost usage, retain known usage through malformed or unsupported decisions, and prove scripted zero cost. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a0bbe98..431a90c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -3734,6 +3734,7 @@ export const experimentExportDocumentSchema = z }); if ( turn.outcome !== 'provider-error' && + turn.outcome !== 'lost-tick' && turn.outcome !== 'operator-skipped' && (Boolean(turn.worldActionResult) !== results || Boolean(turn.communicationResult) !== results ||