From c77c307e40e61067723768c2d8d90a66609f7054 Mon Sep 17 00:00:00 2001 From: ackness Date: Tue, 11 Aug 2026 13:03:21 +0800 Subject: [PATCH 1/3] fix: harden framework turn and plugin flows --- apps/desktop/package.json | 2 +- apps/desktop/src/logging.selfcheck.ts | 25 + apps/desktop/src/logging.ts | 35 +- apps/server/src/routes/api/actions.ts | 946 +++++++++--------- apps/server/src/routes/api/actions/request.ts | 248 +++++ apps/server/src/routes/api/plugin-rpc.ts | 29 +- .../src/routes/api/plugin-rpc/runtime-turn.ts | 2 + apps/server/src/server-log-tee.ts | 39 +- .../tests/api/actions-commit-barrier.test.ts | 64 +- .../server/tests/api/actions-contract.test.ts | 149 +++ .../tests/api/actions-stream-scope.test.ts | 32 +- apps/server/tests/api/plugin-rpc.test.ts | 106 +- .../api/start-game-flow-scenario.test.ts | 5 +- apps/server/tests/lib/server-log-tee.test.ts | 21 + .../__tests__/local-session-sync.test.ts | 107 ++ apps/web/src/services/api/types.ts | 43 +- apps/web/src/services/data-service/local.ts | 10 +- apps/web/src/services/data-service/mappers.ts | 1 + .../session-store-player-abort.test.ts | 40 + .../__tests__/start-game.test.ts | 113 +++ apps/web/src/stores/session-store/actions.ts | 15 +- .../src/stores/session-store/sse-handler.ts | 16 +- .../src/stores/session-store/start-game.ts | 20 +- docs/architecture/flow.md | 18 +- .../2026-08-09_23-15_p1-remediation-flow.md | 15 + .../2026-08-09_23-15_p1-remediation.md | 53 + .../2026-08-09_23-39_p2-hardening-flow.md | 17 + .../2026-08-09_23-39_p2-hardening.md | 48 + docs/guide/desktop-config.en.md | 2 + docs/guide/desktop-config.md | 1 + docs/reference/api.md | 20 +- docs/reference/plugins.md | 41 +- docs/reference/protocol.md | 18 +- docs/reference/tools.md | 2 +- docs/reference/transactions.md | 16 +- docs/reference/world-data.md | 4 +- packages/context/src/prompt-internals.ts | 2 +- packages/context/src/session-context.ts | 9 +- packages/context/src/store-records.ts | 1 + .../context/tests/context-builder.test.ts | 44 +- .../context/tests/session-context.test.ts | 16 + packages/memory/src/index.ts | 1 + packages/memory/src/types.ts | 20 + packages/memory/src/updater.ts | 240 ++++- packages/memory/tests/updater.test.ts | 146 +++ .../src/agent-loop/turn-agent-guard.ts | 47 +- .../src/agent-loop/turn-agent-runtime.ts | 42 +- .../src/agent-loop/turn-agent-tool-loop.ts | 6 +- .../runtime/src/commit/finalize-execution.ts | 14 +- packages/runtime/src/execution-journal.ts | 57 ++ .../function-runtime/turn-function-runtime.ts | 37 +- packages/runtime/src/index.ts | 1 + .../runtime/src/rpc-defaults/submit-form.ts | 377 ++++++- .../src/turn-executor/execution-context.ts | 11 +- .../src/turn-executor/post-turn-memory.ts | 55 +- .../src/turn-executor/session-state.ts | 21 +- .../src/turn-executor/turn-executor-types.ts | 10 + .../src/turn-executor/turn-executor.ts | 77 +- .../turn-executor/turn-result-finalizer.ts | 1 - .../core-plugin-manifest-contract.test.ts | 6 + .../runtime/tests/finalize-execution.test.ts | 72 ++ .../tests/function-runtime-trace.test.ts | 31 + .../tests/session-state-read-dedup.test.ts | 30 +- packages/runtime/tests/submit-form.test.ts | 306 +++++- .../tool-executor-core-plugin-commit.test.ts | 7 +- .../tests/turn-executor-events.test.ts | 65 +- packages/runtime/tests/turn-executor.test.ts | 53 +- packages/shared/src/types/protocol.ts | 4 + packages/shared/src/types/session.ts | 17 +- packages/tools/src/builtin/memory-tools.ts | 1 - .../runtimes/player-init/PLUGIN.en.md | 12 +- .../runtimes/player-init/PLUGIN.md | 19 +- .../char-creator/tests/char-creator.test.js | 19 +- plugins/core-quest/PLUGIN.en.md | 12 +- plugins/core-quest/PLUGIN.md | 8 +- plugins/core-quest/README.md | 2 +- plugins/core-quest/schemas/quests.schema.json | 1 + plugins/core-quest/tests/core-quest.test.js | 220 +++- plugins/core-quest/tools/upsert-quests.js | 238 ++++- plugins/memory/PLUGIN.en.md | 2 +- plugins/memory/PLUGIN.md | 2 +- plugins/memory/README.md | 1 + plugins/world-init/guard.js | 23 + .../world-init/runtimes/schema-gen/PLUGIN.md | 2 +- plugins/world-init/tests/guard.test.ts | 3 + plugins/world-init/tests/tools.test.ts | 61 +- .../tools/set-world-entries-batch.js | 17 + plugins/world-init/tools/set-world-schema.js | 13 +- worlds/emberback/data/quests.yaml | 21 +- 89 files changed, 3901 insertions(+), 925 deletions(-) create mode 100644 apps/desktop/src/logging.selfcheck.ts create mode 100644 apps/server/src/routes/api/actions/request.ts create mode 100644 apps/server/tests/lib/server-log-tee.test.ts create mode 100644 apps/web/src/services/__tests__/local-session-sync.test.ts create mode 100644 apps/web/src/stores/session-store/__tests__/start-game.test.ts create mode 100644 docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation-flow.md create mode 100644 docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation.md create mode 100644 docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening-flow.md create mode 100644 docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening.md create mode 100644 packages/runtime/src/execution-journal.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a08b5ff7e..6d737aadb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,7 +12,7 @@ "dev": "node scripts/dev.mjs", "build": "node scripts/build.mjs", "lint": "tsc --noEmit", - "test": "tsx src/zip-extract.selfcheck.ts && tsx src/trusted-origin.selfcheck.ts", + "test": "tsx src/zip-extract.selfcheck.ts && tsx src/trusted-origin.selfcheck.ts && tsx src/logging.selfcheck.ts", "pack": "pnpm build && electron-builder --dir", "dist": "pnpm build && electron-builder", "dist:mac": "pnpm build && electron-builder --mac", diff --git a/apps/desktop/src/logging.selfcheck.ts b/apps/desktop/src/logging.selfcheck.ts new file mode 100644 index 000000000..60de141e3 --- /dev/null +++ b/apps/desktop/src/logging.selfcheck.ts @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { classifyServerStreamLine } from "./logging.js"; + +assert.deepEqual( + classifyServerStreamLine( + "stderr", + "[covel:warn] [turn-executor] same-layer effects hazard (policy: warn)", + ), + { + level: "warn", + source: "server.err", + message: "[turn-executor] same-layer effects hazard (policy: warn)", + }, +); + +assert.equal( + classifyServerStreamLine("stderr", "fatal sidecar failure").level, + "error", +); +assert.equal( + classifyServerStreamLine("stdout", "server started").level, + "info", +); + +console.log("logging self-check passed"); diff --git a/apps/desktop/src/logging.ts b/apps/desktop/src/logging.ts index ffc078078..7acb91bba 100644 --- a/apps/desktop/src/logging.ts +++ b/apps/desktop/src/logging.ts @@ -3,6 +3,11 @@ import path from "node:path"; export type LogLevel = "info" | "warn" | "error"; +// stderr does not carry console.warn/console.error metadata. Server code uses +// this prefix for recoverable warnings so both desktop and standalone log +// collectors can preserve the intended level. +const SERVER_WARNING_PREFIX = "[covel:warn]"; + export interface LogRotation { readonly maxSizeMb: number; readonly maxFiles: number; @@ -141,7 +146,31 @@ export function writeServerStreamLine( line: string, ): void { if (!line || !line.trim()) return; - const level: LogLevel = origin === "stderr" ? "error" : "info"; - const source = origin === "stderr" ? "server.err" : "server"; - writeChannel(serverChannel, ndjsonLine(level, source, line)); + const classified = classifyServerStreamLine(origin, line); + writeChannel( + serverChannel, + ndjsonLine(classified.level, classified.source, classified.message), + ); +} + +export function classifyServerStreamLine( + origin: "stdout" | "stderr", + line: string, +): { + readonly level: LogLevel; + readonly source: "server" | "server.err"; + readonly message: string; +} { + if (origin === "stdout") { + return { level: "info", source: "server", message: line }; + } + const trimmed = line.trimStart(); + if (trimmed.startsWith(SERVER_WARNING_PREFIX)) { + return { + level: "warn", + source: "server.err", + message: trimmed.slice(SERVER_WARNING_PREFIX.length).trimStart(), + }; + } + return { level: "error", source: "server.err", message: line }; } diff --git a/apps/server/src/routes/api/actions.ts b/apps/server/src/routes/api/actions.ts index d30e11a4a..768ed4e00 100644 --- a/apps/server/src/routes/api/actions.ts +++ b/apps/server/src/routes/api/actions.ts @@ -15,6 +15,7 @@ import { executeTurn, createTraceRecorder, createTurnEmitter, + collectExecutionJournal, finalizeExecution, saveAutoSnapshot, } from "@covel/runtime"; @@ -44,6 +45,7 @@ import { import { getCachedWorld } from "../../world-cache.js"; import { registerActiveTurn } from "./turn-control.js"; import { checkSessionOwner } from "./session/session-guard.js"; +import { validateActionRequest } from "./actions/request.js"; // SSE uses ProtocolEventType names directly — no legacy mapping. // Frontend handleSseEvent handles these standard types. @@ -67,6 +69,16 @@ interface MemorySystemFacade { sessionId: string; narrativeText: string; toolCallSummaries?: readonly string[]; + authoritativeFacts?: { + readonly playerCharacter?: { + readonly name: string; + readonly type: string; + readonly description?: string; + readonly fields?: Readonly>; + }; + readonly playerFieldLabels?: Readonly>; + readonly lastFormValues?: Readonly>; + }; currentBlocks: readonly { label: string; content: string; @@ -113,15 +125,6 @@ export function setMemorySystem(ms: MemorySystemFacade | undefined) { _memorySystem = ms; } -interface ActionRequest { - requestId: string; - type: string; - sessionId: string; - locale?: string; - model?: string; - payload: Record; -} - actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { const store = c.get("store"); const pluginRegistry = c.get("pluginRegistry"); @@ -140,36 +143,14 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { const eventDirectory = c.get("eventDirectory"); const prepareToolsForSession = c.get("prepareToolsForSession"); // optional — see env.d.ts - const body = (await c.req - .json() - .catch(() => null)) as ActionRequest | null; - if (!body || typeof body !== "object") { - return c.json(errorBody("Request body must be a JSON object"), 400); + const rawBody = await c.req.json().catch(() => null); + const bodyResult = validateActionRequest(rawBody); + if (!bodyResult.ok) { + return c.json(errorBody(bodyResult.error), 400); } + const body = bodyResult.value; const { requestId, type, sessionId, locale, model, payload } = body; - // `trigger_event` was removed: its payload was never read and no UI - // called it — the request just re-ran a full turn. Emitting kernel events - // belongs to plugins via the builtin `emit-event` tool. - const SUPPORTED_ACTIONS = [ - "send_message", - "execute_command", - "start_session", - "retry_runtime", - ]; - if (!SUPPORTED_ACTIONS.includes(type)) { - return c.json(errorBody(`Unsupported action type: ${type}`), 400); - } - - // Every action except start_session dereferences `payload` (content / - // command / …); a request missing it would throw a TypeError → opaque 500. - if (type !== "start_session" && (!payload || typeof payload !== "object")) { - return c.json( - errorBody(`payload (object) is required for action "${type}"`), - 400, - ); - } - const session = await store.getSession(sessionId); if (!session) { return c.json(errorBody("Session not found"), 404); @@ -212,9 +193,11 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { } const playerMessage = - type === "start_session" - ? "" // First turn has no player message - : ((payload.content as string) ?? (payload.command as string) ?? ""); + type === "send_message" + ? payload.content + : type === "execute_command" + ? payload.command + : ""; // start/retry carry no new player message const turnId = crypto.randomUUID(); // Locale: an explicit request.locale (sent by the client on every turn @@ -383,455 +366,490 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { }) => { currentTurnId = turnArgs.turnId; commitStatusSettled = false; - const { result, trace, userSettings, committed, wasPreGamePending } = - await sessionLock.withLock(sessionId, async () => { - // This execution now owns the session — events on the bus - // from here on belong to this turn. - subscribeEventForwarding(); - try { - // Authoritative gate: re-read the session status under the - // lock BEFORE any write. A pause/end that raced the pre-stream - // check must not get player messages, interaction records, or - // compaction appended to a non-active session. The throw surfaces - // as an `error.occurred` SSE event via the outer catch. - const liveSession = await store.getSession(sessionId); - if (!liveSession) { - throw new Error( - "session was deleted while the action was queued", - ); - } - if (liveSession.status && liveSession.status !== "active") { - throw new Error( - `session is ${liveSession.status}; it must be active to accept actions`, - ); - } - - // Lazily backfill the scheduling-redesign clock on legacy sessions - // before any band / count read, so `phase` is authoritative for the - // rest of the turn (and executeTurn's own session read). - const clockSession = await ensureSessionClockBackfilled({ - store, - session: liveSession, - activeRuntimes, - }); - - // Captured BEFORE the turn runs: the execution may complete setup - // itself, and the execution's countPolicy must be fixed from the - // pre-turn band (a setup request never counts a player turn). - const wasPreGamePending = isPreGamePending( - clockSession, - activeRuntimes, + const { + result, + trace, + userSettings, + committed, + commitError, + wasPreGamePending, + } = await sessionLock.withLock(sessionId, async () => { + // This execution now owns the session — events on the bus + // from here on belong to this turn. + subscribeEventForwarding(); + try { + // Authoritative gate: re-read the session status under the + // lock BEFORE any write. A pause/end that raced the pre-stream + // check must not get player messages, interaction records, or + // compaction appended to a non-active session. The throw surfaces + // as an `error.occurred` SSE event via the outer catch. + const liveSession = await store.getSession(sessionId); + if (!liveSession) { + throw new Error("session was deleted while the action was queued"); + } + if (liveSession.status && liveSession.status !== "active") { + throw new Error( + `session is ${liveSession.status}; it must be active to accept actions`, ); + } - // Persist player message to messages table (source of truth for refresh recovery) - if (turnArgs.playerMessage) { - const now = new Date().toISOString(); - await store.addMessage({ - id: crypto.randomUUID(), - sessionId, - role: "user", - content: turnArgs.playerMessage, - metadata: { turnId: turnArgs.turnId }, - createdAt: now, - }); + // Prep's editable world document is browser-local until the first + // start action. Persist its value on the session so setup, opening + // continuation, later turns, reconnects, and other server workers + // all build context from the same lore. + let effectiveSession = liveSession; + if ( + type === "start_session" && + payload.loreOverride !== undefined && + liveSession.metadata?.loreOverride !== payload.loreOverride + ) { + const updatedAt = new Date().toISOString(); + const metadata = { + ...liveSession.metadata, + loreOverride: payload.loreOverride, + }; + await store.updateSession(sessionId, { metadata, updatedAt }); + effectiveSession = { + ...liveSession, + metadata, + updatedAt, + }; + } - // Also emit a normalised InteractionRecord so observability and - // downstream consumers see the player's input as part of the unified - // event stream (paired with RuntimeOutput records written by the - // turn executor). - try { - await store.saveInteractionRecord({ + // Lazily backfill the scheduling-redesign clock on legacy sessions + // before any band / count read, so `phase` is authoritative for the + // rest of the turn (and executeTurn's own session read). + const clockSession = await ensureSessionClockBackfilled({ + store, + session: effectiveSession, + activeRuntimes, + }); + + // Captured BEFORE the turn runs: the execution may complete setup + // itself, and the execution's countPolicy must be fixed from the + // pre-turn band (a setup request never counts a player turn). + const wasPreGamePending = isPreGamePending( + clockSession, + activeRuntimes, + ); + + // Stage the REST message mirror and normalized interaction row. + // They land through finalizeExecution.extraInTx with the runtime + // proposals and TurnMessage journal, so refresh / observability + // cannot retain player input from a rolled-back execution. + const playerInputCreatedAt = new Date().toISOString(); + const playerInputWrites = turnArgs.playerMessage + ? { + message: { + id: crypto.randomUUID(), + sessionId, + role: "user" as const, + content: turnArgs.playerMessage, + metadata: { turnId: turnArgs.turnId }, + createdAt: playerInputCreatedAt, + }, + interaction: { id: crypto.randomUUID(), sessionId, turnId: turnArgs.turnId, - timestamp: now, - source: "player", - channel: "web", - type: type === "send_message" ? "message" : "rpc-call", + timestamp: playerInputCreatedAt, + source: "player" as const, + channel: "web" as const, + type: + type === "send_message" + ? ("message" as const) + : ("rpc-call" as const), payload: { content: turnArgs.playerMessage, actionType: type, }, - createdAt: now, - }); - } catch (err) { - console.warn( - "[actions] saveInteractionRecord failed:", - err instanceof Error ? err.message : String(err), - ); + createdAt: playerInputCreatedAt, + }, } - } - - // Create trace recorder for this turn (persists all lifecycle events - // to DB). Carries the SSE traceId so recorder rows correlate with - // emitter + commit-pipeline rows under one traceId. - const trace = createTraceRecorder( - store, - sessionId, - turnArgs.turnId, - traceId, - ); - - // Per-turn trace emitter — fans emit() into trace_events + eventBus. Threaded - // down into ToolCallContext / llm-retry / hooks etc. via executeTurn deps. - // Pass the SSE envelope's traceId so persisted trace_events.traceId matches - // the live-streamed traceId/flowId (without it the emitter falls back to - // turnId, breaking traceId correlation between SSE and /api/traces). - const emitter = createTurnEmitter({ - store, - eventBus, - sessionId, - turnId: turnArgs.turnId, - traceId, - }); - - // NOTE: Session `phase` is no longer a first-class field. The state - // model is `status + turnCount + preGameCompleted`, so there is no - // `phase.changed` event to emit here — callers that still care about a - // coarse "pre-game vs playing" display label derive it from - // `turnCount === 0` vs `> 0`. See audits/2026-04-21-architecture-code-audit. - - // Emit execution started (protocol: execution.started). Goes - // through the serial write queue like every other stream write, so - // it keeps its envelope order relative to forwarded bus events. - await trace.turnStarted({ runtimeCount: activeRuntimes.length }); - await writeEvent("execution.started", { - status: "executing", - runtimeCount: activeRuntimes.length, - }); + : undefined; + + // Create trace recorder for this turn (persists all lifecycle events + // to DB). Carries the SSE traceId so recorder rows correlate with + // emitter + commit-pipeline rows under one traceId. + const trace = createTraceRecorder( + store, + sessionId, + turnArgs.turnId, + traceId, + ); + + // Per-turn trace emitter — fans emit() into trace_events + eventBus. Threaded + // down into ToolCallContext / llm-retry / hooks etc. via executeTurn deps. + // Pass the SSE envelope's traceId so persisted trace_events.traceId matches + // the live-streamed traceId/flowId (without it the emitter falls back to + // turnId, breaking traceId correlation between SSE and /api/traces). + const emitter = createTurnEmitter({ + store, + eventBus, + sessionId, + turnId: turnArgs.turnId, + traceId, + }); - // Refresh the per-session character-tool overrides so create/update- - // character expose the world's CharacterAttributeSchema directly to - // the LLM (Phase 2). No-op when the schema isn't yet populated for - // this session — handlers stay correct on schema-less sessions. The - // optional-chain keeps tests with hand-built DI middleware working. - await prepareToolsForSession?.(sessionId); - - // Execute turn through the API pipeline. - // - // The outer session lock serializes the complete mutation pipeline: - // player input, execution, proposal commits, lifecycle sync, and the - // final automatic snapshot. For PG-backed deployments it uses - // `pg_advisory_lock`; memory/sqlite use the in-process chain lock. - // Resolve plugin userSettings for this turn: world-authored defaults - // (WorldRecord.metadata.pluginSettings) merged under the player's - // per-session overrides (X-Plugin-User-Settings header). The runtime's - // resolveUserSettings fills any still-missing declared key from the - // manifest default. Without this the scheduled loop only ever saw - // manifest defaults — player + world tuning were silently dropped on the - // main route (only plugin-rpc read the header). - const world = session.worldId - ? await getCachedWorld(store, session.worldId) - : null; - const userSettings = mergePluginUserSettings( - readWorldPluginSettings(world?.metadata), - decodePluginUserSettingsHeader( - c.req.header("X-Plugin-User-Settings"), - ), - ); + // `phase` is persisted by the session-clock write in finalizeExecution. + // There is no standalone `phase.changed` event; clients receive the + // committed session state through normal snapshot/session refreshes. + + // Emit execution started (protocol: execution.started). Goes + // through the serial write queue like every other stream write, so + // it keeps its envelope order relative to forwarded bus events. + await trace.turnStarted({ runtimeCount: activeRuntimes.length }); + await writeEvent("execution.started", { + status: "executing", + runtimeCount: activeRuntimes.length, + }); - // A scoped retry_runtime (payload.runtimeId set) reruns ONE - // runtime, not a new player turn. Stamp it non-player origin and - // keep it out of turnCount so retrying doesn't advance the counter - // a second time over the same logical turn. - const isScopedRetry = - type === "retry_runtime" && - typeof payload?.runtimeId === "string" && - payload.runtimeId.length > 0; - - // Seed a scoped retry with the source turn's recorded outputs so - // the retried runtime's `input.inject` / `needs` resolve against - // the original narrative instead of empty manual-trigger context - // (a bare manual trigger resolves them empty — the retried agent - // would see no and do nothing). Source: explicit - // payload.retryFromTurnId (the chip's turn), else the most recent - // player-origin artifact. - // ponytail: full artifact scan; add a keyed store getter if long - // sessions make this show up in traces. - let retrySeedResults: readonly RuntimeResult[] | undefined; - if (isScopedRetry) { - const rows = await store.listTurnResults(sessionId); - const explicit = - typeof payload?.retryFromTurnId === "string" - ? rows.find((r) => r.turnId === payload.retryFromTurnId) - : undefined; - const source = - explicit ?? - [...rows] - .reverse() - .find((r) => (r.origin ?? "player") === "player"); - retrySeedResults = Array.isArray(source?.runtimeResults) - ? (source.runtimeResults as RuntimeResult[]) + // Refresh the per-session character-tool overrides so create/update- + // character expose the world's CharacterAttributeSchema directly to + // the LLM (Phase 2). No-op when the schema isn't yet populated for + // this session — handlers stay correct on schema-less sessions. The + // optional-chain keeps tests with hand-built DI middleware working. + await prepareToolsForSession?.(sessionId); + + // Execute turn through the API pipeline. + // + // The outer session lock serializes the complete mutation pipeline: + // player input, execution, proposal commits, lifecycle sync, and the + // final automatic snapshot. For PG-backed deployments it uses + // `pg_advisory_lock`; memory/sqlite use the in-process chain lock. + // Resolve plugin userSettings for this turn: world-authored defaults + // (WorldRecord.metadata.pluginSettings) merged under the player's + // per-session overrides (X-Plugin-User-Settings header). The runtime's + // resolveUserSettings fills any still-missing declared key from the + // manifest default. Without this the scheduled loop only ever saw + // manifest defaults — player + world tuning were silently dropped on the + // main route (only plugin-rpc read the header). + const world = session.worldId + ? await getCachedWorld(store, session.worldId) + : null; + const userSettings = mergePluginUserSettings( + readWorldPluginSettings(world?.metadata), + decodePluginUserSettingsHeader( + c.req.header("X-Plugin-User-Settings"), + ), + ); + + // A scoped retry_runtime (payload.runtimeId set) reruns ONE + // runtime, not a new player turn. Stamp it non-player origin and + // keep it out of turnCount so retrying doesn't advance the counter + // a second time over the same logical turn. + const isScopedRetry = + type === "retry_runtime" && typeof payload.runtimeId === "string"; + + // Seed a scoped retry with the source turn's recorded outputs so + // the retried runtime's `input.inject` / `needs` resolve against + // the original narrative instead of empty manual-trigger context + // (a bare manual trigger resolves them empty — the retried agent + // would see no and do nothing). Source: explicit + // payload.retryFromTurnId (the chip's turn), else the most recent + // player-origin artifact. + // ponytail: full artifact scan; add a keyed store getter if long + // sessions make this show up in traces. + let retrySeedResults: readonly RuntimeResult[] | undefined; + if (isScopedRetry) { + const rows = await store.listTurnResults(sessionId); + const explicit = + typeof payload.retryFromTurnId === "string" + ? rows.find((r) => r.turnId === payload.retryFromTurnId) : undefined; - } + const source = + explicit ?? + [...rows] + .reverse() + .find((r) => (r.origin ?? "player") === "player"); + retrySeedResults = Array.isArray(source?.runtimeResults) + ? (source.runtimeResults as RuntimeResult[]) + : undefined; + } - const turnInput = { - sessionId, - turnId: turnArgs.turnId, - playerMessage: turnArgs.playerMessage, - locale: effectiveLocale, - modelOverride: model, - // Feed the pre-turn Pre-Game snapshot so the executor can fix the - // execution's countPolicy at creation. - preGamePending: wasPreGamePending, - // Identity of the player's logical turn — the finalizer keys the - // completion ledger on it so this turn is counted at most once. - // A scoped retry (origin: manual below) never counts, so a stray - // id there is inert. - logicalTurnId: crypto.randomUUID(), - ...(userSettings ? { userSettings } : {}), - // Snapshot session-level per-runtime slot overrides so the - // turn executor can consult them when resolving each runtime's - // model. The session record was loaded above (line ~67). - ...(session?.runtimeModelOverrides - ? { runtimeModelOverrides: session.runtimeModelOverrides } - : {}), - ...(turnArgs.suppressPlayerMessage - ? { suppressPlayerMessage: true } - : {}), - // retry_runtime honors payload.runtimeId: scope the rerun to - // that runtime via the manual-trigger path instead of silently - // re-running the whole turn. Without a runtimeId the action keeps - // its historical whole-turn-retry semantics. - ...(isScopedRetry - ? { - manualTrigger: { - runtimeId: payload.runtimeId as string, - ...(retrySeedResults && retrySeedResults.length > 0 - ? { retrySeedResults } - : {}), - }, - origin: "manual" as const, - } - : {}), - }; - // Register the in-flight turn only after this action owns the - // session lock. Release control after execution while retaining the - // lock through proposal commit, lifecycle sync, and snapshot capture. - const registeredTurn = registerActiveTurn( - sessionId, - turnArgs.turnId, - ); - releaseTurnControl = registeredTurn.release; - let result; - try { - result = await executeTurn(turnInput, activeRuntimes, { - loadRuntime: loadRuntimeFn, - llm: llmAdapter, - // The main turn path never passed the eventBus, so every - // `emitSubEvent` inside the executor — including the - // completion barrier's `turn.completed` — silently no-opped on - // the player-facing path (found while adding the - // fault-injection tests). Without it the barrier's only - // observable effect was memory ingestion. - eventBus, - ...(pluginGateway ? { gateway: pluginGateway } : {}), - ...(pluginUtils ? { utils: pluginUtils } : {}), - ...(getPluginSource ? { getPluginSource } : {}), - store, - ...(mediaStore ? { mediaStore } : {}), - toolExecutor, - resolveModel, - emitter, - onDelta: async (delta) => { - await writeEvent("narrative.delta", { - runtimeId: delta.runtimeId, - pluginId: delta.pluginId, - kind: outputKindResolver.getOutputKind(delta.runtimeId), - delta: delta.textDelta, - }); - }, - onRuntimeStart: async (info) => { - await trace.runtimeStarted({ - runtimeId: info.runtimeId, - pluginId: info.pluginId, - ...(info.stage !== undefined ? { stage: info.stage } : {}), - }); - const kind = outputKindResolver.getOutputKind(info.runtimeId); - await writeEvent("runtime.started", { - runtimeId: info.runtimeId, - pluginId: info.pluginId, - ...(info.stage !== undefined ? { stage: info.stage } : {}), - kind, - label: info.pluginId + "/" + kind, - }); - }, - onRuntimeComplete: async (info) => { - await trace.runtimeCompleted({ - runtimeId: info.runtimeId, - pluginId: info.pluginId, - status: info.status, - durationMs: info.durationMs, - }); - const eventType = - info.status === "failed" - ? "runtime.failed" - : info.status === "skipped" - ? "runtime.skipped" - : "runtime.completed"; - await writeEvent(eventType, { - runtimeId: info.runtimeId, - pluginId: info.pluginId, - durationMs: info.durationMs, - status: info.status, - ...(info.status === "failed" && info.error - ? { error: info.error } + const turnInput = { + sessionId, + turnId: turnArgs.turnId, + playerMessage: turnArgs.playerMessage, + locale: effectiveLocale, + modelOverride: model, + // Feed the pre-turn Pre-Game snapshot so the executor can fix the + // execution's countPolicy at creation. + preGamePending: wasPreGamePending, + // Identity of the player's logical turn — the finalizer keys the + // completion ledger on it so this turn is counted at most once. + // A scoped retry (origin: manual below) never counts, so a stray + // id there is inert. + logicalTurnId: crypto.randomUUID(), + ...(userSettings ? { userSettings } : {}), + // Snapshot session-level per-runtime slot overrides so the + // turn executor can consult them when resolving each runtime's + // model. The session record was loaded above (line ~67). + ...(session?.runtimeModelOverrides + ? { runtimeModelOverrides: session.runtimeModelOverrides } + : {}), + ...(turnArgs.suppressPlayerMessage + ? { suppressPlayerMessage: true } + : {}), + // retry_runtime honors payload.runtimeId: scope the rerun to + // that runtime via the manual-trigger path instead of silently + // re-running the whole turn. Without a runtimeId the action keeps + // its historical whole-turn-retry semantics. + ...(isScopedRetry + ? { + manualTrigger: { + runtimeId: payload.runtimeId!, + ...(retrySeedResults && retrySeedResults.length > 0 + ? { retrySeedResults } : {}), - }); - }, - compactor: compactorRunner, - // Prompt-assembly hard prune — last line of defense when - // compaction is skipped/vetoed/insufficient for the model window. - ...(turnContextBudget - ? { - estimator: estimateTokens, - contextBudget: turnContextBudget, - } - : {}), - memorySystem: _memorySystem, - // Let the turn executor construct a unified SessionContextSnapshot. - capabilityPluginIds, - ...(eventDirectory ? { eventDirectory } : {}), - // Player mid-turn steering + abort. - turnControl: registeredTurn.turnControl, - }); - } finally { - registeredTurn.release(); - } - - // Commit the whole execution — top-level plus nested recursiveCall - // results — in ONE transaction via the shared finalize primitive. - // Any proposal failure rolls the whole turn back (committed siblings - // included) and settles the turn_results row to `failed`; a clean - // run settles it `committed`, both inside that transaction. Nested - // rows reuse the top-level turnId, so `[turnId]` settles them all. - // - // hookPipeline / eventBus are forwarded so `PreStateCommit` and - // `PostStateCommit` hooks declared by plugins fire on the production - // write path (previously they only ran in tests). - const hookPipeline = c.get("hookPipeline"); - const outcome = await finalizeExecution({ - store, - sessionId, - ...(result.executionContext - ? { executionContext: result.executionContext } - : {}), - runtimes: activeRuntimes, - results: [ - ...result.runtimeResults, - ...(result.nestedRuntimeResults ?? []), - ], - turnIds: [turnArgs.turnId], - ...(hookPipeline ? { hookPipeline } : {}), + }, + origin: "manual" as const, + } + : {}), + }; + // Register the in-flight turn only after this action owns the + // session lock. Release control after execution while retaining the + // lock through proposal commit, lifecycle sync, and snapshot capture. + const registeredTurn = registerActiveTurn(sessionId, turnArgs.turnId); + releaseTurnControl = registeredTurn.release; + let result; + try { + result = await executeTurn(turnInput, activeRuntimes, { + loadRuntime: loadRuntimeFn, + llm: llmAdapter, + // The main turn path never passed the eventBus, so every + // `emitSubEvent` inside the executor — including the + // completion barrier's `turn.completed` — silently no-opped on + // the player-facing path (found while adding the + // fault-injection tests). Without it the barrier's only + // observable effect was memory ingestion. eventBus, + ...(pluginGateway ? { gateway: pluginGateway } : {}), + ...(pluginUtils ? { utils: pluginUtils } : {}), + ...(getPluginSource ? { getPluginSource } : {}), + store, + ...(mediaStore ? { mediaStore } : {}), + toolExecutor, + resolveModel, emitter, - // Session-clock write folded into the commit transaction: - // logical-turn counting (from executionContext.countPolicy) plus - // the setup mirror / phase flip (from setupCompletion). Replaces - // the old out-of-band advanceSessionTurnCount + the pre-game - // completion write, and rolls back atomically with the proposals. - sessionClock: { - now: new Date().toISOString(), - ...(result.setupCompletion - ? { setupCompletion: result.setupCompletion } - : {}), + onDelta: async (delta) => { + await writeEvent("narrative.delta", { + runtimeId: delta.runtimeId, + pluginId: delta.pluginId, + kind: outputKindResolver.getOutputKind(delta.runtimeId), + delta: delta.textDelta, + }); }, - // Setup attempt ledger + pending/blocked mirror, settled outside - // the commit transaction (a rolled-back commit still burns an - // attempt, so deterministic failures reach `blocked`). - ...(result.setupRan ? { setupRan: result.setupRan } : {}), - // Publishes recordAs exports inside the commit transaction — - // loaded lazily, only for a success result that declares one. - loadOutputSchema: async (runtimeId) => { - const rt = activeRuntimes.find((r) => r.name === runtimeId); - return rt - ? (await loadRuntimeFn(rt, effectiveLocale))?.outputSchema - : undefined; + onRuntimeStart: async (info) => { + await trace.runtimeStarted({ + runtimeId: info.runtimeId, + pluginId: info.pluginId, + ...(info.stage !== undefined ? { stage: info.stage } : {}), + }); + const kind = outputKindResolver.getOutputKind(info.runtimeId); + await writeEvent("runtime.started", { + runtimeId: info.runtimeId, + pluginId: info.pluginId, + ...(info.stage !== undefined ? { stage: info.stage } : {}), + kind, + label: info.pluginId + "/" + kind, + }); }, - // MediaRef canonicalization / ownership for published export values. - ...(mediaStore ? { mediaStore } : {}), + onRuntimeComplete: async (info) => { + await trace.runtimeCompleted({ + runtimeId: info.runtimeId, + pluginId: info.pluginId, + status: info.status, + durationMs: info.durationMs, + }); + const eventType = + info.status === "failed" + ? "runtime.failed" + : info.status === "skipped" + ? "runtime.skipped" + : "runtime.completed"; + await writeEvent(eventType, { + runtimeId: info.runtimeId, + pluginId: info.pluginId, + durationMs: info.durationMs, + status: info.status, + ...(info.status === "failed" && info.error + ? { error: info.error } + : {}), + }); + }, + compactor: compactorRunner, + // Prompt-assembly hard prune — last line of defense when + // compaction is skipped/vetoed/insufficient for the model window. + ...(turnContextBudget + ? { + estimator: estimateTokens, + contextBudget: turnContextBudget, + } + : {}), + memorySystem: _memorySystem, + // Let the turn executor construct a unified SessionContextSnapshot. + capabilityPluginIds, + ...(eventDirectory ? { eventDirectory } : {}), + // Player mid-turn steering + abort. + turnControl: registeredTurn.turnControl, }); - // finalize owns the commit_status settle (committed or failed). - commitStatusSettled = true; - - for (const evt of outcome.events) { - // Emit using ProtocolEventType directly — no legacy mapping. - await writeEvent(evt.type, { - ...evt.payload, - runtimeId: evt.source.runtimeId, - pluginId: evt.source.pluginId, - }); - } - // Commit failures are surfaced as `proposal.failed` SSE events; any - // failure withholds the completion barrier below (turn.completed, - // memory ingestion, auto-snapshot success signal). - for (const fp of outcome.failedProposals) { - await writeEvent("proposal.failed", { - proposalId: fp.proposal.id, - proposalType: fp.proposal.type, - runtimeId: fp.proposal.source.runtimeId, - pluginId: fp.proposal.source.pluginId, - error: fp.error, + } finally { + registeredTurn.release(); + } + + // Commit the whole execution — top-level plus nested recursiveCall + // results — in ONE transaction via the shared finalize primitive. + // Any proposal failure rolls the whole turn back (committed siblings + // included) and settles the turn_results row to `failed`; a clean + // run settles it `committed`, both inside that transaction. Nested + // rows reuse the top-level turnId, so `[turnId]` settles them all. + // + // hookPipeline / eventBus are forwarded so `PreStateCommit` and + // `PostStateCommit` hooks declared by plugins fire on the production + // write path (previously they only ran in tests). + const hookPipeline = c.get("hookPipeline"); + const outcome = await finalizeExecution({ + store, + sessionId, + ...(result.executionContext + ? { executionContext: result.executionContext } + : {}), + runtimes: activeRuntimes, + results: [ + ...result.runtimeResults, + ...(result.nestedRuntimeResults ?? []), + ], + journalMessages: collectExecutionJournal(result), + ...(playerInputWrites + ? { + extraInTx: async (tx) => { + await tx.addMessage(playerInputWrites.message); + await tx.saveInteractionRecord( + playerInputWrites.interaction, + ); + }, + } + : {}), + turnIds: [turnArgs.turnId], + ...(hookPipeline ? { hookPipeline } : {}), + eventBus, + emitter, + // Session-clock write folded into the commit transaction: + // logical-turn counting (from executionContext.countPolicy) plus + // the setup mirror / phase flip (from setupCompletion). Replaces + // the old out-of-band advanceSessionTurnCount + the pre-game + // completion write, and rolls back atomically with the proposals. + sessionClock: { + now: new Date().toISOString(), + ...(result.setupCompletion + ? { setupCompletion: result.setupCompletion } + : {}), + }, + // Setup attempt ledger + pending/blocked mirror, settled outside + // the commit transaction (a rolled-back commit still burns an + // attempt, so deterministic failures reach `blocked`). + ...(result.setupRan ? { setupRan: result.setupRan } : {}), + // Publishes recordAs exports inside the commit transaction — + // loaded lazily, only for a success result that declares one. + loadOutputSchema: async (runtimeId) => { + const rt = activeRuntimes.find((r) => r.name === runtimeId); + return rt + ? (await loadRuntimeFn(rt, effectiveLocale))?.outputSchema + : undefined; + }, + // MediaRef canonicalization / ownership for published export values. + ...(mediaStore ? { mediaStore } : {}), + }); + // finalize owns the commit_status settle (committed or failed). + commitStatusSettled = true; + + for (const evt of outcome.events) { + // Emit using ProtocolEventType directly — no legacy mapping. + await writeEvent(evt.type, { + ...evt.payload, + runtimeId: evt.source.runtimeId, + pluginId: evt.source.pluginId, + }); + } + // Commit failures are surfaced as `proposal.failed` SSE events; any + // failure withholds the completion barrier below (turn.completed, + // memory ingestion, auto-snapshot success signal). + for (const fp of outcome.failedProposals) { + await writeEvent("proposal.failed", { + proposalId: fp.proposal.id, + proposalType: fp.proposal.type, + runtimeId: fp.proposal.source.runtimeId, + pluginId: fp.proposal.source.pluginId, + error: fp.error, + }); + } + const committed = outcome.status === "committed"; + const proposalErrors = outcome.failedProposals + .map((failure) => failure.error) + .filter(Boolean) + .join("; "); + const commitError = committed + ? undefined + : outcome.error || proposalErrors || "Execution commit failed"; + + // Turn accounting now happens INSIDE the finalize transaction (the + // session-clock write above): a committed player turn advances + // completedPlayerTurns via the logical-turn ledger, and the legacy + // turnCount / preGameCompleted are re-derived from the clock. The + // automatic snapshot stays outside, captured last so it contains + // every committed proposal AND the turn number it belongs to. + if (committed) { + try { + await saveAutoSnapshot({ + store, + sessionId, + turnId: turnArgs.turnId, + createdAt: result.timestamp, + eventBus, }); - } - const committed = outcome.status === "committed"; - - // Turn accounting now happens INSIDE the finalize transaction (the - // session-clock write above): a committed player turn advances - // completedPlayerTurns via the logical-turn ledger, and the legacy - // turnCount / preGameCompleted are re-derived from the clock. The - // automatic snapshot stays outside, captured last so it contains - // every committed proposal AND the turn number it belongs to. - if (committed) { - try { - await saveAutoSnapshot({ - store, - sessionId, - turnId: turnArgs.turnId, - createdAt: result.timestamp, - eventBus, - }); - } catch (err) { - // Best-effort checkpoint: a failed snapshot is logged but does - // not fail the turn — the proposals are already durable. - console.warn( - `[actions] auto snapshot failed for session ${sessionId} turn ${turnArgs.turnId}:`, - err instanceof Error ? err.message : String(err), - ); - } - } else { - console.error( - `[actions] proposal commit failed for session ${sessionId} turn ${turnArgs.turnId} — ` + - "withholding auto-snapshot and turn completion", + } catch (err) { + // Best-effort checkpoint: a failed snapshot is logged but does + // not fail the turn — the proposals are already durable. + console.warn( + `[actions] auto snapshot failed for session ${sessionId} turn ${turnArgs.turnId}:`, + err instanceof Error ? err.message : String(err), ); } + } else { + console.error( + `[actions] proposal commit failed for session ${sessionId} turn ${turnArgs.turnId} — ` + + "withholding auto-snapshot and turn completion", + ); + } - // Commit barrier: the authoritative turn.completed event and - // post-turn memory ingestion fire once every proposal committed. - // A failed auto-snapshot does NOT hold them back — the business - // state is already durable (turnCount advanced above on the same - // proposal-only condition), only the best-effort checkpoint is - // missing and the next turn snapshots again. Gating completion on - // the snapshot would strand a fully-committed turn as "incomplete". - if (committed) { - result.completeTurn?.(); - } - - return { - result, - trace, - userSettings, - committed, - wasPreGamePending, - }; - } finally { - // Torn down while the lock is still held: after release the next - // action owns the session, and its events must not be wrapped in - // this stream's turnId/traceId envelope. - eventBusUnsubscribe?.(); - eventBusUnsubscribe = undefined; + // Commit barrier: the authoritative turn.completed event and + // post-turn memory ingestion fire once every proposal committed. + // A failed auto-snapshot does NOT hold them back — the business + // state is already durable (turnCount advanced above on the same + // proposal-only condition), only the best-effort checkpoint is + // missing and the next turn snapshots again. Gating completion on + // the snapshot would strand a fully-committed turn as "incomplete". + if (committed) { + result.completeTurn?.(); } - }); + + return { + result, + trace, + userSettings, + committed, + commitError, + wasPreGamePending, + }; + } finally { + // Torn down while the lock is still held: after release the next + // action owns the session, and its events must not be wrapped in + // this stream's turnId/traceId envelope. + eventBusUnsubscribe?.(); + eventBusUnsubscribe = undefined; + } + }); // ——— Post-lock tail (per turn) ——— // Deferred-follower scheduling and the final SSE writes deliberately run @@ -901,7 +919,7 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { resultCount: result.runtimeResults.length, }); - return { result, committed, wasPreGamePending }; + return { result, committed, commitError, wasPreGamePending }; }; try { @@ -944,6 +962,8 @@ actionRoutes.post("/", rateLimiter({ max: 30 }), async (c) => { runtimeCount: activeRuntimes.length, resultCount: finalRun.result.runtimeResults.length, durationMs: finalRun.result.durationMs, + committed: finalRun.committed, + ...(finalRun.commitError ? { error: finalRun.commitError } : {}), // Surface a turn that was aborted before producing output (e.g. // cost-gate's hard budget cap) so the player gets a visible reason // instead of a silent empty turn. diff --git a/apps/server/src/routes/api/actions/request.ts b/apps/server/src/routes/api/actions/request.ts new file mode 100644 index 000000000..454b7ac78 --- /dev/null +++ b/apps/server/src/routes/api/actions/request.ts @@ -0,0 +1,248 @@ +/** + * Runtime contract for POST /api/actions. + * + * Parse the untrusted body into a discriminated union before the route reads a + * session or creates a turn. Each action owns an explicit payload shape so + * unused or misspelled fields fail closed instead of silently changing the + * meaning of a request. + */ +interface ActionRequestBase { + readonly requestId: string; + readonly sessionId: string; + readonly locale?: string; + readonly model?: string; +} + +export type ActionRequest = + | (ActionRequestBase & { + readonly type: "send_message"; + readonly payload: { readonly content: string }; + }) + | (ActionRequestBase & { + readonly type: "execute_command"; + readonly payload: { readonly command: string }; + }) + | (ActionRequestBase & { + readonly type: "start_session"; + readonly payload: { readonly loreOverride?: string }; + }) + | (ActionRequestBase & { + readonly type: "retry_runtime"; + readonly payload: { + readonly runtimeId?: string; + readonly retryFromTurnId?: string; + }; + }); + +export type ActionRequestValidation = + | { readonly ok: true; readonly value: ActionRequest } + | { readonly ok: false; readonly error: string }; + +const ACTION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/; +const LOCALE_PATTERN = /^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/; + +function isPlainRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Readonly>, + allowed: readonly string[], +): string | undefined { + const allowedSet = new Set(allowed); + return Object.keys(value).find((key) => !allowedSet.has(key)); +} + +function validateString( + value: unknown, + field: string, + maxLength: number, + pattern?: RegExp, +): string | undefined { + if (typeof value !== "string" || value.length === 0) { + return `${field} must be a non-empty string`; + } + if (value.length > maxLength) { + return `${field} must be at most ${maxLength} characters`; + } + if (pattern && !pattern.test(value)) { + return `${field} has an invalid format`; + } + return undefined; +} + +function validateOptionalString( + value: unknown, + field: string, + maxLength: number, + pattern?: RegExp, +): string | undefined { + return value === undefined + ? undefined + : validateString(value, field, maxLength, pattern); +} + +function validateOptionalText( + value: unknown, + field: string, + maxLength: number, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value !== "string") return `${field} must be a string`; + return value.length > maxLength + ? `${field} must be at most ${maxLength} characters` + : undefined; +} + +function validateOptionalModel(value: unknown): string | undefined { + const error = validateOptionalString(value, "model", 256); + if (error || value === undefined) return error; + + for (const character of value as string) { + const codePoint = character.codePointAt(0)!; + if (codePoint <= 0x1f || codePoint === 0x7f) { + return "model has an invalid format"; + } + } + return undefined; +} + +export function validateActionRequest(raw: unknown): ActionRequestValidation { + if (!isPlainRecord(raw)) { + return { ok: false, error: "Request body must be a JSON object" }; + } + const unknownTopLevel = hasOnlyKeys(raw, [ + "requestId", + "type", + "sessionId", + "locale", + "model", + "payload", + ]); + if (unknownTopLevel) { + return { + ok: false, + error: `Unknown action request field: ${unknownTopLevel}`, + }; + } + const commonError = + validateString(raw.requestId, "requestId", 128, ACTION_ID_PATTERN) ?? + validateString(raw.sessionId, "sessionId", 256, ACTION_ID_PATTERN) ?? + validateOptionalString(raw.locale, "locale", 64, LOCALE_PATTERN) ?? + validateOptionalModel(raw.model); + if (commonError) return { ok: false, error: commonError }; + + const base = { + requestId: raw.requestId as string, + sessionId: raw.sessionId as string, + ...(raw.locale !== undefined ? { locale: raw.locale as string } : {}), + ...(raw.model !== undefined ? { model: raw.model as string } : {}), + }; + const payload = raw.payload ?? {}; + if (!isPlainRecord(payload)) { + return { ok: false, error: "payload must be a JSON object" }; + } + + switch (raw.type) { + case "send_message": { + const unknown = hasOnlyKeys(payload, ["content"]); + const error = + (unknown ? `Unknown send_message payload field: ${unknown}` : null) ?? + validateString(payload.content, "send_message.content", 100_000); + return error + ? { ok: false, error } + : { + ok: true, + value: { + ...base, + type: "send_message", + payload: { content: payload.content as string }, + }, + }; + } + case "execute_command": { + const unknown = hasOnlyKeys(payload, ["command"]); + const error = + (unknown + ? `Unknown execute_command payload field: ${unknown}` + : null) ?? + validateString(payload.command, "execute_command.command", 10_000); + return error + ? { ok: false, error } + : { + ok: true, + value: { + ...base, + type: "execute_command", + payload: { command: payload.command as string }, + }, + }; + } + case "start_session": { + const unknown = hasOnlyKeys(payload, ["loreOverride"]); + const error = unknown + ? `Unknown start_session payload field: ${unknown}` + : validateOptionalText( + payload.loreOverride, + "start_session.loreOverride", + 500_000, + ); + return error + ? { ok: false, error } + : { + ok: true, + value: { + ...base, + type: "start_session", + payload: + payload.loreOverride !== undefined + ? { loreOverride: payload.loreOverride as string } + : {}, + }, + }; + } + case "retry_runtime": { + const unknown = hasOnlyKeys(payload, ["runtimeId", "retryFromTurnId"]); + const error = + (unknown ? `Unknown retry_runtime payload field: ${unknown}` : null) ?? + validateOptionalString( + payload.runtimeId, + "retry_runtime.runtimeId", + 200, + ACTION_ID_PATTERN, + ) ?? + validateOptionalString( + payload.retryFromTurnId, + "retry_runtime.retryFromTurnId", + 256, + ACTION_ID_PATTERN, + ) ?? + (payload.retryFromTurnId !== undefined && + payload.runtimeId === undefined + ? "retry_runtime.retryFromTurnId requires runtimeId" + : undefined); + return error + ? { ok: false, error } + : { + ok: true, + value: { + ...base, + type: "retry_runtime", + payload: { + ...(payload.runtimeId !== undefined + ? { runtimeId: payload.runtimeId as string } + : {}), + ...(payload.retryFromTurnId !== undefined + ? { retryFromTurnId: payload.retryFromTurnId as string } + : {}), + }, + }, + }; + } + default: + return { + ok: false, + error: `Unsupported action type: ${String(raw.type)}`, + }; + } +} diff --git a/apps/server/src/routes/api/plugin-rpc.ts b/apps/server/src/routes/api/plugin-rpc.ts index b97994326..439fd2f13 100644 --- a/apps/server/src/routes/api/plugin-rpc.ts +++ b/apps/server/src/routes/api/plugin-rpc.ts @@ -670,16 +670,25 @@ pluginRpcRoutes.post("/:id/plugin-rpc", rateLimiter({ max: 30 }), async (c) => { sessionId, pluginId, }); - const dispatch = await executor.dispatch( - { - pluginId, - action, - payload: body.payload, - }, - // session.locale lets framework defaults (submit-form) localize their - // produced narrative; resolution order request → session → world → app. - { sessionId, store: rpcStore, locale: session.locale }, - ); + // Action handlers can perform read-validate-write sequences (the framework + // submit-form default is one). Serialize them with turns and sibling RPCs + // so the interaction check and idempotent player-input write are atomic at + // the session boundary, including across PG-backed server processes. + const dispatchAction = () => + executor.dispatch( + { + pluginId, + action, + payload: body.payload, + }, + // session.locale lets framework defaults (submit-form) localize their + // produced narrative; resolution order request → session → world → app. + { sessionId, store: rpcStore, locale: session.locale }, + ); + const actionSessionLock = c.get("sessionLock"); + const dispatch = actionSessionLock + ? await actionSessionLock.withLock(sessionId, dispatchAction) + : await dispatchAction(); return c.json({ status: "ok", result: dispatch.result }); } catch (err) { if (err instanceof RpcValidationError) { diff --git a/apps/server/src/routes/api/plugin-rpc/runtime-turn.ts b/apps/server/src/routes/api/plugin-rpc/runtime-turn.ts index 461b4d787..d131c2da1 100644 --- a/apps/server/src/routes/api/plugin-rpc/runtime-turn.ts +++ b/apps/server/src/routes/api/plugin-rpc/runtime-turn.ts @@ -1,5 +1,6 @@ import { createTurnEmitter, + collectExecutionJournal, executeTurn, finalizeExecution, saveAutoSnapshot, @@ -111,6 +112,7 @@ export function createPluginRpcRuntimeTurnRunner( ...turnResult.runtimeResults, ...(turnResult.nestedRuntimeResults ?? []), ], + journalMessages: collectExecutionJournal(turnResult), turnIds: [turnResult.turnId], ...(ctx.hookPipeline ? { hookPipeline: ctx.hookPipeline } : {}), eventBus: ctx.eventBus, diff --git a/apps/server/src/server-log-tee.ts b/apps/server/src/server-log-tee.ts index adc01256f..ba6828890 100644 --- a/apps/server/src/server-log-tee.ts +++ b/apps/server/src/server-log-tee.ts @@ -34,6 +34,7 @@ interface TeeChannel { let activeChannel: TeeChannel | null = null; let maxBytes = 10 * 1024 * 1024; let maxFiles = 10; +const SERVER_WARNING_PREFIX = "[covel:warn]"; function openChannel(filePath: string): TeeChannel { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -93,18 +94,15 @@ function stripAnsi(input: string): string { return input.replace(ANSI_PATTERN, ""); } -function appendNdjson( - level: "info" | "error", - source: "stdout" | "stderr", - line: string, -): void { +function appendNdjson(source: "stdout" | "stderr", line: string): void { if (!activeChannel) return; if (!line || !line.trim()) return; + const classified = classifyServerStreamLine(source, line); const record = JSON.stringify({ ts: new Date().toISOString(), - level, + level: classified.level, source, - msg: stripAnsi(line), + msg: stripAnsi(classified.message), }); const buf = record + "\n"; const byteLen = Buffer.byteLength(buf, "utf8"); @@ -117,13 +115,12 @@ function appendNdjson( /** * Wrap a stream's `write` so every flushed line lands in `server.log` - * via `appendNdjson(level, source, line)`. Multi-line writes are split + * via `appendNdjson(source, line)`. Multi-line writes are split * on `\n`; partial lines are buffered until a newline arrives so a JSON * record never contains half a console message. */ function wrapStream( stream: NodeJS.WriteStream, - level: "info" | "error", source: "stdout" | "stderr", ): void { const original = stream.write.bind(stream); @@ -146,7 +143,7 @@ function wrapStream( while ((nl = pending.indexOf("\n")) !== -1) { const line = pending.slice(0, nl).replace(/\r$/, ""); pending = pending.slice(nl + 1); - appendNdjson(level, source, line); + appendNdjson(source, line); } if (typeof encodingOrCb === "function") { return original(chunk as never, encodingOrCb); @@ -164,6 +161,24 @@ export interface SetupServerLogFileOptions { readonly rotation?: Partial; } +export function classifyServerStreamLine( + source: "stdout" | "stderr", + line: string, +): { + readonly level: "info" | "warn" | "error"; + readonly message: string; +} { + if (source === "stdout") return { level: "info", message: line }; + const trimmed = line.trimStart(); + if (trimmed.startsWith(SERVER_WARNING_PREFIX)) { + return { + level: "warn", + message: trimmed.slice(SERVER_WARNING_PREFIX.length).trimStart(), + }; + } + return { level: "error", message: line }; +} + /** * Install the tee. Idempotent — calling twice is a no-op after the first. * The caller is responsible for deciding whether to call it (see module @@ -185,6 +200,6 @@ export function setupServerLogFile(options: SetupServerLogFileOptions): void { ); return; } - wrapStream(process.stdout, "info", "stdout"); - wrapStream(process.stderr, "error", "stderr"); + wrapStream(process.stdout, "stdout"); + wrapStream(process.stderr, "stderr"); } diff --git a/apps/server/tests/api/actions-commit-barrier.test.ts b/apps/server/tests/api/actions-commit-barrier.test.ts index bfeab3ea5..67eb7615e 100644 --- a/apps/server/tests/api/actions-commit-barrier.test.ts +++ b/apps/server/tests/api/actions-commit-barrier.test.ts @@ -58,10 +58,18 @@ function makeEntry(loaded: LoadedRuntime): PluginRegistryEntry { } /** Drain the actions SSE stream, returning the parsed envelopes. */ -async function drainActionStream( - res: Response, -): Promise> { - const envelopes: Array<{ type: string; traceId?: string }> = []; +async function drainActionStream(res: Response): Promise< + Array<{ + type: string; + traceId?: string; + payload?: Record; + }> +> { + const envelopes: Array<{ + type: string; + traceId?: string; + payload?: Record; + }> = []; if (!res.body) return envelopes; const reader = res.body.getReader(); const decoder = new TextDecoder(); @@ -249,9 +257,10 @@ describe("POST /api/actions — turn accounting follows the commit outcome", () // `{ committed: false }` without throwing, which is the proposal-failure // path (a thrown store error would instead abort the whole turn as // `error.occurred`). + let vetoEnabled = true; const vetoPipeline = { run: async (event: string) => - event === "PreStateCommit" + vetoEnabled && event === "PreStateCommit" ? { action: "abort", reason: "injected commit veto" } : { action: "continue" }, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -292,6 +301,9 @@ describe("POST /api/actions — turn accounting follows the commit outcome", () expect(res.status).toBe(200); const envelopes = await drainActionStream(res); expect(envelopes.map((e) => e.type)).toContain("proposal.failed"); + const terminal = envelopes.find((e) => e.type === "execution.completed"); + expect(terminal?.payload?.committed).toBe(false); + expect(String(terminal?.payload?.error)).toContain("injected commit veto"); // The failed execution persisted its artifact, settled as failed… const failedTurn = (await store.listTurnResults(SESSION_ID)).find( @@ -304,5 +316,47 @@ describe("POST /api/actions — turn accounting follows the commit outcome", () // stay where it was. const session = await store.getSession(SESSION_ID); expect(session?.turnCount).toBe(1); + + // Player/runtime conversation messages share the proposal transaction. + // This test seeded only a turn-result artifact, so no conversation rows + // survive the failed turn. + expect( + (await store.listTurnMessages(SESSION_ID)).map((message) => message.id), + ).toEqual([]); + expect(await store.listMessages(SESSION_ID)).toEqual([]); + expect(await store.listInteractionRecords(SESSION_ID)).toEqual([]); + + // A non-proposal transaction failure has no proposal.failed frame, so the + // terminal envelope itself must carry the generic finalizer error. + vetoEnabled = false; + Object.defineProperty(store, "withTransaction", { + configurable: true, + value: async () => { + throw new Error("injected transaction failure"); + }, + }); + const genericFailureResponse = await app.request("/api/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: "req-fail-transaction", + type: "send_message", + sessionId: SESSION_ID, + payload: { content: "hello again" }, + }), + }); + const genericFailureEnvelopes = await drainActionStream( + genericFailureResponse, + ); + expect(genericFailureEnvelopes.map((e) => e.type)).not.toContain( + "proposal.failed", + ); + const genericTerminal = genericFailureEnvelopes.find( + (e) => e.type === "execution.completed", + ); + expect(genericTerminal?.payload?.committed).toBe(false); + expect(String(genericTerminal?.payload?.error)).toContain( + "injected transaction failure", + ); }); }); diff --git a/apps/server/tests/api/actions-contract.test.ts b/apps/server/tests/api/actions-contract.test.ts index 0a2d395ef..7622cdafc 100644 --- a/apps/server/tests/api/actions-contract.test.ts +++ b/apps/server/tests/api/actions-contract.test.ts @@ -143,6 +143,120 @@ describe("POST /api/actions — action type contract ", () => { expect(body.error).toContain("Unsupported action type"); }); + it.each([ + { + label: "non-string send_message.content", + body: { + requestId: "req-bad-content", + type: "send_message", + sessionId, + payload: { content: { text: "hello" } }, + }, + }, + { + label: "non-string execute_command.command", + body: { + requestId: "req-bad-command", + type: "execute_command", + sessionId, + payload: { command: 42 }, + }, + }, + { + label: "array payload", + body: { + requestId: "req-array-payload", + type: "send_message", + sessionId, + payload: [], + }, + }, + { + label: "unknown payload field", + body: { + requestId: "req-extra-field", + type: "retry_runtime", + sessionId, + payload: { runtimeId: SIDE_ID, admin: true }, + }, + }, + { + label: "non-string retry runtimeId", + body: { + requestId: "req-bad-runtime", + type: "retry_runtime", + sessionId, + payload: { runtimeId: { name: SIDE_ID } }, + }, + }, + { + label: "invalid locale", + body: { + requestId: "req-bad-locale", + type: "send_message", + sessionId, + locale: "../zh-CN", + payload: { content: "hello" }, + }, + }, + { + label: "model containing a control character", + body: { + requestId: "req-bad-model", + type: "send_message", + sessionId, + model: "story\nadmin", + payload: { content: "hello" }, + }, + }, + { + label: "empty requestId", + body: { + requestId: "", + type: "send_message", + sessionId, + payload: { content: "hello" }, + }, + }, + { + label: "unknown start_session field", + body: { + requestId: "req-bad-start", + type: "start_session", + sessionId, + payload: { plugins: ["untrusted"] }, + }, + }, + { + label: "unknown top-level field", + body: { + requestId: "req-extra-top-level", + type: "send_message", + sessionId, + debug: true, + payload: { content: "hello" }, + }, + }, + { + label: "retry source without a scoped runtime", + body: { + requestId: "req-bad-retry-source", + type: "retry_runtime", + sessionId, + payload: { retryFromTurnId: "old-turn" }, + }, + }, + ])("rejects $label before any turn write", async ({ body }) => { + const res = await app.request("/api/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(400); + expect(await store.listTurnResults(sessionId)).toEqual([]); + expect(await store.listTurnMessages(sessionId)).toEqual([]); + }); + it("retry_runtime with runtimeId re-runs only that runtime (manual path)", async () => { const res = await app.request("/api/actions", { method: "POST", @@ -232,6 +346,41 @@ describe("POST /api/actions — action type contract ", () => { expect(await store.listTurnResults(emptySessionId)).toHaveLength(0); }); + it("persists start_session loreOverride on the session", async () => { + const res = await app.request("/api/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: "req-start-lore", + type: "start_session", + sessionId, + payload: { loreOverride: "A player-edited world document." }, + }), + }); + + expect(res.status).toBe(200); + await drainStream(res); + expect((await store.getSession(sessionId))?.metadata?.loreOverride).toBe( + "A player-edited world document.", + ); + + const clear = await app.request("/api/actions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + requestId: "req-start-lore-clear", + type: "start_session", + sessionId, + payload: { loreOverride: "" }, + }), + }); + expect(clear.status).toBe(200); + await drainStream(clear); + expect((await store.getSession(sessionId))?.metadata?.loreOverride).toBe( + "", + ); + }); + it("retry_runtime without runtimeId keeps whole-turn-retry semantics", async () => { const res = await app.request("/api/actions", { method: "POST", diff --git a/apps/server/tests/api/actions-stream-scope.test.ts b/apps/server/tests/api/actions-stream-scope.test.ts index 820f91c22..143ddd2a3 100644 --- a/apps/server/tests/api/actions-stream-scope.test.ts +++ b/apps/server/tests/api/actions-stream-scope.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect } from "vitest"; import { Hono } from "hono"; -import { createMemoryStore } from "@covel/store"; +import { createMemoryStore, type StoreTransaction } from "@covel/store"; import { createEventBus, type EventBus } from "@covel/events"; import { createPluginRegistry, @@ -116,8 +116,9 @@ describe("POST /api/actions — event forwarding is scoped to the lock tenure", // Gate the post-lock tail: the recorder's `turn.completed` trace row is // written AFTER the session lock releases, so blocking it parks the // request in exactly the window where the next action could already own - // the session. The player-message write (role "user") happens under the - // lock — use it to emit the in-lock control event. + // the session. The player-message write (role "user") now happens through + // finalizeExecution's transaction under the lock — use it to emit the + // in-lock control event. let releaseTail!: () => void; const tailGate = new Promise((resolve) => (releaseTail = resolve)); let reachedTail!: () => void; @@ -133,13 +134,24 @@ describe("POST /api/actions — event forwarding is scoped to the lock tenure", return store.addTraceEvent(record as never); }; } - if (prop === "addMessage") { - return async (record: { role: string }) => { - if (record.role === "user") { - emitForwardedEvent(eventBus, "in-lock"); - } - return store.addMessage(record as never); - }; + if (prop === "withTransaction") { + return async (fn: (tx: StoreTransaction) => Promise) => + store.withTransaction!(async (tx) => { + const observedTx = new Proxy(tx, { + get(txTarget, txProp, txReceiver) { + if (txProp === "addMessage") { + return async (record: { role: string }) => { + if (record.role === "user") { + emitForwardedEvent(eventBus, "in-lock"); + } + return tx.addMessage(record as never); + }; + } + return Reflect.get(txTarget, txProp, txReceiver); + }, + }); + return fn(observedTx); + }); } return Reflect.get(target, prop, receiver); }, diff --git a/apps/server/tests/api/plugin-rpc.test.ts b/apps/server/tests/api/plugin-rpc.test.ts index 8494f5d2f..be1aa41bb 100644 --- a/apps/server/tests/api/plugin-rpc.test.ts +++ b/apps/server/tests/api/plugin-rpc.test.ts @@ -27,7 +27,7 @@ import { type LoadedRuntime, type FunctionHandler, } from "@covel/plugin-loader"; -import type { RuntimeManifest } from "@covel/shared"; +import type { InteractionPayload, RuntimeManifest } from "@covel/shared"; import { createEventBus } from "@covel/events"; import { pluginRpcRoutes } from "../../src/routes/api/plugin-rpc.js"; import { sessionRoutes } from "../../src/routes/api/session.js"; @@ -64,6 +64,7 @@ function setup(): { }); const gate = createRpcApprovalGate(); const pluginRegistry = createPluginRegistry(); + const sessionLock = createInProcessSessionLock(); const app = new Hono(); app.use("*", async (c, next) => { c.set("store", store); @@ -74,6 +75,7 @@ function setup(): { // not found" without 500ing on missing DI. Full executeTurn wiring is // covered by the bootstrap integration tests. c.set("pluginRegistry", pluginRegistry); + c.set("sessionLock", sessionLock); await next(); }); app.route("/api/sessions", pluginRpcRoutes); @@ -102,19 +104,18 @@ async function seedSession( async function seedInteractionTemplate( store: DataStore, sessionId: string, - interactionId: string, - content: string, + interaction: InteractionPayload, ): Promise { await store.appendTurnMessage({ - id: `tpl-${interactionId}`, + id: `tpl-${interaction.interactionId}`, sessionId, turnId: "turn-1", sourceType: "runtime", role: "assistant", name: "tpl", - content, + content: "", order: 700, - pendingInput: { formId: interactionId }, + pendingInput: [interaction], createdAt: new Date().toISOString(), }); } @@ -139,10 +140,9 @@ describe("POST /api/sessions/:id/plugin-rpc", () => { let app: Hono; let store: DataStore; let registry: PluginRpcRegistry; - let gate: RpcApprovalGate; beforeEach(async () => { - ({ app, store, registry, gate } = setup()); + ({ app, store, registry } = setup()); await seedSession(store); }); @@ -302,12 +302,13 @@ describe("POST /api/sessions/:id/plugin-rpc", () => { }); it("forwards a choice submission and fills the template with selectedLabel", async () => { - await seedInteractionTemplate( - store, - "sess-rpc-1", - "ch-1", - "You chose {{selectedLabel}}", - ); + await seedInteractionTemplate(store, "sess-rpc-1", { + interactionId: "ch-1", + type: "choice", + prompt: "Choose", + choices: [{ id: "a", label: "Attack" }], + narrativeTemplate: "You chose {{selectedLabel}}", + }); const res = await submitFormRequest(app, "sess-rpc-1", [ { interactionId: "ch-1", @@ -321,14 +322,48 @@ describe("POST /api/sessions/:id/plugin-rpc", () => { expect(body.result.results[0].filledNarrative).toBe("You chose Attack"); }); + it("serializes concurrent identical submissions into one player input", async () => { + await seedInteractionTemplate(store, "sess-rpc-1", { + interactionId: "form-concurrent", + type: "form", + title: "Name", + submitLabel: "Continue", + fields: [{ type: "text", name: "name", label: "Name", required: true }], + }); + const submission = { + interactionId: "form-concurrent", + type: "form", + values: { name: "Aria" }, + }; + const [left, right] = await Promise.all([ + submitFormRequest(app, "sess-rpc-1", [submission]), + submitFormRequest(app, "sess-rpc-1", [submission]), + ]); + expect([left.status, right.status]).toEqual([200, 200]); + const leftBody = (await left.json()) as { + result: { results: Array<{ submissionId: string }> }; + }; + const rightBody = (await right.json()) as { + result: { results: Array<{ submissionId: string }> }; + }; + expect(rightBody.result.results[0]?.submissionId).toBe( + leftBody.result.results[0]?.submissionId, + ); + expect( + (await store.listPlayerInputs("sess-rpc-1")).filter( + (input) => input.formId === "form-concurrent", + ), + ).toHaveLength(1); + }); + it("threads session.locale into the handler: confirmation localizes to en-US", async () => { await seedSession(store, "sess-rpc-en", "en-US"); - await seedInteractionTemplate( - store, - "sess-rpc-en", - "cf-1", - "Result: {{confirmed}}", - ); + await seedInteractionTemplate(store, "sess-rpc-en", { + interactionId: "cf-1", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); const res = await submitFormRequest(app, "sess-rpc-en", [ { interactionId: "cf-1", @@ -344,12 +379,12 @@ describe("POST /api/sessions/:id/plugin-rpc", () => { }); it("confirmation stays 确认 under the default zh-CN session locale", async () => { - await seedInteractionTemplate( - store, - "sess-rpc-1", - "cf-2", - "Result: {{confirmed}}", - ); + await seedInteractionTemplate(store, "sess-rpc-1", { + interactionId: "cf-2", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); const res = await submitFormRequest(app, "sess-rpc-1", [ { interactionId: "cf-2", @@ -364,6 +399,19 @@ describe("POST /api/sessions/:id/plugin-rpc", () => { }); it("processes a batch of form+choice submissions in one request", async () => { + await seedInteractionTemplate(store, "sess-rpc-1", { + interactionId: "b1", + type: "form", + title: "Name", + submitLabel: "Continue", + fields: [{ type: "text", name: "name", label: "Name" }], + }); + await seedInteractionTemplate(store, "sess-rpc-1", { + interactionId: "b2", + type: "choice", + prompt: "Choose", + choices: [{ id: "x", label: "X" }], + }); const res = await submitFormRequest(app, "sess-rpc-1", [ { interactionId: "b1", type: "form", values: { name: "A" } }, { interactionId: "b2", type: "choice", values: { selectedId: "x" } }, @@ -1200,9 +1248,9 @@ describe("POST /api/sessions/:id/plugin-rpc — runtime mode (M8b)", () => { runtimeId: SYNC_RUNTIME, reason: "expected-background-follower-missing", }); - expect(String((jobs[0]?.value as { error?: string }).error)).toContain( - "completed without emitting", - ); + expect( + String((jobs[0]?.value as { error?: string } | undefined)?.error), + ).toContain("completed without emitting"); }); // ── X-Plugin-User-Settings header → ctx.userSettings ── diff --git a/apps/server/tests/api/start-game-flow-scenario.test.ts b/apps/server/tests/api/start-game-flow-scenario.test.ts index d2331b3df..6c54c10d5 100644 --- a/apps/server/tests/api/start-game-flow-scenario.test.ts +++ b/apps/server/tests/api/start-game-flow-scenario.test.ts @@ -158,7 +158,10 @@ function makeLoadedRuntimes(store: DataStore): Map { type: "form", interactionId: "form-char-creation", narrativeTemplate: "Player {{name}} enters as {{concept}}.", - fields: [{ id: "name", label: "Name", type: "text" }], + fields: [ + { id: "name", label: "Name", type: "text" }, + { id: "concept", label: "Concept", type: "text" }, + ], }, ], }; diff --git a/apps/server/tests/lib/server-log-tee.test.ts b/apps/server/tests/lib/server-log-tee.test.ts new file mode 100644 index 000000000..7e2a10664 --- /dev/null +++ b/apps/server/tests/lib/server-log-tee.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { classifyServerStreamLine } from "../../src/server-log-tee.js"; + +describe("classifyServerStreamLine", () => { + it("preserves explicitly marked stderr warnings as warn", () => { + expect( + classifyServerStreamLine( + "stderr", + "[covel:warn] [runtime-retry] narrator attempt=1", + ), + ).toEqual({ + level: "warn", + message: "[runtime-retry] narrator attempt=1", + }); + }); + + it("keeps unmarked stderr as error and stdout as info", () => { + expect(classifyServerStreamLine("stderr", "fatal").level).toBe("error"); + expect(classifyServerStreamLine("stdout", "ready").level).toBe("info"); + }); +}); diff --git a/apps/web/src/services/__tests__/local-session-sync.test.ts b/apps/web/src/services/__tests__/local-session-sync.test.ts new file mode 100644 index 000000000..731ad631c --- /dev/null +++ b/apps/web/src/services/__tests__/local-session-sync.test.ts @@ -0,0 +1,107 @@ +import { createMemoryStore, type DataStore } from "@covel/store"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getWorld: vi.fn(), + createWorld: vi.fn(), + getSession: vi.fn(), + createSession: vi.fn(), + syncMessages: vi.fn(), +})); + +vi.mock("../api.js", () => api); +vi.mock("../api/request.js", () => ({ + isNotFound: () => true, +})); +vi.mock("../app-kv-store.js", () => ({ + getStatePatches: vi.fn(async () => null), + saveStatePatches: vi.fn(async () => {}), + removeStatePatches: vi.fn(async () => {}), + getStateSnapshot: vi.fn(async () => null), + saveStateSnapshot: vi.fn(async () => {}), + removeStateSnapshot: vi.fn(async () => {}), + getSubmittedBlocks: vi.fn(async () => null), + saveSubmittedBlocks: vi.fn(async () => {}), + removeSubmittedBlocks: vi.fn(async () => {}), + getWorldOverlay: vi.fn(async () => null), + saveWorldOverlay: vi.fn(async () => {}), + migrateLocalStorageToIdb: vi.fn(async () => {}), +})); + +const { LocalDataService } = await import("../data-service/local.js"); + +function withStore(store: DataStore): InstanceType { + const service = new LocalDataService(); + ( + service as unknown as { + idbStore: DataStore; + } + ).idbStore = store; + return service; +} + +beforeEach(() => { + vi.clearAllMocks(); + api.getWorld.mockResolvedValue({ id: "world-1" }); + api.getSession.mockRejectedValue(new Error("404")); + api.createSession.mockResolvedValue({ id: "sess-1" }); + api.syncMessages.mockResolvedValue(undefined); +}); + +describe("LocalDataService session sync", () => { + it("returns the locally persisted plugin set and locale", async () => { + const service = withStore(createMemoryStore()); + + const session = await service.createSession( + "world-1", + "preset-1", + "ignored-id", + ["pregame", "world-init", "scene-stage"], + "en-US", + ); + + expect(session).toMatchObject({ + activePlugins: ["pregame", "world-init", "scene-stage"], + locale: "en-US", + }); + await expect(service.getSession(session.id)).resolves.toMatchObject({ + activePlugins: ["pregame", "world-init", "scene-stage"], + locale: "en-US", + }); + }); + + it("creates the server mirror with the selected plugins and locale", async () => { + const store = createMemoryStore(); + const now = new Date().toISOString(); + await store.upsertWorld({ + id: "world-1", + name: "World", + description: "", + createdAt: now, + updatedAt: now, + }); + await store.createSession({ + id: "sess-1", + worldId: "world-1", + status: "active", + turnCount: 0, + preGameCompleted: [], + activePlugins: ["pregame", "world-init", "scene-stage"], + locale: "en-US", + presetId: "preset-1", + createdAt: now, + updatedAt: now, + }); + const service = withStore(store); + + await service.syncToServer("sess-1"); + + expect(api.createSession).toHaveBeenCalledWith( + "world-1", + "preset-1", + "sess-1", + ["pregame", "world-init", "scene-stage"], + "en-US", + ); + }); +}); diff --git a/apps/web/src/services/api/types.ts b/apps/web/src/services/api/types.ts index 707a03e05..0f915e7b7 100644 --- a/apps/web/src/services/api/types.ts +++ b/apps/web/src/services/api/types.ts @@ -2,6 +2,7 @@ import type { I18nText, PluginUserSettingSpec, SessionStatus, + SetupRuntimeState, Stage, WorldDimensions, } from "@covel/shared"; @@ -10,6 +11,7 @@ export type { PluginRpcRequest, PluginRpcResponse, SessionStatus, + SetupRuntimeState, } from "@covel/shared"; // -- Shared API types @@ -36,45 +38,19 @@ export interface WorldRecord { export type GeneratedWorldSaveTarget = "server-file" | "server-store" | "return-only"; -/** - * Lifecycle state of a single one-time "setup" runtime, as tracked on the - * session record (keyed by runtimeId, e.g. `"char-creator/player-init"`). - * A setup runtime that fails repeatedly becomes `blocked`, halting its plugin - * until the player retries or waives it. - */ -export type SetupRuntimeState = - | { - state: "pending"; - pluginVersion: string; - generation: number; - attempts: number; - lastError?: string; - } - | { - state: "done"; - resolution: "completed" | "waived"; - generation: number; - attempts: number; - completedAt: string; - pluginVersion: string; - warning?: string; - } - | { - state: "blocked"; - pluginVersion: string; - generation: number; - attempts: number; - reason: string; - blockedAt: string; - }; - export interface SessionRecord { id: string; worldId: string; status: SessionStatus; + /** Locale persisted on the authoritative server session. */ + locale?: string; turnCount: number; - /** Runtime IDs whose Pre-Game (band 0-99) runs have completed. */ + /** Legacy setup completion list, derived from setupRuntimes. */ preGameCompleted?: readonly string[]; + /** Authoritative setup/main-loop scheduling phase. */ + phase?: "setup" | "playing"; + /** Number of committed main-loop player turns. */ + completedPlayerTurns?: number; activePlugins?: readonly string[]; presetId?: string; taskBindings?: Record; @@ -82,6 +58,7 @@ export interface SessionRecord { /** Per-setup-runtime lifecycle state, keyed by runtimeId. */ setupRuntimes?: Record; createdAt: string; + updatedAt?: string; } /** diff --git a/apps/web/src/services/data-service/local.ts b/apps/web/src/services/data-service/local.ts index 6f987ccc7..4dc3a9c70 100644 --- a/apps/web/src/services/data-service/local.ts +++ b/apps/web/src/services/data-service/local.ts @@ -201,8 +201,10 @@ export class LocalDataService implements DataService { id: humanSessionId(), worldId, status: "active", + locale: locale ?? "zh-CN", turnCount: 0, preGameCompleted: [], + activePlugins: _plugins ?? [], presetId, createdAt: nowIso, }; @@ -426,7 +428,13 @@ export class LocalDataService implements DataService { await api.getSession(serverSessionId); } catch (err) { if (!isNotFound(err)) throw err; - await api.createSession(serverWorldId, session.presetId, serverSessionId); + await api.createSession( + serverWorldId, + session.presetId, + serverSessionId, + session.activePlugins ? [...session.activePlugins] : undefined, + session.locale, + ); } // Upload messages so the server kernel can build LLM context. This is NOT diff --git a/apps/web/src/services/data-service/mappers.ts b/apps/web/src/services/data-service/mappers.ts index 236a64933..e84feded2 100644 --- a/apps/web/src/services/data-service/mappers.ts +++ b/apps/web/src/services/data-service/mappers.ts @@ -27,6 +27,7 @@ export function toFrontendSession(s: StoreSessionRecord): SessionRecord { id: s.id, worldId: s.worldId ?? "", status: s.status, + locale: s.locale, turnCount: s.turnCount, preGameCompleted: s.preGameCompleted, activePlugins: s.activePlugins, diff --git a/apps/web/src/stores/__tests__/session-store-player-abort.test.ts b/apps/web/src/stores/__tests__/session-store-player-abort.test.ts index c3bb82dd5..a4ded4ae0 100644 --- a/apps/web/src/stores/__tests__/session-store-player-abort.test.ts +++ b/apps/web/src/stores/__tests__/session-store-player-abort.test.ts @@ -89,6 +89,7 @@ describe("sse-handler execution.completed abort terminal state", () => { runtimeCount: 1, resultCount: 0, durationMs: 5, + committed: true, abortReason, }, }; @@ -122,6 +123,44 @@ describe("sse-handler execution.completed abort terminal state", () => { const types = dispatch.mock.calls.map(([a]) => a.type); expect(types).not.toContain("DISCARD_TURN_STREAMS"); }); + + it("commit failure discards uncommitted streams and surfaces the terminal error", () => { + const dispatch = vi.fn(); + const deps = makeDeps(dispatch); + deps.deltaBufferRef.current.set("turn-1:narrator/main", { + turnId: "turn-1", + runtimeId: "narrator/main", + pluginId: "narrator", + text: "ghost narrative", + flushSessionId: "sess-1", + }); + const handle = createSseEventHandler(deps); + + handle({ + ...completedEnvelope(""), + payload: { + runtimeCount: 1, + resultCount: 1, + durationMs: 5, + committed: false, + error: "injected commit veto", + }, + }); + + expect(deps.deltaBufferRef.current.size).toBe(0); + expect(dispatch).toHaveBeenCalledWith({ + type: "DISCARD_TURN_STREAMS", + turnId: "turn-1", + }); + expect(dispatch).toHaveBeenCalledWith({ + type: "SET_EXECUTION_ERROR", + error: "injected commit veto", + }); + expect(dispatch).toHaveBeenCalledWith({ + type: "SET_EXECUTING", + value: false, + }); + }); }); describe("sse-handler abort clears the pending delta rAF (H1 race)", () => { @@ -158,6 +197,7 @@ describe("sse-handler abort clears the pending delta rAF (H1 race)", () => { runtimeCount: 1, resultCount: 0, durationMs: 5, + committed: true, abortReason: PLAYER_ABORT_REASON, }, }; diff --git a/apps/web/src/stores/session-store/__tests__/start-game.test.ts b/apps/web/src/stores/session-store/__tests__/start-game.test.ts new file mode 100644 index 000000000..eb6d8479e --- /dev/null +++ b/apps/web/src/stores/session-store/__tests__/start-game.test.ts @@ -0,0 +1,113 @@ +import type { DataService } from "@/services/data-service.js"; +import type { SessionRecord, WorldRecord } from "@/services/api.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const api = vi.hoisted(() => ({ + getSlotConfig: vi.fn(), + getPrepRuntimeBindings: vi.fn(), + updateSession: vi.fn(), + clearPrepRuntimeBindings: vi.fn(), + getSessionSnapshot: vi.fn(), + markServerAck: vi.fn(), +})); +const hydration = vi.hoisted(() => ({ + hydratePluginDataForUiSpecs: vi.fn(), +})); + +vi.mock("@/services/api", () => api); +vi.mock("../plugin-data-hydration.js", () => hydration); +vi.mock("@/stores/plugin-data-store.js", () => ({ + setActiveSession: vi.fn(), +})); + +const { startGameSession } = await import("../start-game.js"); + +const session: SessionRecord = { + id: "sess-1", + worldId: "world-1", + status: "active", + turnCount: 0, + activePlugins: ["pregame", "world-init"], + locale: "en-US", + createdAt: "2026-08-09T00:00:00.000Z", +}; +const world = { + id: "world-1", + name: "World", + description: "", + createdAt: "2026-08-09T00:00:00.000Z", +} as WorldRecord; + +function makeDataService(order: string[]): DataService { + return { + createSession: vi.fn(async () => { + order.push("create"); + return session; + }), + syncToServer: vi.fn(async () => { + order.push("sync"); + }), + } as unknown as DataService; +} + +beforeEach(() => { + vi.clearAllMocks(); + api.getSlotConfig.mockReturnValue({}); + api.getPrepRuntimeBindings.mockReturnValue({ narrator: "fast" }); + api.updateSession.mockResolvedValue(session); + api.getSessionSnapshot.mockResolvedValue({}); + hydration.hydratePluginDataForUiSpecs.mockResolvedValue(undefined); +}); + +describe("startGameSession bootstrap order", () => { + it("publishes the session only after server sync and model bindings", async () => { + const order: string[] = []; + api.updateSession.mockImplementation(async () => { + order.push("bindings"); + return session; + }); + api.clearPrepRuntimeBindings.mockImplementation(() => { + order.push("clear-bindings"); + }); + const dispatch = vi.fn((action: { type: string }) => { + if (action.type === "SET_SESSION") order.push("dispatch-session"); + }); + + await startGameSession({ + ds: makeDataService(order), + dispatch, + sessionIdRef: { current: null }, + world, + presets: [], + llmConfig: null, + plugins: ["pregame", "world-init"], + }); + + expect(order).toEqual([ + "create", + "sync", + "bindings", + "clear-bindings", + "dispatch-session", + ]); + expect(api.markServerAck).toHaveBeenCalledOnce(); + }); + + it("keeps prep bindings when the server patch fails", async () => { + api.updateSession.mockRejectedValue(new Error("patch failed")); + const dispatch = vi.fn(); + + await startGameSession({ + ds: makeDataService([]), + dispatch, + sessionIdRef: { current: null }, + world, + presets: [], + llmConfig: null, + plugins: ["pregame", "world-init"], + }); + + expect(api.clearPrepRuntimeBindings).not.toHaveBeenCalled(); + expect(dispatch).toHaveBeenCalledWith({ type: "SET_SESSION", session }); + }); +}); diff --git a/apps/web/src/stores/session-store/actions.ts b/apps/web/src/stores/session-store/actions.ts index 970682ef8..e55c8fc17 100644 --- a/apps/web/src/stores/session-store/actions.ts +++ b/apps/web/src/stores/session-store/actions.ts @@ -130,7 +130,7 @@ export function useBuildSessionActions({ type: "start_session", sessionId, locale: i18n.language, - payload: loreOverride ? { loreOverride } : {}, + payload: typeof loreOverride === "string" ? { loreOverride } : {}, }, handleSseEvent, dispatch, @@ -360,14 +360,11 @@ export function useBuildSessionActions({ // Run the resulting narrative turn, then re-sync the character snapshot. // - // The character panel reads `gameState.characters` (dataSource - // `session.characters`), and that slice is only filled incrementally by - // the `character.upserted` SSE event — which fires *solely* from the - // `character.upsert` proposal path. The primary character-creation paths - // (char-creator's create/update-character tools and player-init's - // deterministic guard) write straight to the store and mirror to - // plugin_data, emitting only `plugin-data.changed`. They never emit - // `character.upserted`. `characterSchema` has no SSE carrier at all. + // Proposal-backed character writes (including the builtin + // create/update-character tools and player-init guard) emit + // `character.upserted`, so characters update incrementally. + // `characterSchema` still has no SSE carrier; refresh the snapshot after + // setup input so the schema and character slices are reconciled together. // // So after the turn that may have created/updated the player, we pull a // snapshot to refresh both `characters` and `characterSchema`. Done after diff --git a/apps/web/src/stores/session-store/sse-handler.ts b/apps/web/src/stores/session-store/sse-handler.ts index 8e3c56d27..d6ce711e8 100644 --- a/apps/web/src/stores/session-store/sse-handler.ts +++ b/apps/web/src/stores/session-store/sse-handler.ts @@ -441,13 +441,27 @@ export function createSseEventHandler( break; } case "execution.completed": { + const committed = payload.committed !== false; // A turn aborted before producing output (e.g. cost-gate's hard budget // cap) carries an abortReason — surface it so the player isn't left with // a silent empty turn. A player-initiated abort is NOT an error: the // server never commits the partial narrative, so discard the streaming // placeholder instead of showing ghost text + a red retry affordance. const abortReason = payload.abortReason as string | undefined; - if (abortReason === PLAYER_ABORT_REASON) { + if (!committed) { + clearNarrativeDeltaBuffer(deps.deltaBufferRef, deps.deltaRafRef); + if (turnId) clearStreamingTextsForTurn(turnId); + deps.dispatch({ + type: "DISCARD_TURN_STREAMS", + ...(turnId ? { turnId } : {}), + }); + deps.dispatch({ + type: "SET_EXECUTION_ERROR", + error: + (payload.error as string | undefined) ?? + "Execution commit failed", + }); + } else if (abortReason === PLAYER_ABORT_REASON) { // Cancel any pending rAF delta flush + drop buffered deltas first: // otherwise a fast abort (last narrative.delta + execution.completed // in one network flush) lets the queued rAF fire AFTER the discard diff --git a/apps/web/src/stores/session-store/start-game.ts b/apps/web/src/stores/session-store/start-game.ts index a61e82629..29b82ef5b 100644 --- a/apps/web/src/stores/session-store/start-game.ts +++ b/apps/web/src/stores/session-store/start-game.ts @@ -78,11 +78,10 @@ async function persistPrepRuntimeBindings( await api.updateSession(sessionId, { runtimeModelOverrides: overrides, }); + api.clearPrepRuntimeBindings(worldId); } catch { - // Non-fatal: overrides fall back to manifest defaults when missing. + // Non-fatal: keep the Prep bindings so a later retry can persist them. } - - api.clearPrepRuntimeBindings(worldId); } export async function startGameSession({ @@ -102,8 +101,17 @@ export async function startGameSession({ plugins, i18n.language, ); - dispatch({ type: "SET_SESSION", session }); + + // Local mode creates the browser record first. Establish the authoritative + // server mirror before publishing an executable session or issuing any + // server-backed hydration / model-binding calls. Remote mode is already + // authoritative and implements syncToServer as a no-op. + await ds.syncToServer(session.id); + api.markServerAck(); + await persistPrepRuntimeBindings(world.id, session.id); + setActivePluginDataSession(session.id); + dispatch({ type: "SET_SESSION", session }); await hydrateInitialSnapshot(session.id, sessionIdRef, dispatch); @@ -112,10 +120,6 @@ export async function startGameSession({ } catch { // Right-panel hydration will retry when its own ui-spec loader runs. } - - await persistPrepRuntimeBindings(world.id, session.id); - await ds.syncToServer(session.id); - api.markServerAck(); } catch (err) { dispatch({ type: "SET_EXECUTION_ERROR", diff --git a/docs/architecture/flow.md b/docs/architecture/flow.md index 86fb7924e..7e72ae97d 100644 --- a/docs/architecture/flow.md +++ b/docs/architecture/flow.md @@ -63,13 +63,13 @@ stateDiagram-v2 AllSetupDone --> [*] } - Setup --> Playing: Kernel 翻转 phase setup → playing\n completedPlayerTurns 0 → 1 + Setup --> Playing: setup 提交事务翻转 phase setup → playing\n completedPlayerTurns 保持 0 state Playing { direction LR [*] --> WaitingForInput WaitingForInput --> ExecutingTurn: POST /api/actions - ExecutingTurn --> WaitingForInput: execution.completed + ExecutingTurn --> WaitingForInput: execution.completed\n committed=true|false } Playing --> Paused: pauseSession()\n status='paused' @@ -82,9 +82,9 @@ stateDiagram-v2 **业务真值** = `(status, phase, completedPlayerTurns, setupRuntimes)`: - **Setup**:`status === 'active' && phase === 'setup'`。调度器只运行 `stage: setup` 的 runtime;每个 runtime 以显式完成信号(输出 `preGameDone: true`,或 guard 返回 `{ skip: true }`)记入 `setupRuntimes` 状态镜像(`pending` / `done` / `blocked`),玩家可以多次提交表单/消息迭代(例如 `char-creator` 的 `framework.submit-form`)。耗尽重试预算(`maxTriggerCount`)不算完成——该 runtime 落到 `blocked`,会话停留在 setup 阶段等待玩家重试或豁免,不再"跳过坏掉的 setup 继续推进"。 -- **phase 翻转**:所有 setup runtime 都报告完成后,Kernel 在提交事务内把 `phase` 从 `'setup'` 翻到 `'playing'`、把 `completedPlayerTurns` 推进到 1,进入主循环;提交失败则计数、phase 翻转和 setup 镜像一并回滚。 -- **Setup completion followup**:角色表单这类最后一个 setup 输入提交后,`/api/actions` 的同一个请求会先完成 setup,再立即补跑本次已触发的主循环 runtime。这样玩家提交表单后能直接看到第一段正式叙事;审计、trace 和 snapshot 里该请求同时包含 setup completion 与 main-loop followup。 -- **会话提交原子边界**:同一 session 的玩家输入、runtime 执行、proposal commit、会话时钟写入(`phase` / `completedPlayerTurns` / `setupRuntimes`)和自动 snapshot 由同一 session lock 串行化。自动 snapshot 在全部 proposal 提交后捕获,确保对话 cursor、角色、state 与 plugin data 属于同一个已提交回合。 +- **phase 翻转**:所有 setup runtime 都报告完成后,Kernel 在 setup 提交事务内把 `phase` 从 `'setup'` 翻到 `'playing'`;该事务的 `completedPlayerTurns` 仍为 0。提交失败时 phase 翻转和 setup 镜像一并回滚。 +- **Setup completion followup**:角色表单这类最后一个 setup 输入提交后,`/api/actions` 的同一个请求会先提交 setup,再以新的 `turnId` 和独立事务立即补跑主循环 runtime。接力事务成功后才把 `completedPlayerTurns` 从 0 推进到 1。玩家可直接看到第一段正式叙事;同一 SSE 流、trace 和 snapshot 会覆盖 setup completion 与 main-loop followup 两次执行。 +- **会话提交原子边界**:同一 session 的玩家输入、runtime 执行、proposal commit、对话 execution journal(玩家/runtime `TurnMessage`)、会话时钟写入(`phase` / `completedPlayerTurns` / `setupRuntimes`)和自动 snapshot 由同一 session lock 串行化;journal、proposal 与时钟在同一 `finalizeExecution` transaction 中提交或回滚。自动 snapshot 在全部 proposal 提交后捕获,确保对话 cursor、角色、state 与 plugin data 属于同一个已提交回合。`execution.completed.committed` 是客户端收敛 optimistic 输出的终态信号。 - **例外:后台执行只有提交在锁内**。`execution: background` 的 runtime(deferred follower 与 background 模式的 manual 触发)把 handler 跑在 session lock **外**,只有 `processTurnResults`(finalize 事务 + auto-snapshot)进锁。这类 runtime 通常是几分钟的 provider 调用(出图、TTS),持锁执行会让玩家的下一条消息一直排队,PG 部署下更会直接撞上 30s 的锁获取上限。之所以安全:这条路径不写会话时钟(不传 `sessionClock`,且 `completedPlayerTurns` 只数 `origin: "player"`),域写入经 writeBuffer 汇入同一个提交事务而非执行期零散落盘,也不追加对话消息。同一 runtime 的并发执行由 `::` 作业锁串行,保住 handler 里"是否已生成"这类 check-then-act 的原子性(否则会重复计费);提交前在锁内重读会话状态,玩家中途暂停/结束会话时结果被丢弃而非写入。 - **Playing**:`status === 'active' && phase === 'playing'`。每次 `POST /api/actions` 触发一轮完整 Turn pipeline,按 `pre-turn → narrative → post-turn → audit` 四个 stage 依次运行(stage 间严格屏障)。`completedPlayerTurns` 只统计已提交的玩家回合——manual plugin-rpc、后台 follower、嵌套 `recursiveCall` 等非玩家执行各自落 `turn_results` 行(带 `origin` 标记)但不计数;多个执行共享同一 `turnId` 时只计一次。 - **Paused / Ended**:`status === 'paused' | 'ended'`。调度器直接返回空,`/api/actions` 被服务端拒绝。Paused 可 `resumeSession()` 恢复,Ended 是终态。 @@ -121,7 +121,7 @@ flowchart TB Group --> Commit["CommitPipeline.commitAll
PreStateCommit → handler → PostStateCommit"] Commit --> SSE["发 SessionEvent
narrative.delta / narrative.completed
interaction.requested / state.changed
plugin-data.changed / event.emitted / record.updated"] SSE --> PreGameTick{"phase === 'setup' 且
所有 setup runtime
都已报告完成?"} - PreGameTick -->|是| Advance["Kernel: phase setup → playing
completedPlayerTurns 0 → 1"] + PreGameTick -->|是| Advance["setup 提交: phase setup → playing
completedPlayerTurns 保持 0"] PreGameTick -->|否| Keep["保持 phase 不变"] Advance --> End["SSE: execution.completed"] Keep --> End @@ -687,9 +687,11 @@ sequenceDiagram Web->>Server: POST /api/sessions/:id/plugin-rpc submit-form Server-->>Web: 返回 filledNarrative (仅模板填充,不写 turn_messages) - Note over Server,Plugin: setup 可能多次迭代;
最后一个 setup runtime 报 preGameDone: true 后
Kernel 把 phase 翻到 'playing'(completedPlayerTurns 0 → 1) + Note over Server,Plugin: setup 可能多次迭代;
最后一个 setup runtime 报 preGameDone: true 后
setup 事务把 phase 翻到 'playing',completedPlayerTurns 保持 0 Web->>Server: POST /api/actions { type: 'send_message', content: filledNarrative } - Server-->>Web: SSE: execution.started + Server-->>Web: SSE: setup execution.started (turnId A) + Note over Server,Plugin: setup 提交成功后,同一请求以 turnId B 自动接力主循环;
接力提交成功才把 completedPlayerTurns 0 → 1 + Server-->>Web: SSE: main-loop execution.started (turnId B) rect rgb(245, 255, 240) Note over Server,Plugin: Turn 2+ · 主循环 (phase === 'playing',pre-turn → narrative → post-turn → audit) diff --git a/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation-flow.md b/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation-flow.md new file mode 100644 index 000000000..772a45f79 --- /dev/null +++ b/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation-flow.md @@ -0,0 +1,15 @@ +# Framework P1 Remediation Flow + +```mermaid +flowchart LR + A["Prep selection"] --> B["Server session with plugins locale overrides"] + B --> C["Setup DAG"] + C --> D["world-init explicit schema output"] + D --> E["player-init injected schema"] + E --> F["Execution journal + proposals"] + F --> G{"finalize transaction"} + G -->|commit| H["TurnMessages + state + clock"] + G -->|rollback| I["failed terminal"] + H --> J["authoritative narrative.completed"] + I --> K["discard optimistic streams"] +``` diff --git a/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation.md b/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation.md new file mode 100644 index 000000000..246ee4c7f --- /dev/null +++ b/docs/framework-audit-remediation/2026-08-09_23-15_p1-remediation.md @@ -0,0 +1,53 @@ +# Framework P1 Audit Remediation Plan + +## Goal + +修复 2026-08-09 静态审计确认的四个默认路径 P1:Local session bootstrap 合同、setup Schema 数据流、TurnMessage 事务归属、commit 失败 SSE 终态。 + +## Scope + +- Web Local DataService 与 start-game bootstrap 顺序 +- setup runtime 的显式结果注入 +- runtime execution journal 与 `finalizeExecution` +- `/api/actions` terminal events 和 Web stream rollback +- 对应单元/API 回归测试及权威文档同步 + +F-05/F-06 输入边界与 F-07 全量文档清理进入下一波;本次允许同步被 P1 修改直接影响的协议文字。 + +## Assumptions + +- Remote DataService 的 plugins/locale 传递是合同基线。 +- runtime 的 DAG edge 保证 `completedResults` 可见,不承诺未提交 Store 可见。 +- optimistic `narrative.delta` 继续保留;失败终态负责撤销。 +- 所有 production Store 实现支持 `withTransaction`;degraded test/mocks 保持 best-effort 语义并明确测试。 + +## Risks + +- TurnMessage 延迟落库会影响 Prompt history、compaction、retry artifacts 和 trigger stats。 +- setup output 增加 Schema 可能增大单个 runtime result;三个内置 world 的属性规模可控。 +- SSE terminal contract 变化需要 server/web 同步落地,避免旧客户端停留在 executing 状态。 +- Local bootstrap 调序可能改变首屏 hydration 时机,需要保留 best-effort 恢复路径。 + +## Steps + +1. 为 Local sync 添加 plugins/locale/overrides 合同测试,修正 server mirror 创建和 start-game 顺序。 +2. 让 world-init completed output 携带 Schema,通过 player-init `input.inject` 使用显式上游数据,补 setup regression。 +3. 将 player/runtime TurnMessage 收集为 execution journal,在 finalizer transaction 成功路径写入,补 commit/rollback regression。 +4. 让 actions route 发出明确 failed terminal,Web 在失败终态撤销 stream,补 API/reducer tests。 +5. 跑最小相关 Vitest suites、TypeScript lint,再审阅 diff 和更新审计状态。 + +## Validation + +- `rtk mise exec -- pnpm --filter @covel/web test -- ` +- `rtk mise exec -- pnpm --filter @covel/runtime test -- ` +- `rtk mise exec -- pnpm --filter @covel/server test -- ` +- `rtk mise exec -- pnpm --filter lint`,仅在聚焦测试通过后执行受影响 package +- `rtk git diff --check` + +不启动 dev server、浏览器、整体 build 或模型调用。 + +## Rollback + +- 四项修改按 finding 保持独立 commit-ready patch 边界。 +- 若 journal 重构影响面超出预期,先保留 F-01/F-02/F-04,并将 F-03 回退到 commit-status filtering 方案。 +- SSE 变更保持 wire event union 向后兼容;必要时让 `execution.completed` 携带 status 过渡,Web 同时接受旧 payload。 diff --git a/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening-flow.md b/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening-flow.md new file mode 100644 index 000000000..e9e6f7d4f --- /dev/null +++ b/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening-flow.md @@ -0,0 +1,17 @@ +# Framework P2 Input Hardening Flow + +```mermaid +flowchart LR + A["HTTP body"] --> B{"discriminated validation"} + B -->|invalid| C["400 without writes"] + B -->|action| D["session lock"] + B -->|start_session| J["persist loreOverride in session metadata"] + J --> K["SessionContext world.lore on every turn"] + D --> E["committed interaction lookup"] + E --> F{"type and schema valid"} + F -->|invalid| C + F -->|new| G["transactional player input save"] + F -->|same retry| H["return existing submission"] + G --> I["accepted result"] + H --> I +``` diff --git a/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening.md b/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening.md new file mode 100644 index 000000000..5aefa573f --- /dev/null +++ b/docs/framework-audit-remediation/2026-08-09_23-39_p2-hardening.md @@ -0,0 +1,48 @@ +# Framework P2 Input Hardening Plan + +## Goal + +修复静态审计 F-05/F-06,并同步 F-07 中直接影响这些入口的公开类型、注释与权威文档。 + +## Scope + +- `framework.submit-form` 的 committed interaction 定位、类型/字段/选项校验与幂等提交 +- `POST /api/actions` 的 action discriminated payload 与公共字段边界 +- plugin-rpc action 的 session lock 串行化 +- Session wire 类型、过时 direct-write/countPolicy 注释及 API/flow 文档 +- Prep `loreOverride` 的 session 持久化与逐回合 SessionContext 覆盖 + +`cost-gate` 的多进程计数属于已文档化部署限制,本轮不引入分布式配额存储。与输入边界无关的 dormant compatibility 分支保持原状。 + +## Assumptions + +- F-03 后 `TurnMessage` 只包含 committed interaction,因此可作为 submit-form 的真实性来源。 +- plugin-rpc action 在 session lock 内串行后,`listPlayerInputs → savePlayerInput` 可提供跨进程同 session 幂等边界。 +- 旧表单的 `{ formId, fields }` pendingInput 兼容为 `type: form`;新 interaction 使用标准 discriminated shape。 + +## Risks + +- 历史或第三方 interaction 若缺少必要 schema,会从 fallback 接受改为 400,需要保留明确错误。 +- 表单字段的浏览器值以字符串传输;number/checkbox 校验需兼容当前 wire 表达。 +- actions 校验过严可能拒绝现有调用,因此仅声明当前实际消费字段并保留 `start_session.loreOverride`。 + +## Steps + +1. 先补 submit-form 伪造、错类型、required、choice、重复提交回归。 +2. 定位并规范 committed interaction,整批预校验后事务写入;重复同值返回原 submission。 +3. 补 actions 无效 payload/ID/locale/model 回归,实现判别式解析。 +4. 同步 plugin-rpc lock、Session 类型、注释与 API/架构文档。 +5. 运行最小 Vitest、受影响 TypeScript lint 与 `rtk git diff --check`。 + +## Validation + +- `rtk mise exec -- pnpm --filter @covel/runtime exec vitest run tests/submit-form.test.ts` +- `rtk mise exec -- pnpm --filter @covel/server exec vitest run tests/api/actions-contract.test.ts tests/api/plugin-rpc.test.ts` +- 受影响 package `lint` 与 `rtk git diff --check` + +不启动 dev server、浏览器或模型调用。 + +## Rollback + +- submit-form 的真实性、schema 校验和幂等逻辑集中在一个 handler,可独立回退。 +- actions parser 是路由前置纯函数;若兼容字段遗漏,可只扩充对应 action payload schema。 diff --git a/docs/guide/desktop-config.en.md b/docs/guide/desktop-config.en.md index 138f3cc59..4af12ecd9 100644 --- a/docs/guide/desktop-config.en.md +++ b/docs/guide/desktop-config.en.md @@ -27,6 +27,8 @@ On first launch the desktop app creates `~/.covel/`. Config and user plugins liv server.port ← last boot port (diagnostics) ``` +Sidecar stderr is recorded as `error` by default. Recoverable framework warnings carry a `[covel:warn]` transport marker; collectors remove the marker and persist them at `warn`, so `policy: warn` scheduling diagnostics and automatic retries do not inflate error counts. + ## `~/.covel/config.toml` Seeded with a commented template on first launch. Fields: diff --git a/docs/guide/desktop-config.md b/docs/guide/desktop-config.md index 209373d31..503d77ea4 100644 --- a/docs/guide/desktop-config.md +++ b/docs/guide/desktop-config.md @@ -32,6 +32,7 @@ - `/api/health` 心跳被 Hono logger 显式跳过,不再刷屏;通过 `COVEL_LOG_QUIET_PATHS=/api/foo,/api/bar` 可追加要静默的路径 - 业务级 trace(LLM 调用、proposal、tool 调用)**不写文件**,留在 DB `trace_events` 表,通过 `/debug` 页面或 JSON 导出查看 - `pnpm dev:server` 单跑时,server 自身会把 stdout/stderr 同时落到 `server.log`(终端仍可见原文);`COVEL_SERVER_LOG_FILE=""` 可禁用,`COVEL_SERVER_LOG_FILE=/path/foo.log` 可改路径 +- sidecar 的普通 stderr 记为 `error`;框架对可恢复 warning 使用 `[covel:warn]` 传输标记,日志收集器去掉标记后以 `warn` 持久化,`policy: warn` 调度诊断和自动重试因此不会污染 error 统计 - 老版本写入的 `electron.log` 会随轮转自然过期,不会被自动迁移 ## `~/.covel/config.toml` diff --git a/docs/reference/api.md b/docs/reference/api.md index e5d55dcee..e374324f4 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -1169,7 +1169,7 @@ Turn 是游戏的核心交互单元。每次玩家发言触发一个 Turn,服 ### 玩家交互 -当 Turn 执行后产生 `pendingInputs`(如表单、选择题、确认框),玩家需要通过 `framework.submit-form` 提交响应。框架会将玩家输入转化为自然语言叙事,追加到对话历史中。若该响应完成最后一个 Pre-Game runtime,随后发起的 `/api/actions` `send_message` 会在同一个请求里完成 Pre-Game 并立即补跑已触发的主循环 runtime,因此同一个 `turnId` 可能同时包含 setup completion 和第一段正式叙事。 +当 Turn 执行后产生 `pendingInputs`(如表单、选择题、确认框),玩家需要通过 `framework.submit-form` 提交响应。handler 校验并持久化提交,返回自然语言 `filledNarrative`;Web 再把该文本作为下一次 `/api/actions` 的玩家消息。若该动作完成最后一个 setup runtime,服务端会在同一 SSE 流中以新的 `turnId` 和独立事务自动接力主循环,因此 setup completion 与第一段正式叙事属于两个 turn。 #### `POST /api/sessions/:id/plugin-rpc` (`framework.submit-form`) @@ -1279,10 +1279,13 @@ Turn 是游戏的核心交互单元。每次玩家发言触发一个 Turn,服 **使用说明:** +- handler 只接受当前 session 对话日志中已经提交的 assistant interaction;`turnId` / `interactionId` / `type` 必须与原交互一致,客户端无法凭空构造表单或改写交互类型 +- `form` 会校验 required、字段集合和字段类型;`choice.selectedId` 必须来自原 options,`selectedLabel` 由服务端按原 option 规范化;`confirmation.confirmed` 必须是 boolean +- 同一 `(turnId, interactionId)` 重复提交相同值会返回原 `submissionId`;不同值返回 400。批量提交会先全部校验,再在事务内统一写入 - `filledNarrative` 是将玩家输入填入模板后的**纯自然语言**文本,不含 JSON 结构 -- 该文本作为玩家消息追加到对话历史,供叙事者在下一轮 Turn 中参考(**不再生成合成的 assistant-role 消息**) +- handler 本身不写 `turn_messages`;Web 把该文本作为下一次 action 的玩家消息,供叙事者参考 - 模板由插件提供,使用 `{{fieldName}}` 占位符语法 -- 如果找不到模板,会生成一条简单的回退叙事(如 `[玩家输入] name: 艾尔文, class: 战士`) +- 已提交交互缺少模板时会生成简单的回退叙事(如 `[玩家输入] name: 艾尔文, class: 战士`) - **本地化**:`confirmation` 的 `{{confirmed}}` 取值(确认/取消)与回退叙事前缀(`[玩家输入]`/`[玩家选择]`/`[玩家确认]`/`[玩家取消]`)按**会话 locale** 解析——框架据 `session.locale` 把这些文案注入 handler(resolution order:请求 → 会话 → world → app 默认 `zh-CN`)。`en-US` 会产出 `Confirm`/`Cancel` 与 `[Player input]`/`[Player choice]`/`[Player confirmed]`/`[Player cancelled]`;未知 locale 回落 `zh-CN`(与历史输出逐字一致)。 --- @@ -1359,9 +1362,9 @@ Turn 是游戏的核心交互单元。每次玩家发言触发一个 Turn,服 **框架默认 action:** -| Action | 说明 | -| ------------- | ------------------------------------------------------ | -| `submit-form` | 持久化玩家输入、找模板消息、按 `{{字段}}` 填充自然语言 | +| Action | 说明 | +| ------------- | ------------------------------------------------------------------------------ | +| `submit-form` | 绑定已提交 interaction,校验并幂等持久化玩家输入,再按 `{{字段}}` 填充自然语言 | **响应 200 — action 级:** @@ -2575,6 +2578,8 @@ id: evt-002 前端主要使用此端点进行游戏交互。将动作请求(发送消息、执行命令等)翻译为 Turn 执行,并通过 SSE 流式返回结果。 +请求体按 `type` 作判别联合校验:顶层只接受 `requestId`、`type`、`sessionId`、`locale`、`model`、`payload`;每种 action 的 payload 也拒绝未声明字段。`requestId` / `sessionId` / runtime/turn ID 必须是有界安全标识符,locale 必须符合 BCP-47 风格格式;非法请求在创建 turn 或写入消息前返回 400。 + **请求体:** ```json @@ -2595,10 +2600,11 @@ id: evt-002 | ----------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `content` | `send_message` | 玩家自然语言输入。`actions.ts` 优先读取此字段。 | | `command` | `execute_command` | 以 `/` 开头的命令(如 `/look`),与 `content` 互斥。 | +| `loreOverride` | `start_session` | 可选。Prep 页编辑后的世界文档;服务端持久到 session metadata,setup、opening continuation 与后续回合的 `world.lore` 都优先使用该值。空字符串表示显式清空。 | | `runtimeId` | `retry_runtime` | 可选。收窄重跑到指定 runtime(走 manual-trigger 路径);缺省保持整回合重跑语义(M-07)。 | | `retryFromTurnId` | `retry_runtime` | 可选(需与 `runtimeId` 同用)。指定作为上下文种子的源回合:服务端加载该回合的 `turn_results` 工件播种执行,使被重试 runtime 的 `input.inject`/`needs` 按原回合叙事解析。缺省回退到最近一个 player-origin 工件。前端失败 chip 的重试按钮走这条路径(不删叙事消息)。 | -**`start_session` 的前置条件**:会话必须已有非空 `activePlugins`。插件集合由会话创建时决定(显式 `plugins` 数组,或世界 manifest 播种的推荐集),`start_session` 只负责在注册表里激活它们。空集合会被 **400** 拒绝(`Session has no active plugins. …`),而不是回退到"激活全部已注册插件"——那个回退会把玩家从未选择的社区插件、以及互斥的两个叙事引擎同时拉进会话,并持久化到会话生命周期结束。 +**`start_session` 的前置条件**:会话必须已有非空 `activePlugins`。插件集合由会话创建请求的 `plugins` 数组决定;Web Prep 会先按 world `pluginPolicy` 计算默认选择,再把结果显式传给创建接口。服务端创建路由不读取 world policy,只补 builtin core、`requires` 关系并处理 conflicts。`start_session` 只负责在注册表里激活已持久化集合。空集合会被 **400** 拒绝(`Session has no active plugins. …`),不会回退到"激活全部已注册插件";该回退会把玩家从未选择的社区插件及互斥叙事引擎同时拉进会话,并持久化到会话生命周期结束。 **开场接力(opening continuation)**:当一次玩家动作(`send_message` / `execute_command` / `start_session`)完成了**最后一个** setup runtime(setup 执行独立提交,phase 翻转到 `playing`),同一个请求会在同一条 SSE 流上**自动接力一个主循环回合**(全新的 `turnId`、独立事务,读取刚提交的 setup 状态),让叙事 runtime 直接产出开场叙事——玩家提交完开局表单后无需再手动发一条消息。接力回合是第一个计数的玩家回合(`completedPlayerTurns` 0 → 1)。整条流仍只发**一个** `execution.completed`(取接力回合的数据)。守卫:`retry_runtime` 不接力;执行被中止(`abortReason`)、提交失败、或 setup 仍有未完成项(还有后续开局交互)时不接力。 diff --git a/docs/reference/plugins.md b/docs/reference/plugins.md index 0cf292be2..e2210e5ac 100644 --- a/docs/reference/plugins.md +++ b/docs/reference/plugins.md @@ -92,7 +92,7 @@ | `narrative` | `narrator` · `chat-mode-narrator` | 主叙事生成器(互斥,二选一激活) | | `post-turn` | `guide` · `codex` · `core-quest` · `affinity` · `inventory` · `npc-graph/extractor` · `char-creator/character-tracker` · `scene-prompts` · `mimo-tts/auto-narrate` · `branch-reply` | 除 `branch-reply` 外都以 `{ capability: narrative-engine }` 依赖当前模式的叙事引擎(`branch-reply` 无 `needs`,按 `narrativeOutput` 非空这一契约自行发现叙事);彼此独立 → **同 stage 内并行执行** | -`setup` stage(会话 `phase === "setup"` 时运行)走:`pregame → world-init/schema-gen → char-creator/player-init`,顺序完全由声明边决定:`world-init/schema-gen` 声明弱排序 `after: [pregame]`(pregame 失败不拦 schema 生成);`char-creator/player-init` 声明 turn-scoped `needs: [pregame, world-init/schema-gen]`(player-init 读取 schema-gen 写出的 `world.schema`,`needs` 既是同一 pass 内的 DAG 边、也是同回合门控)。三者均为 `stage: setup` + `trigger: auto`(`maxTriggerCount` 为重试预算)。 +`setup` stage(会话 `phase === "setup"` 时运行)走:`pregame → world-init/schema-gen → char-creator/player-init`,顺序完全由声明边决定:`world-init/schema-gen` 声明弱排序 `after: [pregame]`(pregame 失败不拦 schema 生成);`char-creator/player-init` 声明 turn-scoped `needs: [pregame, world-init/schema-gen]`(`needs` 既是同一 pass 内的 DAG 边、也是同回合门控),并通过 `input.inject` 读取 schema-gen output 的 `worldSchema`。DAG 顺序只保证上游结果可见;未提交的 proposal store write 要到 finalizer transaction 后才可读取,因此同轮数据传递必须使用 runtime output/inputs。三者均为 `stage: setup` + `trigger: auto`(`maxTriggerCount` 为重试预算)。 所有插件单声明 `stage` + `needs`/`after`,无例外;`event` / `manual` runtime 不设 `stage`。 @@ -194,6 +194,8 @@ | tools.builtin | 无(setup 期只写世界 schema,不回读自身 plugin-data) | | ui.right | `./ui/world-overview.json`, `./ui/world-schema.json` | +无论 guard 复用已存在 schema、采用世界声明、从 dimensions 派生,还是 agent 生成,成功/完成输出都会携带结构化 `worldSchema`。下游 setup runtime 可在同一 execution 中通过 runtime inject 消费它;持久 `world.schema` 仍在 proposal commit 后成为后续 execution 的 store 真值。 + **Guard 门控**: `guard.js` 在 LLM 调用前执行(纯函数,零 LLM 开销),按优先级决定角色属性 schema,命中任一即返回 `{ skip: true }` 跳过 LLM: 1. **当前 session 已有 schema + 词条** → 直接复用。 @@ -428,9 +430,9 @@ namespace="meta" key=ontology value=NpcGraphOntology (Phase 3 wire-up) | ui.right | `quest-log-panel.json` — 任务面板(进行中含 objectives 勾选清单 / 已完成 / 已失败) | | ui.message | `quest-changes-block.json` — 本回合任务变更块 | -**职责**:只记录叙事明确出现的任务信号——不发明任务、每回合新任务 ≤3、目标勾选与完成/失败必须有叙事证据、无信号回合不调工具。`upsert-quests`(≤5/次)按 name 归一化合并:已有任务提供字段覆盖,objectives 按 text 匹配更新勾选、未命中追加;世界预置任务(`` 已注入)只推进不重建。变更摘要写 `message` namespace 驱动消息块。 +**职责**:只记录叙事明确出现的任务信号——不发明任务、每回合新任务 ≤3、目标勾选与完成/失败必须有叙事证据、无信号回合不调工具。`upsert-quests`(≤5/次)按 name 归一化合并:已有任务提供字段覆盖,objectives 依次按稳定 `id`、规范化 text、保守语义匹配更新勾选,命中后保留已有目标原文,未命中才追加;世界预置任务(`` 已注入)只推进不重建。变更摘要写 `message` namespace 驱动消息块。 -**世界导入**:`world.data.yaml` 用 `schema: plugin://core-quest/quests` + `to: plugin:core-quest/quests` + `key: id` 预置任务,记录形状 `{ id, name, description, status?, objectives?: [{text, done?}], giver?, reward? }`(参考 `worlds/emberback/data/quests.yaml`)。 +**世界导入**:`world.data.yaml` 用 `schema: plugin://core-quest/quests` + `to: plugin:core-quest/quests` + `key: id` 预置任务,记录形状 `{ id, name, description, status?, objectives?: [{id?, text, done?}], giver?, reward? }`;建议世界包为每个 objective 提供任务内稳定的 `id`(参考 `worlds/emberback/data/quests.yaml`)。 --- @@ -505,16 +507,17 @@ namespace="meta" key=ontology value=NpcGraphOntology (Phase 3 wire-up) ### char-creator/player-init -| 字段 | 值 | -| ----------- | -------------------------------------------------------------------------------------------- | -| pluginType | `core-plugin`(不可禁用) | -| stage | `setup` | -| runtimeType | `agent`(默认,LLM 生成开场表单;guard 命中时跳过) | -| trigger | `auto`(`guard` 门控) | -| needs | `[pregame, world-init/schema-gen]`(turn-scoped:既是同 pass 的 DAG 边,也是同回合上游门控) | -| guard | `./guard.js` — 若 player 已存在或已收到表单提交则 skip LLM | -| model | `plugin` | -| ui.right | `../../ui/character-panel.json` | +| 字段 | 值 | +| ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| pluginType | `core-plugin`(不可禁用) | +| stage | `setup` | +| runtimeType | `agent`(默认,LLM 生成开场表单;guard 命中时跳过) | +| trigger | `auto`(`guard` 门控) | +| needs | `[pregame, world-init/schema-gen]`(turn-scoped:既是同 pass 的 DAG 边,也是同回合上游门控) | +| input.inject | `world-init/schema-gen.worldSchema` → ``;同轮结构化 schema 优先,已提交的 `world.schema` 兜底 | +| guard | `./guard.js` — 若 player 已存在或已收到表单提交则 skip LLM | +| model | `plugin` | +| ui.right | `../../ui/character-panel.json` | **两步流程**(第 1 步由 LLM agent 完成,第 2 步由 `guard.js` 确定性完成): @@ -526,7 +529,7 @@ namespace="meta" key=ontology value=NpcGraphOntology (Phase 3 wire-up) 2. **第 2 步 - 提交创建**(`` 包含表单值时): - 读取最近一次 player input submission - 合并 schema `defaultValue` - - 直接写入 `characters` 表与 `plugin_data[characters]` + - 通过 guard 的 execution write buffer 生成 `character.upsert` 与 `plugin.data` proposals - 输出 `preGameDone: true`,标记本 runtime 已完成 setup 初始化(框架将其累加到 `session.setupRuntimes`) **当前代码状态**: 这一条路径保持在插件包内部,实现位于 `runtimes/player-init/guard.js`(deterministic 提交分支)。schema `defaultValue` 在写入边界合并进存库 `fields`(与 builtin `create-character` 一致),使右栏显示、模型 `get-character` 与 prompt 注入读到同一份字段,schema 通过 well-known namespace/key 发现而非硬编码 world-data 插件 id。如果后续希望统一 deterministic runtime 的 trace 与工具链,可以把这条流程收敛到 builtin character tools。 @@ -1282,6 +1285,8 @@ Guard 适用于"先检查再决定是否需要 LLM"的场景,替代了之前 builtin `memory` 插件声明默认的四个通用块(`story_state` / `character_relationships` / `scene` / `player_profile`)。任意插件或世界包都可追加自己的块;**标签重复时按信任层级决胜(builtin > official > community):高信任声明覆盖低信任声明,与发现顺序无关**——因此 community 插件无法靠抢先加载来静默覆盖 builtin 默认块的定义(如改写 `story_state` 的 `extractionHint`)。同一信任层级内取首次声明(稳定);当同层级的多个插件以**不同定义**声明同一标签时,框架打印一条 dev 警告。信任层级取自插件的发现来源(加载路径,不可伪造),框架不按具体插件 id 决胜。未声明任何 `memoryBlocks` 时,框架回退到 `@covel/memory` 内置的同名通用默认块。 +每轮结束后的抽取输入同时包含叙事、工具摘要,以及已提交会话状态中的玩家角色和最近一次表单值。结构化会话事实具有最高事实优先级;`player_profile` 的首行由框架根据角色记录与世界属性显示名确定性生成,LLM 只维护其后的动态状态摘要。这样后续回合无法翻译、改写或覆盖玩家已确认的姓名与属性值。 + **世界包**在 `world.yaml` 顶层(而非 `PLUGIN.md`)声明 `memoryBlocks`(字段形状相同)。与插件块的全局聚合不同,世界块**按 session 解析**:记忆系统把该 session 所属世界的块合并到全局插件块之上——基础块(插件 / 框架默认)在标签冲突时优先(builtin 默认受保护),世界只**新增**未占用的标签。因此侦探世界的会话才会出现 `clues` / `suspects`,其它题材会话不受影响。世界侧声明与示例见 [world-data.md #世界记忆块memoryblocks](world-data.md#世界记忆块memoryblocks)。 | 字段 | 类型 | 说明 | @@ -1584,9 +1589,9 @@ rpc: **框架默认 actions(无需声明,通过 `pluginId: "framework"` sentinel 调用):** -| Action | 说明 | -| ------------- | ---------------------------------------------------- | -| `submit-form` | 持久化玩家表单 / 选择 / 确认提交,填充模板 narrative | +| Action | 说明 | +| ------------- | ------------------------------------------------------------------------------------ | +| `submit-form` | 绑定已提交 interaction,严格校验并幂等持久化表单 / 选择 / 确认,再填充模板 narrative | 详细 API 说明见 [api.md `POST /api/sessions/:id/plugin-rpc`](api.md#post-apisessionsidplugin-rpc),作者指南见 [../guide/plugin-authoring.md §2.3.1](../guide/plugin-authoring.md)。 @@ -1605,7 +1610,7 @@ rpc: | `field` | `string`(必填) | 从源 runtime `output` 里取的字段名 | | `as` | `string`(必填) | 包裹 XML 标签,如 `""` | -如果源 runtime 本回合没有执行、失败、或指定字段不存在,该 entry 静默跳过,不会污染其他注入块。 +如果源 runtime 本回合没有执行、失败、或指定字段不存在,该 entry 静默跳过,不会污染其他注入块。字符串按原文注入;数组和对象使用稳定的 JSON 文本渲染,避免出现 `"[object Object]"`。 #### `kind: plugin-data`(本插件自己的 plugin-data 状态注入) diff --git a/docs/reference/protocol.md b/docs/reference/protocol.md index c62ac10ef..959ee0950 100644 --- a/docs/reference/protocol.md +++ b/docs/reference/protocol.md @@ -64,15 +64,15 @@ ### 执行生命周期事件 -| 事件类型 | 方向 | 描述 | 负载 | -| --------------------- | ---- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `execution.started` | S→C | 回合执行开始 | `{ runtimeCount }` | -| `runtime.started` | S→C | 单个 runtime 开始 | `{ runtimeId, pluginId, label }` | -| `runtime.completed` | S→C | 单个 runtime 完成 | `{ runtimeId, pluginId, durationMs }` | -| `runtime.failed` | S→C | 单个 runtime 失败 | `{ runtimeId, pluginId, error }` | -| `execution.completed` | S→C | 回合执行完成 | `{ runtimeCount, resultCount, durationMs, abortReason? }`(`abortReason` 仅在回合被中止时出现:cost-gate 硬预算上限、玩家 abort(值 `"aborted-by-player"`)等——前端据此提示玩家而非静默空回合) | +| 事件类型 | 方向 | 描述 | 负载 | +| --------------------- | ---- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `execution.started` | S→C | 回合执行开始 | `{ runtimeCount }` | +| `runtime.started` | S→C | 单个 runtime 开始 | `{ runtimeId, pluginId, label }` | +| `runtime.completed` | S→C | 单个 runtime 完成 | `{ runtimeId, pluginId, durationMs }` | +| `runtime.failed` | S→C | 单个 runtime 失败 | `{ runtimeId, pluginId, error }` | +| `execution.completed` | S→C | 回合执行终态 | `{ runtimeCount, resultCount, durationMs, committed, error?, abortReason? }`。`committed: true` 表示 proposal、execution journal 与会话时钟已落库;`false` 时 `error` 携带 proposal 或通用事务错误,客户端撤销该回合的 optimistic stream。`abortReason` 仅在回合被中止时出现(玩家 abort 值为 `"aborted-by-player"`) | -> **开场接力**:当一次玩家动作完成了最后一个 setup runtime,`POST /api/actions` 的同一条 SSE 流会自动接力一个主循环回合(见 [api.md § POST /api/actions](./api.md))。此时流内会出现**两轮** `execution.started` / runtime 生命周期事件(信封 `turnId` 不同——setup 回合 + 接力回合),但只有**一个** `execution.completed` 收尾(前端以它复位 executing 状态)。 +> **开场接力**:当一次玩家动作完成了最后一个 setup runtime,`POST /api/actions` 的同一条 SSE 流会自动接力一个主循环回合(见 [api.md § POST /api/actions](./api.md))。此时流内会出现**两轮** `execution.started` / runtime 生命周期事件(信封 `turnId` 不同——setup 回合 + 接力回合),但只有**一个** `execution.completed` 收尾(前端以它复位 executing 状态并按 `committed` 收敛 optimistic 输出)。setup 提交失败时不会启动接力,终态直接返回 `committed: false`。 ### 回合中控制(W4:steer / abort) @@ -446,7 +446,7 @@ execution.started → executionSteps runtime.started → executionSteps runtime.completed → executionSteps runtime.failed → executionSteps -execution.completed → executing = false +execution.completed → committed=true: finalize;committed=false: discard optimistic stream + executionError;两者均 executing=false error.occurred → executionError ``` diff --git a/docs/reference/tools.md b/docs/reference/tools.md index 5dd57c661..78bb39041 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -36,7 +36,7 @@ | upsert-npc-graph | local | npc-graph | auto-allow | 批量写入 NPC 节点与关系边(按 name 引用,工具内部去重并分配短 ID) | | list-npc-graph | local | npc-graph | auto-allow | 读取现有 NPC 图;图已注入 prompt,仅在需要某关系完整 `fact` 时按需调用 | | generate-scene-prompts | local | scene-prompts | auto-allow | 写入对话模式的玩家口吻快捷回复 | -| upsert-quests | local | core-quest | auto-allow | 批量创建/推进任务(≤5/次,按 name 合并;objectives 按 text 匹配勾选/追加) | +| upsert-quests | local | core-quest | auto-allow | 批量创建/推进任务(≤5/次,按 name 合并;objectives 按稳定 ID / 文本匹配勾选) | | update-affinity | local | affinity | auto-allow | 批量记玩家↔NPC 好感增量(≤5/次,clamp ±100,派生 6 档 tier + history 最近 10 条) | | update-inventory | local | inventory | auto-allow | 批量物品得失/装备变化(≤8/次,add/remove/set/equip/unequip,减到 0 墓碑化) | diff --git a/docs/reference/transactions.md b/docs/reference/transactions.md index 3e52cdda4..c03e2e211 100644 --- a/docs/reference/transactions.md +++ b/docs/reference/transactions.md @@ -209,6 +209,12 @@ Node-only and is never pulled into the IdbStore browser bundle. > 失败返回的 `{ committed: false }`(如 PreStateCommit veto、缺字段的 state.patch)。 > 已提交的兄弟 runtime 一并回滚,事务外不留痕迹。这是相对旧行为的**刻意变更** > (旧行为保留已提交兄弟)。 +> - **对话 execution journal 共享提交命运**:当前玩家输入与非 manual runtime 的 +> `TurnMessage` 在执行期只缓存在内存 journal;所有 proposal 通过后才由 +> `finalizeExecution` 在同一事务中 append。回滚执行不会进入后续 Prompt、trigger +> 统计或 compaction。`actions.ts` 同时通过 `extraInTx` 提交 REST messages 镜像与 +> player InteractionRecord,刷新和观测面也不会保留回滚输入。manual/background 路径 +> 继续遵循各自不追加对话历史的合同。 > - `turn_results.commit_status` 在同一事务内于成功时结算为 `committed`;回滚时在 > 事务外幂等结算为 `failed`。嵌套 recursiveCall 复用顶层 `turnId`,因此顶层的 > `[turnId]` 一次结算即覆盖所有嵌套行。 @@ -224,10 +230,13 @@ Node-only and is never pulled into the IdbStore browser bundle. > 通过 `sessionClock` 参数把逻辑回合计数(`completedPlayerTurns` 的 logical-turn > ledger 幂等推进)与 setup 频段翻转(`phase: setup → playing` + `setupRuntimes` > 镜像)折叠进 proposal 提交后、`commit_status` 结算前的同一事务( -> `commit/session-clock.ts` 的 `applySessionClockTx`)。派生字段 `turnCount` / -> `preGameCompleted` 由三字段公式算出并同事务写入。任一 proposal 失败即整体回滚—— -> 计数、phase、派生字段都不推进,ledger 不写入。manual / background / resume +> `commit/session-clock.ts` 的 `applySessionClockTx`)。legacy 字段 `turnCount` / +> `preGameCompleted` 保持冻结,在 API / snapshot 读取时由三字段公式派生。任一 proposal 失败即整体回滚—— +> 计数、phase、setup 镜像都不推进,ledger 不写入。manual / background / resume > finalize 不传 `sessionClock`,时钟不动。 +> - **Action 级 plugin-rpc 锁边界**:action handler 在 session lock 内完成读、校验和写入; +> `framework.submit-form` 再用 store transaction 原子提交批量 player input。因而同一 session +> 的 turn 与重复表单提交不会穿插,PG 多进程部署也由同一分布式锁键串行化。 > > **降级**:不暴露 `withTransaction` 的 store(薄测试 mock / 旧后端)退回逐条提交, > 不承诺跨 runtime 回滚——与这些 store 一贯的尽力而为语义一致,并 warn 一次。 @@ -244,6 +253,7 @@ if (typeof store.withTransaction === "function") { const out = await processRuntimeResult(result, tx, ...); if (out.failedProposals.length > 0) throw new ProposalCommitFailure(...); // → 整回合回滚 } + for (const message of journalMessages) await tx.appendTurnMessage(message); await extraInTx?.(tx); // caller 专属的事务内追加写(resume) for (const turnId of turnIds) await tx.setTurnResultCommitStatus(sessionId, turnId, "committed"); }); diff --git a/docs/reference/world-data.md b/docs/reference/world-data.md index a2e9b36d8..1fb49c652 100644 --- a/docs/reference/world-data.md +++ b/docs/reference/world-data.md @@ -468,11 +468,11 @@ key: id | 插件 | schema URI | to | 记录形状 | | ------------ | ---------------------------- | -------------------------- | -------------------------------------------------------------------------------------------- | -| `core-quest` | `plugin://core-quest/quests` | `plugin:core-quest/quests` | `{ id, name, description, status?, objectives?: [{text, done?}], giver?, reward? }` | +| `core-quest` | `plugin://core-quest/quests` | `plugin:core-quest/quests` | `{ id, name, description, status?, objectives?: [{id?, text, done?}], giver?, reward? }` | | `inventory` | `plugin://inventory/items` | `plugin:inventory/items` | `{ id, name, quantity, description?, tags?: string[], equipped?: boolean }` | | `affinity` | `plugin://affinity/affinity` | `plugin:affinity/affinity` | `{ id, name, score (int -100..100), notes? }`(tier/history 等派生字段由工具首次写入时补齐) | -三者都用 `key: id`。任务预置后由 `core-quest` agent 只推进不重建;物品预置即开局行囊;好感预置给关键 NPC 一个非零起点(正负皆可)。 +三者都用 `key: id`。任务预置后由 `core-quest` agent 只推进不重建;任务目标建议填写任务内稳定的 `id`,让后续推进即使略微改写 `text` 也能勾选同一目标。物品预置即开局行囊;好感预置给关键 NPC 一个非零起点(正负皆可)。 ### Preflight 与 Sync diff --git a/packages/context/src/prompt-internals.ts b/packages/context/src/prompt-internals.ts index 4cdcda40c..a91209d3f 100644 --- a/packages/context/src/prompt-internals.ts +++ b/packages/context/src/prompt-internals.ts @@ -139,7 +139,7 @@ function resolveRuntimeInject( if (value === undefined || value === null) return null; const tagName = validateTagName(parseTagName(inject.as)); - return `<${tagName}>${escapeXmlContent(String(value))}`; + return `<${tagName}>${escapeXmlContent(renderTemplateValue(value))}`; } /** diff --git a/packages/context/src/session-context.ts b/packages/context/src/session-context.ts index 49a94dd8f..824e8a191 100644 --- a/packages/context/src/session-context.ts +++ b/packages/context/src/session-context.ts @@ -80,7 +80,7 @@ export async function buildSessionContextSnapshot( ): Promise { // Session read is for completeness — caller already gates on active status. const [ - , + sessionRecord, characters, lastFormValues, workingMemory, @@ -95,9 +95,14 @@ export async function buildSessionContextSnapshot( loadActivePersona(store, sessionId, opts.personaPluginId), ]); - const worldRecord = opts.worldId + const storedWorldRecord = opts.worldId ? await safeGetWorld(store, opts.worldId) : null; + const loreOverride = sessionRecord?.metadata?.loreOverride; + const worldRecord = + storedWorldRecord && typeof loreOverride === "string" + ? { ...storedWorldRecord, lore: loreOverride } + : storedWorldRecord; const worldSchema = await loadWorldSchema( store, diff --git a/packages/context/src/store-records.ts b/packages/context/src/store-records.ts index 898819924..38152faad 100644 --- a/packages/context/src/store-records.ts +++ b/packages/context/src/store-records.ts @@ -19,6 +19,7 @@ export interface SessionRecord { readonly preGameCompleted: readonly string[]; readonly locale: string; readonly activePlugins: readonly string[]; + readonly metadata?: Readonly>; readonly createdAt: string; readonly updatedAt: string; readonly embeddingModelId?: number | null; diff --git a/packages/context/tests/context-builder.test.ts b/packages/context/tests/context-builder.test.ts index 2ac4d8391..b17310dc3 100644 --- a/packages/context/tests/context-builder.test.ts +++ b/packages/context/tests/context-builder.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { interpolateTemplate, buildInjectBlocks, @@ -172,6 +172,48 @@ describe("buildInjectBlocks", () => { ); }); + it("serializes structured runtime injects as JSON", () => { + const params: ContextBuildParams = { + promptTemplate: "", + manifest: makeManifest({ + input: { + inject: [ + { + kind: "runtime", + from: "world-init/schema-gen", + field: "worldSchema", + as: "", + }, + ], + }, + }), + turnInput: makeTurnInput(), + completedResults: new Map([ + [ + "world-init/schema-gen", + makeRuntimeResult({ + output: { + worldSchema: { + "character-attributes": { + version: 1, + attributes: [{ id: "club", type: "string" }], + }, + }, + }, + }), + ], + ]), + }; + + const result = buildInjectBlocks(params); + expect(result).toContain( + '{\n "character-attributes"', + ); + expect(result).toContain('"id": "club"'); + expect(result).toContain(""); + expect(result).not.toContain("[object Object]"); + }); + it("should return empty string when inject references a missing result", () => { const params: ContextBuildParams = { promptTemplate: "", diff --git a/packages/context/tests/session-context.test.ts b/packages/context/tests/session-context.test.ts index 5926c7004..dc03753dd 100644 --- a/packages/context/tests/session-context.test.ts +++ b/packages/context/tests/session-context.test.ts @@ -161,6 +161,22 @@ describe("buildSessionContextSnapshot — basic shape", () => { // ── Test B: Structured world context ──────────────────────────── describe("buildSessionContextSnapshot — world context", () => { + it("uses the session lore override on every context rebuild", async () => { + const store = createMemoryStore(); + await store.upsertWorld(makeWorld({ lore: "Original lore" })); + await store.createSession( + makeSession({ metadata: { loreOverride: "Player-edited lore" } }), + ); + + const snapshot = await buildSessionContextSnapshot(store, "sess-1", { + locale: "zh-CN", + turnNumber: 1, + worldId: "w1", + }); + + expect(snapshot.world.lore).toBe("Player-edited lore"); + }); + it("loads world metadata, schema, and lorebook entries into world.*", async () => { const store = createMemoryStore(); const world = makeWorld(); diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index d782fc1ef..96a32388b 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -22,6 +22,7 @@ export type { MemoryLLMAdapter, MemoryUpdaterConfig, MemoryUpdateResult, + MemoryAuthoritativeFacts, MemoryUpdater, RecallSearchResult, RecallSearcher, diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index c76fc1d99..64fe09175 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -218,6 +218,25 @@ export interface MemoryUpdateResult { readonly error?: string; } +/** + * Structured session facts read from committed framework state. + * + * The memory updater treats these values as authoritative when narrative text + * or an older memory block disagrees with them. Keeping the shape small avoids + * turning the updater into a second full context assembler. + */ +export interface MemoryAuthoritativeFacts { + readonly playerCharacter?: { + readonly name: string; + readonly type: string; + readonly description?: string; + readonly fields?: Readonly>; + }; + /** Localized display labels keyed by character field id. */ + readonly playerFieldLabels?: Readonly>; + readonly lastFormValues?: Readonly>; +} + export interface MemoryUpdater { /** * Analyze completed turn results and update core memory blocks. @@ -234,6 +253,7 @@ export interface MemoryUpdater { sessionId: string; narrativeText: string; toolCallSummaries?: readonly string[]; + authoritativeFacts?: MemoryAuthoritativeFacts; currentBlocks: readonly CoreMemoryBlock[]; locale?: string; }): Promise; diff --git a/packages/memory/src/updater.ts b/packages/memory/src/updater.ts index 14fbb51b0..b0f2e7407 100644 --- a/packages/memory/src/updater.ts +++ b/packages/memory/src/updater.ts @@ -23,6 +23,7 @@ import type { CoreMemoryBlock, CoreMemoryBlockSchema, CoreMemoryLabel, + MemoryAuthoritativeFacts, MemoryLLMAdapter, MemoryManager, MemoryUpdateResult, @@ -59,6 +60,7 @@ ${descriptions} 只输出有变化的块。如果本轮没有值得更新的信息,输出 \`{}\`。 每个块内容控制在 300-500 字以内,使用简洁的事实陈述,不要用文学化的描写。 +如果用户消息中的“会话事实(权威)”与叙事、推断或旧记忆冲突,必须以会话事实为准。 示例输出(用实际的块标签替换): @@ -85,6 +87,7 @@ Output a single JSON object where keys are block labels that need updating and v Only output blocks that changed. If nothing worth updating happened, output \`{}\`. Keep each block under 300-500 words. Use concise factual statements, not literary descriptions. +If "Authoritative Session Facts" conflict with the narrative, an inference, or an older memory block, the authoritative facts always win. Example output (replace with actual block labels): @@ -102,6 +105,7 @@ export function createMemoryUpdater( sessionId: string; narrativeText: string; toolCallSummaries?: readonly string[]; + authoritativeFacts?: MemoryAuthoritativeFacts; currentBlocks: readonly CoreMemoryBlock[]; locale?: string; }): Promise; @@ -120,6 +124,7 @@ export function createMemoryUpdater( sessionId: string; narrativeText: string; toolCallSummaries?: readonly string[]; + authoritativeFacts?: MemoryAuthoritativeFacts; currentBlocks: readonly CoreMemoryBlock[]; locale?: string; }): Promise { @@ -127,6 +132,7 @@ export function createMemoryUpdater( sessionId, narrativeText, toolCallSummaries, + authoritativeFacts, currentBlocks, locale, } = params; @@ -139,19 +145,42 @@ export function createMemoryUpdater( const schema = (await config?.resolveBlocks?.(sessionId)) ?? staticSchema; const validLabels = new Set(schema.map((b) => b.label)); - // Build user prompt with current blocks + new events - const blockSection = currentBlocks - .filter((b) => b.content.trim()) - .map((b) => `[${b.label}]\n${b.content}`) - .join("\n\n"); - const toolSection = toolCallSummaries?.length ? `\n\n## 本轮工具调用摘要\n${toolCallSummaries.join("\n")}` : ""; - const userPrompt = `## 当前记忆块\n${blockSection || "(全部为空,首次初始化)"}\n\n## 本轮叙事\n${narrativeText}${toolSection}\n\n请输出需要更新的记忆块 JSON。`; + const authoritativeSection = buildAuthoritativeFactsSection( + authoritativeFacts, + lang, + ); + + let authoritativeBlocksChanged: CoreMemoryLabel[] = []; try { + // Persist confirmed character fields before waiting on the summarizer. + // This deterministic correction must survive a slow or failed LLM call. + const authoritativeUpdates = new Map(); + enforceAuthoritativePlayerProfile({ + updates: authoritativeUpdates, + currentBlocks, + authoritativeFacts, + lang, + }); + if (authoritativeUpdates.size > 0) { + await manager.updateBlocks(sessionId, authoritativeUpdates); + authoritativeBlocksChanged = [...authoritativeUpdates.keys()]; + } + + const effectiveCurrentBlocks = applyUpdatesToBlockSnapshot( + currentBlocks, + authoritativeUpdates, + ); + const blockSection = effectiveCurrentBlocks + .filter((b) => b.content.trim()) + .map((b) => `[${b.label}]\n${b.content}`) + .join("\n\n"); + const userPrompt = `## 当前记忆块\n${blockSection || "(全部为空,首次初始化)"}${authoritativeSection}\n\n## 本轮叙事\n${narrativeText}${toolSection}\n\n请输出需要更新的记忆块 JSON。`; + const response = await llm.complete({ systemPrompt: buildSystemPrompt(schema, lang, effectiveLocale), messages: [{ role: "user", content: userPrompt }], @@ -159,21 +188,33 @@ export function createMemoryUpdater( }); const parsed = parseBlockUpdates(response.content, validLabels); + enforceAuthoritativePlayerProfile({ + updates: parsed, + currentBlocks: effectiveCurrentBlocks, + authoritativeFacts, + lang, + }); if (parsed.size === 0) { - return { updated: false, blocksChanged: [] }; + return { + updated: authoritativeBlocksChanged.length > 0, + blocksChanged: authoritativeBlocksChanged, + }; } await manager.updateBlocks(sessionId, parsed); return { updated: true, - blocksChanged: [...parsed.keys()], + blocksChanged: [ + ...new Set([...authoritativeBlocksChanged, ...parsed.keys()]), + ], }; } catch (err) { - // Memory update failure is non-fatal — blocks stay unchanged + // Dynamic-summary failure is non-fatal. A deterministic authoritative + // correction that already landed remains valid and is reported as such. return { - updated: false, - blocksChanged: [], + updated: authoritativeBlocksChanged.length > 0, + blocksChanged: authoritativeBlocksChanged, error: err instanceof Error ? err.message : String(err), }; } @@ -218,6 +259,181 @@ export function createMemoryUpdater( }; } +function applyUpdatesToBlockSnapshot( + blocks: readonly CoreMemoryBlock[], + updates: ReadonlyMap, +): readonly CoreMemoryBlock[] { + if (updates.size === 0) return blocks; + return blocks.map((block) => { + const content = updates.get(block.label); + return content === undefined ? block : { ...block, content }; + }); +} + +const CONFIRMED_PROFILE_PREFIX = { + zh: "角色资料(已确认):", + en: "Confirmed character profile: ", +} as const; + +/** + * Keep player-selected identity fields deterministic while leaving the LLM in + * charge of the dynamic status prose that follows. Prompt priority alone is + * insufficient here: a summarizer can translate or paraphrase an enum label + * on a later turn, so the framework owns one canonical first line. + */ +function enforceAuthoritativePlayerProfile(args: { + updates: Map; + currentBlocks: readonly CoreMemoryBlock[]; + authoritativeFacts: MemoryAuthoritativeFacts | undefined; + lang: "zh" | "en"; +}): void { + const { updates, currentBlocks, authoritativeFacts, lang } = args; + const character = authoritativeFacts?.playerCharacter; + if ( + !character || + (!updates.has("player_profile") && + !currentBlocks.some((block) => block.label === "player_profile")) + ) { + return; + } + + const authoritativeLine = formatAuthoritativePlayerProfile( + authoritativeFacts, + lang, + ); + if (!authoritativeLine) return; + + const currentContent = + updates.get("player_profile") ?? + currentBlocks.find((block) => block.label === "player_profile")?.content ?? + ""; + const dynamicContent = stripProfileFactProse( + stripManagedProfileLine(currentContent), + authoritativeFacts, + lang, + ); + const nextContent = [authoritativeLine, dynamicContent] + .filter(Boolean) + .join("\n") + .trim(); + + const persistedContent = + currentBlocks.find((block) => block.label === "player_profile")?.content ?? + ""; + if ( + updates.has("player_profile") || + nextContent !== persistedContent.trim() + ) { + updates.set("player_profile", nextContent); + } +} + +function formatAuthoritativePlayerProfile( + facts: MemoryAuthoritativeFacts, + lang: "zh" | "en", +): string | undefined { + const character = facts.playerCharacter; + if (!character?.name.trim()) return undefined; + + const parts = [ + lang === "zh" + ? `姓名:${character.name.trim()}` + : `Name: ${character.name.trim()}`, + ]; + for (const [fieldId, rawValue] of Object.entries(character.fields ?? {})) { + const value = formatAuthoritativeValue(rawValue); + if (!value) continue; + const label = facts.playerFieldLabels?.[fieldId]?.trim() || fieldId; + parts.push(lang === "zh" ? `${label}:${value}` : `${label}: ${value}`); + } + + const separator = lang === "zh" ? ";" : "; "; + const terminator = lang === "zh" ? "。" : "."; + return `${CONFIRMED_PROFILE_PREFIX[lang]}${parts.join(separator)}${terminator}`; +} + +function formatAuthoritativeValue(value: unknown): string | undefined { + if (typeof value === "string") return value.trim() || undefined; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + if (typeof value === "boolean") return String(value); + if ( + Array.isArray(value) && + value.length <= 8 && + value.every((item) => ["string", "number", "boolean"].includes(typeof item)) + ) { + return value.map(String).join(", "); + } + return undefined; +} + +function stripManagedProfileLine(content: string): string { + const prefixes = Object.values(CONFIRMED_PROFILE_PREFIX); + return content + .split(/\r?\n/) + .filter( + (line) => !prefixes.some((prefix) => line.trim().startsWith(prefix)), + ) + .join("\n") + .trim(); +} + +function stripProfileFactProse( + content: string, + facts: MemoryAuthoritativeFacts, + lang: "zh" | "en", +): string { + if (!content) return ""; + + const labels = Object.entries(facts.playerCharacter?.fields ?? {}).flatMap( + ([fieldId]) => + [fieldId, facts.playerFieldLabels?.[fieldId]].filter( + (value): value is string => Boolean(value?.trim()), + ), + ); + const values = Object.values(facts.playerCharacter?.fields ?? {}) + .map(formatAuthoritativeValue) + .filter((value): value is string => Boolean(value && value.length >= 2)); + const identityPattern = + lang === "zh" ? /(?:身份|姓名|名字)\s*[::]/ : /(?:identity|name)\s*:/i; + + return content + .split(/(?<=[。!?.!?])\s*|\r?\n+/) + .map((part) => part.trim()) + .filter(Boolean) + .filter((part) => { + if (identityPattern.test(part)) return false; + const labelMatches = labels.reduce( + (count, label) => count + (part.includes(label) ? 1 : 0), + 0, + ); + const valueMatches = values.reduce( + (count, value) => count + (part.includes(value) ? 1 : 0), + 0, + ); + return !(labelMatches >= 2 || valueMatches >= 1); + }) + .join(lang === "zh" ? "" : " ") + .trim(); +} + +function buildAuthoritativeFactsSection( + facts: MemoryAuthoritativeFacts | undefined, + lang: "zh" | "en", +): string { + if (!facts || Object.keys(facts).length === 0) return ""; + + try { + const serialized = JSON.stringify(facts, null, 2); + if (!serialized || serialized === "{}") return ""; + const bounded = serialized.slice(0, 4_000); + return lang === "zh" + ? `\n\n## 会话事实(权威)\n以下结构化值来自已提交的会话状态;发生冲突时以这些值为准。\n${bounded}` + : `\n\n## Authoritative Session Facts\nThese structured values come from committed session state; use them whenever other context conflicts.\n${bounded}`; + } catch { + return ""; + } +} + /** * Parse the LLM response into a map of block updates. * Handles: raw JSON, markdown-wrapped JSON, partial responses. diff --git a/packages/memory/tests/updater.test.ts b/packages/memory/tests/updater.test.ts index d0bf3d47b..5f5dd96b4 100644 --- a/packages/memory/tests/updater.test.ts +++ b/packages/memory/tests/updater.test.ts @@ -83,6 +83,152 @@ describe("MemoryUpdater", () => { expect(scene!.content).toBe("百灵沼泽入口,黄昏时分"); }); + it("injects committed character and form values as authoritative facts", async () => { + let captured: Parameters[0] | undefined; + const llm: MemoryLLMAdapter = { + async complete(request) { + captured = request; + return { content: "{}" }; + }, + }; + const updater = createMemoryUpdater(manager, llm); + + await updater.updateAfterTurn({ + sessionId: "sess-authoritative", + narrativeText: "叙事没有复述角色创建表单。", + authoritativeFacts: { + playerCharacter: { + name: "阿砾", + type: "player", + fields: { carapaceSense: "甲感略强于常人", socialStyle: "会来事" }, + }, + lastFormValues: { + carapaceSense: "甲感略强于常人", + socialStyle: "会来事", + }, + }, + currentBlocks, + }); + + expect(captured?.systemPrompt).toContain("必须以会话事实为准"); + const userPrompt = String(captured?.messages[0]?.content ?? ""); + expect(userPrompt).toContain("## 会话事实(权威)"); + expect(userPrompt).toContain('"carapaceSense": "甲感略强于常人"'); + expect(userPrompt).toContain('"socialStyle": "会来事"'); + }); + + it("deterministically preserves confirmed player fields across later turns", async () => { + const responses = [ + { + player_profile: + "身份:拾荒学徒。姓名:阿砾。壳感二分,谈锋圆滑。当前状态:已进入尖塔。", + }, + { + player_profile: + "姓名:阿砾;甲觉二分;谈锋圆滑。当前状态:继续沿旋梯下行。", + }, + ]; + const llm: MemoryLLMAdapter = { + async complete() { + return { content: JSON.stringify(responses.shift() ?? {}) }; + }, + }; + const updater = createMemoryUpdater(manager, llm); + const authoritativeFacts = { + playerCharacter: { + name: "阿砾", + type: "player", + fields: { shellSense: "二分", tongue: "圆滑" }, + }, + playerFieldLabels: { shellSense: "甲感", tongue: "谈锋" }, + lastFormValues: { shellSense: "二分", tongue: "圆滑" }, + } as const; + + await updater.updateAfterTurn({ + sessionId: "sess-stable-profile", + narrativeText: "玩家进入尖塔。", + authoritativeFacts, + currentBlocks, + }); + const afterFirst = await manager.loadBlocks("sess-stable-profile"); + const firstProfile = afterFirst.find( + (block) => block.label === "player_profile", + )?.content; + expect(firstProfile).toContain( + "角色资料(已确认):姓名:阿砾;甲感:二分;谈锋:圆滑。", + ); + expect(firstProfile).toContain("当前状态:已进入尖塔。"); + expect(firstProfile).not.toContain("壳感"); + + await updater.updateAfterTurn({ + sessionId: "sess-stable-profile", + narrativeText: "玩家继续下行。", + authoritativeFacts, + currentBlocks: afterFirst, + }); + const afterSecond = await manager.getBlock( + "sess-stable-profile", + "player_profile", + ); + expect(afterSecond?.content).toContain( + "角色资料(已确认):姓名:阿砾;甲感:二分;谈锋:圆滑。", + ); + expect(afterSecond?.content).toContain("当前状态:继续沿旋梯下行。"); + expect(afterSecond?.content).not.toContain("甲觉"); + }); + + it("persists confirmed player fields before a slow summarizer completes", async () => { + let releaseLlm!: () => void; + let markLlmStarted!: () => void; + const llmStarted = new Promise((resolve) => { + markLlmStarted = resolve; + }); + const llmReleased = new Promise((resolve) => { + releaseLlm = resolve; + }); + const slowLlm: MemoryLLMAdapter = { + async complete() { + markLlmStarted(); + await llmReleased; + return { content: "{}" }; + }, + }; + const updater = createMemoryUpdater(manager, slowLlm); + const update = updater.updateAfterTurn({ + sessionId: "sess-early-profile", + narrativeText: "摘要调用仍在进行。", + authoritativeFacts: { + playerCharacter: { + name: "阿砾", + type: "player", + fields: { shellSense: "二分" }, + }, + playerFieldLabels: { shellSense: "甲感" }, + }, + currentBlocks: currentBlocks.map((block) => + block.label === "player_profile" + ? { ...block, content: "姓名:阿砾。壳感二分。" } + : block, + ), + }); + + await llmStarted; + const whileLlmPending = await manager.getBlock( + "sess-early-profile", + "player_profile", + ); + expect(whileLlmPending?.content).toContain( + "角色资料(已确认):姓名:阿砾;甲感:二分。", + ); + expect(whileLlmPending?.content).not.toContain("壳感"); + + releaseLlm(); + await expect(update).resolves.toMatchObject({ + updated: true, + blocksChanged: ["player_profile"], + }); + }); + it("should handle markdown-wrapped JSON", async () => { const llm = createMockLLM('```json\n{"scene": "新场景"}\n```'); const updater = createMemoryUpdater(manager, llm); diff --git a/packages/runtime/src/agent-loop/turn-agent-guard.ts b/packages/runtime/src/agent-loop/turn-agent-guard.ts index df8ff3959..db52e9813 100644 --- a/packages/runtime/src/agent-loop/turn-agent-guard.ts +++ b/packages/runtime/src/agent-loop/turn-agent-guard.ts @@ -5,6 +5,7 @@ import type { NestedTurnResult, RecursiveCallDelta, } from "@covel/shared"; +import { attachExecutionJournal } from "../execution-journal.js"; import { getRuntimeSpec, stageMessageOrder } from "@covel/shared"; import type { LoadedRuntime } from "@covel/plugin-loader"; import { withPendingProposals } from "@covel/tools"; @@ -311,26 +312,6 @@ export async function executeAgentGuard({ timestamp: new Date().toISOString(), }; - if ( - deps.store && - typeof guardOutput.narrativeOutput === "string" && - guardOutput.narrativeOutput - ) { - await deps.store.appendTurnMessage({ - id: crypto.randomUUID(), - sessionId: input.sessionId, - turnId: input.turnId, - sourceType: "runtime", - sourcePluginId: manifest.pluginId, - sourceRuntimeId: manifest.name, - role: "assistant", - name: manifest.name, - content: guardOutput.narrativeOutput as string, - order: stageMessageOrder(getRuntimeSpec(manifest).stage), - createdAt: new Date().toISOString(), - }); - } - // Guard skipped: emit completed (without ever emitting started) so frontend // shows "skipped" instead of an infinite spinner. try { @@ -358,7 +339,7 @@ export async function executeAgentGuard({ ); // PostRuntime hook — guard-skipped path - return runPostRuntimeHook( + const postResult = await runPostRuntimeHook( { pipeline: hookPipeline, sessionId: input.sessionId, @@ -370,6 +351,30 @@ export async function executeAgentGuard({ }, result, ); + const postOutput = postResult.output as Record | null; + if ( + deps.store && + postResult.status === "skipped" && + typeof postOutput?.narrativeOutput === "string" && + postOutput.narrativeOutput + ) { + attachExecutionJournal(postResult, [ + { + id: crypto.randomUUID(), + sessionId: input.sessionId, + turnId: input.turnId, + sourceType: "runtime", + sourcePluginId: manifest.pluginId, + sourceRuntimeId: manifest.name, + role: "assistant", + name: manifest.name, + content: postOutput.narrativeOutput, + order: stageMessageOrder(getRuntimeSpec(manifest).stage), + createdAt: new Date().toISOString(), + }, + ]); + } + return postResult; } } return undefined; diff --git a/packages/runtime/src/agent-loop/turn-agent-runtime.ts b/packages/runtime/src/agent-loop/turn-agent-runtime.ts index 402112bc9..6ca02aec6 100644 --- a/packages/runtime/src/agent-loop/turn-agent-runtime.ts +++ b/packages/runtime/src/agent-loop/turn-agent-runtime.ts @@ -5,6 +5,7 @@ import type { RuntimeActivation, InputSlot, } from "@covel/shared"; +import { attachExecutionJournal } from "../execution-journal.js"; import { getRuntimeSpec, stageMessageOrder } from "@covel/shared"; import type { LoadedRuntime } from "@covel/plugin-loader"; import { @@ -471,11 +472,10 @@ export async function executeAgentRuntime({ const result = await runPostRuntimeHook(postRuntimeOpts, rawResult); const finalOutput = (result.output ?? output) as Record; - // Save runtime output as an append-only TurnMessage. Manual plugin-rpc - // calls return their output to the caller and commit proposals through - // plugin-rpc, so they stay out of conversation history. Skipped when a - // PostRuntime hook rewrote the status to a non-success — an unsuccessful - // result must not enter prompt history as narrative. + // Stage the runtime output in the execution journal. finalizeExecution + // appends it inside the proposal/session-clock transaction. Manual + // plugin-rpc calls stay out of conversation history, matching the existing + // contract; a PostRuntime non-success also produces no message. if (deps.store && !input.manualTrigger && result.status === "success") { // Extract narrative content. const narrativeContent = @@ -495,21 +495,23 @@ export async function executeAgentRuntime({ // Extract UI render instructions if present const ui = finalOutput.ui as unknown[] | undefined; - await deps.store.appendTurnMessage({ - id: crypto.randomUUID(), - sessionId: input.sessionId, - turnId: input.turnId, - sourceType: "runtime", - sourcePluginId: manifest.pluginId, - sourceRuntimeId: manifest.name, - role: "assistant", - name: manifest.name, - content: narrativeContent, - order: stageMessageOrder(getRuntimeSpec(manifest).stage), - pendingInput, - ui, - createdAt: new Date().toISOString(), - }); + attachExecutionJournal(result, [ + { + id: crypto.randomUUID(), + sessionId: input.sessionId, + turnId: input.turnId, + sourceType: "runtime", + sourcePluginId: manifest.pluginId, + sourceRuntimeId: manifest.name, + role: "assistant", + name: manifest.name, + content: narrativeContent, + order: stageMessageOrder(getRuntimeSpec(manifest).stage), + pendingInput, + ui, + createdAt: new Date().toISOString(), + }, + ]); } try { diff --git a/packages/runtime/src/agent-loop/turn-agent-tool-loop.ts b/packages/runtime/src/agent-loop/turn-agent-tool-loop.ts index f4ee692b3..ada5aeaea 100644 --- a/packages/runtime/src/agent-loop/turn-agent-tool-loop.ts +++ b/packages/runtime/src/agent-loop/turn-agent-tool-loop.ts @@ -189,7 +189,7 @@ export async function runAgentToolLoop({ const cause = info.error instanceof Error ? info.error.message : String(info.error); console.warn( - `[runtime-retry] ${manifest.name} attempt=${info.attempt} reason=${info.reason} cause=${cause.slice(0, 200)}`, + `[covel:warn] [runtime-retry] ${manifest.name} attempt=${info.attempt} reason=${info.reason} cause=${cause.slice(0, 200)}`, ); }; @@ -609,7 +609,7 @@ export async function runAgentToolLoop({ if (noToolCallCorrections === 0) { noToolCallCorrections++; console.warn( - `[runtime-retry] ${manifest.name} attempt=${noToolCallCorrections} reason=no-tool-call cause=finished without calling any tool`, + `[covel:warn] [runtime-retry] ${manifest.name} attempt=${noToolCallCorrections} reason=no-tool-call cause=finished without calling any tool`, ); messages.push({ role: "system", @@ -620,7 +620,7 @@ export async function runAgentToolLoop({ continue; } console.warn( - `[runtime-retry] ${manifest.name} reason=no-tool-call cause=still no business tool call after correction; releasing`, + `[covel:warn] [runtime-retry] ${manifest.name} reason=no-tool-call cause=still no business tool call after correction; releasing`, ); requiredToolUseUnmet = true; } diff --git a/packages/runtime/src/commit/finalize-execution.ts b/packages/runtime/src/commit/finalize-execution.ts index b6470ea4c..b259b9af2 100644 --- a/packages/runtime/src/commit/finalize-execution.ts +++ b/packages/runtime/src/commit/finalize-execution.ts @@ -21,7 +21,11 @@ * promised, matching what those stores could do before. */ -import type { DataStore, StoreTransaction } from "@covel/store"; +import type { + DataStore, + StoreTransaction, + TurnMessageRecord, +} from "@covel/store"; import type { EventBus } from "@covel/events"; import type { ExecutionContext, @@ -85,6 +89,8 @@ export interface FinalizeExecutionArgs { readonly runtimes: readonly FinalizeManifest[]; /** Flattened results to commit: top-level plus nested recursiveCall results. */ readonly results: readonly FinalizableResult[]; + /** Conversation entries committed atomically with this execution. */ + readonly journalMessages?: readonly TurnMessageRecord[]; /** * `turn_results` rows to settle. Nested rows reuse the top-level `turnId`, * so the top-level id alone settles them all. Empty when the caller persists @@ -383,6 +389,9 @@ export async function finalizeExecution( throw new ProposalCommitFailure(out.failedProposals); } } + for (const message of args.journalMessages ?? []) { + await tx.appendTurnMessage(message); + } await extraInTx?.(tx); // Advance the session clock in the same transaction as the domain // writes, so a rollback undoes the counter / phase flip too. @@ -480,6 +489,9 @@ export async function finalizeExecution( } try { // DataStore satisfies StoreTransaction structurally (it has every member). + for (const message of args.journalMessages ?? []) { + await store.appendTurnMessage(message); + } await extraInTx?.(store as unknown as StoreTransaction); if (shouldWriteClock) { await applySessionClockTx(store as unknown as StoreTransaction, { diff --git a/packages/runtime/src/execution-journal.ts b/packages/runtime/src/execution-journal.ts new file mode 100644 index 000000000..f67c7b7f1 --- /dev/null +++ b/packages/runtime/src/execution-journal.ts @@ -0,0 +1,57 @@ +/** + * Execution-scoped conversation journal. + * + * Player/runtime TurnMessages are collected while the turn runs and appended + * by `finalizeExecution` inside the same transaction as proposals and the + * session clock. Symbols keep the pending journal out of persisted runtime / + * turn-result artifacts while preserving it across the in-process handoff to + * the commit-owning caller. + */ + +import type { TurnResult } from "@covel/shared"; +import type { TurnMessageRecord } from "@covel/store"; + +const EXECUTION_JOURNAL = Symbol.for("@covel/runtime/execution-journal"); + +type JournalCarrier = object & { + readonly [EXECUTION_JOURNAL]?: readonly TurnMessageRecord[]; +}; + +export function attachExecutionJournal( + carrier: T, + messages: readonly TurnMessageRecord[], +): T { + if (messages.length === 0) return carrier; + const existing = + (carrier as JournalCarrier)[EXECUTION_JOURNAL] ?? + ([] as readonly TurnMessageRecord[]); + Object.defineProperty(carrier, EXECUTION_JOURNAL, { + value: [...existing, ...messages], + enumerable: false, + configurable: true, + }); + return carrier; +} + +function journalOf(carrier: object): readonly TurnMessageRecord[] { + return (carrier as JournalCarrier)[EXECUTION_JOURNAL] ?? []; +} + +/** Collect and de-duplicate every pending message produced by one execution. */ +export function collectExecutionJournal( + turnResult: Pick, +): readonly TurnMessageRecord[] { + const all = [ + ...journalOf(turnResult), + ...turnResult.runtimeResults.flatMap((result) => journalOf(result)), + ...(turnResult.nestedRuntimeResults ?? []).flatMap((result) => + journalOf(result), + ), + ]; + const seen = new Set(); + return all.filter((message) => { + if (seen.has(message.id)) return false; + seen.add(message.id); + return true; + }); +} diff --git a/packages/runtime/src/function-runtime/turn-function-runtime.ts b/packages/runtime/src/function-runtime/turn-function-runtime.ts index 14442bf97..7cd3bc997 100644 --- a/packages/runtime/src/function-runtime/turn-function-runtime.ts +++ b/packages/runtime/src/function-runtime/turn-function-runtime.ts @@ -8,6 +8,7 @@ import type { ExecutionContext, InputSlot, } from "@covel/shared"; +import { attachExecutionJournal } from "../execution-journal.js"; import { getRuntimeSpec, stageMessageOrder } from "@covel/shared"; import type { LoadedRuntime } from "@covel/plugin-loader"; import type { SuspensionRecord } from "@covel/store"; @@ -657,7 +658,7 @@ export async function executeFunctionRuntime({ const finalOutput = (result.output ?? output) as Record; - // Save function output as TurnMessage (same as agent runtimes). + // Stage function output in the execution journal (same as agent runtimes). // Manual plugin-rpc calls return their output to the caller and commit // proposals through plugin-rpc, so they stay out of conversation history. // Skipped when a PostRuntime hook rewrote the status to a non-success. @@ -668,20 +669,28 @@ export async function executeFunctionRuntime({ : typeof finalOutput.content === "string" ? finalOutput.content : JSON.stringify(finalOutput); + const interactions = Array.isArray(finalOutput.interactions) + ? finalOutput.interactions + : undefined; + const ui = Array.isArray(finalOutput.ui) ? finalOutput.ui : undefined; - await deps.store.appendTurnMessage({ - id: crypto.randomUUID(), - sessionId: input.sessionId, - turnId: input.turnId, - sourceType: "runtime", - sourcePluginId: manifest.pluginId, - sourceRuntimeId: manifest.name, - role: "assistant", - name: manifest.name, - content: narrativeContent, - order: stageMessageOrder(getRuntimeSpec(manifest).stage), - createdAt: new Date().toISOString(), - }); + attachExecutionJournal(result, [ + { + id: crypto.randomUUID(), + sessionId: input.sessionId, + turnId: input.turnId, + sourceType: "runtime", + sourcePluginId: manifest.pluginId, + sourceRuntimeId: manifest.name, + role: "assistant", + name: manifest.name, + content: narrativeContent, + order: stageMessageOrder(getRuntimeSpec(manifest).stage), + pendingInput: interactions, + ui, + createdAt: new Date().toISOString(), + }, + ]); } try { diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 657f94b09..84b7fca21 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -39,6 +39,7 @@ export { isTurnAbortedError, } from "./turn-executor/turn-control.js"; export type { TurnControl } from "./turn-executor/turn-control.js"; +export { collectExecutionJournal } from "./execution-journal.js"; export { createRuntimeMediaContext } from "./function-runtime/runtime-media-context.js"; export type { MediaStoreLike } from "./function-runtime/runtime-media-context.js"; diff --git a/packages/runtime/src/rpc-defaults/submit-form.ts b/packages/runtime/src/rpc-defaults/submit-form.ts index 0d15ee990..055425a52 100644 --- a/packages/runtime/src/rpc-defaults/submit-form.ts +++ b/packages/runtime/src/rpc-defaults/submit-form.ts @@ -7,6 +7,7 @@ */ import type { InteractionType } from "@covel/shared"; +import type { DataStore } from "@covel/store"; import type { RpcHandler, RpcHandlerContext } from "../rpc/rpc-registry.js"; interface Submission { @@ -18,7 +19,7 @@ interface Submission { interface SubmitFormPayload { readonly turnId: string; - readonly submissions?: readonly Submission[]; + readonly submissions?: unknown; } interface SubmitFormResult { @@ -33,12 +34,22 @@ interface SubmitFormResult { interface MessageLike { readonly turnId: string; + readonly sourceType: string; + readonly role: string; readonly pendingInput?: unknown; readonly content: string; readonly name?: string; readonly order: number; } +interface LocatedInteraction { + readonly message: MessageLike; + readonly interaction: Record & { + readonly interactionId: string; + readonly type: InteractionType; + }; +} + // Exported for the alignment test that pins this Set against InteractionType. export const VALID_TYPES = new Set([ "form", @@ -90,21 +101,221 @@ function resolveLabels(locale?: string): SubmitFormLabels { ); } -function findTemplateMessage( +function findCommittedInteraction( messages: readonly MessageLike[], turnId: string, interactionId: string, -): MessageLike | undefined { - return messages.find((m) => { - if (m.turnId !== turnId || !m.pendingInput) return false; +): LocatedInteraction | undefined { + for (const m of messages) { + if ( + m.turnId !== turnId || + m.sourceType !== "runtime" || + m.role !== "assistant" || + !m.pendingInput + ) { + continue; + } const pi = m.pendingInput; if (Array.isArray(pi)) { - return (pi as Array>).some( - (i) => i.interactionId === interactionId, + const interaction = pi.find( + (candidate): candidate is Record => + !!candidate && + typeof candidate === "object" && + !Array.isArray(candidate) && + candidate.interactionId === interactionId, ); + if (interaction && VALID_TYPES.has(interaction.type as InteractionType)) { + return { + message: m, + interaction: interaction as LocatedInteraction["interaction"], + }; + } + continue; } - return (pi as Record).formId === interactionId; - }); + if (typeof pi !== "object") continue; + const legacy = pi as Record; + if (legacy.formId === interactionId) { + return { + message: m, + interaction: { ...legacy, interactionId, type: "form" }, + }; + } + } + return undefined; +} + +function assertOnlyKeys( + values: Readonly>, + allowed: ReadonlySet, + interactionId: string, +): void { + const unknown = Object.keys(values).find((key) => !allowed.has(key)); + if (unknown) { + throw new RpcValidationError( + `Unknown field "${unknown}" for interactionId: ${interactionId}`, + ); + } +} + +function isMissingRequired(value: unknown): boolean { + return ( + value === undefined || + value === null || + (typeof value === "string" && value.trim().length === 0) + ); +} + +function optionValue(option: unknown): string | undefined { + if (typeof option === "string") return option; + if (!option || typeof option !== "object" || Array.isArray(option)) { + return undefined; + } + const value = (option as Record).value; + return typeof value === "string" ? value : undefined; +} + +function validateFormValues( + interaction: LocatedInteraction["interaction"], + values: Readonly>, +): Record { + // Historical PlayerInputForm rows may carry only `formId`. Keep those rows + // submittable; current InteractionPayload rows always carry the schema below. + if (!Array.isArray(interaction.fields)) return { ...values }; + + const fields = interaction.fields as Array>; + const declared = new Map>(); + for (const field of fields) { + const name = + typeof field.name === "string" + ? field.name + : typeof field.id === "string" + ? field.id + : ""; + if (!name) { + throw new RpcValidationError( + `Committed form ${interaction.interactionId} contains a field without a name`, + ); + } + declared.set(name, field); + } + assertOnlyKeys(values, new Set(declared.keys()), interaction.interactionId); + + for (const [name, field] of declared) { + const value = values[name]; + if (field.required === true && isMissingRequired(value)) { + throw new RpcValidationError( + `Required field "${name}" is missing for interactionId: ${interaction.interactionId}`, + ); + } + if (value === undefined || value === null) continue; + + switch (field.type) { + case "text": + case "textarea": + if (typeof value !== "string") { + throw new RpcValidationError(`Field "${name}" must be a string`); + } + break; + case "number": + if (!( + (typeof value === "number" && Number.isFinite(value)) || + (typeof value === "string" && + value.trim().length > 0 && + Number.isFinite(Number(value))) + )) { + throw new RpcValidationError(`Field "${name}" must be a number`); + } + break; + case "checkbox": + if ( + typeof value !== "boolean" && + value !== "true" && + value !== "false" + ) { + throw new RpcValidationError(`Field "${name}" must be a checkbox`); + } + break; + case "select": { + if (typeof value !== "string") { + throw new RpcValidationError(`Field "${name}" must be a string`); + } + const allowed = Array.isArray(field.options) + ? field.options.map(optionValue).filter((item) => item !== undefined) + : []; + if (!allowed.includes(value)) { + throw new RpcValidationError( + `Field "${name}" must match a declared option`, + ); + } + break; + } + default: + throw new RpcValidationError( + `Committed form ${interaction.interactionId} has unsupported field type: ${String(field.type)}`, + ); + } + } + return { ...values }; +} + +function validateSubmissionValues( + sub: Submission, + interaction: LocatedInteraction["interaction"], +): Record { + if (sub.type !== interaction.type) { + throw new RpcValidationError( + `Submission type must match committed interaction type "${interaction.type}" for interactionId: ${sub.interactionId}`, + ); + } + + switch (interaction.type) { + case "form": + return validateFormValues(interaction, sub.values); + case "choice": { + assertOnlyKeys( + sub.values, + new Set(["selectedId", "selectedLabel"]), + sub.interactionId, + ); + const selectedId = sub.values.selectedId; + if (typeof selectedId !== "string" || !selectedId) { + throw new RpcValidationError( + `selectedId (string) is required for interactionId: ${sub.interactionId}`, + ); + } + const choices = Array.isArray(interaction.choices) + ? (interaction.choices as Array>) + : []; + const selected = choices.find((choice) => choice.id === selectedId); + if (!selected || typeof selected.label !== "string") { + throw new RpcValidationError( + `selectedId must match a declared choice for interactionId: ${sub.interactionId}`, + ); + } + return { selectedId, selectedLabel: selected.label }; + } + case "confirmation": + assertOnlyKeys(sub.values, new Set(["confirmed"]), sub.interactionId); + if (typeof sub.values.confirmed !== "boolean") { + throw new RpcValidationError( + `confirmed (boolean) is required for interactionId: ${sub.interactionId}`, + ); + } + return { confirmed: sub.values.confirmed }; + } +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + if (value && typeof value === "object") { + const entries = Object.entries(value as Record).sort( + ([left], [right]) => left.localeCompare(right), + ); + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); } function buildReplacements( @@ -128,7 +339,11 @@ function buildReplacements( } } -function fallbackNarrative(sub: Submission, labels: SubmitFormLabels): string { +function fallbackNarrative( + sub: Submission, + labels: SubmitFormLabels, + interaction?: Readonly>, +): string { switch (sub.type) { case "form": { const entries = Object.entries(sub.values) @@ -139,29 +354,20 @@ function fallbackNarrative(sub: Submission, labels: SubmitFormLabels): string { case "choice": return `${labels.choicePrefix} ${String(sub.values.selectedLabel ?? sub.values.selectedId)}`; case "confirmation": - return `${sub.values.confirmed ? labels.confirmedPrefix : labels.cancelledPrefix} ${String(sub.values.prompt ?? "")}`; + return `${sub.values.confirmed ? labels.confirmedPrefix : labels.cancelledPrefix} ${String(interaction?.prompt ?? "")}`; } } function fillTemplate( sub: Submission, - templateMessage: MessageLike | undefined, + located: LocatedInteraction, labels: SubmitFormLabels, ): string { - if (!templateMessage) return fallbackNarrative(sub, labels); - - let template = templateMessage.content; - if (Array.isArray(templateMessage.pendingInput)) { - const interaction = ( - templateMessage.pendingInput as Array> - ).find((i) => i.interactionId === sub.interactionId); - if ( - interaction?.narrativeTemplate && - typeof interaction.narrativeTemplate === "string" - ) { - template = interaction.narrativeTemplate; - } + let template = located.message.content; + if (typeof located.interaction.narrativeTemplate === "string") { + template = located.interaction.narrativeTemplate; } + if (!template) return fallbackNarrative(sub, labels, located.interaction); const replacements = buildReplacements(sub, labels); return template.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key: string) => { @@ -193,15 +399,20 @@ export const submitFormHandler: RpcHandler = async ( throw new RpcValidationError("turnId (string) is required"); } - const submissions: Submission[] = body.submissions - ? [...body.submissions] - : []; - - if (submissions.length === 0) { + if (!Array.isArray(body.submissions) || body.submissions.length === 0) { throw new RpcValidationError("submissions[] is required"); } - for (const sub of submissions) { + const submissions: Submission[] = []; + for (const rawSubmission of body.submissions) { + if ( + !rawSubmission || + typeof rawSubmission !== "object" || + Array.isArray(rawSubmission) + ) { + throw new RpcValidationError("Each submission must be an object"); + } + const sub = rawSubmission as Submission; if (!sub.interactionId || typeof sub.interactionId !== "string") { throw new RpcValidationError( "Each submission requires interactionId (string)", @@ -221,38 +432,102 @@ export const submitFormHandler: RpcHandler = async ( `submission.values must be an object for interactionId: ${sub.interactionId}`, ); } + submissions.push(sub); } - const messages = (await store.listTurnMessages( + // Framework defaults run with the trusted store view, whose runtime surface + // is the full DataStore. Keep the public RpcHandlerStore contract narrow for + // third-party handlers and narrow this cast to the two framework-only reads / + // transaction methods used here. + const frameworkStore = store as typeof store & + Pick; + const messages = (await frameworkStore.listTurnMessages( sessionId, )) as readonly MessageLike[]; - const out: Array = []; + const existingInputs = await frameworkStore.listPlayerInputs(sessionId); + const prepared: Array<{ + readonly submissionId: string; + readonly interactionId: string; + readonly values: Record; + readonly filledNarrative: string; + readonly shouldPersist: boolean; + }> = []; + const preparedByKey = new Map(); for (const sub of submissions) { - const submissionId = crypto.randomUUID(); - await store.savePlayerInput({ - id: submissionId, - sessionId, - turnId: body.turnId, - formId: sub.interactionId, - values: sub.values, - createdAt: new Date().toISOString(), - }); - - const templateMessage = findTemplateMessage( + const located = findCommittedInteraction( messages, body.turnId, sub.interactionId, ); - const filledNarrative = fillTemplate(sub, templateMessage, labels); + if (!located) { + throw new RpcValidationError( + `No committed interaction found for turnId=${body.turnId}, interactionId=${sub.interactionId}`, + ); + } + const values = validateSubmissionValues(sub, located.interaction); + const normalizedSub: Submission = { ...sub, values }; + const key = `${body.turnId}\0${sub.interactionId}`; + const duplicateInBatch = preparedByKey.get(key); + if (duplicateInBatch) { + if (stableJson(duplicateInBatch.values) !== stableJson(values)) { + throw new RpcValidationError( + `Interaction ${sub.interactionId} is submitted more than once with conflicting values`, + ); + } + prepared.push({ ...duplicateInBatch, shouldPersist: false }); + continue; + } - out.push({ - submissionId, + const existing = existingInputs.find( + (input) => + input.turnId === body.turnId && input.formId === sub.interactionId, + ); + if (existing && stableJson(existing.values) !== stableJson(values)) { + throw new RpcValidationError( + `Interaction ${sub.interactionId} was already submitted with different values`, + ); + } + const item = { + submissionId: existing?.id ?? crypto.randomUUID(), interactionId: sub.interactionId, - filledNarrative, - accepted: true, - }); + values, + filledNarrative: fillTemplate(normalizedSub, located, labels), + shouldPersist: !existing, + }; + prepared.push(item); + preparedByKey.set(key, item); } - return { accepted: true, results: out }; + const writes = prepared.filter((item) => item.shouldPersist); + const persist = async (target: Pick) => { + const createdAt = new Date().toISOString(); + for (const item of writes) { + await target.savePlayerInput({ + id: item.submissionId, + sessionId, + turnId: body.turnId, + formId: item.interactionId, + values: item.values, + createdAt, + }); + } + }; + if (writes.length > 0) { + if (typeof frameworkStore.withTransaction === "function") { + await frameworkStore.withTransaction(async (tx) => persist(tx)); + } else { + await persist(frameworkStore); + } + } + + return { + accepted: true, + results: prepared.map((item) => ({ + submissionId: item.submissionId, + interactionId: item.interactionId, + filledNarrative: item.filledNarrative, + accepted: true, + })), + }; }; diff --git a/packages/runtime/src/turn-executor/execution-context.ts b/packages/runtime/src/turn-executor/execution-context.ts index 4e35263f5..95a5f8cb2 100644 --- a/packages/runtime/src/turn-executor/execution-context.ts +++ b/packages/runtime/src/turn-executor/execution-context.ts @@ -51,12 +51,11 @@ export function toLegacyOrigin(origin: ExecutionOrigin): LegacyOrigin { * `executionId`, `origin: "recursive"`, `countPolicy: "none"`) rather than * inheriting the parent's. * - * `countPolicy` is recorded but NOT yet consumed this step — the finalizer - * still gates turn accounting on the projected legacy origin. It is - * `complete-player-turn` only for a genuine main-loop player turn (player - * origin, Pre-Game not pending); a setup request or any non-player execution is - * `none`. `preGamePending` is the transition input the actions route feeds from - * its pre-turn snapshot. + * The finalizer consumes `countPolicy` inside the same transaction as runtime + * proposals. It is `complete-player-turn` only for a genuine main-loop player + * turn (player origin, setup not pending); a setup request or any non-player + * execution is `none`. `preGamePending` is the transition input the actions + * route feeds from its pre-turn snapshot. */ export function createExecutionContext(input: TurnInput): ExecutionContext { const origin = normalizeOrigin(input.origin); diff --git a/packages/runtime/src/turn-executor/post-turn-memory.ts b/packages/runtime/src/turn-executor/post-turn-memory.ts index ce60f55ca..43c37cc9e 100644 --- a/packages/runtime/src/turn-executor/post-turn-memory.ts +++ b/packages/runtime/src/turn-executor/post-turn-memory.ts @@ -1,4 +1,10 @@ -import type { TurnInput, TurnResult } from "@covel/shared"; +import type { SessionContextSnapshot } from "@covel/context"; +import { + resolveI18nText, + type I18nText, + type TurnInput, + type TurnResult, +} from "@covel/shared"; import type { TurnExecutorDeps } from "./turn-executor-types.js"; import type { CoreMemoryBlock } from "./session-state.js"; @@ -7,8 +13,9 @@ export function schedulePostTurnMemoryUpdate(args: { readonly turnResult: TurnResult; readonly deps: TurnExecutorDeps; readonly coreMemoryBlocks: readonly CoreMemoryBlock[]; + readonly sessionContext?: SessionContextSnapshot; }): void { - const { input, turnResult, deps, coreMemoryBlocks } = args; + const { input, turnResult, deps, coreMemoryBlocks, sessionContext } = args; if (!deps.memorySystem || coreMemoryBlocks.length === 0) { return; } @@ -25,12 +32,29 @@ export function schedulePostTurnMemoryUpdate(args: { (tc) => `[${tc.toolName}] ${JSON.stringify(tc.input).slice(0, 200)}`, ), ); + const playerCharacter = sessionContext?.characters.find( + (character) => character.type === "player", + ); + const lastFormValues = sessionContext?.sessionMeta.lastFormValues; + const playerFieldLabels = extractPlayerFieldLabels( + sessionContext?.world.schema, + input.locale, + ); + const authoritativeFacts = + playerCharacter || lastFormValues + ? { + ...(playerCharacter ? { playerCharacter } : {}), + ...(playerFieldLabels ? { playerFieldLabels } : {}), + ...(lastFormValues ? { lastFormValues } : {}), + } + : undefined; deps.memorySystem.updater .updateAfterTurn({ sessionId: input.sessionId, narrativeText, toolCallSummaries: toolSummaries.length > 0 ? toolSummaries : undefined, + authoritativeFacts, currentBlocks: coreMemoryBlocks, locale: input.locale, }) @@ -49,6 +73,33 @@ export function schedulePostTurnMemoryUpdate(args: { }); } +function extractPlayerFieldLabels( + worldSchema: Readonly> | undefined, + locale: string | undefined, +): Readonly> | undefined { + const characterSchema = worldSchema?.["character-attributes"]; + if (!characterSchema || typeof characterSchema !== "object") return undefined; + + const attributes = (characterSchema as Record).attributes; + if (!Array.isArray(attributes)) return undefined; + + const labels: Record = {}; + for (const attribute of attributes) { + if (!attribute || typeof attribute !== "object") continue; + const { id, name } = attribute as Record; + if (typeof id !== "string" || !id.trim()) continue; + if (typeof name === "string") { + labels[id] = name; + continue; + } + if (name && typeof name === "object" && !Array.isArray(name)) { + const resolved = resolveI18nText(name as I18nText, locale); + if (resolved) labels[id] = resolved; + } + } + return Object.keys(labels).length > 0 ? labels : undefined; +} + function collectNarrativeParts(turnResult: TurnResult): string[] { const narrativeParts: string[] = []; for (const rr of turnResult.runtimeResults) { diff --git a/packages/runtime/src/turn-executor/session-state.ts b/packages/runtime/src/turn-executor/session-state.ts index a18371c4e..527886970 100644 --- a/packages/runtime/src/turn-executor/session-state.ts +++ b/packages/runtime/src/turn-executor/session-state.ts @@ -44,6 +44,8 @@ export type CoreMemoryBlock = CoreMemoryBlockView; export interface LoadedTurnSessionState { readonly messageHistory: readonly TurnMessageRecord[]; + /** Player message waiting for the execution's commit transaction. */ + readonly journalMessages: readonly TurnMessageRecord[]; readonly runtimeTriggerCounts: ReadonlyMap; readonly sessionMeta: TurnSessionMeta; readonly sessionStatus: "active" | "paused" | "ended"; @@ -78,6 +80,7 @@ export async function loadTurnSessionState(args: { let messageHistory: readonly TurnMessageRecord[] = []; let turnNumber = 0; let runtimeTriggerCounts: ReadonlyMap = new Map(); + const journalMessages: TurnMessageRecord[] = []; if (deps.store) { const [uncompacted, stats] = await Promise.all([ deps.store.listUncompactedTurnMessages(input.sessionId), @@ -99,18 +102,13 @@ export async function loadTurnSessionState(args: { order: 0, createdAt: new Date().toISOString(), }; - await deps.store.appendTurnMessage(playerMessage); - // The record just appended is the newest row, so concatenating locally is - // equivalent to re-reading — and skips a second unbounded history scan on - // the per-turn critical path (audit 2026-07-11 R-13). - messageHistory = [...messageHistory, playerMessage]; + journalMessages.push(playerMessage); } if (deps.compactor && deps.store && shouldAppendPlayerMessage) { - // Reuse messageHistory (set above after appending the player message) — no - // write happens between there and here, so re-reading the full history was - // a redundant unbounded scan on the per-turn critical path. The reload - // below still runs after the compactor actually mutates history. + // Compact committed history only. The current player message remains in + // the execution journal until proposals commit, so a rolled-back turn can + // never enter a summary. const freshMessages = messageHistory; const hookOpts = { pipeline: deps.hookPipeline, @@ -141,8 +139,8 @@ export async function loadTurnSessionState(args: { } } - // triggerCounts already came from getTurnMessageStats above; the player - // message appended this turn is a "player" row, so no runtime count moved. + // Trigger counts come only from committed history; this execution's journal + // is intentionally invisible until finalize succeeds. let sessionStatus: "active" | "paused" | "ended" = "active"; let preGameCompleted: readonly string[] = []; let phase: "setup" | "playing" | undefined; @@ -187,6 +185,7 @@ export async function loadTurnSessionState(args: { return { messageHistory, + journalMessages, runtimeTriggerCounts, sessionMeta: { turnNumber, diff --git a/packages/runtime/src/turn-executor/turn-executor-types.ts b/packages/runtime/src/turn-executor/turn-executor-types.ts index c29d5bafe..f4c754665 100644 --- a/packages/runtime/src/turn-executor/turn-executor-types.ts +++ b/packages/runtime/src/turn-executor/turn-executor-types.ts @@ -183,6 +183,16 @@ export interface TurnExecutorDeps extends AgentLoopDeps { sessionId: string; narrativeText: string; toolCallSummaries?: readonly string[]; + authoritativeFacts?: { + readonly playerCharacter?: { + readonly name: string; + readonly type: string; + readonly description?: string; + readonly fields?: Readonly>; + }; + readonly playerFieldLabels?: Readonly>; + readonly lastFormValues?: Readonly>; + }; currentBlocks: readonly CoreMemoryBlockView[]; locale?: string; }): Promise<{ diff --git a/packages/runtime/src/turn-executor/turn-executor.ts b/packages/runtime/src/turn-executor/turn-executor.ts index 9a721e7db..3ee315c1f 100644 --- a/packages/runtime/src/turn-executor/turn-executor.ts +++ b/packages/runtime/src/turn-executor/turn-executor.ts @@ -31,10 +31,7 @@ import { runPreScheduleHook, } from "../hooks/wire-helpers.js"; import { emitSubEvent } from "./turn-runtime-helpers.js"; -import { - __testOnly_parseFinalOutputEnvelope, - looksLikeStructuredRuntimeOutput, -} from "./turn-output-helpers.js"; +import { __testOnly_parseFinalOutputEnvelope } from "./turn-output-helpers.js"; import { executeOneRuntime } from "./turn-runtime-execution.js"; import type { RuntimeInvocation } from "./turn-runtime-execution.js"; import { @@ -52,12 +49,12 @@ import { import { runWithHookScope } from "../hooks/hook-scope.js"; import { runEventChain } from "../trigger/turn-event-chain.js"; import { - MaxRecursionExceeded, type RecursiveTurnInput, type TurnExecutorDeps, type TurnExecutorOptions, } from "./turn-executor-types.js"; import { finalizeTurnResult } from "./turn-result-finalizer.js"; +import { attachExecutionJournal } from "../execution-journal.js"; import { createExecutionContext } from "./execution-context.js"; import { PLAYER_ABORT_REASON } from "./turn-control.js"; import { markPreGameCompletion } from "./pre-game-completion.js"; @@ -263,12 +260,25 @@ async function executeTurnImpl( deps, shouldAppendPlayerMessage, }); - const { messageHistory, runtimeTriggerCounts, sessionStatus, turnNumber } = - sessionState; + const { + messageHistory, + journalMessages, + runtimeTriggerCounts, + sessionStatus, + turnNumber, + } = sessionState; // Logical-turn number for this execution (frozen): the count of committed // main-loop player turns plus one. Drives scheduled cadence / startTurn and // is independent of the raw player-message count `turnNumber`. const logicalTurn = sessionState.completedPlayerTurns + 1; + // Scheduling observes the current player action even though its journal row + // is still uncommitted. This preserves cooldown semantics from the former + // append-before-schedule path without exposing the row to the store, + // compaction, or sibling requests before finalize succeeds. + const triggerMessageHistory = + journalMessages.length > 0 + ? [...messageHistory, ...journalMessages] + : messageHistory; // Abort early if session is paused or ended — no runtimes should execute. if (sessionStatus !== "active") { @@ -353,7 +363,7 @@ async function executeTurnImpl( const { manualTarget, triggered, abortReason } = selectTriggeredRuntimes({ activeRuntimes, manualRuntimeId: input.manualTrigger?.runtimeId, - messageHistory, + messageHistory: triggerMessageHistory, preGameCompleted, runtimeTriggerCounts, setupRuntimes: setupRuntimesSnapshot, @@ -427,7 +437,7 @@ async function executeTurnImpl( resolveEffectsPolicy(), ); for (const d of hazardDiagnostics) { - console.warn(`[turn-executor] ${d.message}`); + console.warn(`[covel:warn] [turn-executor] ${d.message}`); emitSubEvent( deps.eventBus, "runtime", @@ -585,15 +595,11 @@ async function executeTurnImpl( // its result surfaces as failed with a turn-aborted message and carries no // PROPOSALS, so nothing proposal-shaped is committed. // - // That is not the same as "nothing was written". A few builtin tools write - // straight to the store instead of returning a proposal — the character - // tools (create/update-character) and the memory tools (core-memory block - // updates). Whatever they wrote before the abort is already durable and is - // NOT rolled back, because it never entered the commit pipeline that the - // abort short-circuits. The same holds for a runtime that fails after such - // a call, and for PreStateCommit: a hook cannot veto those writes because - // they never reach it. Routing them through proposals is the fix; until - // then this is the honest guarantee. + // The builtin character tools and core-memory update tool both return + // proposals, so their writes are discarded with the aborted result. Trusted + // plugin-data deletion remains a deliberately documented direct-write escape + // hatch, but no production runtime uses it; any future caller must add a + // delete proposal before relying on rollback semantics. const playerAborted = (): boolean => deps.turnControl?.signal?.aborted === true; @@ -700,16 +706,16 @@ async function executeTurnImpl( runtimeTurnsSinceLastTrigger: new Map( activeRuntimes.map((rt) => [ rt.name, - countPlayerMessagesSinceRuntime(messageHistory, rt.name), + countPlayerMessagesSinceRuntime(triggerMessageHistory, rt.name), ]), ), }); // ── Pre-Game completion tracking ──────────────────────────────── // - // The Pre-Game band (priority 0–99) runs while setup is pending and is - // responsible for one-off initialisation: welcome text, world schema - // generation, opening character form, etc. A Pre-Game runtime is considered + // The setup stage runs while the session phase is `setup` and is responsible + // for one-off initialisation: welcome text, world schema generation, opening + // character form, etc. A setup runtime is considered // "done" when ANY of the following hold: // // 1. Its output reports `preGameDone: true` @@ -818,14 +824,21 @@ async function executeTurnImpl( sessionId: input.sessionId, durationMs: baseResult.durationMs, }); - schedulePostTurnMemoryUpdate({ - input, - turnResult: baseResult, - deps, - coreMemoryBlocks, + // The commit owner invokes this callback only after the transaction + // lands. Refresh here so authoritative character/form facts include + // writes produced by this turn instead of the pre-execution snapshot. + void refreshSessionContext().then((committedSessionContext) => { + schedulePostTurnMemoryUpdate({ + input, + turnResult: baseResult, + deps, + coreMemoryBlocks, + sessionContext: committedSessionContext, + }); }); }, }; + attachExecutionJournal(turnResult, journalMessages); // ── TurnStop hook — Post* hooks cannot abort ──────── await runTurnStopHook( @@ -842,7 +855,11 @@ async function executeTurnImpl( }, ); - return playerAborted() - ? { ...turnResult, abortReason: PLAYER_ABORT_REASON } - : turnResult; + if (playerAborted()) { + return attachExecutionJournal( + { ...turnResult, abortReason: PLAYER_ABORT_REASON }, + journalMessages, + ); + } + return turnResult; } diff --git a/packages/runtime/src/turn-executor/turn-result-finalizer.ts b/packages/runtime/src/turn-executor/turn-result-finalizer.ts index c102226be..927f5096b 100644 --- a/packages/runtime/src/turn-executor/turn-result-finalizer.ts +++ b/packages/runtime/src/turn-executor/turn-result-finalizer.ts @@ -144,7 +144,6 @@ export async function finalizeTurnResult({ timestamp: new Date().toISOString(), ...(deferredFollowers.length > 0 ? { deferredFollowers } : {}), }; - await persistTurnResult( turnResult, deps, diff --git a/packages/runtime/tests/core-plugin-manifest-contract.test.ts b/packages/runtime/tests/core-plugin-manifest-contract.test.ts index 4405dc7f5..ae8c7698c 100644 --- a/packages/runtime/tests/core-plugin-manifest-contract.test.ts +++ b/packages/runtime/tests/core-plugin-manifest-contract.test.ts @@ -90,6 +90,12 @@ describe("core plugin manifest contract", () => { field: "narrativeOutput", as: "", }, + { + kind: "runtime", + from: "world-init/schema-gen", + field: "worldSchema", + as: "", + }, ]); // All three resolve to the setup stage. pregame / schema-gen still ride the diff --git a/packages/runtime/tests/finalize-execution.test.ts b/packages/runtime/tests/finalize-execution.test.ts index fa621bced..74a771504 100644 --- a/packages/runtime/tests/finalize-execution.test.ts +++ b/packages/runtime/tests/finalize-execution.test.ts @@ -133,6 +133,78 @@ describe("finalizeExecution", () => { expect(emits).toHaveLength(0); }); + it("commits execution journal messages with successful proposals", async () => { + const store = createMemoryStore(); + await savePendingTurn(store); + + const outcome = await finalizeExecution({ + store, + sessionId: SESSION_ID, + runtimes: [makeRuntime("rt-a")], + results: [makeResult("rt-a", statePatch("hp", 10))], + turnIds: [TURN_ID], + journalMessages: [ + { + id: "msg-player", + sessionId: SESSION_ID, + turnId: TURN_ID, + sourceType: "player", + role: "user", + content: "advance", + order: 0, + createdAt: "2026-08-09T00:00:00.000Z", + }, + { + id: "msg-runtime", + sessionId: SESSION_ID, + turnId: TURN_ID, + sourceType: "runtime", + sourcePluginId: "rt-a", + sourceRuntimeId: "rt-a", + role: "assistant", + content: "done", + order: 500, + createdAt: "2026-08-09T00:00:01.000Z", + }, + ], + }); + + expect(outcome.status).toBe("committed"); + expect( + (await store.listTurnMessages(SESSION_ID)).map((message) => message.id), + ).toEqual(["msg-player", "msg-runtime"]); + }); + + it("rolls execution journal messages back with a failed proposal", async () => { + const store = createMemoryStore(); + await savePendingTurn(store); + + const outcome = await finalizeExecution({ + store, + sessionId: SESSION_ID, + runtimes: [makeRuntime("rt-a")], + results: [makeResult("rt-a", badStatePatch())], + turnIds: [TURN_ID], + journalMessages: [ + { + id: "msg-rolled-back", + sessionId: SESSION_ID, + turnId: TURN_ID, + sourceType: "runtime", + sourcePluginId: "rt-a", + sourceRuntimeId: "rt-a", + role: "assistant", + content: "ghost", + order: 500, + createdAt: "2026-08-09T00:00:00.000Z", + }, + ], + }); + + expect(outcome.status).toBe("failed"); + expect(await store.listTurnMessages(SESSION_ID)).toEqual([]); + }); + it("commits the whole execution in one transaction and flushes deferred fan-out in order", async () => { const store = createMemoryStore(); await savePendingTurn(store); diff --git a/packages/runtime/tests/function-runtime-trace.test.ts b/packages/runtime/tests/function-runtime-trace.test.ts index e216cd302..bb00a8b01 100644 --- a/packages/runtime/tests/function-runtime-trace.test.ts +++ b/packages/runtime/tests/function-runtime-trace.test.ts @@ -19,6 +19,7 @@ import type { LoadedRuntime, PluginRuntimeGateway } from "@covel/plugin-loader"; import { createMemoryStore, type DataStore } from "@covel/store"; import { createEventBus, type EventBus } from "@covel/events"; import { executeTurn } from "../src/turn-executor/turn-executor.js"; +import { collectExecutionJournal } from "../src/execution-journal.js"; import type { TurnExecutorDeps } from "../src/turn-executor/turn-executor.js"; import { createTurnEmitter, @@ -143,6 +144,36 @@ describe("function-runtime trace", () => { expect(typeof completed!.payload.durationMs).toBe("number"); }); + it("stages structured interactions on the function runtime journal message", async () => { + const interaction = { + interactionId: "fn-form", + type: "form", + narrativeTemplate: "Hello {{name}}", + fields: [{ name: "name", type: "text", required: true }], + }; + const loaded: LoadedRuntime = { + manifest: makeFunctionManifest(), + promptTemplate: "", + handler: async () => ({ + narrativeOutput: "form ready", + interactions: [interaction], + }), + }; + + const result = await executeTurn( + makeTurnInput(), + [loaded.manifest], + makeDeps(loaded), + ); + + expect(collectExecutionJournal(result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ pendingInput: [interaction] }), + ]), + ); + expect(await store.listTurnMessages("sess-fn")).toEqual([]); + }); + it("emits function.completed(suspended) when the handler returns a suspended status", async () => { const { emitter, events } = captureEmitter(); const loaded: LoadedRuntime = { diff --git a/packages/runtime/tests/session-state-read-dedup.test.ts b/packages/runtime/tests/session-state-read-dedup.test.ts index 1efd076cf..0be8e9ba1 100644 --- a/packages/runtime/tests/session-state-read-dedup.test.ts +++ b/packages/runtime/tests/session-state-read-dedup.test.ts @@ -9,9 +9,9 @@ import type { TurnExecutorDeps } from "../src/turn-executor/turn-executor-types. * loadTurnSessionState used to full-read listTurnMessages twice per turn. * Today the per-turn reads are (a) one listUncompactedTurnMessages for the * raw suffix, (b) one getTurnMessageStats aggregate for turnNumber / trigger - * counts — never a full listTurnMessages. The appended player record is - * concatenated locally; these tests pin the read pattern and that the - * returned history still includes the player message. + * counts — never a full listTurnMessages. The current player record stays in + * the execution journal until commit; these tests pin both the read pattern + * and that committed history remains isolated from pending input. */ async function makeStore(): Promise { @@ -89,14 +89,13 @@ describe("loadTurnSessionState read dedup (audit R-13)", () => { listUncompactedTurnMessages: 1, getTurnMessageStats: 1, }); - // History includes both the pre-existing message and the appended one. - expect(state.messageHistory).toHaveLength(2); - const appended = state.messageHistory[1]!; - expect(appended.content).toBe("hello"); - expect(appended.turnId).toBe("turn-1"); - expect(appended.sourceType).toBe("player"); - // The appended record was actually persisted, not just concatenated. - expect(await store.listTurnMessages("sess-dedup")).toHaveLength(2); + expect(state.messageHistory).toHaveLength(1); + const pending = state.journalMessages[0]!; + expect(pending.content).toBe("hello"); + expect(pending.turnId).toBe("turn-1"); + expect(pending.sourceType).toBe("player"); + // Pending input is persisted only by finalizeExecution after proposals pass. + expect(await store.listTurnMessages("sess-dedup")).toHaveLength(1); // turnNumber counts player messages BEFORE this turn's append. expect(state.turnNumber).toBe(1); }); @@ -159,12 +158,15 @@ describe("loadTurnSessionState read dedup (audit R-13)", () => { shouldAppendPlayerMessage: true, }); - // Compacted rows are absent from the in-memory history… + // Compacted rows and the pending player input are absent from committed + // in-memory history… expect(state.messageHistory.map((m) => m.id)).toEqual( state.messageHistory.map((m) => m.id).filter((id) => id !== "tm-0"), ); - expect(state.messageHistory).toHaveLength(1); - // …but turnNumber / trigger counts still see the whole log. + expect(state.messageHistory).toHaveLength(0); + expect(state.journalMessages).toHaveLength(1); + expect(state.journalMessages[0]?.content).toBe("hello"); + // …but turnNumber / trigger counts still see the whole committed log. expect(state.turnNumber).toBe(1); expect(state.runtimeTriggerCounts.get("demo/narrator")).toBe(1); }); diff --git a/packages/runtime/tests/submit-form.test.ts b/packages/runtime/tests/submit-form.test.ts index 114279934..8e0675494 100644 --- a/packages/runtime/tests/submit-form.test.ts +++ b/packages/runtime/tests/submit-form.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createMemoryStore, type DataStore } from "@covel/store"; -import type { InteractionType } from "@covel/shared"; +import type { InteractionPayload, InteractionType } from "@covel/shared"; import { submitFormHandler, RpcValidationError, @@ -48,6 +48,25 @@ async function seedTemplate( }); } +async function seedInteraction( + store: DataStore, + interaction: InteractionPayload, + content = "", +): Promise { + await store.appendTurnMessage({ + id: crypto.randomUUID(), + sessionId: SESSION, + turnId: TURN, + sourceType: "runtime", + role: "assistant", + name: "interaction-template", + content, + order: 700, + pendingInput: [interaction], + createdAt: new Date().toISOString(), + }); +} + async function submitOne( store: DataStore, sub: { @@ -83,7 +102,13 @@ describe("submitFormHandler (Epic A)", () => { }); it("fills a choice template using selectedLabel, falling back to selectedId", async () => { - await seedTemplate(store, "ch-1", "You chose {{selectedLabel}}"); + await seedInteraction(store, { + interactionId: "ch-1", + type: "choice", + prompt: "Choose", + choices: [{ id: "a", label: "Attack" }], + narrativeTemplate: "You chose {{selectedLabel}}", + }); expect( await submitOne(store, { interactionId: "ch-1", @@ -92,7 +117,13 @@ describe("submitFormHandler (Epic A)", () => { }), ).toBe("You chose Attack"); - await seedTemplate(store, "ch-2", "You chose {{selectedLabel}}"); + await seedInteraction(store, { + interactionId: "ch-2", + type: "choice", + prompt: "Choose", + choices: [{ id: "flee", label: "flee" }], + narrativeTemplate: "You chose {{selectedLabel}}", + }); expect( await submitOne(store, { interactionId: "ch-2", @@ -104,7 +135,12 @@ describe("submitFormHandler (Epic A)", () => { // ── i18n confirmation (the core fix) ──────────────────────────── it("fills confirmation {{confirmed}} with 确认 for zh-CN", async () => { - await seedTemplate(store, "cf-1", "Result: {{confirmed}}"); + await seedInteraction(store, { + interactionId: "cf-1", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); expect( await submitOne( store, @@ -119,7 +155,12 @@ describe("submitFormHandler (Epic A)", () => { }); it("fills confirmation {{confirmed}} with Confirm for en-US (regression for hardcoded 确认/取消)", async () => { - await seedTemplate(store, "cf-2", "Result: {{confirmed}}"); + await seedInteraction(store, { + interactionId: "cf-2", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); expect( await submitOne( store, @@ -134,7 +175,12 @@ describe("submitFormHandler (Epic A)", () => { }); it("fills cancelled confirmation with Cancel for en-US", async () => { - await seedTemplate(store, "cf-3", "Result: {{confirmed}}"); + await seedInteraction(store, { + interactionId: "cf-3", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); expect( await submitOne( store, @@ -149,7 +195,12 @@ describe("submitFormHandler (Epic A)", () => { }); it("defaults to zh-CN confirmation labels when locale is undefined (back-compat)", async () => { - await seedTemplate(store, "cf-4", "Result: {{confirmed}}"); + await seedInteraction(store, { + interactionId: "cf-4", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); expect( await submitOne(store, { interactionId: "cf-4", @@ -160,7 +211,12 @@ describe("submitFormHandler (Epic A)", () => { }); it("falls back to zh-CN labels for an unsupported locale (no undefined deref)", async () => { - await seedTemplate(store, "cf-5", "Result: {{confirmed}}"); + await seedInteraction(store, { + interactionId: "cf-5", + type: "confirmation", + prompt: "Proceed?", + narrativeTemplate: "Result: {{confirmed}}", + }); expect( await submitOne( store, @@ -176,41 +232,53 @@ describe("submitFormHandler (Epic A)", () => { // ── fallbackNarrative (no matching template) ──────────────────── it("localizes the fallback form prefix (zh-CN byte-compat vs en-US)", async () => { + await seedTemplate(store, "x-zh", ""); expect( await submitOne( store, - { interactionId: "x", type: "form", values: { k: "v" } }, + { interactionId: "x-zh", type: "form", values: { k: "v" } }, "zh-CN", ), ).toBe("[玩家输入] k: v"); + await seedTemplate(store, "x-en", ""); expect( await submitOne( store, - { interactionId: "x", type: "form", values: { k: "v" } }, + { interactionId: "x-en", type: "form", values: { k: "v" } }, "en-US", ), ).toBe("[Player input] k: v"); }); it("localizes the fallback confirmation prefix per locale", async () => { + await seedInteraction(store, { + interactionId: "confirm-zh", + type: "confirmation", + prompt: "Proceed?", + }); expect( await submitOne( store, { - interactionId: "x", + interactionId: "confirm-zh", type: "confirmation", - values: { confirmed: true, prompt: "Proceed?" }, + values: { confirmed: true }, }, "zh-CN", ), ).toBe("[玩家确认] Proceed?"); + await seedInteraction(store, { + interactionId: "confirm-en", + type: "confirmation", + prompt: "Proceed?", + }); expect( await submitOne( store, { - interactionId: "x", + interactionId: "confirm-en", type: "confirmation", - values: { confirmed: false, prompt: "Proceed?" }, + values: { confirmed: false }, }, "en-US", ), @@ -218,31 +286,50 @@ describe("submitFormHandler (Epic A)", () => { }); it("byte-compat: undefined locale matches pre-i18n zh-CN output for all three types", async () => { + await seedTemplate(store, "x-form", ""); expect( await submitOne(store, { - interactionId: "x", + interactionId: "x-form", type: "form", values: { a: 1 }, }), ).toBe("[玩家输入] a: 1"); + await seedInteraction(store, { + interactionId: "x-choice", + type: "choice", + prompt: "Choose", + choices: [{ id: "s", label: "S" }], + }); expect( await submitOne(store, { - interactionId: "x", + interactionId: "x-choice", type: "choice", values: { selectedId: "s", selectedLabel: "S" }, }), ).toBe("[玩家选择] S"); + await seedInteraction(store, { + interactionId: "x-confirm", + type: "confirmation", + prompt: "P", + }); expect( await submitOne(store, { - interactionId: "x", + interactionId: "x-confirm", type: "confirmation", - values: { confirmed: true, prompt: "P" }, + values: { confirmed: true }, }), ).toBe("[玩家确认] P"); }); // ── batch + persistence ───────────────────────────────────────── it("processes a batch and returns one result per submission in order", async () => { + await seedTemplate(store, "b1", ""); + await seedInteraction(store, { + interactionId: "b2", + type: "choice", + prompt: "Choose", + choices: [{ id: "x", label: "X" }], + }); const result = (await submitFormHandler( { turnId: TURN, @@ -257,6 +344,8 @@ describe("submitFormHandler (Epic A)", () => { }); it("persists one player input per submission", async () => { + await seedTemplate(store, "p1", ""); + await seedTemplate(store, "p2", ""); await submitFormHandler( { turnId: TURN, @@ -302,6 +391,21 @@ describe("submitFormHandler (Epic A)", () => { ).rejects.toThrow(RpcValidationError); }); + it("throws when submissions is not an array", async () => { + await expect( + submitFormHandler( + { turnId: TURN, submissions: { interactionId: "x" } }, + makeCtx(store), + ), + ).rejects.toThrow(RpcValidationError); + }); + + it("throws when a submission is not an object", async () => { + await expect( + submitFormHandler({ turnId: TURN, submissions: [null] }, makeCtx(store)), + ).rejects.toThrow(RpcValidationError); + }); + it("throws when a submission interactionId is missing", async () => { await expect( submitFormHandler( @@ -335,6 +439,172 @@ describe("submitFormHandler (Epic A)", () => { ).rejects.toThrow(RpcValidationError); }); + it("rejects an interaction that was never committed and persists nothing", async () => { + await expect( + submitOne(store, { + interactionId: "forged-form", + type: "form", + values: { name: "Mallory" }, + }), + ).rejects.toThrow(/committed interaction/i); + expect(await store.listPlayerInputs(SESSION)).toEqual([]); + }); + + it("requires the submitted type to match the committed interaction", async () => { + await seedInteraction(store, { + interactionId: "choose-path", + type: "choice", + prompt: "Where?", + choices: [{ id: "north", label: "North" }], + }); + await expect( + submitOne(store, { + interactionId: "choose-path", + type: "form", + values: { selectedId: "north" }, + }), + ).rejects.toThrow(/type.*choice/i); + }); + + it("validates required fields, field types, select options, and unknown keys", async () => { + await seedInteraction(store, { + interactionId: "profile", + type: "form", + title: "Profile", + submitLabel: "Continue", + fields: [ + { type: "text", name: "name", label: "Name", required: true }, + { type: "number", name: "age", label: "Age" }, + { + type: "select", + name: "origin", + label: "Origin", + options: ["forest", { value: "city", label: "The city" }], + }, + ], + }); + + await expect( + submitOne(store, { + interactionId: "profile", + type: "form", + values: { age: "old" }, + }), + ).rejects.toThrow(/required.*name/i); + await expect( + submitOne(store, { + interactionId: "profile", + type: "form", + values: { name: "Aria", age: "old" }, + }), + ).rejects.toThrow(/age.*number/i); + await expect( + submitOne(store, { + interactionId: "profile", + type: "form", + values: { name: "Aria", origin: "moon" }, + }), + ).rejects.toThrow(/origin.*option/i); + await expect( + submitOne(store, { + interactionId: "profile", + type: "form", + values: { name: "Aria", admin: true }, + }), + ).rejects.toThrow(/unknown field.*admin/i); + }); + + it("canonicalizes choice labels from the committed option", async () => { + await seedInteraction(store, { + interactionId: "choose-path", + type: "choice", + prompt: "Where?", + narrativeTemplate: "You chose {{selectedLabel}}", + choices: [{ id: "north", label: "North road" }], + }); + expect( + await submitOne(store, { + interactionId: "choose-path", + type: "choice", + values: { selectedId: "north", selectedLabel: "Injected label" }, + }), + ).toBe("You chose North road"); + expect((await store.listPlayerInputs(SESSION))[0]?.values).toEqual({ + selectedId: "north", + selectedLabel: "North road", + }); + }); + + it("replays an identical submission idempotently and rejects conflicting values", async () => { + await seedInteraction(store, { + interactionId: "name-form", + type: "form", + title: "Name", + submitLabel: "Continue", + fields: [{ type: "text", name: "name", label: "Name", required: true }], + narrativeTemplate: "Hello {{name}}", + }); + const payload = { + turnId: TURN, + submissions: [ + { + interactionId: "name-form", + type: "form" as const, + values: { name: "Aria" }, + }, + ], + }; + const first = (await submitFormHandler(payload, makeCtx(store))) as { + results: Array<{ submissionId: string }>; + }; + const replay = (await submitFormHandler(payload, makeCtx(store))) as { + results: Array<{ submissionId: string }>; + }; + expect(replay.results[0]?.submissionId).toBe( + first.results[0]?.submissionId, + ); + expect(await store.listPlayerInputs(SESSION)).toHaveLength(1); + + await expect( + submitOne(store, { + interactionId: "name-form", + type: "form", + values: { name: "Different" }, + }), + ).rejects.toThrow(/already submitted/i); + }); + + it("validates the whole batch before writing any player input", async () => { + await seedInteraction(store, { + interactionId: "valid-form", + type: "form", + title: "Name", + submitLabel: "Continue", + fields: [{ type: "text", name: "name", label: "Name", required: true }], + }); + await expect( + submitFormHandler( + { + turnId: TURN, + submissions: [ + { + interactionId: "valid-form", + type: "form", + values: { name: "Aria" }, + }, + { + interactionId: "forged-form", + type: "form", + values: { name: "Mallory" }, + }, + ], + }, + makeCtx(store), + ), + ).rejects.toThrow(/committed interaction/i); + expect(await store.listPlayerInputs(SESSION)).toEqual([]); + }); + // ── InteractionType alignment (critique: 3 drift points) ──────── it("VALID_TYPES is exhaustively aligned with the InteractionType union", () => { // Compile-time: this object must enumerate every InteractionType member or diff --git a/packages/runtime/tests/tool-executor-core-plugin-commit.test.ts b/packages/runtime/tests/tool-executor-core-plugin-commit.test.ts index 60243cafe..2ea4d7742 100644 --- a/packages/runtime/tests/tool-executor-core-plugin-commit.test.ts +++ b/packages/runtime/tests/tool-executor-core-plugin-commit.test.ts @@ -65,7 +65,12 @@ describe("ToolExecutor + core plugin pending proposals + commit pipeline", () => ], }), }, - context, + { + ...context, + // Tool calls in one runtime share the proposals accumulated so far. + // world-init uses the schema proposal as its same-turn completion gate. + pendingProposals: schemaResult.pendingProposals, + }, ); expect(schemaResult.success).toBe(true); diff --git a/packages/runtime/tests/turn-executor-events.test.ts b/packages/runtime/tests/turn-executor-events.test.ts index 20dc0e566..313bcc572 100644 --- a/packages/runtime/tests/turn-executor-events.test.ts +++ b/packages/runtime/tests/turn-executor-events.test.ts @@ -140,11 +140,34 @@ describe("TurnExecutor EventBus Bridge", () => { }, updater: { updateAfterTurn }, }; + const store = await createMainLoopStore("sess-1"); + await store.upsertCharacter({ + id: "player-1", + sessionId: "sess-1", + name: "阿砾", + type: "player", + description: "玄负上的少年", + fields: { carapaceSense: "甲感略强于常人", socialStyle: "会来事" }, + version: 1, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }); + await store.savePlayerInput({ + id: "input-1", + sessionId: "sess-1", + turnId: "form-turn", + formId: "character-form", + values: { + carapaceSense: "甲感略强于常人", + socialStyle: "会来事", + }, + createdAt: "2024-01-01T00:00:00Z", + }); const deps: TurnExecutorDeps = { loadRuntime: async () => narratorLoaded, llm: mockLLM, eventBus, - store: await createMainLoopStore("sess-1"), + store, memorySystem, }; @@ -153,12 +176,50 @@ describe("TurnExecutor EventBus Bridge", () => { await new Promise((resolve) => setImmediate(resolve)); expect(updateAfterTurn).not.toHaveBeenCalled(); - // Successful commit path: the barrier fires exactly one ingestion. + // Simulate character/form writes landing in the commit transaction after + // executeTurn captured its initial context snapshot. + await store.upsertCharacter({ + id: "player-1", + sessionId: "sess-1", + name: "阿砾", + type: "player", + description: "玄负上的少年", + fields: { carapaceSense: "二分", socialStyle: "圆滑" }, + version: 2, + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-02T00:00:00Z", + }); + await store.savePlayerInput({ + id: "input-2", + sessionId: "sess-1", + turnId: "turn-1", + formId: "character-form", + values: { carapaceSense: "二分", socialStyle: "圆滑" }, + createdAt: "2024-01-02T00:00:00Z", + }); + + // Successful commit path: the barrier refreshes committed context and + // fires exactly one ingestion. failed.completeTurn?.(); failed.completeTurn?.(); await new Promise((resolve) => setImmediate(resolve)); expect(updateAfterTurn).toHaveBeenCalledTimes(1); expect(updateAfterTurn.mock.calls[0][0].sessionId).toBe("sess-1"); + expect(updateAfterTurn.mock.calls[0][0].authoritativeFacts).toEqual({ + playerCharacter: { + name: "阿砾", + type: "player", + description: "玄负上的少年", + fields: { + carapaceSense: "二分", + socialStyle: "圆滑", + }, + }, + lastFormValues: { + carapaceSense: "二分", + socialStyle: "圆滑", + }, + }); }); it("should emit runtime.started and runtime.completed events", async () => { diff --git a/packages/runtime/tests/turn-executor.test.ts b/packages/runtime/tests/turn-executor.test.ts index b1b70e759..55a98ea99 100644 --- a/packages/runtime/tests/turn-executor.test.ts +++ b/packages/runtime/tests/turn-executor.test.ts @@ -17,6 +17,7 @@ import type { LoadedRuntime } from "@covel/plugin-loader"; import { createMemoryStore } from "@covel/store"; import { tool } from "@covel/tools"; import { executeTurn } from "../src/turn-executor/turn-executor.js"; +import { collectExecutionJournal } from "../src/execution-journal.js"; import type { TurnExecutorDeps } from "../src/turn-executor/turn-executor.js"; import type { LLMAdapter, LLMResponse } from "../src/llm/llm-adapter.js"; import { z } from "zod"; @@ -128,6 +129,40 @@ describe("TurnExecutor E2E", () => { ).toContain("黑暗的森林"); }); + it("counts the staged current player message for runtime cooldowns", async () => { + const store = await createMainLoopStore("sess-1"); + await store.appendTurnMessage({ + id: "prior-runtime-0", + sessionId: "sess-1", + turnId: "prior-turn", + sourceType: "runtime", + sourceRuntimeId: narratorManifest.name, + role: "assistant", + content: "prior narrative", + order: 1, + createdAt: "2024-01-01T00:00:01Z", + }); + const scheduled = { + ...narratorManifest, + trigger: { type: "scheduled" as const, interval: 1, cooldownTurns: 1 }, + }; + const deps: TurnExecutorDeps = { + loadRuntime: async () => ({ ...narratorLoaded, manifest: scheduled }), + llm: mockLLM, + store, + }; + + const result = await executeTurn(makeTurnInput(), [scheduled], deps); + + expect(result.runtimeResults).toHaveLength(1); + expect(collectExecutionJournal(result)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sourceType: "player", turnId: "turn-1" }), + ]), + ); + expect(await store.listTurnMessages("sess-1")).toHaveLength(2); + }); + it("should trim trailing meta choice prompts from story output", async () => { mockLLM.response = { content: @@ -416,7 +451,7 @@ describe("TurnExecutor E2E", () => { expect(result.runtimeResults[0].pluginId).toBe("narrator"); }); - it("should save player and runtime messages to store when store is provided", async () => { + it("should stage player and runtime messages until the commit barrier", async () => { const store = await createMainLoopStore("sess-msg"); const deps: TurnExecutorDeps = { @@ -425,16 +460,21 @@ describe("TurnExecutor E2E", () => { store, }; - await executeTurn( + const result = await executeTurn( makeTurnInput({ sessionId: "sess-msg", playerMessage: "我走进森林" }), [narratorManifest], deps, ); - const messages = await store.listTurnMessages("sess-msg"); + const persisted = await store.listTurnMessages("sess-msg"); + const messages = collectExecutionJournal(result); - // Should have at least 2 messages: player message + runtime output - expect(messages.length).toBeGreaterThanOrEqual(2); + // executeTurn only stages this turn's rows; finalizeExecution owns writes. + expect(persisted.map((message) => message.id)).toEqual(["prior-player-0"]); + expect(messages.map((message) => message.sourceType)).toEqual([ + "player", + "runtime", + ]); // The current turn's player message is the last player message appended. const playerMessages = messages.filter((m) => m.sourceType === "player"); @@ -857,9 +897,7 @@ describe("TurnExecutor _interaction protocol", () => { // MockLLM that calls create-form (which now returns _interaction) const mockLLM = new MockLLM(); - let callCount = 0; mockLLM.generate = async (params) => { - callCount++; const hasToolResult = params.messages.some((m) => m.role === "tool"); if (hasToolResult) { return { @@ -919,7 +957,6 @@ describe("TurnExecutor _interaction protocol", () => { const isolatedManifest = { ...charManifest, needs: undefined, - needs: undefined, }; const result = await executeTurn(makeTurnInput(), [isolatedManifest], deps); diff --git a/packages/shared/src/types/protocol.ts b/packages/shared/src/types/protocol.ts index 7e30022f8..44f1ee004 100644 --- a/packages/shared/src/types/protocol.ts +++ b/packages/shared/src/types/protocol.ts @@ -93,6 +93,10 @@ export interface ExecutionCompletedPayload { readonly runtimeCount: number; readonly resultCount: number; readonly durationMs: number; + /** Whether every proposal and execution-journal row reached durable storage. */ + readonly committed: boolean; + /** Commit failure detail. Present when `committed` is false. */ + readonly error?: string; /** * Set when the turn was aborted before producing results (e.g. a * TurnStart-aborting hook such as cost-gate's hard budget cap). The client diff --git a/packages/shared/src/types/session.ts b/packages/shared/src/types/session.ts index e4b334178..05a21dd0a 100644 --- a/packages/shared/src/types/session.ts +++ b/packages/shared/src/types/session.ts @@ -3,6 +3,8 @@ * DB record `SessionRecord` in `@covel/store` is a superset with backend fields. */ +import type { SetupRuntimeState } from "./runtime-lifecycle.js"; + export type SessionStatus = "active" | "paused" | "ended"; export interface SessionEmbeddingInfo { @@ -19,16 +21,21 @@ export interface Session { /** Lifecycle flag. `active` under normal play; `paused`/`ended` stops scheduling. */ readonly status: SessionStatus; /** - * Band selector. `0` = Pre-Game (only priority 0-99 scheduled, may iterate - * multiple player submissions). `>=1` = main loop (only priority 100-1000). - * Advances from 0 → 1 when all Pre-Game runtimes report done. + * Legacy main-loop progress field, derived at read time from `phase` and + * `completedPlayerTurns`. The kernel no longer schedules by numeric bands. */ readonly turnCount: number; /** - * RuntimeIds of Pre-Game band (priority 0-99) runtimes that have already - * completed this session. Used to gate the `turnCount: 0 → 1` transition. + * Legacy setup completion list, derived from the `done` entries in + * `setupRuntimes`. */ readonly preGameCompleted: readonly string[]; + /** Authoritative setup/main-loop scheduling phase. Optional on legacy rows. */ + readonly phase?: "setup" | "playing"; + /** Number of committed main-loop player turns. Optional on legacy rows. */ + readonly completedPlayerTurns?: number; + /** Per-runtime setup lifecycle mirror, keyed by runtimeId. */ + readonly setupRuntimes?: Readonly>; readonly activePlugins: readonly string[]; readonly locale: string; readonly createdAt: string; diff --git a/packages/tools/src/builtin/memory-tools.ts b/packages/tools/src/builtin/memory-tools.ts index a5b892c3e..ce04e51e6 100644 --- a/packages/tools/src/builtin/memory-tools.ts +++ b/packages/tools/src/builtin/memory-tools.ts @@ -59,7 +59,6 @@ interface MemoryBlockReader { sessionId: string, label: string, ): Promise<{ label: string; content: string; updatedAt: string } | null>; - updateBlock(sessionId: string, label: string, content: string): Promise; } export interface MemoryToolDeps { diff --git a/plugins/char-creator/runtimes/player-init/PLUGIN.en.md b/plugins/char-creator/runtimes/player-init/PLUGIN.en.md index 9aa703c44..8ab1929e6 100644 --- a/plugins/char-creator/runtimes/player-init/PLUGIN.en.md +++ b/plugins/char-creator/runtimes/player-init/PLUGIN.en.md @@ -26,9 +26,13 @@ The opening summary is provided in the `` block at the end of t ## Character attribute schema (world-dimension system) - +`` is appended to the prompt with the authoritative +schema produced by world-init during this setup execution. Prefer that block +when present; retries and recovery may fall back to the committed schema below. + + {{ world.schema }} - + --- @@ -39,10 +43,10 @@ The opening summary is provided in the `` block at the end of t ### Field generation rules -**You MUST consult `` for attribute definitions**: +**You MUST consult `` first, or `` as a fallback, for attribute definitions**: 1. A `characterName` field MUST be present (`required: true`, type: text) -2. From `character-attributes.attributes` in ``, pick **at most 3** attributes that make sense for the player to choose +2. From the schema's `character-attributes.attributes`, pick **at most 3** attributes that make sense for the player to choose 3. Selection priority: `bio` category > `abilities` category > `stats` category 4. Field `name` MUST exactly match the schema attribute `id` 5. Type mapping: `enum` → `select`; `string` → `text`; `number` → generate 3–5 `select` options from a reasonable range; `array` → `text` (comma-separated placeholder) diff --git a/plugins/char-creator/runtimes/player-init/PLUGIN.md b/plugins/char-creator/runtimes/player-init/PLUGIN.md index c5ece1ea2..019416c36 100644 --- a/plugins/char-creator/runtimes/player-init/PLUGIN.md +++ b/plugins/char-creator/runtimes/player-init/PLUGIN.md @@ -37,6 +37,13 @@ input: from: pregame field: narrativeOutput as: "" + # world-init's schema write is a proposal and is intentionally uncommitted + # until the setup execution finalizes. Carry the same value explicitly so + # this downstream runtime never relies on a cross-runtime Store read. + - kind: runtime + from: world-init/schema-gen + field: worldSchema + as: "" tools: builtin: - create-form @@ -72,9 +79,13 @@ postHistory: ## 角色属性 Schema(世界维度系统定义) - +`` 位于 Prompt 末尾,包含本次 setup 中由 world-init +刚生成的权威 Schema。该块存在时优先使用;恢复/重试场景可回退到下面已提交的 +``。 + + {{ world.schema }} - + --- @@ -85,10 +96,10 @@ postHistory: ### 表单字段生成规则 -**必须参考 `` 中的角色属性定义**: +**必须参考 ``(优先)或 `` 中的角色属性定义**: 1. **`characterName` 字段必须存在**(`required: true`,type: text) -2. 从 `` 的 `character-attributes.attributes` 中选取 **最多 3 个** 适合玩家选择的属性 +2. 从 Schema 的 `character-attributes.attributes` 中选取 **最多 3 个** 适合玩家选择的属性 3. 选取优先级:`bio` 分类 > `abilities` 分类 > `stats` 分类 4. 字段 `name` 必须与 schema 属性 `id` **完全一致** 5. 类型映射:`enum` → `select`;`string` → `text`;`number` → 从合理范围生成 3-5 个 select 选项;`array` → `text`(placeholder 逗号分隔) diff --git a/plugins/char-creator/tests/char-creator.test.js b/plugins/char-creator/tests/char-creator.test.js index 4e8c15b70..8a909b586 100644 --- a/plugins/char-creator/tests/char-creator.test.js +++ b/plugins/char-creator/tests/char-creator.test.js @@ -66,16 +66,23 @@ describe("char-creator plugin", () => { expect(manifest.tools?.builtin).toEqual(["create-form"]); }); - it("injects pregame.narrativeOutput as ", () => { + it("injects the same-turn pregame opening and generated world schema", () => { // Pre-Game band: narrator is NOT scheduled on turn 0, so player-init // consumes the opening summary produced by pregame (priority 10) // rather than the (missing) narrator output. See plugin README / the // turn-executor scheduler band gate. - expect(manifest.input?.inject).toHaveLength(1); - const inject = manifest.input.inject[0]; - expect(inject.from).toBe("pregame"); - expect(inject.field).toBe("narrativeOutput"); - expect(inject.as).toBe(""); + expect(manifest.input?.inject).toEqual([ + expect.objectContaining({ + from: "pregame", + field: "narrativeOutput", + as: "", + }), + expect.objectContaining({ + from: "world-init/schema-gen", + field: "worldSchema", + as: "", + }), + ]); }); it("declares turn-scoped needs so it waits for pregame and schema init", () => { diff --git a/plugins/core-quest/PLUGIN.en.md b/plugins/core-quest/PLUGIN.en.md index af0a1ccc4..908c515f0 100644 --- a/plugins/core-quest/PLUGIN.en.md +++ b/plugins/core-quest/PLUGIN.en.md @@ -69,7 +69,7 @@ The only write channel is the `upsert-quests` tool. For each quest provide: - `name` (required): stable quest name, the sole de-duplication key - `description`: 1-2 factual sentences on the quest's origin and goal - `status`: `active` / `completed` / `failed`; **omit to keep the current status**, new quests default to `active` -- `objectives`: checklist `[{ text, done }]`; a `text` matching an existing objective verbatim updates its check state, otherwise it appends as a new objective; omit `done` to keep the current state +- `objectives`: checklist `[{ id?, text, done }]`; copy an existing `id` when advancing it. Normalized text and a conservative semantic match are fallbacks. A match preserves the canonical text and updates its check state; omit `done` to keep the current state - `giver` / `reward`: only when the narrative names them explicitly ## Tool invocation example @@ -91,7 +91,13 @@ The only write channel is the `upsert-quests` tool. For each quest provide: }, { "name": "Investigate the Rear-Mountain Anomaly", - "objectives": [{ "text": "Secure Su Wan's assistance", "done": true }] + "objectives": [ + { + "id": "secure-su-wan", + "text": "Secure Su Wan's assistance", + "done": true + } + ] } ] } @@ -106,6 +112,6 @@ Do not call any writer tool. End the turn and return the empty string `""`. Exis - Up to **3** new quests per turn; beyond that keep only the top 3 - `name` must be stable and self-explanatory — later turns rely on it to merge progress - `description` must be 1-2 **factual sentences**, never mood painting -- Objective `text` must stay verbatim-stable: when advancing, copy the existing objective text exactly, do not rephrase +- When advancing an objective, copy its `id` from `` and keep the existing wording where practical - **When the turn produced no quest signal, do not force anything.** A fake quest is worse than a missed one. - Emit no additional text after the writer tool call. diff --git a/plugins/core-quest/PLUGIN.md b/plugins/core-quest/PLUGIN.md index 73bfc324d..7128884c0 100644 --- a/plugins/core-quest/PLUGIN.md +++ b/plugins/core-quest/PLUGIN.md @@ -120,7 +120,7 @@ postHistory: - `name`(必填):稳定的任务名,合并去重的唯一依据 - `description`:1-2 句事实陈述,说明任务由来和目标 - `status`:`active` / `completed` / `failed`;**省略表示维持现状**,新任务默认 `active` -- `objectives`:目标清单 `[{ text, done }]`;`text` 与已有目标逐字匹配则更新勾选,否则追加为新目标;`done` 省略表示维持现状 +- `objectives`:目标清单 `[{ id?, text, done }]`;推进时优先照抄已有 `id`,工具会继续以规范化文本和保守语义匹配兜底;命中后保留原始目标文案并更新勾选,`done` 省略表示维持现状 - `giver` / `reward`:叙事明确给出时才填 ## 工具调用示例 @@ -142,7 +142,9 @@ postHistory: }, { "name": "调查后山异常", - "objectives": [{ "text": "取得苏婉的协助", "done": true }] + "objectives": [ + { "id": "secure-su-wan", "text": "取得苏婉的协助", "done": true } + ] } ] } @@ -157,6 +159,6 @@ postHistory: - 一轮最多登记 **3 个**新任务;超过就只取最重要的 3 个 - `name` 必须稳定且可独立理解 —— 后续回合要靠它合并推进 - `description` 必须是 1-2 句**事实陈述**,不能是氛围渲染 -- `objectives` 的 `text` 要逐字稳定:推进时照抄已有目标原文,不要改写措辞 +- 推进已有 objective 时照抄 `` 中的 `id`;同时尽量保留原文,便于审计 - **本轮没有任务信号时,千万不要硬凑**。日志里多一条假任务比漏一条真任务更糟糕 - 调用写入工具后不输出任何额外文本 diff --git a/plugins/core-quest/README.md b/plugins/core-quest/README.md index d4df75225..540a9d22b 100644 --- a/plugins/core-quest/README.md +++ b/plugins/core-quest/README.md @@ -13,7 +13,7 @@ ## 数据与行为 - 读取最新叙事和已有任务摘要(`input.inject`),叙事引擎失败的回合不运行。 -- 任务写入 `plugin_data[core-quest][quests]`,按 `name` 去重合并:已有任务只推进(目标按 text 匹配勾选、新目标追加、省略字段维持现状),不存在则创建。 +- 任务写入 `plugin_data[core-quest][quests]`,按 `name` 去重合并:已有任务只推进(目标按稳定 `id`、规范化文本、保守语义兜底依次匹配;命中后保留原文并更新勾选),不存在则创建。 - 世界包可经 `worldData` 向 `quests` namespace 预置主线 / 支线任务,工具按同名合并推进。 - 本回合变更摘要(新任务 / 推进 / 完成 / 失败)写入 `message` namespace,驱动消息块。 - 没有任务信号的回合跳过写入。 diff --git a/plugins/core-quest/schemas/quests.schema.json b/plugins/core-quest/schemas/quests.schema.json index 825abbfc1..70447053b 100644 --- a/plugins/core-quest/schemas/quests.schema.json +++ b/plugins/core-quest/schemas/quests.schema.json @@ -25,6 +25,7 @@ "type": "object", "required": ["text"], "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 128 }, "text": { "type": "string", "minLength": 1 }, "done": { "type": "boolean" } }, diff --git a/plugins/core-quest/tests/core-quest.test.js b/plugins/core-quest/tests/core-quest.test.js index a269b07ee..ad35d3c2b 100644 --- a/plugins/core-quest/tests/core-quest.test.js +++ b/plugins/core-quest/tests/core-quest.test.js @@ -4,7 +4,7 @@ * Covers: * * 1. Local tool `upsert-quests` (L2): create with defaults, merge-by-name - * semantics, objective checklist matching, status transitions, the + * semantics, stable/semantic objective checklist matching, status transitions, the * 5-quest cap, world-pack preseeded records, and the message-namespace * change summary — verified against an in-memory store stub. * 2. Plugin manifest: post-turn agent runtime shape, narrative-engine gate, @@ -162,8 +162,8 @@ describe("upsert-quests", () => { expect(stored.value.isNew).toBe(true); expect(stored.value.updatedTurn).toBe(3); expect(stored.value.objectives).toEqual([ - { text: "潜入西侧旧药园", done: false }, - { text: "找到断魂钩", done: false }, + { id: expect.any(String), text: "潜入西侧旧药园", done: false }, + { id: expect.any(String), text: "找到断魂钩", done: false }, ]); expect(stored.value.chips).toEqual([ "☐ 潜入西侧旧药园", @@ -228,7 +228,7 @@ describe("upsert-quests", () => { expect(rows[0].value.isNew).toBe(false); }); - it("matches objectives by verbatim text: known text checks done, new text appends", async () => { + it("matches objectives by normalized text: known text checks done, new text appends", async () => { // Arrange await executeAndCommit( upsertQuestsTool, @@ -265,9 +265,9 @@ describe("upsert-quests", () => { // Assert const stored = await findQuestByName("调查后山异常"); expect(stored.value.objectives).toEqual([ - { text: "取得苏婉的协助", done: true }, - { text: "夜探后山", done: false }, - { text: "查明灵脉异动来源", done: false }, + { id: expect.any(String), text: "取得苏婉的协助", done: true }, + { id: expect.any(String), text: "夜探后山", done: false }, + { id: expect.any(String), text: "查明灵脉异动来源", done: false }, ]); expect(stored.value.chips).toContain("✓ 取得苏婉的协助"); expect(stored.value.chips).toContain("☐ 夜探后山"); @@ -306,6 +306,210 @@ describe("upsert-quests", () => { expect(stored.value.objectives[0].done).toBe(true); }); + it("uses a stable objective id to merge rewritten text", async () => { + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "未标注的泊点", + objectives: [ + { id: "enter-black-tower", text: "缒链下降,进入黑色尖塔" }, + ], + }, + ], + }, + ctx, + mockStore, + ); + + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "未标注的泊点", + objectives: [ + { id: "enter-black-tower", text: "进入黑塔内部", done: true }, + ], + }, + ], + }, + ctx, + mockStore, + ); + + const stored = await findQuestByName("未标注的泊点"); + expect(stored.value.objectives).toEqual([ + { + id: "enter-black-tower", + text: "缒链下降,进入黑色尖塔", + done: true, + }, + ]); + }); + + it("conservatively merges the observed expanded paraphrase without an id", async () => { + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "未标注的泊点", + objectives: [ + { text: "赶在封锚前备齐装具,完成下降准备" }, + { text: "缒链下降,进入黑色尖塔" }, + { text: "带回能解释玄负停驻的证物" }, + ], + }, + ], + }, + ctx, + mockStore, + ); + + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "未标注的泊点", + objectives: [{ text: "赶在封锚前挂链下降至沉城尖塔", done: true }], + }, + ], + }, + ctx, + mockStore, + ); + + const stored = await findQuestByName("未标注的泊点"); + expect(stored.value.objectives).toHaveLength(3); + expect(stored.value.objectives).toEqual([ + expect.objectContaining({ + text: "赶在封锚前备齐装具,完成下降准备", + done: false, + }), + expect.objectContaining({ + text: "缒链下降,进入黑色尖塔", + done: true, + }), + expect.objectContaining({ + text: "带回能解释玄负停驻的证物", + done: false, + }), + ]); + }); + + it("heals semantically duplicated objectives already present in storage", async () => { + await mockStore.setPluginData({ + id: "imported-record", + sessionId: "sess-1", + pluginId: "core-quest", + namespace: "quests", + key: "unmarked-mooring", + value: { + id: "unmarked-mooring", + name: "未标注的泊点", + description: "世界包预置任务", + status: "active", + objectives: [ + { + id: "prepare-descent", + text: "赶在封锚前备齐装具,完成下降准备", + done: false, + }, + { + id: "enter-black-tower", + text: "缒链下降,进入黑色尖塔", + done: false, + }, + { + id: "objective-duplicate", + text: "赶在封锚前挂链下降至沉城尖塔", + done: true, + }, + { + id: "return-evidence", + text: "带回能解释玄负停驻的证物", + done: false, + }, + ], + }, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "未标注的泊点", + objectives: [{ text: "进入黑塔内部", done: true }], + }, + ], + }, + ctx, + mockStore, + ); + + const stored = await findQuestByName("未标注的泊点"); + expect(stored.value.objectives).toEqual([ + { + id: "prepare-descent", + text: "赶在封锚前备齐装具,完成下降准备", + done: false, + }, + { + id: "enter-black-tower", + text: "缒链下降,进入黑色尖塔", + done: true, + }, + { + id: "return-evidence", + text: "带回能解释玄负停驻的证物", + done: false, + }, + ]); + }); + + it("keeps similar but distinct objectives separate", async () => { + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "塔内侦察", + objectives: [{ text: "进入黑塔内部" }], + }, + ], + }, + ctx, + mockStore, + ); + + await executeAndCommit( + upsertQuestsTool, + { + quests: [ + { + name: "塔内侦察", + objectives: [{ text: "进入营地内部", done: true }], + }, + ], + }, + ctx, + mockStore, + ); + + const stored = await findQuestByName("塔内侦察"); + expect(stored.value.objectives).toHaveLength(2); + expect(stored.value.objectives.map((objective) => objective.text)).toEqual([ + "进入黑塔内部", + "进入营地内部", + ]); + }); + it("classifies a status transition to completed / failed in the change summary", async () => { // Arrange await executeAndCommit( @@ -422,7 +626,7 @@ describe("upsert-quests", () => { expect(rows[0].key).toBe("main-quest-01"); expect(rows[0].value.description).toBe("世界包预置的主线任务。"); expect(rows[0].value.objectives).toEqual([ - { text: "夜探后山", done: true }, + { id: expect.any(String), text: "夜探后山", done: true }, ]); }); diff --git a/plugins/core-quest/tools/upsert-quests.js b/plugins/core-quest/tools/upsert-quests.js index df3bc7fd3..cb5909df9 100644 --- a/plugins/core-quest/tools/upsert-quests.js +++ b/plugins/core-quest/tools/upsert-quests.js @@ -12,11 +12,11 @@ * including world-pack preseeded records imported via `dataSchemas`. * 2. De-duplicating by normalized name and assigning stable short IDs to * new quests via `shortIdBatch` (e.g. `quest-寻回断魂钩`). - * 3. Merging updates into existing quests: provided fields override, - * objectives are matched by verbatim text (known text updates `done`, - * new text appends), omitted `status`/`done` keep their current state - * so a partial update can never regress a completed quest or uncheck - * a finished objective. + * 3. Merging updates into existing quests: provided fields override; + * objectives match by stable ID, normalized text, then a conservative + * semantic fallback. Omitted `status`/`done` keep their current state so + * a partial update can never regress a completed quest or uncheck a + * finished objective. * 4. Deriving `chips` (a checklist/giver/reward string array) on every * write so the right-panel EntryCard can render objectives without a * framework-side lookup. @@ -40,11 +40,19 @@ const CHANGE_META = { export default function ({ tool, z, shortIdBatch, store }) { const objectiveSchema = z.object({ + id: z + .string() + .min(1) + .max(128) + .optional() + .describe( + "Stable objective ID from existing quest data; copy it when advancing an objective", + ), text: z .string() .min(1) .describe( - "Objective text, matched verbatim against existing objectives — copy existing text exactly when advancing", + "Objective text; existing objectives match by ID first, then normalized or conservatively equivalent text", ), // Optional on purpose (no zod default): an omitted `done` must stay // distinguishable from an explicit `false`, or re-submitting a known @@ -78,7 +86,7 @@ export default function ({ tool, z, shortIdBatch, store }) { .array(objectiveSchema) .optional() .describe( - "Checklist objectives; known text updates its check state, new text appends", + "Checklist objectives; copy an existing objective ID when available so wording changes still update the same item", ), giver: z .string() @@ -90,7 +98,7 @@ export default function ({ tool, z, shortIdBatch, store }) { return tool({ name: "upsert-quests", description: - "Batch create or advance quests (max 5 per call). Quests are de-duplicated by name: a known name merges the provided fields into the existing record (objectives matched by verbatim text), a new name creates a quest. No need to list existing data first — the tool merges internally.", + "Batch create or advance quests (max 5 per call). Quests are de-duplicated by name: a known name merges provided fields into the existing record; objectives match by stable ID, normalized text, or a conservative semantic fallback. A new name creates a quest. No need to list existing data first — the tool merges internally.", parameters: z.object({ quests: z .array(questSchema) @@ -161,6 +169,7 @@ export default function ({ tool, z, shortIdBatch, store }) { const objectives = mergeObjectives( previous.objectives, quest.objectives, + previous.id ?? match.key, ); const previousStatus = previous.status ?? "active"; const status = quest.status ?? previousStatus; @@ -198,10 +207,7 @@ export default function ({ tool, z, shortIdBatch, store }) { name: quest.name, description: quest.description ?? "", status: quest.status ?? "active", - objectives: (quest.objectives ?? []).map((objective) => ({ - text: objective.text.trim(), - done: objective.done ?? false, - })), + objectives: mergeObjectives([], quest.objectives, id), giver: quest.giver, reward: quest.reward, isNew: true, @@ -264,31 +270,219 @@ function normalizeName(name) { } /** - * Merge incoming objectives into the existing list: verbatim text match - * updates the check state (only when `done` was provided), unknown text - * appends. Returns a fresh array — the previous list is never mutated. + * Merge incoming objectives into the existing list. Stable ID is the primary + * identity, normalized text handles punctuation/spacing drift, and a + * conservative similarity check catches compact paraphrases without merging + * ambiguous candidates. Existing canonical text is preserved on every match. + * Returns a fresh array — the previous list is never mutated. */ -function mergeObjectives(previous, incoming) { - const merged = (previous ?? []).map((objective) => ({ - text: objective.text, - done: objective.done ?? false, - })); +function mergeObjectives(previous, incoming, questId) { + const merged = []; + for (const objective of previous ?? []) { + const text = (objective?.text ?? "").trim(); + if (!text) continue; + const candidate = { + ...objective, + id: + normalizeObjectiveId(objective.id) ?? stableObjectiveId(questId, text), + text, + done: objective.done ?? false, + }; + const duplicateIndex = findObjectiveIndex( + merged, + candidate.id, + candidate.text, + ); + if (duplicateIndex >= 0) { + merged[duplicateIndex] = { + ...merged[duplicateIndex], + done: merged[duplicateIndex].done || candidate.done, + }; + } else { + merged.push(candidate); + } + } + for (const objective of incoming ?? []) { const text = (objective.text ?? "").trim(); if (!text) continue; - const index = merged.findIndex((o) => (o.text ?? "").trim() === text); + const incomingId = normalizeObjectiveId(objective.id); + const index = findObjectiveIndex(merged, incomingId, text); + if (index >= 0) { merged[index] = { ...merged[index], done: objective.done ?? merged[index].done, }; } else { - merged.push({ text, done: objective.done ?? false }); + merged.push({ + id: incomingId ?? stableObjectiveId(questId, text), + text, + done: objective.done ?? false, + }); } } return merged; } +function findObjectiveIndex(existing, objectiveId, text) { + let index = objectiveId + ? existing.findIndex((candidate) => candidate.id === objectiveId) + : -1; + if (index < 0) { + const normalizedText = normalizeObjectiveText(text); + index = existing.findIndex( + (candidate) => normalizeObjectiveText(candidate.text) === normalizedText, + ); + } + return index >= 0 ? index : findEquivalentObjectiveIndex(existing, text); +} + +function normalizeObjectiveId(id) { + return typeof id === "string" && id.trim() ? id.trim() : undefined; +} + +function normalizeObjectiveText(text) { + const normalized = (text ?? "") + .normalize("NFKC") + .trim() + .toLowerCase() + .replace(/[\p{P}\p{S}\s]+/gu, ""); + // Leading urgency clauses describe timing rather than objective identity. + // Keeping them would make two different objectives under the same deadline + // look more similar than the same action expressed with different detail. + return normalized + .replace(/^赶在.{1,12}?前/u, "") + .replace(/^在.{1,12}?之前/u, "") + .replace(/^(立即|立刻|尽快|马上)/u, ""); +} + +function stableObjectiveId(questId, text) { + const input = `${questId}:${normalizeObjectiveText(text)}`; + let hash = 0x811c9dc5; + for (let index = 0; index < input.length; index += 1) { + hash ^= input.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return `objective-${(hash >>> 0).toString(36)}`; +} + +function findEquivalentObjectiveIndex(existing, incomingText) { + const incoming = normalizeObjectiveText(incomingText); + const candidates = existing + .map((objective, index) => ({ + index, + score: objectiveSimilarity( + normalizeObjectiveText(objective.text), + incoming, + ), + })) + .filter((candidate) => candidate.score > 0) + .sort((left, right) => right.score - left.score); + + if (candidates.length === 0) return -1; + if ( + candidates.length > 1 && + candidates[0].score - candidates[1].score < 0.15 + ) { + return -1; + } + return candidates[0].index; +} + +function objectiveSimilarity(left, right) { + if (left.length < 4 || right.length < 4) return 0; + if (hasConflictingMovementIntent(left, right)) return 0; + const commonSequence = longestCommonSubsequenceLength(left, right); + const shorter = Math.min(left.length, right.length); + const longer = Math.max(left.length, right.length); + const commonBigrams = countCommonBigrams(left, right); + const sequenceMatch = + commonSequence >= 4 && + commonSequence / shorter >= 0.65 && + commonSequence / longer >= 0.4; + const phraseMatch = + commonBigrams >= 3 && commonBigrams / Math.max(1, shorter - 1) >= 0.3; + + if ( + (!sequenceMatch && !phraseMatch) || + (containsHan(left) && + containsHan(right) && + countCommonSalientHanCharacters(left, right) < 2) + ) { + return 0; + } + + return ( + commonSequence / shorter + + commonSequence / longer + + commonBigrams / Math.max(1, shorter - 1) + ); +} + +function hasConflictingMovementIntent(left, right) { + const entering = ["进入", "潜入", "抵达", "到达"]; + const leaving = ["离开", "撤离", "返回", "逃离"]; + return ( + (entering.some((term) => left.includes(term)) && + leaving.some((term) => right.includes(term))) || + (leaving.some((term) => left.includes(term)) && + entering.some((term) => right.includes(term))) + ); +} + +function containsHan(text) { + return /\p{Script=Han}/u.test(text); +} + +function countCommonSalientHanCharacters(left, right) { + const generic = new Set( + "进入内部上下前后完成前往到达抵达离开撤退返回并将把从向的了与和", + ); + const rightCharacters = new Set( + [...right].filter( + (character) => containsHan(character) && !generic.has(character), + ), + ); + return new Set( + [...left].filter( + (character) => + containsHan(character) && + !generic.has(character) && + rightCharacters.has(character), + ), + ).size; +} + +function longestCommonSubsequenceLength(left, right) { + const row = new Uint16Array(right.length + 1); + for (let leftIndex = 1; leftIndex <= left.length; leftIndex += 1) { + let diagonal = 0; + for (let rightIndex = 1; rightIndex <= right.length; rightIndex += 1) { + const above = row[rightIndex]; + row[rightIndex] = + left[leftIndex - 1] === right[rightIndex - 1] + ? diagonal + 1 + : Math.max(row[rightIndex], row[rightIndex - 1]); + diagonal = above; + } + } + return row[right.length]; +} + +function countCommonBigrams(left, right) { + const rightBigrams = new Set(); + for (let index = 0; index < right.length - 1; index += 1) { + rightBigrams.add(right.slice(index, index + 2)); + } + const common = new Set(); + for (let index = 0; index < left.length - 1; index += 1) { + const bigram = left.slice(index, index + 2); + if (rightBigrams.has(bigram)) common.add(bigram); + } + return common.size; +} + /** * Derive the language-neutral chip strings the right panel renders: * one ✓/☐ chip per objective, plus ⚑ giver and ✦ reward when present. diff --git a/plugins/memory/PLUGIN.en.md b/plugins/memory/PLUGIN.en.md index 7d9a4090c..a2fd25609 100644 --- a/plugins/memory/PLUGIN.en.md +++ b/plugins/memory/PLUGIN.en.md @@ -8,4 +8,4 @@ description: en: Shows what the story remembers, including plot, scene, relationships, and hero status. --- -Pure UI plugin. It declares the right-hand memory panel and, via `memoryBlocks`, the four default generic memory blocks (Story State / Character Relationships / Current Scene / Player Profile) with their extraction hints. Core-memory reads and writes are handled automatically by the framework's Memory System (@covel/memory) at the end of every turn, driven by these block definitions. Any plugin or world may declare its own `memoryBlocks` (e.g. `clues` / `suspects` / `timeline`); the framework aggregates them to drive extraction and rendering without touching framework core. +Pure UI plugin. It declares the right-hand memory panel and, via `memoryBlocks`, the four default generic memory blocks (Story State / Character Relationships / Current Scene / Player Profile) with their extraction hints. Core-memory reads and writes are handled automatically by the framework's Memory System (@covel/memory) at the end of every turn, driven by these block definitions; the framework deterministically maintains the confirmed-character first line of `player_profile` from committed fields. Any plugin or world may declare its own `memoryBlocks` (e.g. `clues` / `suspects` / `timeline`); the framework aggregates them to drive extraction and rendering without touching framework core. diff --git a/plugins/memory/PLUGIN.md b/plugins/memory/PLUGIN.md index 248b617df..53d11142d 100644 --- a/plugins/memory/PLUGIN.md +++ b/plugins/memory/PLUGIN.md @@ -44,4 +44,4 @@ memoryBlocks: en: "Player character status summary: abilities, possessions, situation, and current objectives." --- -纯 UI 插件。本插件声明右侧记忆面板,并通过 `memoryBlocks` 声明默认的四个通用记忆块(剧情状态 / 角色关系 / 当前场景 / 玩家状态)及其抽取提示词。核心记忆的读写由框架 Memory System(@covel/memory)在每轮结束后按这些块定义自动完成。任意插件或世界包都可以声明自己的 `memoryBlocks`(如 `clues` / `suspects` / `timeline`),框架会聚合后驱动抽取与渲染,无需改动框架核心。 +纯 UI 插件。本插件声明右侧记忆面板,并通过 `memoryBlocks` 声明默认的四个通用记忆块(剧情状态 / 角色关系 / 当前场景 / 玩家状态)及其抽取提示词。核心记忆的读写由框架 Memory System(@covel/memory)在每轮结束后按这些块定义自动完成;`player_profile` 的已确认角色资料首行由框架根据已提交字段确定性维护。任意插件或世界包都可以声明自己的 `memoryBlocks`(如 `clues` / `suspects` / `timeline`),框架会聚合后驱动抽取与渲染,无需改动框架核心。 diff --git a/plugins/memory/README.md b/plugins/memory/README.md index b2a383668..8f382eb0c 100644 --- a/plugins/memory/README.md +++ b/plugins/memory/README.md @@ -11,6 +11,7 @@ - 不调用模型,也不负责写入记忆。 - 框架 memory system 在回合结束后更新记忆块。 +- `player_profile` 首行由框架从已提交角色字段确定性生成,后续动态状态由摘要模型维护。 - 本插件只声明用于查看记忆块的面板。 ## 开发 diff --git a/plugins/world-init/guard.js b/plugins/world-init/guard.js index 86813b3a9..d85e27c90 100644 --- a/plugins/world-init/guard.js +++ b/plugins/world-init/guard.js @@ -170,6 +170,26 @@ function deriveSchema(dimensions) { return attrs; } +/** + * Shape consumed by SessionContext's `world.schema` and by player-init's + * same-turn runtime injection. Keeping one canonical view prevents the setup + * DAG from depending on an uncommitted plugin-data read. + * + * @param {Array>} attributes + */ +function worldSchemaView(attributes) { + return { + "character-attributes": { version: 1, attributes }, + }; +} + +/** @param {Array<{key: string, value: unknown}>} records */ +function worldSchemaViewFromRecords(records) { + return Object.fromEntries( + records.map((record) => [record.key, record.value]), + ); +} + export default async function guard(ctx) { const { sessionId, store, pluginId, locale } = ctx; const s = /** @type {any} */ (store); @@ -186,6 +206,7 @@ export default async function guard(ctx) { initialized: true, schemaCount: existing.length, entryCount: entries?.length ?? 0, + worldSchema: worldSchemaViewFromRecords(existing), narrativeOutput: pick( locale, `[系统] 世界维度数据已加载(${existing.length} 个 schema, ${entries?.length ?? 0} 个词条)`, @@ -245,6 +266,7 @@ export default async function guard(ctx) { importedDimensions: entryRecords.length > 0, entryCount: entryRecords.length, schemaCount: declaredAttributes.length, + worldSchema: worldSchemaView(declaredAttributes), narrativeOutput: pick( locale, `[系统] 从世界包导入角色属性 Schema(${declaredAttributes.length} 个属性${entryRecords.length ? `,${entryRecords.length} 个维度词条` : ""})`, @@ -314,6 +336,7 @@ export default async function guard(ctx) { importedDimensions: true, entryCount: entryRecords.length, schemaCount: attributes.length, + worldSchema: worldSchemaView(attributes), narrativeOutput: pick( locale, `[系统] 从世界包全量导入:${entryRecords.length} 个维度词条,${attributes.length} 个角色属性`, diff --git a/plugins/world-init/runtimes/schema-gen/PLUGIN.md b/plugins/world-init/runtimes/schema-gen/PLUGIN.md index 38b435eef..1dcfb4d10 100644 --- a/plugins/world-init/runtimes/schema-gen/PLUGIN.md +++ b/plugins/world-init/runtimes/schema-gen/PLUGIN.md @@ -198,4 +198,4 @@ ui: - 数值属性必须有合理的 min/max 范围 - 只需 2 次工具调用:`set-world-schema` + `set-world-entries-batch` - 完成后简短总结你创建了什么 -- 完成两次工具调用后,在最终输出里写 `preGameDone: true`(以 JSON 片段或结构化形式暴露在 runtime output 中) +- `set-world-entries-batch` 只会在检测到本轮 `set-world-schema` proposal 后成功;其结构化工具结果会携带 `worldSchema` 与 `preGameDone: true`,供同轮下游 runtime 使用 diff --git a/plugins/world-init/tests/guard.test.ts b/plugins/world-init/tests/guard.test.ts index 9ab243c00..8477d8777 100644 --- a/plugins/world-init/tests/guard.test.ts +++ b/plugins/world-init/tests/guard.test.ts @@ -172,6 +172,9 @@ describe("world-init guard", () => { // Did NOT fall through to cross-session reuse. expect(result.reusedFrom).toBeUndefined(); expect(result.schemaCount).toBe(2); + expect(result.worldSchema).toEqual({ + "character-attributes": { version: 1, attributes: declared }, + }); const written = await store.listPluginData( "sess-new", diff --git a/plugins/world-init/tests/tools.test.ts b/plugins/world-init/tests/tools.test.ts index 37d1badf4..fa8103124 100644 --- a/plugins/world-init/tests/tools.test.ts +++ b/plugins/world-init/tests/tools.test.ts @@ -59,6 +59,12 @@ describe("world-init local tools", () => { success: true, attributeCount: 2, categories: ["stats", "social"], + worldSchema: { + "character-attributes": { + version: 1, + attributes: expect.any(Array), + }, + }, }); const proposals = getPendingProposals(result); @@ -92,7 +98,21 @@ describe("world-init local tools", () => { it("queues plugin-data and lorebook proposals for world entries", async () => { const store = createMemoryStore(); + const schemaTool = setWorldSchema({ tool, z, store }); const entriesTool = setWorldEntriesBatch({ tool, z, store }); + const schemaResult = await schemaTool.execute( + { + attributes: [ + { + id: "hp", + name: "生命值", + type: "number", + category: "stats", + }, + ], + }, + context, + ); const result = await entriesTool.execute( { @@ -101,13 +121,20 @@ describe("world-init local tools", () => { { key: "factions", value: { groups: ["青萍宗"] } }, ], }, - context, + { ...context, pendingProposals: getPendingProposals(schemaResult) }, ); expect(result).toMatchObject({ success: true, count: 2, keys: ["geography", "factions"], + preGameDone: true, + worldSchema: { + "character-attributes": { + version: 1, + attributes: [expect.objectContaining({ id: "hp" })], + }, + }, }); const proposals = getPendingProposals(result); @@ -151,9 +178,39 @@ describe("world-init local tools", () => { }); }); + it("requires the schema proposal before completing world entries", async () => { + const store = createMemoryStore(); + const entriesTool = setWorldEntriesBatch({ tool, z, store }); + + await expect( + entriesTool.execute( + { + entries: [{ key: "geography", value: { regions: ["云梦泽"] } }], + }, + context, + ), + ).rejects.toThrow( + "set-world-schema must succeed before set-world-entries-batch", + ); + }); + it("commits queued world entries atomically through the kernel path", async () => { const store = createMemoryStore(); + const schemaTool = setWorldSchema({ tool, z, store }); const entriesTool = setWorldEntriesBatch({ tool, z, store }); + const schemaResult = await schemaTool.execute( + { + attributes: [ + { + id: "hp", + name: "生命值", + type: "number", + category: "stats", + }, + ], + }, + context, + ); const result = await entriesTool.execute( { @@ -162,7 +219,7 @@ describe("world-init local tools", () => { { key: "factions", value: { groups: ["青萍宗"] } }, ], }, - context, + { ...context, pendingProposals: getPendingProposals(schemaResult) }, ); const commitResults = await createCommitPipeline(store).commitAll( diff --git a/plugins/world-init/tools/set-world-entries-batch.js b/plugins/world-init/tools/set-world-entries-batch.js index 705a6819a..142624cb9 100644 --- a/plugins/world-init/tools/set-world-entries-batch.js +++ b/plugins/world-init/tools/set-world-entries-batch.js @@ -41,6 +41,21 @@ export default function ({ tool, z, store }) { }), execute: async (params, context) => { const now = new Date().toISOString(); + const schemaProposal = [...(context.pendingProposals ?? [])] + .reverse() + .find( + (proposal) => + proposal.type === "plugin.data" && + proposal.payload?.namespace === "schema" && + proposal.payload?.key === "character-attributes", + ); + const schemaValue = schemaProposal?.payload?.value; + if (!schemaValue || typeof schemaValue !== "object") { + throw new Error( + "set-world-schema must succeed before set-world-entries-batch", + ); + } + const worldSchema = { "character-attributes": schemaValue }; // 1) Legacy plugin_data write — unchanged read path for old sessions. const pluginDataItems = params.entries.map((entry) => ({ @@ -69,6 +84,8 @@ export default function ({ tool, z, store }) { success: true, count: pluginDataItems.length, keys: params.entries.map((e) => e.key), + worldSchema, + preGameDone: true, }, [ makeProposal(context, now, "plugin.data.batch", { diff --git a/plugins/world-init/tools/set-world-schema.js b/plugins/world-init/tools/set-world-schema.js index 393073f46..dd18c37c2 100644 --- a/plugins/world-init/tools/set-world-schema.js +++ b/plugins/world-init/tools/set-world-schema.js @@ -2,12 +2,12 @@ * set-world-schema — Store character attribute schema for this world. * Single call to define all character attributes at once. * - * @param {{ tool: Function, z: import('zod'), store: any }} injection + * @param {{ tool: Function, z: import('zod') }} injection */ import { makeProposal } from "@covel/plugin-handlers-utils"; import { withPendingProposals } from "@covel/tools"; -export default function ({ tool, z, store }) { +export default function ({ tool, z }) { // A display label is either a plain string or an i18n record // (`{ "zh-CN": "门派", "en-US": "Faction" }`). The LLM normally emits a plain // string; a world that ships its own schema may declare bilingual labels. @@ -75,17 +75,24 @@ export default function ({ tool, z, store }) { }), execute: async (params, context) => { const now = new Date().toISOString(); + const worldSchema = { + "character-attributes": { + version: 1, + attributes: params.attributes, + }, + }; return withPendingProposals( { success: true, attributeCount: params.attributes.length, categories: [...new Set(params.attributes.map((a) => a.category))], + worldSchema, }, [ makeProposal(context, now, "plugin.data", { namespace: "schema", key: "character-attributes", - value: { version: 1, attributes: params.attributes }, + value: worldSchema["character-attributes"], }), ], ); diff --git a/worlds/emberback/data/quests.yaml b/worlds/emberback/data/quests.yaml index c5c602e8b..1eb66ad2c 100644 --- a/worlds/emberback/data/quests.yaml +++ b/worlds/emberback/data/quests.yaml @@ -6,11 +6,14 @@ giver: 秦九镰 reward: 鳞钱六十,并在悬钩堂正式挂名 objectives: - - text: 赶在封锚前备齐装具,完成下降准备 + - id: prepare-descent + text: 赶在封锚前备齐装具,完成下降准备 done: false - - text: 缒链下降,进入黑色尖塔 + - id: enter-black-tower + text: 缒链下降,进入黑色尖塔 done: false - - text: 带回能解释玄负停驻的证物 + - id: return-evidence + text: 带回能解释玄负停驻的证物 done: false - id: cold-patch-survey name: 尾甲滩寒斑测绘 @@ -19,9 +22,11 @@ giver: 温辞 reward: 鳞钱二十五,外加司辰阁的一份人情 objectives: - - text: 走完尾甲滩三处寒斑,记录边界与甲温 + - id: survey-cold-patches + text: 走完尾甲滩三处寒斑,记录边界与甲温 done: false - - text: 把测绘结果亲手交给温辞 + - id: deliver-survey + text: 把测绘结果亲手交给温辞 done: false - id: sealed-parcel name: 一只不过秤的匣子 @@ -30,7 +35,9 @@ giver: 哑弦 reward: 鳞钱三十,或一条关于白汐旧图上记号的线索 objectives: - - text: 避开锚卡的盘查,把匣子带到冷鳞垛 + - id: evade-inspection + text: 避开锚卡的盘查,把匣子带到冷鳞垛 done: false - - text: 原封不动交给接头人 + - id: deliver-sealed-parcel + text: 原封不动交给接头人 done: false From 70778ae9a8daf8e4ecbf99e6c45d72820688576c Mon Sep 17 00:00:00 2001 From: ackness Date: Tue, 11 Aug 2026 15:40:35 +0800 Subject: [PATCH 2/3] chore(release): v0.0.25 --- README.md | 4 ++-- README.zh-CN.md | 4 ++-- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- docs/CHANGELOG.md | 23 +++++++++++++++++++ package.json | 2 +- packages/ai-provider/package.json | 2 +- packages/approval/package.json | 2 +- packages/context/package.json | 2 +- packages/create/package.json | 2 +- packages/events/package.json | 2 +- packages/memory/package.json | 2 +- packages/plugin-handlers-utils/package.json | 2 +- packages/plugin-loader/package.json | 2 +- packages/plugin-test-utils/package.json | 2 +- packages/runtime/package.json | 2 +- packages/settings/package.json | 2 +- packages/shared/package.json | 2 +- packages/state/package.json | 2 +- packages/store/package.json | 2 +- packages/test-runtime/package.json | 2 +- packages/tools/package.json | 2 +- plugins/_archive/player-identity/package.json | 2 +- plugins/affinity/package.json | 2 +- plugins/branch-reply/package.json | 2 +- plugins/char-creator/package.json | 2 +- plugins/character-blueprint/package.json | 2 +- plugins/character-presence/package.json | 2 +- plugins/chat-mode-narrator/package.json | 2 +- plugins/codex/package.json | 2 +- plugins/core-quest/package.json | 2 +- plugins/cost-gate/package.json | 2 +- plugins/dashscope-image-gen/package.json | 2 +- plugins/dice-check/package.json | 2 +- plugins/director/package.json | 2 +- plugins/guide/package.json | 2 +- plugins/inventory/package.json | 2 +- plugins/living-world-rules/package.json | 2 +- plugins/memory/package.json | 2 +- plugins/mimo-tts/package.json | 2 +- plugins/narrator/package.json | 2 +- plugins/npc-graph/package.json | 2 +- plugins/openai-image-gen/package.json | 2 +- plugins/pregame/package.json | 2 +- plugins/scene-cast/package.json | 2 +- plugins/scene-prompts/package.json | 2 +- plugins/scene-stage/package.json | 2 +- plugins/story-guard/package.json | 2 +- plugins/world-init/package.json | 2 +- 50 files changed, 74 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 06522812d..63304400e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ **English** · [简体中文](./README.zh-CN.md) -[![Version](https://img.shields.io/badge/version-v0.0.24-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.24) +[![Version](https://img.shields.io/badge/version-v0.0.25-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.25) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Stage](https://img.shields.io/badge/stage-early--access-orange)](<>) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ackness/covel) @@ -13,7 +13,7 @@ Covel is an AI RPG where the world keeps running between your turns: NPCs track how they feel about you, lore accumulates as you play, and memory carries the thread across the session. Every mechanic behind that is an **autonomous agent shipped as a plugin** — disable one, swap one, or write your own. -> **Current public release: v0.0.24**, early access — APIs, data formats, and plugin frontmatter may change between versions. Prebuilt binaries target macOS Apple Silicon and Windows x64; other platforms build from source. +> **Current public release: v0.0.25**, early access — APIs, data formats, and plugin frontmatter may change between versions. Prebuilt binaries target macOS Apple Silicon and Windows x64; other platforms build from source. ## Highlights diff --git a/README.zh-CN.md b/README.zh-CN.md index 1560949b1..c1a233d83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ [English](./README.md) · **简体中文** -[![Version](https://img.shields.io/badge/version-v0.0.24-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.24) +[![Version](https://img.shields.io/badge/version-v0.0.25-8b5cf6)](https://github.com/ackness/covel/releases/tag/v0.0.25) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Stage](https://img.shields.io/badge/stage-early--access-orange)](<>) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/ackness/covel) @@ -13,7 +13,7 @@ Covel 是一款 AI 驱动的 RPG,回合之间世界仍在运转:NPC 记录着对你的态度、世界典籍随游玩积累、记忆贯穿整局。支撑这一切的每个机制都是一个**以插件形式分发的自主 agent** —— 禁用一个、替换一个,或者自己写一个。 -> **当前公开版本:v0.0.24**,早期阶段 —— API、数据格式、插件 frontmatter 可能随版本变化。官方预编译包面向 macOS Apple Silicon 与 Windows x64,其余平台从源码构建。 +> **当前公开版本:v0.0.25**,早期阶段 —— API、数据格式、插件 frontmatter 可能随版本变化。官方预编译包面向 macOS Apple Silicon 与 Windows x64,其余平台从源码构建。 ## 亮点 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 6d737aadb..4ac2aecfa 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@covel/desktop", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "main": "dist/main.mjs", diff --git a/apps/server/package.json b/apps/server/package.json index bf4cfe508..288d292ff 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@covel/server", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "files": [ diff --git a/apps/web/package.json b/apps/web/package.json index eaee5559c..e42032f83 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@covel/web", - "version": "0.0.24", + "version": "0.0.25", "private": true, "deprecated": false, "type": "module", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index a42fc4b63..9c254a1e1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to this project will be documented in this file. Follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.25] - 2026-08-11 + +This release hardens the complete default game flow, from choosing a world and running setup plugins through narrative execution, post-turn plugins, persistence, and client recovery. + +### Added + +- **Transaction-scoped execution journals.** Runtime messages and domain proposals are buffered until the turn finalizer commits, so a failed commit leaves no partial player message, interaction, or plugin write behind. +- **Framework audit remediation records and logging self-checks.** The P1/P2 data-flow plans, architecture diagrams, server log-tee coverage, and desktop logging self-check make the new contracts explicit and diagnosable. + +### Changed + +- **Setup runtimes pass world schemas through explicit same-execution outputs.** Character creation no longer depends on reading uncommitted world-init state from the store; downstream setup runtimes consume the scheduler-visible result instead. +- **Post-turn memory uses authoritative committed context.** The executor refreshes session context after commit, resolves localized character labels, and applies deterministic merge rules so the current turn's form and character values reach memory intact. +- **Quest updates preserve stable identities.** `core-quest` assigns durable ids and semantically merges repeated extraction results, preventing duplicate quests and accidental state resets across turns. + +### Fixed + +- **Local session bootstrap now mirrors the complete server contract.** Active plugins, locale, model overrides, and lore overrides are synchronized before the session becomes active, eliminating first-turn configuration races. +- **Turn commit and SSE failure handling are atomic end to end.** Interaction-record failures roll back the entire finalize transaction; failed terminal events clear optimistic streamed text and leave the client in a settled state. +- **Action, form, and plugin-RPC boundaries reject malformed or conflicting input.** Discriminated action parsing validates identifiers, locales, models, submitted field types and options; form submissions are transactional and idempotent; plugin RPC actions serialize on the session lock. +- **World-init writes follow the runtime proposal contract.** Guards inspect the execution write buffer, batch writes respect schema availability, and setup no longer relies on direct store mutation. +- **Server and desktop logs retain complete lines reliably.** Log tee writes, shutdown behavior, and desktop log configuration now preserve diagnostics across the supported launch paths. + ## [0.0.24] - 2026-08-01 ### Added diff --git a/package.json b/package.json index ae97c8e32..0a7fc8004 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "covel", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/packages/ai-provider/package.json b/packages/ai-provider/package.json index 5f072bbc3..34e1713f0 100644 --- a/packages/ai-provider/package.json +++ b/packages/ai-provider/package.json @@ -1,6 +1,6 @@ { "name": "@covel/ai-provider", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/approval/package.json b/packages/approval/package.json index 3e1578516..9bbf7a980 100644 --- a/packages/approval/package.json +++ b/packages/approval/package.json @@ -1,6 +1,6 @@ { "name": "@covel/approval", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/context/package.json b/packages/context/package.json index 22d8f58b4..6824d5073 100644 --- a/packages/context/package.json +++ b/packages/context/package.json @@ -1,6 +1,6 @@ { "name": "@covel/context", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/create/package.json b/packages/create/package.json index e8979a787..a0c39c983 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -1,6 +1,6 @@ { "name": "@covel/create", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/events/package.json b/packages/events/package.json index cd9506198..c18c42470 100644 --- a/packages/events/package.json +++ b/packages/events/package.json @@ -1,6 +1,6 @@ { "name": "@covel/events", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/memory/package.json b/packages/memory/package.json index 008201423..4dfd9bb5d 100644 --- a/packages/memory/package.json +++ b/packages/memory/package.json @@ -1,6 +1,6 @@ { "name": "@covel/memory", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-handlers-utils/package.json b/packages/plugin-handlers-utils/package.json index 79d57dbe2..c33d555b8 100644 --- a/packages/plugin-handlers-utils/package.json +++ b/packages/plugin-handlers-utils/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-handlers-utils", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-loader/package.json b/packages/plugin-loader/package.json index e6249c4f3..40a0c46fb 100644 --- a/packages/plugin-loader/package.json +++ b/packages/plugin-loader/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-loader", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/plugin-test-utils/package.json b/packages/plugin-test-utils/package.json index 60d99c4d4..377ce7cca 100644 --- a/packages/plugin-test-utils/package.json +++ b/packages/plugin-test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-test-utils", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b0f087373..777ed9eb1 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@covel/runtime", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/settings/package.json b/packages/settings/package.json index 3fbaa676d..a3d54fe91 100644 --- a/packages/settings/package.json +++ b/packages/settings/package.json @@ -1,6 +1,6 @@ { "name": "@covel/settings", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/shared/package.json b/packages/shared/package.json index 1a8443472..2ad5a3158 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@covel/shared", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/state/package.json b/packages/state/package.json index 7f67c5bc6..da0279f25 100644 --- a/packages/state/package.json +++ b/packages/state/package.json @@ -1,6 +1,6 @@ { "name": "@covel/state", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/packages/store/package.json b/packages/store/package.json index b7a117b2e..82d6f2957 100644 --- a/packages/store/package.json +++ b/packages/store/package.json @@ -1,6 +1,6 @@ { "name": "@covel/store", - "version": "0.0.24", + "version": "0.0.25", "type": "module", "private": true, "exports": { diff --git a/packages/test-runtime/package.json b/packages/test-runtime/package.json index 5e1f27393..9700a428e 100644 --- a/packages/test-runtime/package.json +++ b/packages/test-runtime/package.json @@ -1,6 +1,6 @@ { "name": "@covel/test-runtime", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "bin": { diff --git a/packages/tools/package.json b/packages/tools/package.json index dfce64c26..7c93d4587 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,6 +1,6 @@ { "name": "@covel/tools", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "exports": { diff --git a/plugins/_archive/player-identity/package.json b/plugins/_archive/player-identity/package.json index 042ec2122..f1286d5ff 100644 --- a/plugins/_archive/player-identity/package.json +++ b/plugins/_archive/player-identity/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-player-identity", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/affinity/package.json b/plugins/affinity/package.json index 42d2f7428..0b02c62b9 100644 --- a/plugins/affinity/package.json +++ b/plugins/affinity/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-affinity", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/branch-reply/package.json b/plugins/branch-reply/package.json index cea11e48e..553cd516f 100644 --- a/plugins/branch-reply/package.json +++ b/plugins/branch-reply/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-branch-reply", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/char-creator/package.json b/plugins/char-creator/package.json index 996c5faee..f1a4068a6 100644 --- a/plugins/char-creator/package.json +++ b/plugins/char-creator/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-char-creator", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/character-blueprint/package.json b/plugins/character-blueprint/package.json index 4fa950a5c..232c60ef0 100644 --- a/plugins/character-blueprint/package.json +++ b/plugins/character-blueprint/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-character-blueprint", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/character-presence/package.json b/plugins/character-presence/package.json index 99a3d5054..5559b97f1 100644 --- a/plugins/character-presence/package.json +++ b/plugins/character-presence/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-character-presence", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/chat-mode-narrator/package.json b/plugins/chat-mode-narrator/package.json index e93868554..07fb80de0 100644 --- a/plugins/chat-mode-narrator/package.json +++ b/plugins/chat-mode-narrator/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-chat-mode-narrator", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/codex/package.json b/plugins/codex/package.json index 830801548..6f29f9f03 100644 --- a/plugins/codex/package.json +++ b/plugins/codex/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-codex", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/core-quest/package.json b/plugins/core-quest/package.json index 860a87b68..04bd7056b 100644 --- a/plugins/core-quest/package.json +++ b/plugins/core-quest/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-core-quest", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/cost-gate/package.json b/plugins/cost-gate/package.json index 24725a93d..4e10c19eb 100644 --- a/plugins/cost-gate/package.json +++ b/plugins/cost-gate/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-cost-gate", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/dashscope-image-gen/package.json b/plugins/dashscope-image-gen/package.json index 657ddf37f..e48abe837 100644 --- a/plugins/dashscope-image-gen/package.json +++ b/plugins/dashscope-image-gen/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-dashscope-image-gen", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "description": "DashScope (Aliyun) image generation — manual-triggered two-stage pipeline: LLM-crafted prompt → wan2.x image model.", diff --git a/plugins/dice-check/package.json b/plugins/dice-check/package.json index 913b0855b..875a823a4 100644 --- a/plugins/dice-check/package.json +++ b/plugins/dice-check/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-dice-check", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/director/package.json b/plugins/director/package.json index dd31f9d0a..80aaaec0a 100644 --- a/plugins/director/package.json +++ b/plugins/director/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-director", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/guide/package.json b/plugins/guide/package.json index 3cdb59d1d..281252d86 100644 --- a/plugins/guide/package.json +++ b/plugins/guide/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-guide", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/inventory/package.json b/plugins/inventory/package.json index 14fad6ecc..18f30a3c2 100644 --- a/plugins/inventory/package.json +++ b/plugins/inventory/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-inventory", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/living-world-rules/package.json b/plugins/living-world-rules/package.json index e36c25bf1..d0dda38ce 100644 --- a/plugins/living-world-rules/package.json +++ b/plugins/living-world-rules/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-living-world-rules", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/memory/package.json b/plugins/memory/package.json index 2697bb5b6..9ef57718c 100644 --- a/plugins/memory/package.json +++ b/plugins/memory/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-memory", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module" } diff --git a/plugins/mimo-tts/package.json b/plugins/mimo-tts/package.json index bb3ad4a74..2b4874a6d 100644 --- a/plugins/mimo-tts/package.json +++ b/plugins/mimo-tts/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-mimo-tts", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "description": "Xiaomi MiMo TTS — speak narrator output as voice (auto + manual), persisted as MediaRef, replayable from a per-turn audio tab.", diff --git a/plugins/narrator/package.json b/plugins/narrator/package.json index b5c26d696..0e170144e 100644 --- a/plugins/narrator/package.json +++ b/plugins/narrator/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-narrator", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/npc-graph/package.json b/plugins/npc-graph/package.json index ce344ece3..cd28b1ea4 100644 --- a/plugins/npc-graph/package.json +++ b/plugins/npc-graph/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-npc-graph", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/openai-image-gen/package.json b/plugins/openai-image-gen/package.json index 70ec68041..d5d5a810f 100644 --- a/plugins/openai-image-gen/package.json +++ b/plugins/openai-image-gen/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-openai-image-gen", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "description": "OpenAI-compatible image generation via the framework's unified ctx.images pipeline, works with OpenAI and any OpenAI-compatible third-party (gpt-image-2 default).", diff --git a/plugins/pregame/package.json b/plugins/pregame/package.json index f722a692e..bf2c4b2b5 100644 --- a/plugins/pregame/package.json +++ b/plugins/pregame/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-pregame", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/scene-cast/package.json b/plugins/scene-cast/package.json index 01c8e8573..562688f97 100644 --- a/plugins/scene-cast/package.json +++ b/plugins/scene-cast/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-scene-cast", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/scene-prompts/package.json b/plugins/scene-prompts/package.json index 8577f4a3c..d120f26c6 100644 --- a/plugins/scene-prompts/package.json +++ b/plugins/scene-prompts/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-scene-prompts", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/scene-stage/package.json b/plugins/scene-stage/package.json index c41761ee5..c56522319 100644 --- a/plugins/scene-stage/package.json +++ b/plugins/scene-stage/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-scene-stage", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/story-guard/package.json b/plugins/story-guard/package.json index b11df5084..733cb6530 100644 --- a/plugins/story-guard/package.json +++ b/plugins/story-guard/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-story-guard", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { diff --git a/plugins/world-init/package.json b/plugins/world-init/package.json index e447f7d5e..ebbcfa1ef 100644 --- a/plugins/world-init/package.json +++ b/plugins/world-init/package.json @@ -1,6 +1,6 @@ { "name": "@covel/plugin-world-init", - "version": "0.0.24", + "version": "0.0.25", "private": true, "type": "module", "scripts": { From 0e59f375146edba97ae4c40d03a895204158e6f8 Mon Sep 17 00:00:00 2001 From: ackness Date: Tue, 11 Aug 2026 17:20:37 +0800 Subject: [PATCH 3/3] docs: align release instructions with tag workflow --- docs/CONTRIBUTING.en.md | 7 ++++--- docs/CONTRIBUTING.md | 7 ++++--- docs/guide/plugin-authoring.md | 2 +- docs/reference/api.md | 16 ++++++++-------- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/CONTRIBUTING.en.md b/docs/CONTRIBUTING.en.md index 4eff00323..23c3b1c40 100644 --- a/docs/CONTRIBUTING.en.md +++ b/docs/CONTRIBUTING.en.md @@ -77,15 +77,16 @@ Common types: `feat` / `fix` / `refactor` / `docs` / `test` / `chore` / `perf` / Covel releases are driven by Git tags. 1. All changes merged into `main` with green CI -2. Update the `[Unreleased]` section at the top of [`CHANGELOG.md`](./CHANGELOG.md) (located under `docs/`) and migrate it to the new version +2. Add a `## [] - YYYY-MM-DD` section at the top of [`CHANGELOG.md`](./CHANGELOG.md) 3. Unify version numbers across workspace packages (semver: `0.0.1-beta` / `0.1.0` / `1.0.0` …) 4. Run release preflight, commit, and tag: ```bash pnpm release:preflight git commit -am "chore(release): v0.0.4" - git tag v0.0.4 - git push origin main --tags + git tag -a v0.0.4 -m "Covel v0.0.4" + git push origin main + git push origin v0.0.4 ``` 5. [`.github/workflows/release.yml`](../.github/workflows/release.yml) will, on any `v*` tag push: diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 1bad00b9f..8c4956f73 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -77,7 +77,7 @@ pnpm e2e # Playwright 端到端 Covel 的发布由 Git tag 驱动。 1. 所有改动合并到 `main` 且 CI 通过 -2. 更新 [`CHANGELOG.md`](./CHANGELOG.md) 顶部的 `[Unreleased]` 段落,迁移到新版本号 +2. 在 [`CHANGELOG.md`](./CHANGELOG.md) 顶部新增 `## [] - YYYY-MM-DD` 版本段落 3. 统一版本号: ```bash @@ -90,8 +90,9 @@ Covel 的发布由 Git tag 驱动。 ```bash pnpm release:preflight git commit -am "chore(release): v0.0.4" - git tag v0.0.4 - git push origin main --tags + git tag -a v0.0.4 -m "Covel v0.0.4" + git push origin main + git push origin v0.0.4 ``` 5. [`.github/workflows/release.yml`](../.github/workflows/release.yml) 将在 `v*` tag 推送时自动: diff --git a/docs/guide/plugin-authoring.md b/docs/guide/plugin-authoring.md index c9b891f0e..0aa042c58 100644 --- a/docs/guide/plugin-authoring.md +++ b/docs/guide/plugin-authoring.md @@ -136,7 +136,7 @@ CI 的 `check-plugin-i18n` 校验 `ui/*.json` spec、`PLUGIN.md` frontmatter,* ### B. 现有插件参考 -来源:`plugins/**/PLUGIN.md` 的 frontmatter(截至 v0.0.22)。 +来源:当前 `plugins/**/PLUGIN.md` 的 frontmatter;下表列出代表性 runtime,并非完整清单。 | Runtime | Stage | 触发 | 类型 | 工具 / 关键能力 | 学习价值 | | -------------------------------- | ----------- | ------------------------------------- | ------------- | ------------------------------------------------------------ | ------------------------------- | diff --git a/docs/reference/api.md b/docs/reference/api.md index e374324f4..0d792b0e3 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -20,7 +20,7 @@ Covel HTTP API 参考文档。通过这些端点,你可以在没有前端 UI > **注意**: `@covel/store` 工厂也支持 `idb`,用于浏览器能力调用;常规服务器部署使用 `memory` / `sqlite` / `pg`。 -### 错误响应约定(v0.0.5 统一) +### 错误响应约定(自 v0.0.5 起统一) 所有 JSON 错误响应统一收敛为以下信封(`apps/server/src/api-error.ts`): @@ -333,14 +333,14 @@ setup runtime 反复失败、耗尽重试预算(`maxTriggerCount`)后进入 ### 状态查询 -| 方法 | 路径 | 描述 | -| ---- | ---------------------------------- | ------------------------------------------- | -| GET | `/api/sessions/:id/state` | 获取所有状态表 | -| GET | `/api/sessions/:id/state-patches` | 获取状态变更补丁列表 | -| GET | `/api/sessions/:id/state-snapshot` | 获取完整状态快照 | -| PUT | `/api/sessions/:id/state-snapshot` | **v0.0.5 未实现** —— 返回 `501`,见下方说明 | +| 方法 | 路径 | 描述 | +| ---- | ---------------------------------- | ---------------------------------------- | +| GET | `/api/sessions/:id/state` | 获取所有状态表 | +| GET | `/api/sessions/:id/state-patches` | 获取状态变更补丁列表 | +| GET | `/api/sessions/:id/state-snapshot` | 获取完整状态快照 | +| PUT | `/api/sessions/:id/state-snapshot` | **当前未实现** —— 返回 `501`,见下方说明 | -> **`PUT /api/sessions/:id/state-snapshot`(v0.0.5 未实现)**:恢复状态快照需要 StateManager +> **`PUT /api/sessions/:id/state-snapshot`(当前未实现;自 v0.0.5 明确返回 501)**:恢复状态快照需要 StateManager > 重建状态表,当前状态模型未暴露该能力。路由有意保留(而非删除)以保持契约可见、不丢失排期, > 当会话存在时返回 > `501 { "error": "State snapshot restoration not implemented. State will be rebuilt from turn execution.", "code": "not_implemented" }`