diff --git a/AGENTS.md b/AGENTS.md index 431e8f6..065113e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete | `/interactions` | human-in-the-loop ask channel, both halves: the shared wire/persisted-part contract (`ChatInteraction`, part codecs, composer-as-answer routing, content-signature dedupe) + the server side — structural sidecar `/agents/sessions/{id}/interactions` client and `createInteractionAnswerRoute()` (list/answer endpoint factory: body validation, gone→410 mapping, duplicate-answer safety net, unblock verification, `resolveConnection` product seam) | peer `@tangle-network/agent-interface` (schema types only); connection is structural — no sandbox-SDK import. Server half must never reach client bundles (`tests/interactions-browser-safe.test.ts`) | | `/plans` | browser-safe durable-plan chat projection: lifecycle event parsing, persisted `type:'plan'` codec, revision-aware transcript keys, and monotonic per-revision transitions | structurally byte-matches the durable plan returned by peer `@tangle-network/sandbox`'s `SandboxSession.plan()`; authority remains SDK-owned | | `/durable-chat` | reusable server workflow around the authoritative plan/question channels: authorization-scoped state/command ports, CAS plan decisions, stable follow-up receipts, independently retryable product effects, interaction answer intent/ack/finalize settlement, producer/route adapters, and a reference process-local test store | structural Sandbox plan authority + `/interactions` sidecar route; products supply auth scope, production storage, Sandbox connection, idempotent effects, and follow-up transport | -| `/chat-routes` | the assembled server chat vertical (#188 Phase 1): `createChatTurnRoutes()` — body parse/validate → injected `authorize` seam → producer seam → turn-buffer tap wired BY DEFAULT → NDJSON `Response` + replay endpoint + composed `/interactions` answer endpoints + `/chat-store` persistence (user row on send, assistant row with parts/usage on completion); six optional product seams (`turnLock`/`contextGate`/`beforeTurn`/`onRawEvent` are `@experimental` — single-consumer, proven by a reference consumer, kept FLAT for back-compat; `lifecycle`/`heartbeat` are stable) — `turnLock` (single-flight acquire/release), `contextGate` (pre-producer domain-readiness short-circuit), `beforeTurn` (observe/augment producer input), `lifecycle` (deterministic start/complete/error telemetry, idempotent + settled even on a synchronous pre-stream throw), `heartbeat` (keepalive during silent producer waits), `onRawEvent` (raw producer-event tap) — plus `transformFinalText` (pre-persist redaction applied to BOTH the final-text scalar AND every persisted assistant TEXT part, so `/redact` closes the at-rest PII leak, not just the streamed scalar) and run-failure surfacing (`onTurnComplete` receives `failed`/`failureReason` from a terminal `error`/`session.run.failed` event so an errored turn is skipped for billing + rendered as an error row, never billed/marked-complete with empty text); `createSandboxChatProducer` (raw sandbox events → flattened client vocabulary + persisted parts; authoritative rich usage from `result`/`done`; composed visible raw-error text; severed-stream catch that preserves partial content and ends normally with structured `sandbox.stream_failed` so `onTurnComplete({failed:true})` fires; live dangling-tool terminalization; pending-interaction finalization; non-renderable-ask auto-decline + warning notices, with per-turn plan gating through `isRenderableInteraction`); `runDetachedTurn` (autonomous/detached turn → live buffer bridge: taps the SAME turn-event buffer the interactive lane uses so a browser opening a session MID autonomous-run — mission step, queue job, inbound-email review — tails it token-by-token exactly like an interactive turn; idempotent [an already-`complete` turn returns its cached result without re-streaming a second event sequence] AND crash-safe [a `running` turn — a prior attempt died mid-tap — consults `completedResult` to detect a run that finished server-side, else clears the partial buffer via the `resetBuffer` seam before re-streaming so restarted seqs don't corrupt], marks `running` under `scopeId` for `listRunning` re-attach, surfaces the structured `assistantParts` projection (`result.parts`) so the durable row keeps tool/file/plan parts and not just flat text, forwards the producer's `declineInteraction`/`isRenderableInteraction`/`promoteFilePart` seams — an UNATTENDED run MUST wire `declineInteraction` or a non-renderable ask deadlocks the broker, usage/text/parts fallback via `completedResult` when the stream carries no receipt, terminal-error event → `failed` (including a raw sandbox stream throw normalized by the producer; buffer/tap failures still re-throw); the caller supplies only the raw sandbox stream + store + ids — box/prompt/tooling stay in the product); `createUploadRoute` (the `--chat` scaffold's inline-`data:`-or-ephemeral-sandbox upload path; multipart → inline `data:` part ≤700 KiB, else base64 write through a structural sandbox sink + path-ref part — the >1 MiB gateway cap makes the two-step mandatory); the store-backed attachment vertical (#224) — `resolveChatAttachments` (validates a turn's `attachments` field, re-deriving size from the STORED body, never the wire-reported one), `buildDispatchParts` (assembles attachments + mentions into dispatched `PromptInputPart[]`, inlining under the `DISPATCH_*` byte/parts budget or demoting to a path part), `promoteAgentFilePart` (a harness-emitted file part → a durable store-backed attachment, wired opt-in through `createSandboxChatProducer`'s `promoteFilePart` seam) — all storage-parameterized via `ReadAttachmentFn`/`WriteAttachmentFn` (`./attachment-store`): REQUIRED injection, no default store; `createAttachmentUploadRoute` (#234): hardened durable-store upload factory shared by fleet apps in place of a hand-rolled vault route — two-phase atomic batch, content-sniffed type/kind gate (`sniffBinary`/`checkAttachmentType`, shared client+server), per-kind + aggregate size caps, sanitized filenames; storage via the injected `WriteAttachmentFn`, auth/rate-limit/scope via the injected `authorize`, returns the full `ChatAttachmentInput[]`; import-free `./wire` contract (`ChatTurnRequestPayload`, inline-part byte gate) re-exported via `/web-react`'s chat-stream | peer `@tangle-network/agent-runtime` (`handleChatTurn` IS the turn engine — zero loop logic here; subpath-only, not in the root barrel); composes `/stream`, `/chat-store`, `/interactions`, `/web`. Reference assembly: `examples/chat-app.md` | +| `/chat-routes` | the assembled server chat vertical (#188 Phase 1): `createChatTurnRoutes()` — body parse/validate → injected `authorize` seam → producer seam → turn-buffer tap wired BY DEFAULT → NDJSON `Response` + replay endpoint + composed `/interactions` answer endpoints + `/chat-store` persistence (user row on send, assistant row with parts/usage on completion); six optional product seams (`turnLock`/`contextGate`/`beforeTurn`/`onRawEvent` are `@experimental` — single-consumer, proven by a reference consumer, kept FLAT for back-compat; `lifecycle`/`heartbeat` are stable) — `turnLock` (single-flight acquire/release), `contextGate` (pre-producer domain-readiness short-circuit), `beforeTurn` (observe/augment producer input), `lifecycle` (deterministic start/complete/error telemetry, idempotent + settled even on a synchronous pre-stream throw), `heartbeat` (keepalive during silent producer waits), `onRawEvent` (raw producer-event tap) — plus `transformFinalText` (pre-persist redaction applied to BOTH the final-text scalar AND every persisted assistant TEXT part, so `/redact` closes the at-rest PII leak, not just the streamed scalar) and run-failure surfacing (`onTurnComplete` receives `failed`/`failureReason` from a terminal `error`/`session.run.failed` event so an errored turn is skipped for billing + rendered as an error row, never billed/marked-complete with empty text); `createSandboxChatProducer` (raw sandbox events → flattened client vocabulary + persisted parts; authoritative rich usage from `result`/`done`; composed visible raw-error text; severed-stream catch that preserves partial content and ends normally with structured `sandbox.stream_failed` so `onTurnComplete({failed:true})` fires; live dangling-tool terminalization; pending-interaction finalization; non-renderable-ask auto-decline + warning notices, with per-turn plan gating through `isRenderableInteraction`); `runDetachedTurn` (autonomous/detached turn → live buffer bridge: taps the SAME turn-event buffer the interactive lane uses so a browser opening a session MID autonomous-run — mission step, queue job, inbound-email review — tails it token-by-token exactly like an interactive turn; idempotent [an already-`complete` turn returns its cached result without re-streaming a second event sequence] AND crash-safe [a `running` turn — a prior attempt died mid-tap — consults `completedResult` to detect a run that finished server-side, else clears the partial buffer via the `resetBuffer` seam before re-streaming so restarted seqs don't corrupt], marks `running` under `scopeId` for `listRunning` re-attach, surfaces the structured `assistantParts` projection (`result.parts`) so the durable row keeps tool/file/plan parts and not just flat text, forwards the producer's `declineInteraction`/`isRenderableInteraction`/`promoteFilePart` seams — an UNATTENDED run MUST wire `declineInteraction` or a non-renderable ask deadlocks the broker, usage/text/parts fallback via `completedResult` when the stream carries no receipt, terminal-error event → `failed` (including a raw sandbox stream throw normalized by the producer; buffer/tap failures still re-throw); the caller supplies only the raw sandbox stream + store + ids — box/prompt/tooling stay in the product); `createUploadRoute` (the `--chat` scaffold's inline-`data:`-or-ephemeral-sandbox upload path; multipart → inline `data:` part ≤700 KiB, else base64 write through a structural sandbox sink + path-ref part — the >1 MiB gateway cap makes the two-step mandatory); the store-backed attachment vertical (#224) — `resolveChatAttachments` (validates a turn's `attachments` field, re-deriving size from the STORED body, never the wire-reported one), `buildDispatchParts` (assembles attachments + mentions into dispatched `PromptInputPart[]`, inlining under the `DISPATCH_*` byte/parts budget or demoting to a path part), `promoteAgentFilePart` (a harness-emitted file part → a durable store-backed attachment, wired opt-in through `createSandboxChatProducer`'s `promoteFilePart` seam) — all storage-parameterized via `ReadAttachmentFn`/`WriteAttachmentFn` (`./attachment-store`): REQUIRED injection, no default store; `createAttachmentUploadRoute` (#234): hardened durable-store upload factory shared by fleet apps in place of a hand-rolled vault route — two-phase atomic batch, content-sniffed type/kind gate (`sniffBinary`/`checkAttachmentType`, shared client+server), per-kind + aggregate size caps, sanitized filenames; storage via the injected `WriteAttachmentFn`, auth/rate-limit/scope via the injected `authorize`, returns the full `ChatAttachmentInput[]`; import-free `./wire` contract (`ChatTurnRequestPayload`, inline-part byte gate) re-exported via `/web-react`'s chat-stream; INCREMENTAL assistant persistence (`./draft-persistence`) — the assistant row is inserted early and coalesce-patched as the turn streams (time-floored default 2 s + dirty gate + single-flight + best-effort, tuned via `incrementalPersistence`), so a viewer arriving mid-run is served from DURABLE storage instead of the streaming gateway's hot event buffer; that is what lets the buffer stay a short reconnect window (its Redis cost is linear in `ttl x concurrent sessions`, so raising the TTL to cover late viewers is a category error). Idempotency reuses the turn's OWN identity — the row id derives from `deriveExecutionId` (interactive) / `turnId` (detached), so a re-entered turn patches the row a crashed attempt started instead of duplicating parts; the final write is insert-or-patch under that same id and produces the row today's single write produces. Mid-stream parts come from `/stream`'s `draftAssistantParts` (finalize MINUS the dangling-tool terminalizer — a running tool must never persist as a failure). Enabled by default when the store has `updateMessage`; `false` opts out; a store without it is byte-unchanged. `runDetachedTurn`'s `persist` seam transfers row OWNERSHIP to the shell (returns `messageId`; the caller stops inserting) | peer `@tangle-network/agent-runtime` (`handleChatTurn` IS the turn engine — zero loop logic here; subpath-only, not in the root barrel); composes `/stream`, `/chat-store`, `/interactions`, `/web`. Reference assembly: `examples/chat-app.md` | | `/turn-stream` | shared durable turn replay/broadcast/lock on a Cloudflare DO (#221): pure segment/lock core, `TurnStreamDO` transport shell (thread + workspace channels, WS hibernation fanout, `sync`/`afterSeq` reconnect replay, durable turn-event rows + running-turn index, product extension seams `handleProductRequest`/`shouldDeferLockRelease`/`productSyncEvents`), and the vertical seam adapters — `createDurableObjectTurnEventStore` (the real `turnStore`), `createDurableTurnLock` (dual-scope single-flight `turnLock`, one reconcile-then-retry pass via `reconcileStaleDurableTurnLock`), `createTurnStreamUpgradeHandler`, broadcast helpers, memory harness. Server-only, subpath-only; Cloudflare is structural (bind by re-exporting the class) | composes `/stream`'s `TurnEventStore` contract + `/chat-routes`' `reconcileStaleTurnLock` policy — no new peer | | `/tangle` | app-registration consent URL + cached broker-token provider | structural `TangleAppsClient` (from agent-integrations) | | `/billing` | per-workspace budget-capped key manager (mint/rotate/rollover/usage) | structural tcloud provisioner + store + crypto seams | diff --git a/docs/CODEMAP.md b/docs/CODEMAP.md index 005e91c..0b6b8fa 100644 --- a/docs/CODEMAP.md +++ b/docs/CODEMAP.md @@ -6,7 +6,7 @@ _73 entries — tsup.config `entry`. Regenerate with `agent-docs`._ | Entry | Exports | Depends on | |---|---|---| -| [`.`](api/index.md) | 766 | `assets`, `assistant`, `billing`, `brand`, `brand-extraction`, `chat-routes`, `chat-store`, `config`, `crypto`, `design-canvas`, `design-canvas-react`, `durable-chat`, `eval`, `eval-campaign`, `harness`, `intakes`, `intakes-react`, `integrations`, `interactions`, `knowledge`, `knowledge-loop`, `missions`, `model-resolution`, `object-store`, `plans`, `platform`, `preflight`, `preset-cloudflare`, `profile`, `prompt`, `redact`, `run`, `runtime`, `sandbox`, `sequences`, `sequences-react`, `skills`, `skills-placement`, `store`, `stream`, `studio`, `studio-react`, `tangle`, `teams`, `teams-react`, `theme`, `theme-contract`, `tools`, `trace`, `turn-stream`, `vault`, `web`, `web-react` | +| [`.`](api/index.md) | 767 | `assets`, `assistant`, `billing`, `brand`, `brand-extraction`, `chat-routes`, `chat-store`, `config`, `crypto`, `design-canvas`, `design-canvas-react`, `durable-chat`, `eval`, `eval-campaign`, `harness`, `intakes`, `intakes-react`, `integrations`, `interactions`, `knowledge`, `knowledge-loop`, `missions`, `model-resolution`, `object-store`, `plans`, `platform`, `preflight`, `preset-cloudflare`, `profile`, `prompt`, `redact`, `run`, `runtime`, `sandbox`, `sequences`, `sequences-react`, `skills`, `skills-placement`, `store`, `stream`, `studio`, `studio-react`, `tangle`, `teams`, `teams-react`, `theme`, `theme-contract`, `tools`, `trace`, `turn-stream`, `vault`, `web`, `web-react` | | [`./app-auth`](api/app-auth.md) | 11 | `platform` | | [`./assets`](api/assets.md) | 43 | — | | [`./assistant`](api/assistant.md) | 55 | `runtime`, `web-react` | @@ -14,8 +14,8 @@ _73 entries — tsup.config `entry`. Regenerate with `agent-docs`._ | [`./brand`](api/brand.md) | 5 | — | | [`./brand-extraction`](api/brand-extraction.md) | 19 | — | | [`./catalog`](api/catalog.md) | 6 | `runtime` | -| [`./chat-routes`](api/chat-routes.md) | 131 | `chat-store`, `interactions`, `plans`, `sandbox`, `stream`, `web` | -| [`./chat-store`](api/chat-store.md) | 59 | `chat-routes`, `interactions`, `plans`, `stream`, `web-react` | +| [`./chat-routes`](api/chat-routes.md) | 142 | `chat-store`, `interactions`, `plans`, `sandbox`, `stream`, `web` | +| [`./chat-store`](api/chat-store.md) | 60 | `chat-routes`, `interactions`, `plans`, `stream`, `web-react` | | [`./composer`](api/composer.md) | 22 | — | | [`./config`](api/config.md) | 13 | `knowledge`, `runtime` | | [`./crypto`](api/crypto.md) | 10 | `billing` | @@ -56,7 +56,7 @@ _73 entries — tsup.config `entry`. Regenerate with `agent-docs`._ | [`./skills`](api/skills.md) | 27 | — | | [`./skills-placement`](api/skills-placement.md) | 6 | `harness`, `skills` | | [`./store`](api/store.md) | 8 | — | -| [`./stream`](api/stream.md) | 44 | `interactions`, `plans` | +| [`./stream`](api/stream.md) | 45 | `interactions`, `plans` | | [`./studio`](api/studio.md) | 37 | — | | [`./studio-react`](api/studio-react.md) | 30 | `studio` | | [`./tangle`](api/tangle.md) | 7 | — | @@ -84,11 +84,11 @@ _73 entries — tsup.config `entry`. Regenerate with `agent-docs`._ ## `.` -Source: `src/index.ts` · 766 exports +Source: `src/index.ts` · 767 exports Depends on: `assets`, `assistant`, `billing`, `brand`, `brand-extraction`, `chat-routes`, `chat-store`, `config`, `crypto`, `design-canvas`, `design-canvas-react`, `durable-chat`, `eval`, `eval-campaign`, `harness`, `intakes`, `intakes-react`, `integrations`, `interactions`, `knowledge`, `knowledge-loop`, `missions`, `model-resolution`, `object-store`, `plans`, `platform`, `preflight`, `preset-cloudflare`, `profile`, `prompt`, `redact`, `run`, `runtime`, `sandbox`, `sequences`, `sequences-react`, `skills`, `skills-placement`, `store`, `stream`, `studio`, `studio-react`, `tangle`, `teams`, `teams-react`, `theme`, `theme-contract`, `tools`, `trace`, `turn-stream`, `vault`, `web`, `web-react` -`AddCitationArgs`, `AddCitationResult`, `addSecurityHeaders`, `AgentAppConfig`, `agentAppConfigJsonSchema`, `AgentAppTheme`, `AgentIdentityConfig`, `AgentIntegrationsConfig`, `AgentKnowledgeConfig`, `AgentRuntime`, `AgentRuntimeModelConfig`, `AgentTaxonomyConfig`, `AgentTurnOptions`, `AgentUiConfig`, `AnySurfaceKind`, `APP_TOOL_NAMES`, `applyDurableInteractionAnswer`, `applyDurableInteractionAsk`, `applyDurableInteractionCancel`, `applyMissionEvent`, `ApprovalEvent`, `ApprovalEventSchema`, `AppToolContext`, `AppToolDefinition`, `AppToolDescriptor`, `AppToolHandlers`, `AppToolLoopOptions`, `AppToolMcpServer`, `AppToolName`, `AppToolOutcome`, `AppToolProducedEvent`, `AppToolRuntimeExecutor`, `AppToolTaxonomy`, `asMissionStreamEvent`, `asRecord`, `assertEnvWithinLimits`, `assertHarnessModelCompatible`, `assertMediaUrl`, `assertProvisionPayloadWithinCap`, `assertSafeKeySegment`, `AssetContentMap`, `AssetFormat`, `AssetSpec`, `AssetStatus`, `AssetVariant`, `asString`, `attachmentInputToPart`, `attachmentKindForMime`, `attachmentPartKey`, `attachmentPartsFromMessageParts`, `attachReasoningEffort`, `AuthenticatedSandboxUser`, `AuthenticateOptions`, `authenticateToolRequest`, `bearerSubprotocolToken`, `bearerToken`, `BeforeInteractionAnswerArgs`, `BrandTokens`, `BrandTokensSchema`, `BrokerToken`, `BrokerTokenMinter`, `BrokerTokenProvider`, `BrokerTokenProviderOptions`, `budgetGateProposalId`, `BufferedTurnEvent`, `BufferedTurnOptions`, `BufferedTurnTap`, `buildAgentMissionPlan`, `buildAppToolMcpServer`, `buildAppToolMcpServers`, `BuildAppToolMcpServersOptions`, `buildAppToolOpenAITools`, `BuildAppToolsOptions`, `buildAttachmentPromptBlock`, `buildCatalog`, `buildConsentUrl`, `buildFlowTrace`, `buildHttpMcpServer`, `BuildHttpMcpServerOptions`, `buildKnowledgeRequirements`, `BuildMcpServerOptions`, `buildRedactedDocument`, `BuildRedactedDocumentOptions`, `buildSandboxRuntimeProxyHeaders`, `buildSandboxToolFileMounts`, `BuildSandboxToolFileMountsOptions`, `buildSandboxToolPathSetupScript`, `buildScopedMcpServerEntry`, `buildUserTextParts`, `BULK_DELETE_MAX_THREADS`, `cancelStatusFor`, `canTransitionInteractionStatus`, `canTransitionPlanStatus`, `CanvasRenderPalette`, `CapabilityTokenOptions`, `CatalogModel`, `CertifiedDelivery`, `CertifiedDeliveryConfig`, `ChatAttachmentKind`, `ChatAttachmentPart`, `ChatFilePart`, `ChatImagePart`, `ChatInteraction`, `ChatInteractionField`, `ChatInteractionPart`, `ChatInteractionStatus`, `ChatMentionKind`, `ChatMentionPart`, `ChatMessagePart`, `ChatNoticePart`, `ChatPartTime`, `ChatPlan`, `ChatPlanPart`, `ChatPlanPersistedPart`, `ChatPlanStatus`, `ChatReasoningPart`, `ChatSelectField`, `ChatStepFinishPart`, `ChatStepStartPart`, `ChatStoreInputError`, `ChatSubtaskPart`, `ChatTextPart`, `ChatToolPart`, `ChatToolState`, `ChatToolStatus`, `ChatUsageTokens`, `checkRateLimit`, `checkThemeContract`, `childSpanContext`, `classifySeveredStream`, `clearCookieHeader`, `coalesceChatStreamEvents`, `coalesceDeltas`, `coerceHarness`, `collapseRedundantTextParts`, `CompleteMissionInput`, `CompletionRequirement`, `CompletionVerdict`, `composeMissionFlowTrace`, `composerAnswerData`, `composerAnswerDeliveries`, `ComposerAnswerDelivery`, `ConsentUrlInput`, `ConversionMetrics`, `ConversionMetricsSchema`, `CookieOptions`, `CopyContent`, `CopyContentSchema`, `CopyPlatform`, `CorrectnessChecker`, `createAgentRuntime`, `CreateAgentRuntimeOptions`, `createAppToolRuntimeExecutor`, `createBrokerTokenProvider`, `createBufferedTurnTap`, `createCapabilityToken`, `createCertifiedDelivery`, `createD1KnowledgeStateAccessor`, `createD1TurnEventStore`, `createDurableChatEventProjection`, `createDurableChatScope`, `createDurableInteractionProjectionAdapter`, `createDurableInteractionRoutePersistence`, `CreateDurableInteractionRoutePersistenceOptions`, `createDurableInteractionSettlement`, `createDurablePlanRoutes`, `createExpiringCapabilityToken`, `createFieldCrypto`, `createInMemoryDurableChatStateStore`, `createInMemoryMissionStore`, `createInteractionAnswerRoute`, `createKnowledgeLoop`, `CreateKnowledgeLoopDeps`, `createLlmCorrectnessChecker`, `createMcpToolHandler`, `CreateMcpToolHandlerOptions`, `createMemoryTurnEventStore`, `createMissionEngine`, `CreateMissionInput`, `createMissionService`, `createMissionTraceContext`, `createOpenAICompatStreamTurn`, `createPlatformBalanceManager`, `createPresetDrizzleSchema`, `createPresetFieldCrypto`, `createPresetToolHandlers`, `createPresetWorkspaceKeyManager`, `createPresetWorkspaceKeyStore`, `createProxiedArtifactRoute`, `createR2ObjectStore`, `createReviewerDecider`, `createSandboxTerminalToken`, `createSurfaceRegistry`, `createTangleRouterModelConfig`, `CreateTangleRouterModelConfigOptions`, `createTcloudKeyProvisioner`, `createTokenRecallChecker`, `createWorkspaceKeyManager`, `createWorkspaceSandboxConnectionHandler`, `createWorkspaceSandboxManager`, `createWorkspaceSandboxRuntimeProxyHandler`, `createWorkspaceSandboxTerminalUpgradeHandler`, `customToolToOpenAI`, `D1Like`, `D1LikeForTurns`, `D1PreparedLike`, `darkTheme`, `decodeHexKey`, `decryptAesGcm`, `decryptBytes`, `decryptWithKey`, `dedupeQuestionInteractionsByContent`, `DEFAULT_APP_TOOL_PATHS`, `DEFAULT_ATTACHMENT_PROMPT_HEADER`, `DEFAULT_HARNESS`, `DEFAULT_HEADER_NAMES`, `DEFAULT_MISSION_STEP_KINDS`, `DEFAULT_REDACTION_PATTERNS`, `DEFAULT_SANDBOX_RESOURCES`, `DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR`, `DEFAULT_TANGLE_ROUTER_BASE_URL`, `deferredCorpusHash`, `defineAgentApp`, `defineAppTool`, `defineSurfaceKind`, `delegationActivityToFlowSpans`, `deleteSecret`, `deriveKey`, `DeriveKeyOptions`, `deriveSignals`, `detectInteractiveQuestion`, `detectSpans`, `dispatchAppTool`, `DispatchOptions`, `DistributionSummary`, `driveSandboxTurn`, `DriveSandboxTurnOptions`, `DrizzleColumnLike`, `DrizzleSqliteCoreLike`, `DurableAnswerIntentJournal`, `DurableAnswerIntentRecord`, `DurableAnswerIntentState`, `DurableChatConflictError`, `DurableChatError`, `DurableChatErrorCode`, `DurableChatEventProjection`, `DurableChatGoneError`, `DurableChatScope`, `durableChatScopeKey`, `DurableChatStateStore`, `DurableChatUnavailableError`, `DurableFollowUpReceipt`, `DurableInteractionAcknowledgement`, `DurableInteractionGuarantee`, `durableInteractionIntentKey`, `DurableInteractionProjection`, `DurableInteractionProjectionAdapter`, `DurableInteractionRouteArgs`, `DurableInteractionRoutePersistence`, `DurableInteractionSettlement`, `DurableInteractionSettlementFactoryOptions`, `DurableInteractionSettlementOptions`, `DurablePlanAuthority`, `DurablePlanAuthorityCurrentResult`, `DurablePlanAuthorityDecision`, `DurablePlanAuthorityResult`, `DurablePlanAuthorization`, `DurablePlanCommandJournal`, `DurablePlanCommandKey`, `DurablePlanCommandRecord`, `DurablePlanCommandState`, `DurablePlanDecision`, `DurablePlanEffectRecord`, `DurablePlanProjection`, `DurablePlanRouteAuthorizeArgs`, `DurablePlanRouteOptions`, `DurablePlanRoutes`, `DurablePlanStateStore`, `DurablePlanStore`, `EmailBodySection`, `EmailContent`, `EmailContentSchema`, `EmailCtaSection`, `EmailDividerSection`, `EmailFeatureSection`, `EmailHeroSection`, `EmailSection`, `EmailTestimonialSection`, `encodeEvent`, `encodeSandboxRuntimePath`, `encryptAesGcm`, `encryptBytes`, `encryptWithKey`, `ensureWorkspaceSandbox`, `EnsureWorkspaceSandboxOptions`, `ENV_TOTAL_MAX_BYTES`, `ENV_VALUE_MAX_BYTES`, `ExpiringCapabilityTokenOptions`, `extractProducedState`, `extractRequestContext`, `fetchModelCatalog`, `fieldAcceptsFreeText`, `finalizeAssistantParts`, `finalizePendingInteractionParts`, `findCustomTool`, `flattenHistory`, `FlowSpan`, `FlowTrace`, `formatPreflightReport`, `getClient`, `getPartKey`, `handleAppToolRequest`, `HandleToolRequestOptions`, `Harness`, `historyContentWithAttachments`, `httpHeadProbe`, `HttpHeadProbeConfig`, `HubExecClient`, `HubExecClientOptions`, `HubExecErrorCode`, `HubExecResult`, `HubInvokeDeps`, `HubInvokeInput`, `HubInvokeOutcome`, `ImageBackground`, `ImageContent`, `ImageContentSchema`, `ImageImageLayer`, `ImageLayer`, `ImageLayerType`, `ImageLogoLayer`, `ImageShapeLayer`, `ImageSlide`, `ImageTextLayer`, `InMemoryDurableChatStateStore`, `InMemoryDurableChatStore`, `InMemoryMissionStore`, `INTERACTION_CANCEL_EVENT`, `INTERACTION_EVENT`, `INTERACTION_RESOLVED_EVENT`, `InteractionAnswerBodyValidation`, `InteractionAnswerRoute`, `InteractionAnswerRouteOptions`, `InteractionAnswers`, `InteractionAnswerValue`, `InteractionCancelData`, `InteractionClientOutcome`, `InteractionConnectionResolution`, `InteractionData`, `interactionFromWireRequest`, `InteractionOutcome`, `interactionPartKey`, `InteractionPersistedPart`, `InteractionRequest`, `InteractionRequestWire`, `InteractionRouteLogger`, `interactionToPersistedPart`, `invokeIntegrationHub`, `isAppToolName`, `isChatAttachmentPart`, `isChatInteractionPart`, `isChatMentionPart`, `isChatPlanPart`, `isChatStepFinishPart`, `isChatTextPart`, `isChatToolPart`, `isHarness`, `isMissionStopRequested`, `isMissionTerminal`, `isModelCompatibleWithHarness`, `isRenderableInteractionKind`, `isSafeInteractionFieldKey`, `isSandboxTerminalWsUpgrade`, `isTangleBillingEnforcementDisabled`, `isTangleExecutionKeyError`, `isTerminalInteractionStatus`, `isTerminalPromptEvent`, `JsonObject`, `JsonRecord`, `KeyCrypto`, `KeyProvisioner`, `KnowledgeCandidate`, `KnowledgeDecider`, `KnowledgeDeciderInput`, `KnowledgeDecision`, `KnowledgeGateVerdict`, `KnowledgeLoop`, `KnowledgeLoopConfig`, `KnowledgeLoopDriver`, `KnowledgeRequirementSpec`, `KnowledgeSignal`, `KnowledgeSourceSpec`, `KnowledgeStateAccessor`, `KNOWN_HARNESSES`, `KvLike`, `lightTheme`, `listSessionInteractions`, `LivenessProbeConfig`, `LoopAssistantToolCall`, `LoopEvent`, `LoopMessage`, `LoopToolCall`, `LoopTraceEventLike`, `loopTraceEventsToFlowSpans`, `mapInteractionRespondFailure`, `maskSpans`, `matchSandboxTerminalWsPath`, `MCP_PROTOCOL_VERSIONS`, `McpProtocolVersion`, `McpServerInfo`, `McpToolDefinition`, `MemberSyncSeam`, `mentionInputToPart`, `mentionPartsFromMessageParts`, `mergeExtraMcp`, `mergeHistoryIntoParts`, `mergeMissionState`, `mergePersistedPart`, `mergeSurfaceOverlay`, `messageHasTurnId`, `mintSandboxScopedToken`, `mintTerminalProxyToken`, `MISSING_TOOL_TERMINAL_ERROR`, `MISSING_TOOL_TERMINAL_REASON`, `MISSION_CONTROL_CHANNEL_ID`, `MissionApprovalsPort`, `MissionAuditEvent`, `MissionConcurrencyError`, `MissionCostLedger`, `MissionEngine`, `MissionEngineOptions`, `MissionEventSink`, `MissionFlowStep`, `MissionGateKind`, `MissionGateOptions`, `MissionGateProposal`, `MissionOutcome`, `MissionPlanRunOptions`, `MissionProposalResolution`, `MissionRecord`, `MissionService`, `MissionServiceOptions`, `MissionState`, `MissionStatus`, `MissionStep`, `MissionStepState`, `MissionStepStatus`, `MissionStorePort`, `MissionStreamEvent`, `MissionStreamStatus`, `MissionStreamStep`, `MissionStreamStepStatus`, `MissionTraceContext`, `MissionUpdateGuard`, `MissionUpdatePatch`, `ModelCatalog`, `modelProvider`, `noopEventSink`, `normalizeClientTurnId`, `normalizeModelId`, `normalizePersistedPart`, `normalizePlanDecision`, `normalizeTime`, `normalizeToolEvent`, `NoticeKind`, `noticePart`, `noticePartKey`, `NoticePersistedPart`, `ObjectBody`, `objectKey`, `ObjectKeyParts`, `ObjectStore`, `OpenAICompatStreamTurnOptions`, `OpenAIFunctionTool`, `OpenAIStreamChunk`, `Outcome`, `outcomeStatus`, `parseAssetSpec`, `ParsedIntegrationAction`, `ParsedMission`, `ParsedMissionStep`, `parseInteractionAnswers`, `ParseInteractionAnswersResult`, `parseInteractionCancel`, `parseInteractionRequest`, `ParseInteractionResult`, `parseJsonObjectBody`, `parseMissionBlocks`, `ParseMissionBlocksOptions`, `parsePlanSubmittedEvent`, `ParsePlanSubmittedResult`, `parseSessionStreamEnvelope`, `peekWorkspaceSandbox`, `PeekWorkspaceSandboxOutcome`, `PersistedChatMessageForTurn`, `persistedPartToInteraction`, `persistedPartToPlan`, `PLAN_SUBMITTED_EVENT`, `planAuthorityIdempotencyKey`, `planCommandKey`, `planEffectKey`, `planFollowUpTurnId`, `PlanLimit`, `PlanOutcome`, `planPartKey`, `planRevisionKey`, `planToPersistedPart`, `PlatformBalanceInfo`, `PlatformBalanceManager`, `PlatformBalanceManagerOptions`, `PlatformBillingClient`, `PlatformIdentity`, `PlatformProductUsage`, `PreflightProbe`, `PreflightProbeResult`, `PreflightProbeVerdict`, `PreflightReport`, `PreparedDurableInteractionAnswer`, `PRESET_MIGRATION_SQL`, `PRESET_TABLES`, `PresetBillingOptions`, `PresetKnowledgeAccessorOptions`, `PresetToolHandlerOptions`, `producedFromToolEvents`, `ProducedState`, `ProfileComposeOptions`, `PromptInputPart`, `ProviderResolutionConfig`, `PROVISION_PAYLOAD_MAX_BYTES`, `ProvisionPayloadSections`, `ProvisionProfileSection`, `pumpBufferedTurn`, `PumpBufferedTurnOptions`, `PutObjectOptions`, `questionInteractionContentSignature`, `R2LikeBucket`, `R2LikeObjectBody`, `R2LikeObjectHead`, `RateLimitResult`, `readCookieValue`, `readSandboxBinaryBytes`, `readSecret`, `readToolArgs`, `recordDurableInteractionAnswer`, `recordDurableInteractionCancel`, `RedactedDocSegment`, `RedactedDocument`, `redactForIngestion`, `RedactForIngestionOptions`, `RedactionPattern`, `RedactionSpan`, `reduceMissionEvents`, `renderHistogram`, `RenderUiArgs`, `RenderUiResult`, `renderWaterfall`, `replayTurnEvents`, `ReplayTurnEventsOptions`, `RequestContext`, `requireString`, `resetClientCache`, `resolveChatTurn`, `ResolvedAgentProfile`, `ResolvedChatTurn`, `ResolvedModel`, `ResolvedSessionHarness`, `ResolvedTangleExecutionKey`, `ResolvedToolCapabilities`, `resolveIntegrationAction`, `ResolveInteractionConnectionArgs`, `resolveModel`, `ResolveModelOptions`, `resolveSandboxClientCredentials`, `ResolveSandboxClientCredentialsOptions`, `resolveSessionHarness`, `ResolveSessionHarnessInput`, `resolveTangleDevOrUserKey`, `ResolveTangleDevOrUserKeyOptions`, `resolveTangleExecutionEnvironment`, `resolveTangleModelConfig`, `resolveToolCapabilities`, `ResolveToolCapabilitiesOptions`, `resolveToolId`, `resolveToolName`, `resolveUserTangleExecutionKey`, `resolveUserTangleExecutionKeyForUser`, `ResolveUserTangleExecutionKeyForUserOptions`, `ResolveUserTangleExecutionKeyOptions`, `respondToSessionInteraction`, `restrictTaxonomy`, `RetryableStepError`, `RevealResult`, `revealSpan`, `RevealSpanOptions`, `reviewCandidate`, `routerChatProbe`, `RouterChatProbeConfig`, `RouterModel`, `runAppToolLoop`, `runPreflight`, `runSandboxPrompt`, `runSandboxToolPathSetup`, `RuntimeEventLike`, `RuntimeExecutorOptions`, `safeParseAssetSpec`, `SandboxApiCredentials`, `sandboxAuthProbe`, `SandboxAuthProbeConfig`, `SandboxBuildContext`, `SandboxClientCredentials`, `SandboxCredentialEnvironment`, `SandboxDispatch`, `SandboxDispatchDoneResult`, `SandboxDispatchInProgressResult`, `SandboxDispatchInput`, `SandboxDispatchResult`, `SandboxExecChannel`, `SandboxExecOptions`, `SandboxFileBytesOutcome`, `SandboxFileSizeOutcome`, `SandboxPermissionLevel`, `SandboxResourceConfig`, `SandboxRestoreSpec`, `SandboxRuntimeAuthRefreshError`, `SandboxRuntimeConfig`, `SandboxRuntimeConnection`, `SandboxScope`, `SandboxStepTransition`, `SandboxTerminalTokenOptions`, `SandboxTerminalTokenResult`, `SandboxTerminalTokenSubject`, `SandboxTerminalWsMatch`, `sandboxToolBinDir`, `sandboxToolPath`, `SandboxToolPathOptions`, `sandboxToolRootDir`, `SandboxToolSpec`, `SatisfiedBy`, `SatisfiedByRule`, `ScheduleFollowupArgs`, `ScheduleFollowupResult`, `ScopedMcpServerEntryOptions`, `ScopedTokenResult`, `SecretStore`, `secretStoreFromClient`, `SecurityHeaderOptions`, `serializeCookie`, `SetStepStatusPatch`, `SharedBillingState`, `shellQuote`, `SidecarInteractionsConnection`, `SidecarInteractionsError`, `SidecarInteractionsResult`, `signObjectUrl`, `SignObjectUrlArgs`, `snapHarnessToModel`, `snapModelToHarness`, `splitDeferredProfileFiles`, `stablePlanReceipt`, `stampInteractionAnswers`, `statSandboxFileSize`, `stepActivityFlowTrace`, `stepAgentActivity`, `StepAgentActivity`, `StepGateClassification`, `stepGateProposalId`, `StepOutcome`, `StepSpanContext`, `StoppedSandboxResumeFailure`, `StoppedSandboxResumeRecovery`, `StorableHarnessPartKind`, `StorageConfig`, `storeSecret`, `streamAppToolLoop`, `StreamAppToolLoopOptions`, `StreamEvent`, `StreamLoopYield`, `streamSandboxPrompt`, `StreamSandboxPromptOptions`, `SubmitProposalArgs`, `SubmitProposalResult`, `summarize`, `SurfaceKindDefinition`, `SurfaceMcpServer`, `SurfaceMergeBase`, `SurfaceOverlay`, `SurfacePermissionValue`, `SurfaceRegistry`, `syncSandboxMemberAdd`, `syncSandboxMemberRemove`, `syncSandboxMemberRole`, `TangleBillingEnforcementOptions`, `TangleExecutionEnvironment`, `TangleExecutionKeyError`, `TangleExecutionKeyErrorCode`, `tangleExecutionKeyHttpError`, `TangleExecutionKeyHttpError`, `TangleExecutionKeySource`, `TangleModelConfig`, `TaskGold`, `TcloudKeyClient`, `terminalizeDanglingAssistantToolUpdates`, `terminalizeDanglingToolPart`, `terminalizeDanglingToolParts`, `TerminalProxyIdentity`, `terminalTokenFromRequest`, `themeColor`, `ThemeContractMiss`, `ThemeContractOptions`, `ThemeContractResult`, `themeToCssVars`, `threadTitleFromMessage`, `TimedEvent`, `timedEventsFromLines`, `toChatMessageParts`, `toLoopEvents`, `ToolAuthResult`, `ToolCapability`, `ToolHeaderNames`, `ToolInputError`, `ToolLoopEvent`, `ToolLoopResult`, `ToolLoopStopReason`, `traceEnv`, `trimOrNull`, `TURN_EVENTS_MIGRATION_SQL`, `TURN_STATUS_SCOPE_MIGRATION_SQL`, `TurnEventStore`, `TurnStatus`, `upsertDurableInteractionAsk`, `validateInteractionAnswerBody`, `VaultKv`, `verifyCapabilityToken`, `verifyCompletion`, `verifyExpiringCapabilityToken`, `verifyObjectUrl`, `VerifyObjectUrlResult`, `verifySandboxTerminalToken`, `verifyTerminalProxyToken`, `VideoCaption`, `VideoContent`, `VideoContentSchema`, `VideoCountdownScene`, `VideoImageRevealScene`, `VideoScene`, `VideoSlideScene`, `VideoTextAnimationScene`, `volumeGateProposalId`, `weightedComposite`, `WithAgentActivity`, `WorkspaceKeyManager`, `WorkspaceKeyManagerOptions`, `WorkspaceKeyRecord`, `WorkspaceKeyStore`, `WorkspaceModelKeyUsage`, `WorkspaceSandboxConnectionArgs`, `WorkspaceSandboxConnectionHandlerOptions`, `WorkspaceSandboxEnsureContext`, `WorkspaceSandboxInstanceLike`, `WorkspaceSandboxManager`, `WorkspaceSandboxManagerOptions`, `WorkspaceSandboxRuntimeProxyArgs`, `WorkspaceSandboxRuntimeProxyHandlerOptions`, `WorkspaceSandboxTerminalUpgradeHandlerOptions`, `WriteProfileFilesOptions`, `writeProfileFilesToBox` +`AddCitationArgs`, `AddCitationResult`, `addSecurityHeaders`, `AgentAppConfig`, `agentAppConfigJsonSchema`, `AgentAppTheme`, `AgentIdentityConfig`, `AgentIntegrationsConfig`, `AgentKnowledgeConfig`, `AgentRuntime`, `AgentRuntimeModelConfig`, `AgentTaxonomyConfig`, `AgentTurnOptions`, `AgentUiConfig`, `AnySurfaceKind`, `APP_TOOL_NAMES`, `applyDurableInteractionAnswer`, `applyDurableInteractionAsk`, `applyDurableInteractionCancel`, `applyMissionEvent`, `ApprovalEvent`, `ApprovalEventSchema`, `AppToolContext`, `AppToolDefinition`, `AppToolDescriptor`, `AppToolHandlers`, `AppToolLoopOptions`, `AppToolMcpServer`, `AppToolName`, `AppToolOutcome`, `AppToolProducedEvent`, `AppToolRuntimeExecutor`, `AppToolTaxonomy`, `asMissionStreamEvent`, `asRecord`, `assertEnvWithinLimits`, `assertHarnessModelCompatible`, `assertMediaUrl`, `assertProvisionPayloadWithinCap`, `assertSafeKeySegment`, `AssetContentMap`, `AssetFormat`, `AssetSpec`, `AssetStatus`, `AssetVariant`, `asString`, `attachmentInputToPart`, `attachmentKindForMime`, `attachmentPartKey`, `attachmentPartsFromMessageParts`, `attachReasoningEffort`, `AuthenticatedSandboxUser`, `AuthenticateOptions`, `authenticateToolRequest`, `bearerSubprotocolToken`, `bearerToken`, `BeforeInteractionAnswerArgs`, `BrandTokens`, `BrandTokensSchema`, `BrokerToken`, `BrokerTokenMinter`, `BrokerTokenProvider`, `BrokerTokenProviderOptions`, `budgetGateProposalId`, `BufferedTurnEvent`, `BufferedTurnOptions`, `BufferedTurnTap`, `buildAgentMissionPlan`, `buildAppToolMcpServer`, `buildAppToolMcpServers`, `BuildAppToolMcpServersOptions`, `buildAppToolOpenAITools`, `BuildAppToolsOptions`, `buildAttachmentPromptBlock`, `buildCatalog`, `buildConsentUrl`, `buildFlowTrace`, `buildHttpMcpServer`, `BuildHttpMcpServerOptions`, `buildKnowledgeRequirements`, `BuildMcpServerOptions`, `buildRedactedDocument`, `BuildRedactedDocumentOptions`, `buildSandboxRuntimeProxyHeaders`, `buildSandboxToolFileMounts`, `BuildSandboxToolFileMountsOptions`, `buildSandboxToolPathSetupScript`, `buildScopedMcpServerEntry`, `buildUserTextParts`, `BULK_DELETE_MAX_THREADS`, `cancelStatusFor`, `canTransitionInteractionStatus`, `canTransitionPlanStatus`, `CanvasRenderPalette`, `CapabilityTokenOptions`, `CatalogModel`, `CertifiedDelivery`, `CertifiedDeliveryConfig`, `ChatAttachmentKind`, `ChatAttachmentPart`, `ChatFilePart`, `ChatImagePart`, `ChatInteraction`, `ChatInteractionField`, `ChatInteractionPart`, `ChatInteractionStatus`, `ChatMentionKind`, `ChatMentionPart`, `ChatMessagePart`, `ChatNoticePart`, `ChatPartTime`, `ChatPlan`, `ChatPlanPart`, `ChatPlanPersistedPart`, `ChatPlanStatus`, `ChatReasoningPart`, `ChatSelectField`, `ChatStepFinishPart`, `ChatStepStartPart`, `ChatStoreInputError`, `ChatSubtaskPart`, `ChatTextPart`, `ChatToolPart`, `ChatToolState`, `ChatToolStatus`, `ChatUsageTokens`, `checkRateLimit`, `checkThemeContract`, `childSpanContext`, `classifySeveredStream`, `clearCookieHeader`, `coalesceChatStreamEvents`, `coalesceDeltas`, `coerceHarness`, `collapseRedundantTextParts`, `CompleteMissionInput`, `CompletionRequirement`, `CompletionVerdict`, `composeMissionFlowTrace`, `composerAnswerData`, `composerAnswerDeliveries`, `ComposerAnswerDelivery`, `ConsentUrlInput`, `ConversionMetrics`, `ConversionMetricsSchema`, `CookieOptions`, `CopyContent`, `CopyContentSchema`, `CopyPlatform`, `CorrectnessChecker`, `createAgentRuntime`, `CreateAgentRuntimeOptions`, `createAppToolRuntimeExecutor`, `createBrokerTokenProvider`, `createBufferedTurnTap`, `createCapabilityToken`, `createCertifiedDelivery`, `createD1KnowledgeStateAccessor`, `createD1TurnEventStore`, `createDurableChatEventProjection`, `createDurableChatScope`, `createDurableInteractionProjectionAdapter`, `createDurableInteractionRoutePersistence`, `CreateDurableInteractionRoutePersistenceOptions`, `createDurableInteractionSettlement`, `createDurablePlanRoutes`, `createExpiringCapabilityToken`, `createFieldCrypto`, `createInMemoryDurableChatStateStore`, `createInMemoryMissionStore`, `createInteractionAnswerRoute`, `createKnowledgeLoop`, `CreateKnowledgeLoopDeps`, `createLlmCorrectnessChecker`, `createMcpToolHandler`, `CreateMcpToolHandlerOptions`, `createMemoryTurnEventStore`, `createMissionEngine`, `CreateMissionInput`, `createMissionService`, `createMissionTraceContext`, `createOpenAICompatStreamTurn`, `createPlatformBalanceManager`, `createPresetDrizzleSchema`, `createPresetFieldCrypto`, `createPresetToolHandlers`, `createPresetWorkspaceKeyManager`, `createPresetWorkspaceKeyStore`, `createProxiedArtifactRoute`, `createR2ObjectStore`, `createReviewerDecider`, `createSandboxTerminalToken`, `createSurfaceRegistry`, `createTangleRouterModelConfig`, `CreateTangleRouterModelConfigOptions`, `createTcloudKeyProvisioner`, `createTokenRecallChecker`, `createWorkspaceKeyManager`, `createWorkspaceSandboxConnectionHandler`, `createWorkspaceSandboxManager`, `createWorkspaceSandboxRuntimeProxyHandler`, `createWorkspaceSandboxTerminalUpgradeHandler`, `customToolToOpenAI`, `D1Like`, `D1LikeForTurns`, `D1PreparedLike`, `darkTheme`, `decodeHexKey`, `decryptAesGcm`, `decryptBytes`, `decryptWithKey`, `dedupeQuestionInteractionsByContent`, `DEFAULT_APP_TOOL_PATHS`, `DEFAULT_ATTACHMENT_PROMPT_HEADER`, `DEFAULT_HARNESS`, `DEFAULT_HEADER_NAMES`, `DEFAULT_MISSION_STEP_KINDS`, `DEFAULT_REDACTION_PATTERNS`, `DEFAULT_SANDBOX_RESOURCES`, `DEFAULT_TANGLE_BILLING_ENFORCEMENT_ENV_VAR`, `DEFAULT_TANGLE_ROUTER_BASE_URL`, `deferredCorpusHash`, `defineAgentApp`, `defineAppTool`, `defineSurfaceKind`, `delegationActivityToFlowSpans`, `deleteSecret`, `deriveKey`, `DeriveKeyOptions`, `deriveSignals`, `detectInteractiveQuestion`, `detectSpans`, `dispatchAppTool`, `DispatchOptions`, `DistributionSummary`, `draftAssistantParts`, `driveSandboxTurn`, `DriveSandboxTurnOptions`, `DrizzleColumnLike`, `DrizzleSqliteCoreLike`, `DurableAnswerIntentJournal`, `DurableAnswerIntentRecord`, `DurableAnswerIntentState`, `DurableChatConflictError`, `DurableChatError`, `DurableChatErrorCode`, `DurableChatEventProjection`, `DurableChatGoneError`, `DurableChatScope`, `durableChatScopeKey`, `DurableChatStateStore`, `DurableChatUnavailableError`, `DurableFollowUpReceipt`, `DurableInteractionAcknowledgement`, `DurableInteractionGuarantee`, `durableInteractionIntentKey`, `DurableInteractionProjection`, `DurableInteractionProjectionAdapter`, `DurableInteractionRouteArgs`, `DurableInteractionRoutePersistence`, `DurableInteractionSettlement`, `DurableInteractionSettlementFactoryOptions`, `DurableInteractionSettlementOptions`, `DurablePlanAuthority`, `DurablePlanAuthorityCurrentResult`, `DurablePlanAuthorityDecision`, `DurablePlanAuthorityResult`, `DurablePlanAuthorization`, `DurablePlanCommandJournal`, `DurablePlanCommandKey`, `DurablePlanCommandRecord`, `DurablePlanCommandState`, `DurablePlanDecision`, `DurablePlanEffectRecord`, `DurablePlanProjection`, `DurablePlanRouteAuthorizeArgs`, `DurablePlanRouteOptions`, `DurablePlanRoutes`, `DurablePlanStateStore`, `DurablePlanStore`, `EmailBodySection`, `EmailContent`, `EmailContentSchema`, `EmailCtaSection`, `EmailDividerSection`, `EmailFeatureSection`, `EmailHeroSection`, `EmailSection`, `EmailTestimonialSection`, `encodeEvent`, `encodeSandboxRuntimePath`, `encryptAesGcm`, `encryptBytes`, `encryptWithKey`, `ensureWorkspaceSandbox`, `EnsureWorkspaceSandboxOptions`, `ENV_TOTAL_MAX_BYTES`, `ENV_VALUE_MAX_BYTES`, `ExpiringCapabilityTokenOptions`, `extractProducedState`, `extractRequestContext`, `fetchModelCatalog`, `fieldAcceptsFreeText`, `finalizeAssistantParts`, `finalizePendingInteractionParts`, `findCustomTool`, `flattenHistory`, `FlowSpan`, `FlowTrace`, `formatPreflightReport`, `getClient`, `getPartKey`, `handleAppToolRequest`, `HandleToolRequestOptions`, `Harness`, `historyContentWithAttachments`, `httpHeadProbe`, `HttpHeadProbeConfig`, `HubExecClient`, `HubExecClientOptions`, `HubExecErrorCode`, `HubExecResult`, `HubInvokeDeps`, `HubInvokeInput`, `HubInvokeOutcome`, `ImageBackground`, `ImageContent`, `ImageContentSchema`, `ImageImageLayer`, `ImageLayer`, `ImageLayerType`, `ImageLogoLayer`, `ImageShapeLayer`, `ImageSlide`, `ImageTextLayer`, `InMemoryDurableChatStateStore`, `InMemoryDurableChatStore`, `InMemoryMissionStore`, `INTERACTION_CANCEL_EVENT`, `INTERACTION_EVENT`, `INTERACTION_RESOLVED_EVENT`, `InteractionAnswerBodyValidation`, `InteractionAnswerRoute`, `InteractionAnswerRouteOptions`, `InteractionAnswers`, `InteractionAnswerValue`, `InteractionCancelData`, `InteractionClientOutcome`, `InteractionConnectionResolution`, `InteractionData`, `interactionFromWireRequest`, `InteractionOutcome`, `interactionPartKey`, `InteractionPersistedPart`, `InteractionRequest`, `InteractionRequestWire`, `InteractionRouteLogger`, `interactionToPersistedPart`, `invokeIntegrationHub`, `isAppToolName`, `isChatAttachmentPart`, `isChatInteractionPart`, `isChatMentionPart`, `isChatPlanPart`, `isChatStepFinishPart`, `isChatTextPart`, `isChatToolPart`, `isHarness`, `isMissionStopRequested`, `isMissionTerminal`, `isModelCompatibleWithHarness`, `isRenderableInteractionKind`, `isSafeInteractionFieldKey`, `isSandboxTerminalWsUpgrade`, `isTangleBillingEnforcementDisabled`, `isTangleExecutionKeyError`, `isTerminalInteractionStatus`, `isTerminalPromptEvent`, `JsonObject`, `JsonRecord`, `KeyCrypto`, `KeyProvisioner`, `KnowledgeCandidate`, `KnowledgeDecider`, `KnowledgeDeciderInput`, `KnowledgeDecision`, `KnowledgeGateVerdict`, `KnowledgeLoop`, `KnowledgeLoopConfig`, `KnowledgeLoopDriver`, `KnowledgeRequirementSpec`, `KnowledgeSignal`, `KnowledgeSourceSpec`, `KnowledgeStateAccessor`, `KNOWN_HARNESSES`, `KvLike`, `lightTheme`, `listSessionInteractions`, `LivenessProbeConfig`, `LoopAssistantToolCall`, `LoopEvent`, `LoopMessage`, `LoopToolCall`, `LoopTraceEventLike`, `loopTraceEventsToFlowSpans`, `mapInteractionRespondFailure`, `maskSpans`, `matchSandboxTerminalWsPath`, `MCP_PROTOCOL_VERSIONS`, `McpProtocolVersion`, `McpServerInfo`, `McpToolDefinition`, `MemberSyncSeam`, `mentionInputToPart`, `mentionPartsFromMessageParts`, `mergeExtraMcp`, `mergeHistoryIntoParts`, `mergeMissionState`, `mergePersistedPart`, `mergeSurfaceOverlay`, `messageHasTurnId`, `mintSandboxScopedToken`, `mintTerminalProxyToken`, `MISSING_TOOL_TERMINAL_ERROR`, `MISSING_TOOL_TERMINAL_REASON`, `MISSION_CONTROL_CHANNEL_ID`, `MissionApprovalsPort`, `MissionAuditEvent`, `MissionConcurrencyError`, `MissionCostLedger`, `MissionEngine`, `MissionEngineOptions`, `MissionEventSink`, `MissionFlowStep`, `MissionGateKind`, `MissionGateOptions`, `MissionGateProposal`, `MissionOutcome`, `MissionPlanRunOptions`, `MissionProposalResolution`, `MissionRecord`, `MissionService`, `MissionServiceOptions`, `MissionState`, `MissionStatus`, `MissionStep`, `MissionStepState`, `MissionStepStatus`, `MissionStorePort`, `MissionStreamEvent`, `MissionStreamStatus`, `MissionStreamStep`, `MissionStreamStepStatus`, `MissionTraceContext`, `MissionUpdateGuard`, `MissionUpdatePatch`, `ModelCatalog`, `modelProvider`, `noopEventSink`, `normalizeClientTurnId`, `normalizeModelId`, `normalizePersistedPart`, `normalizePlanDecision`, `normalizeTime`, `normalizeToolEvent`, `NoticeKind`, `noticePart`, `noticePartKey`, `NoticePersistedPart`, `ObjectBody`, `objectKey`, `ObjectKeyParts`, `ObjectStore`, `OpenAICompatStreamTurnOptions`, `OpenAIFunctionTool`, `OpenAIStreamChunk`, `Outcome`, `outcomeStatus`, `parseAssetSpec`, `ParsedIntegrationAction`, `ParsedMission`, `ParsedMissionStep`, `parseInteractionAnswers`, `ParseInteractionAnswersResult`, `parseInteractionCancel`, `parseInteractionRequest`, `ParseInteractionResult`, `parseJsonObjectBody`, `parseMissionBlocks`, `ParseMissionBlocksOptions`, `parsePlanSubmittedEvent`, `ParsePlanSubmittedResult`, `parseSessionStreamEnvelope`, `peekWorkspaceSandbox`, `PeekWorkspaceSandboxOutcome`, `PersistedChatMessageForTurn`, `persistedPartToInteraction`, `persistedPartToPlan`, `PLAN_SUBMITTED_EVENT`, `planAuthorityIdempotencyKey`, `planCommandKey`, `planEffectKey`, `planFollowUpTurnId`, `PlanLimit`, `PlanOutcome`, `planPartKey`, `planRevisionKey`, `planToPersistedPart`, `PlatformBalanceInfo`, `PlatformBalanceManager`, `PlatformBalanceManagerOptions`, `PlatformBillingClient`, `PlatformIdentity`, `PlatformProductUsage`, `PreflightProbe`, `PreflightProbeResult`, `PreflightProbeVerdict`, `PreflightReport`, `PreparedDurableInteractionAnswer`, `PRESET_MIGRATION_SQL`, `PRESET_TABLES`, `PresetBillingOptions`, `PresetKnowledgeAccessorOptions`, `PresetToolHandlerOptions`, `producedFromToolEvents`, `ProducedState`, `ProfileComposeOptions`, `PromptInputPart`, `ProviderResolutionConfig`, `PROVISION_PAYLOAD_MAX_BYTES`, `ProvisionPayloadSections`, `ProvisionProfileSection`, `pumpBufferedTurn`, `PumpBufferedTurnOptions`, `PutObjectOptions`, `questionInteractionContentSignature`, `R2LikeBucket`, `R2LikeObjectBody`, `R2LikeObjectHead`, `RateLimitResult`, `readCookieValue`, `readSandboxBinaryBytes`, `readSecret`, `readToolArgs`, `recordDurableInteractionAnswer`, `recordDurableInteractionCancel`, `RedactedDocSegment`, `RedactedDocument`, `redactForIngestion`, `RedactForIngestionOptions`, `RedactionPattern`, `RedactionSpan`, `reduceMissionEvents`, `renderHistogram`, `RenderUiArgs`, `RenderUiResult`, `renderWaterfall`, `replayTurnEvents`, `ReplayTurnEventsOptions`, `RequestContext`, `requireString`, `resetClientCache`, `resolveChatTurn`, `ResolvedAgentProfile`, `ResolvedChatTurn`, `ResolvedModel`, `ResolvedSessionHarness`, `ResolvedTangleExecutionKey`, `ResolvedToolCapabilities`, `resolveIntegrationAction`, `ResolveInteractionConnectionArgs`, `resolveModel`, `ResolveModelOptions`, `resolveSandboxClientCredentials`, `ResolveSandboxClientCredentialsOptions`, `resolveSessionHarness`, `ResolveSessionHarnessInput`, `resolveTangleDevOrUserKey`, `ResolveTangleDevOrUserKeyOptions`, `resolveTangleExecutionEnvironment`, `resolveTangleModelConfig`, `resolveToolCapabilities`, `ResolveToolCapabilitiesOptions`, `resolveToolId`, `resolveToolName`, `resolveUserTangleExecutionKey`, `resolveUserTangleExecutionKeyForUser`, `ResolveUserTangleExecutionKeyForUserOptions`, `ResolveUserTangleExecutionKeyOptions`, `respondToSessionInteraction`, `restrictTaxonomy`, `RetryableStepError`, `RevealResult`, `revealSpan`, `RevealSpanOptions`, `reviewCandidate`, `routerChatProbe`, `RouterChatProbeConfig`, `RouterModel`, `runAppToolLoop`, `runPreflight`, `runSandboxPrompt`, `runSandboxToolPathSetup`, `RuntimeEventLike`, `RuntimeExecutorOptions`, `safeParseAssetSpec`, `SandboxApiCredentials`, `sandboxAuthProbe`, `SandboxAuthProbeConfig`, `SandboxBuildContext`, `SandboxClientCredentials`, `SandboxCredentialEnvironment`, `SandboxDispatch`, `SandboxDispatchDoneResult`, `SandboxDispatchInProgressResult`, `SandboxDispatchInput`, `SandboxDispatchResult`, `SandboxExecChannel`, `SandboxExecOptions`, `SandboxFileBytesOutcome`, `SandboxFileSizeOutcome`, `SandboxPermissionLevel`, `SandboxResourceConfig`, `SandboxRestoreSpec`, `SandboxRuntimeAuthRefreshError`, `SandboxRuntimeConfig`, `SandboxRuntimeConnection`, `SandboxScope`, `SandboxStepTransition`, `SandboxTerminalTokenOptions`, `SandboxTerminalTokenResult`, `SandboxTerminalTokenSubject`, `SandboxTerminalWsMatch`, `sandboxToolBinDir`, `sandboxToolPath`, `SandboxToolPathOptions`, `sandboxToolRootDir`, `SandboxToolSpec`, `SatisfiedBy`, `SatisfiedByRule`, `ScheduleFollowupArgs`, `ScheduleFollowupResult`, `ScopedMcpServerEntryOptions`, `ScopedTokenResult`, `SecretStore`, `secretStoreFromClient`, `SecurityHeaderOptions`, `serializeCookie`, `SetStepStatusPatch`, `SharedBillingState`, `shellQuote`, `SidecarInteractionsConnection`, `SidecarInteractionsError`, `SidecarInteractionsResult`, `signObjectUrl`, `SignObjectUrlArgs`, `snapHarnessToModel`, `snapModelToHarness`, `splitDeferredProfileFiles`, `stablePlanReceipt`, `stampInteractionAnswers`, `statSandboxFileSize`, `stepActivityFlowTrace`, `stepAgentActivity`, `StepAgentActivity`, `StepGateClassification`, `stepGateProposalId`, `StepOutcome`, `StepSpanContext`, `StoppedSandboxResumeFailure`, `StoppedSandboxResumeRecovery`, `StorableHarnessPartKind`, `StorageConfig`, `storeSecret`, `streamAppToolLoop`, `StreamAppToolLoopOptions`, `StreamEvent`, `StreamLoopYield`, `streamSandboxPrompt`, `StreamSandboxPromptOptions`, `SubmitProposalArgs`, `SubmitProposalResult`, `summarize`, `SurfaceKindDefinition`, `SurfaceMcpServer`, `SurfaceMergeBase`, `SurfaceOverlay`, `SurfacePermissionValue`, `SurfaceRegistry`, `syncSandboxMemberAdd`, `syncSandboxMemberRemove`, `syncSandboxMemberRole`, `TangleBillingEnforcementOptions`, `TangleExecutionEnvironment`, `TangleExecutionKeyError`, `TangleExecutionKeyErrorCode`, `tangleExecutionKeyHttpError`, `TangleExecutionKeyHttpError`, `TangleExecutionKeySource`, `TangleModelConfig`, `TaskGold`, `TcloudKeyClient`, `terminalizeDanglingAssistantToolUpdates`, `terminalizeDanglingToolPart`, `terminalizeDanglingToolParts`, `TerminalProxyIdentity`, `terminalTokenFromRequest`, `themeColor`, `ThemeContractMiss`, `ThemeContractOptions`, `ThemeContractResult`, `themeToCssVars`, `threadTitleFromMessage`, `TimedEvent`, `timedEventsFromLines`, `toChatMessageParts`, `toLoopEvents`, `ToolAuthResult`, `ToolCapability`, `ToolHeaderNames`, `ToolInputError`, `ToolLoopEvent`, `ToolLoopResult`, `ToolLoopStopReason`, `traceEnv`, `trimOrNull`, `TURN_EVENTS_MIGRATION_SQL`, `TURN_STATUS_SCOPE_MIGRATION_SQL`, `TurnEventStore`, `TurnStatus`, `upsertDurableInteractionAsk`, `validateInteractionAnswerBody`, `VaultKv`, `verifyCapabilityToken`, `verifyCompletion`, `verifyExpiringCapabilityToken`, `verifyObjectUrl`, `VerifyObjectUrlResult`, `verifySandboxTerminalToken`, `verifyTerminalProxyToken`, `VideoCaption`, `VideoContent`, `VideoContentSchema`, `VideoCountdownScene`, `VideoImageRevealScene`, `VideoScene`, `VideoSlideScene`, `VideoTextAnimationScene`, `volumeGateProposalId`, `weightedComposite`, `WithAgentActivity`, `WorkspaceKeyManager`, `WorkspaceKeyManagerOptions`, `WorkspaceKeyRecord`, `WorkspaceKeyStore`, `WorkspaceModelKeyUsage`, `WorkspaceSandboxConnectionArgs`, `WorkspaceSandboxConnectionHandlerOptions`, `WorkspaceSandboxEnsureContext`, `WorkspaceSandboxInstanceLike`, `WorkspaceSandboxManager`, `WorkspaceSandboxManagerOptions`, `WorkspaceSandboxRuntimeProxyArgs`, `WorkspaceSandboxRuntimeProxyHandlerOptions`, `WorkspaceSandboxTerminalUpgradeHandlerOptions`, `WriteProfileFilesOptions`, `writeProfileFilesToBox` [Full API →](api/index.md) @@ -156,21 +156,21 @@ Depends on: `runtime` ## `./chat-routes` -Source: `src/chat-routes/index.ts` · 131 exports +Source: `src/chat-routes/index.ts` · 142 exports Depends on: `chat-store`, `interactions`, `plans`, `sandbox`, `stream`, `web` -`ALLOWED_ATTACHMENT_SNIFFED_MIMES`, `assertPromptPartsWithinCap`, `ATTACHMENT_ACCEPT`, `ATTACHMENT_MAX_COUNT`, `AttachmentPathArgs`, `AttachmentPathCheck`, `AttachmentReadResult`, `attachmentSizeErrorMessage`, `attachmentTotalSizeErrorMessage`, `AttachmentTypeCheckResult`, `AttachmentUploadAuthorization`, `AttachmentWriteResult`, `base64WireLen`, `buildDispatchParts`, `BuildDispatchPartsInput`, `buildMentionPromptBlock`, `bytesToBase64`, `ChatAttachmentInput`, `ChatAttachmentKind`, `ChatMentionKind`, `ChatRouteDurableProjection`, `ChatRouteDurableProjectionLogger`, `ChatTurnAuthorization`, `ChatTurnAuthorizeArgs`, `ChatTurnFilePartInput`, `ChatTurnGateResult`, `ChatTurnHeartbeat`, `ChatTurnInputError`, `ChatTurnInputPatch`, `ChatTurnLifecycle`, `ChatTurnLifecycleComplete`, `ChatTurnLifecycleError`, `ChatTurnLifecycleStart`, `ChatTurnLock`, `ChatTurnLockResult`, `ChatTurnMessageStore`, `ChatTurnPartInput`, `ChatTurnProduceArgs`, `chatTurnRequestInit`, `ChatTurnRequestPayload`, `ChatTurnRouteProducer`, `ChatTurnRoutes`, `ChatTurnTextPartInput`, `ChatTurnUsage`, `checkAttachmentType`, `createAttachmentUploadRoute`, `CreateAttachmentUploadRouteOptions`, `createChatTurnRoutes`, `CreateChatTurnRoutesOptions`, `createSandboxChatProducer`, `createSandboxFileIndexRoute`, `CreateSandboxFileIndexRouteOptions`, `createUploadRoute`, `CreateUploadRouteOptions`, `DEFAULT_STALE_TURN_LOCK_GRACE_MS`, `DEFAULT_TERMINAL_TURN_LOCK_GRACE_MS`, `defaultValidateAttachmentPath`, `DetachedTurnFinal`, `DetachedTurnOptions`, `DetachedTurnParts`, `DetachedTurnResult`, `DISPATCH_MAX_MEDIA_PARTS`, `DISPATCH_MAX_PARTS`, `DISPATCH_REQUEST_MAX_BYTES`, `DISPATCH_STRUCTURAL_RESERVE_BYTES`, `DispatchPartsOutcome`, `FileIndexAuthorization`, `FileIndexCache`, `FileIndexReadyResponse`, `FileIndexResponse`, `FileIndexWarmingResponse`, `FileMention`, `fileMentionsToParts`, `FileMentionsToPartsOptions`, `FilePartPromotionOutcome`, `formatBytes`, `INLINE_PARTS_MAX_BYTES`, `MAX_ATTACHMENT_TOTAL_BYTES`, `MAX_BINARY_ATTACHMENT_BYTES`, `MAX_TEXT_ATTACHMENT_BYTES`, `mediaTypeForMentionPath`, `MENTION_MAX_COUNT`, `mentionKindForPath`, `parseChatTurnParts`, `parseFileMentions`, `ProducerErrorEvent`, `ProducerNoticeEvent`, `ProducerPassthroughEvent`, `ProducerPassthroughEventType`, `ProducerReasoningEvent`, `ProducerTextEvent`, `ProducerToolCallEvent`, `ProducerToolResultEvent`, `ProducerUsageEvent`, `ProducerWireEvent`, `PROMOTE_MAX_FILE_BYTES`, `promoteAgentFilePart`, `PromoteAgentFilePartOptions`, `PromoteFilePartResult`, `PromptInputPart`, `promptPartsByteSize`, `RawAgentFilePart`, `ReadAttachmentFn`, `ReadSandboxMentionFn`, `reconcileStaleTurnLock`, `ReconcileStaleTurnLockOptions`, `ReconcileStaleTurnLockResult`, `resolveChatAttachments`, `ResolveChatAttachmentsOptions`, `ResolveChatAttachmentsResult`, `runDetachedTurn`, `SandboxChatProducerOptions`, `SandboxFileTreeSource`, `SandboxMentionPathCheck`, `SandboxTreeFile`, `SandboxTreeResult`, `SandboxUploadSink`, `sanitizeAttachmentFileName`, `sanitizeUploadFilename`, `sniffBinary`, `sniffMimeFromName`, `SniffResult`, `StaleTurnLockSandboxProbeResult`, `StaleTurnLockSessionProbeResult`, `UPLOAD_INLINE_MAX_BYTES`, `UPLOAD_MAX_FILE_BYTES`, `UploadAuthorization`, `UploadedChatFile`, `validateSandboxMentionPath`, `withDurableChatProjection`, `WriteAttachmentFn` +`ALLOWED_ATTACHMENT_SNIFFED_MIMES`, `assertPromptPartsWithinCap`, `AssistantDraftSnapshot`, `AssistantDraftStore`, `AssistantDraftWriter`, `AssistantDraftWriterOptions`, `assistantRowIdForTurn`, `AssistantRowValues`, `ATTACHMENT_ACCEPT`, `ATTACHMENT_MAX_COUNT`, `AttachmentPathArgs`, `AttachmentPathCheck`, `AttachmentReadResult`, `attachmentSizeErrorMessage`, `attachmentTotalSizeErrorMessage`, `AttachmentTypeCheckResult`, `AttachmentUploadAuthorization`, `AttachmentWriteResult`, `base64WireLen`, `buildDispatchParts`, `BuildDispatchPartsInput`, `buildMentionPromptBlock`, `bytesToBase64`, `ChatAttachmentInput`, `ChatAttachmentKind`, `ChatMentionKind`, `ChatRouteDurableProjection`, `ChatRouteDurableProjectionLogger`, `ChatTurnAuthorization`, `ChatTurnAuthorizeArgs`, `ChatTurnFilePartInput`, `ChatTurnGateResult`, `ChatTurnHeartbeat`, `ChatTurnInputError`, `ChatTurnInputPatch`, `ChatTurnLifecycle`, `ChatTurnLifecycleComplete`, `ChatTurnLifecycleError`, `ChatTurnLifecycleStart`, `ChatTurnLock`, `ChatTurnLockResult`, `ChatTurnMessageStore`, `ChatTurnPartInput`, `ChatTurnProduceArgs`, `chatTurnRequestInit`, `ChatTurnRequestPayload`, `ChatTurnRouteProducer`, `ChatTurnRoutes`, `ChatTurnTextPartInput`, `ChatTurnUsage`, `checkAttachmentType`, `createAssistantDraftWriter`, `createAttachmentUploadRoute`, `CreateAttachmentUploadRouteOptions`, `createChatTurnRoutes`, `CreateChatTurnRoutesOptions`, `createSandboxChatProducer`, `createSandboxFileIndexRoute`, `CreateSandboxFileIndexRouteOptions`, `createUploadRoute`, `CreateUploadRouteOptions`, `DEFAULT_STALE_TURN_LOCK_GRACE_MS`, `DEFAULT_TERMINAL_TURN_LOCK_GRACE_MS`, `defaultValidateAttachmentPath`, `DetachedTurnFinal`, `DetachedTurnOptions`, `DetachedTurnParts`, `DetachedTurnResult`, `DISPATCH_MAX_MEDIA_PARTS`, `DISPATCH_MAX_PARTS`, `DISPATCH_REQUEST_MAX_BYTES`, `DISPATCH_STRUCTURAL_RESERVE_BYTES`, `DispatchPartsOutcome`, `DraftPersistenceTuning`, `DraftStoredMessage`, `FileIndexAuthorization`, `FileIndexCache`, `FileIndexReadyResponse`, `FileIndexResponse`, `FileIndexWarmingResponse`, `FileMention`, `fileMentionsToParts`, `FileMentionsToPartsOptions`, `FilePartPromotionOutcome`, `formatBytes`, `INLINE_PARTS_MAX_BYTES`, `isDraftContentEvent`, `MAX_ATTACHMENT_TOTAL_BYTES`, `MAX_BINARY_ATTACHMENT_BYTES`, `MAX_TEXT_ATTACHMENT_BYTES`, `mediaTypeForMentionPath`, `MENTION_MAX_COUNT`, `mentionKindForPath`, `parseChatTurnParts`, `parseFileMentions`, `ProducerErrorEvent`, `ProducerNoticeEvent`, `ProducerPassthroughEvent`, `ProducerPassthroughEventType`, `ProducerReasoningEvent`, `ProducerTextEvent`, `ProducerToolCallEvent`, `ProducerToolResultEvent`, `ProducerUsageEvent`, `ProducerWireEvent`, `PROMOTE_MAX_FILE_BYTES`, `promoteAgentFilePart`, `PromoteAgentFilePartOptions`, `PromoteFilePartResult`, `PromptInputPart`, `promptPartsByteSize`, `RawAgentFilePart`, `ReadAttachmentFn`, `ReadSandboxMentionFn`, `reconcileStaleTurnLock`, `ReconcileStaleTurnLockOptions`, `ReconcileStaleTurnLockResult`, `resolveChatAttachments`, `ResolveChatAttachmentsOptions`, `ResolveChatAttachmentsResult`, `runDetachedTurn`, `SandboxChatProducerOptions`, `SandboxFileTreeSource`, `SandboxMentionPathCheck`, `SandboxTreeFile`, `SandboxTreeResult`, `SandboxUploadSink`, `sanitizeAttachmentFileName`, `sanitizeUploadFilename`, `sniffBinary`, `sniffMimeFromName`, `SniffResult`, `StaleTurnLockSandboxProbeResult`, `StaleTurnLockSessionProbeResult`, `storeSupportsDraftPersistence`, `UPLOAD_INLINE_MAX_BYTES`, `UPLOAD_MAX_FILE_BYTES`, `UploadAuthorization`, `UploadedChatFile`, `validateSandboxMentionPath`, `withDurableChatProjection`, `WriteAttachmentFn` [Full API →](api/chat-routes.md) ## `./chat-store` -Source: `src/chat-store/index.ts` · 59 exports +Source: `src/chat-store/index.ts` · 60 exports Depends on: `chat-routes`, `interactions`, `plans`, `stream`, `web-react` -`AppendMessageInput`, `attachmentInputToPart`, `attachmentKindForMime`, `attachmentPartKey`, `attachmentPartsFromMessageParts`, `buildAttachmentPromptBlock`, `BULK_DELETE_MAX_THREADS`, `BulkDeleteThreadsInput`, `ChatAttachmentKind`, `ChatAttachmentPart`, `ChatDatabase`, `ChatFilePart`, `ChatImagePart`, `ChatInteractionPart`, `ChatMentionKind`, `ChatMentionPart`, `ChatMessagePart`, `ChatMessageRow`, `ChatNoticePart`, `ChatParentTable`, `ChatPartTime`, `ChatPlanPart`, `ChatReasoningPart`, `ChatStepFinishPart`, `ChatStepStartPart`, `ChatStore`, `ChatStoreInputError`, `ChatSubtaskPart`, `ChatTables`, `ChatTextPart`, `ChatThreadRow`, `ChatToolPart`, `ChatToolState`, `ChatToolStatus`, `ChatUsageTokens`, `createChatStore`, `createChatTables`, `CreateChatTablesOptions`, `CreateThreadInput`, `DEFAULT_ATTACHMENT_PROMPT_HEADER`, `historyContentWithAttachments`, `isChatAttachmentPart`, `isChatInteractionPart`, `isChatMentionPart`, `isChatPlanPart`, `isChatStepFinishPart`, `isChatTextPart`, `isChatToolPart`, `ListMessagesOptions`, `ListThreadsInput`, `ListThreadsResult`, `mentionInputToPart`, `mentionPartsFromMessageParts`, `NewChatMessageRow`, `NewChatThreadRow`, `StorableHarnessPartKind`, `threadTitleFromMessage`, `toChatMessageParts`, `WorkspaceAccessCheck` +`AppendMessageInput`, `attachmentInputToPart`, `attachmentKindForMime`, `attachmentPartKey`, `attachmentPartsFromMessageParts`, `buildAttachmentPromptBlock`, `BULK_DELETE_MAX_THREADS`, `BulkDeleteThreadsInput`, `ChatAttachmentKind`, `ChatAttachmentPart`, `ChatDatabase`, `ChatFilePart`, `ChatImagePart`, `ChatInteractionPart`, `ChatMentionKind`, `ChatMentionPart`, `ChatMessagePart`, `ChatMessageRow`, `ChatNoticePart`, `ChatParentTable`, `ChatPartTime`, `ChatPlanPart`, `ChatReasoningPart`, `ChatStepFinishPart`, `ChatStepStartPart`, `ChatStore`, `ChatStoreInputError`, `ChatSubtaskPart`, `ChatTables`, `ChatTextPart`, `ChatThreadRow`, `ChatToolPart`, `ChatToolState`, `ChatToolStatus`, `ChatUsageTokens`, `createChatStore`, `createChatTables`, `CreateChatTablesOptions`, `CreateThreadInput`, `DEFAULT_ATTACHMENT_PROMPT_HEADER`, `historyContentWithAttachments`, `isChatAttachmentPart`, `isChatInteractionPart`, `isChatMentionPart`, `isChatPlanPart`, `isChatStepFinishPart`, `isChatTextPart`, `isChatToolPart`, `ListMessagesOptions`, `ListThreadsInput`, `ListThreadsResult`, `mentionInputToPart`, `mentionPartsFromMessageParts`, `NewChatMessageRow`, `NewChatThreadRow`, `StorableHarnessPartKind`, `threadTitleFromMessage`, `toChatMessageParts`, `UpdateMessageInput`, `WorkspaceAccessCheck` [Full API →](api/chat-store.md) @@ -542,11 +542,11 @@ Source: `src/store/index.ts` · 8 exports ## `./stream` -Source: `src/stream/index.ts` · 44 exports +Source: `src/stream/index.ts` · 45 exports Depends on: `interactions`, `plans` -`asRecord`, `asString`, `attachmentPartKey`, `BufferedTurnEvent`, `BufferedTurnOptions`, `BufferedTurnTap`, `buildUserTextParts`, `coalesceChatStreamEvents`, `coalesceDeltas`, `collapseRedundantTextParts`, `createBufferedTurnTap`, `createD1TurnEventStore`, `createMemoryTurnEventStore`, `D1LikeForTurns`, `encodeEvent`, `finalizeAssistantParts`, `finalizePendingInteractionParts`, `getPartKey`, `JsonRecord`, `mergePersistedPart`, `messageHasTurnId`, `MISSING_TOOL_TERMINAL_ERROR`, `MISSING_TOOL_TERMINAL_REASON`, `normalizeClientTurnId`, `normalizePersistedPart`, `normalizeTime`, `normalizeToolEvent`, `PersistedChatMessageForTurn`, `pumpBufferedTurn`, `PumpBufferedTurnOptions`, `replayTurnEvents`, `ReplayTurnEventsOptions`, `resolveChatTurn`, `ResolvedChatTurn`, `resolveToolId`, `resolveToolName`, `StreamEvent`, `terminalizeDanglingAssistantToolUpdates`, `terminalizeDanglingToolPart`, `terminalizeDanglingToolParts`, `TURN_EVENTS_MIGRATION_SQL`, `TURN_STATUS_SCOPE_MIGRATION_SQL`, `TurnEventStore`, `TurnStatus` +`asRecord`, `asString`, `attachmentPartKey`, `BufferedTurnEvent`, `BufferedTurnOptions`, `BufferedTurnTap`, `buildUserTextParts`, `coalesceChatStreamEvents`, `coalesceDeltas`, `collapseRedundantTextParts`, `createBufferedTurnTap`, `createD1TurnEventStore`, `createMemoryTurnEventStore`, `D1LikeForTurns`, `draftAssistantParts`, `encodeEvent`, `finalizeAssistantParts`, `finalizePendingInteractionParts`, `getPartKey`, `JsonRecord`, `mergePersistedPart`, `messageHasTurnId`, `MISSING_TOOL_TERMINAL_ERROR`, `MISSING_TOOL_TERMINAL_REASON`, `normalizeClientTurnId`, `normalizePersistedPart`, `normalizeTime`, `normalizeToolEvent`, `PersistedChatMessageForTurn`, `pumpBufferedTurn`, `PumpBufferedTurnOptions`, `replayTurnEvents`, `ReplayTurnEventsOptions`, `resolveChatTurn`, `ResolvedChatTurn`, `resolveToolId`, `resolveToolName`, `StreamEvent`, `terminalizeDanglingAssistantToolUpdates`, `terminalizeDanglingToolPart`, `terminalizeDanglingToolParts`, `TURN_EVENTS_MIGRATION_SQL`, `TURN_STATUS_SCOPE_MIGRATION_SQL`, `TurnEventStore`, `TurnStatus` [Full API →](api/stream.md) diff --git a/docs/api/chat-routes.md b/docs/api/chat-routes.md index b49b0f7..287186c 100644 --- a/docs/api/chat-routes.md +++ b/docs/api/chat-routes.md @@ -4,7 +4,7 @@ Source: `src/chat-routes/index.ts` -131 exports. +142 exports. ### `ALLOWED_ATTACHMENT_SNIFFED_MIMES` @@ -22,6 +22,54 @@ ReadonlySet (parts: ChatTurnPartInput[], maxBytes?: number) => void ``` +### `AssistantDraftSnapshot` + +`interface` — Live snapshot of the assistant body, taken from the producer's own accumulators. + +```ts +interface AssistantDraftSnapshot +``` + +### `AssistantDraftStore` + +`interface` — The store capability incremental persistence needs on top of `appendMessage`. + +```ts +interface AssistantDraftStore +``` + +### `AssistantDraftWriter` + +`interface` — Coalescing writer that keeps one durable assistant row in step with a streaming turn. + +```ts +interface AssistantDraftWriter +``` + +### `AssistantDraftWriterOptions` + +`interface` — Define the inputs required to construct an assistant draft writer + +```ts +interface AssistantDraftWriterOptions +``` + +### `assistantRowIdForTurn` + +`function` — The default deterministic assistant-row id for a turn. + +```ts +(turnKey: string) => string +``` + +### `AssistantRowValues` + +`interface` — Values written to the assistant row — the intersection of the append and patch shapes, so one snapshot serves both. + +```ts +interface AssistantRowValues +``` + ### `ATTACHMENT_ACCEPT` `const` — Accept list for the composer file picker + type validation, same grammar as the native `` attribute. @@ -366,6 +414,14 @@ interface ChatTurnUsage (fileName: string, sniff: SniffResult, allowed?: ReadonlySet) => AttachmentTypeCheckResult ``` +### `createAssistantDraftWriter` + +`function` — Build the coalescing draft writer for one turn. + +```ts +(options: AssistantDraftWriterOptions) => AssistantDraftWriter +``` + ### `createAttachmentUploadRoute` `function` — Resolve an attachment upload route handler with customizable limits and validation options @@ -534,6 +590,22 @@ number type DispatchPartsOutcome ``` +### `DraftPersistenceTuning` + +`interface` — Product-tunable cadence. + +```ts +interface DraftPersistenceTuning +``` + +### `DraftStoredMessage` + +`interface` — Message row shape the writer reads back when re-entering a turn. + +```ts +interface DraftStoredMessage +``` + ### `FileIndexAuthorization` `type` — Define authorization details and parameters for indexing a file workspace with optional caching and ignore rules @@ -622,6 +694,14 @@ type FilePartPromotionOutcome 950000 ``` +### `isDraftContentEvent` + +`function` — True when this event should arm a draft write. + +```ts +(event: { type?: unknown; }) => boolean +``` + ### `MAX_ATTACHMENT_TOTAL_BYTES` `const` — Aggregate raw-byte ceiling across one message's attachments. @@ -998,6 +1078,14 @@ type StaleTurnLockSandboxProbeResult type StaleTurnLockSessionProbeResult ``` +### `storeSupportsDraftPersistence` + +`function` — True when a store can support incremental persistence at all. + +```ts +(store: AssistantDraftStore) => boolean +``` + ### `UPLOAD_INLINE_MAX_BYTES` `const` — 700 KiB: base64 inflates ~4/3, so an inline part stays comfortably under the ~1 MiB gateway body cap alongside the JSON envelope. diff --git a/docs/api/chat-store.md b/docs/api/chat-store.md index 1b886f7..34fa798 100644 --- a/docs/api/chat-store.md +++ b/docs/api/chat-store.md @@ -4,7 +4,7 @@ Source: `src/chat-store/index.ts` -59 exports. +60 exports. ### `AppendMessageInput` @@ -470,6 +470,14 @@ type StorableHarnessPartKind (parts: Record[]) => ChatMessagePart[] ``` +### `UpdateMessageInput` + +`interface` — Fields an existing message row may be patched with. + +```ts +interface UpdateMessageInput +``` + ### `WorkspaceAccessCheck` `type` — Product-injected access check. diff --git a/docs/api/index.md b/docs/api/index.md index 232b1c7..7b64069 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -4,7 +4,7 @@ Source: `src/index.ts` -766 exports. +767 exports. ### `AddCitationArgs` @@ -1886,6 +1886,14 @@ interface DispatchOptions interface DistributionSummary ``` +### `draftAssistantParts` + +`function` — The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer. + +```ts +(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[] +``` + ### `driveSandboxTurn` `function` — Resolve a sandbox turn by processing a message with given configuration and options @@ -4499,7 +4507,7 @@ interface RequestContext `function` — Resolve a chat turn by determining message reuse and constructing user message parts ```ts -(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso… +(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning… ``` ### `ResolvedAgentProfile` diff --git a/docs/api/stream.md b/docs/api/stream.md index 69be1fb..26e2bba 100644 --- a/docs/api/stream.md +++ b/docs/api/stream.md @@ -4,7 +4,7 @@ Source: `src/stream/index.ts` -44 exports. +45 exports. ### `asRecord` @@ -118,6 +118,14 @@ interface BufferedTurnTap interface D1LikeForTurns ``` +### `draftAssistantParts` + +`function` — The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer. + +```ts +(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[] +``` + ### `encodeEvent` `function` — Encode a StreamEvent object into a Uint8Array using the provided TextEncoder @@ -267,7 +275,7 @@ interface ReplayTurnEventsOptions `function` — Resolve a chat turn by determining message reuse and constructing user message parts ```ts -(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso… +(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning… ``` ### `ResolvedChatTurn` diff --git a/docs/codemap.json b/docs/codemap.json index cc52cb8..58ad693 100644 --- a/docs/codemap.json +++ b/docs/codemap.json @@ -1474,6 +1474,12 @@ "signature": "interface DistributionSummary", "doc": "Summarize key statistics of a numerical distribution including count, min, percentiles, and max" }, + { + "name": "draftAssistantParts", + "kind": "function", + "signature": "(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[]", + "doc": "The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer." + }, { "name": "driveSandboxTurn", "kind": "function", @@ -3433,7 +3439,7 @@ { "name": "resolveChatTurn", "kind": "function", - "signature": "(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso…", + "signature": "(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning…", "doc": "Resolve a chat turn by determining message reuse and constructing user message parts" }, { @@ -5698,6 +5704,42 @@ "signature": "(parts: ChatTurnPartInput[], maxBytes?: number) => void", "doc": "Throws `ChatTurnInputError` (413) when the parts' inline payload would blow the gateway cap." }, + { + "name": "AssistantDraftSnapshot", + "kind": "interface", + "signature": "interface AssistantDraftSnapshot", + "doc": "Live snapshot of the assistant body, taken from the producer's own accumulators." + }, + { + "name": "AssistantDraftStore", + "kind": "interface", + "signature": "interface AssistantDraftStore", + "doc": "The store capability incremental persistence needs on top of `appendMessage`." + }, + { + "name": "AssistantDraftWriter", + "kind": "interface", + "signature": "interface AssistantDraftWriter", + "doc": "Coalescing writer that keeps one durable assistant row in step with a streaming turn." + }, + { + "name": "AssistantDraftWriterOptions", + "kind": "interface", + "signature": "interface AssistantDraftWriterOptions", + "doc": "Define the inputs required to construct an assistant draft writer" + }, + { + "name": "assistantRowIdForTurn", + "kind": "function", + "signature": "(turnKey: string) => string", + "doc": "The default deterministic assistant-row id for a turn." + }, + { + "name": "AssistantRowValues", + "kind": "interface", + "signature": "interface AssistantRowValues", + "doc": "Values written to the assistant row — the intersection of the append and patch shapes, so one snapshot serves both." + }, { "name": "ATTACHMENT_ACCEPT", "kind": "const", @@ -5956,6 +5998,12 @@ "signature": "(fileName: string, sniff: SniffResult, allowed?: ReadonlySet) => AttachmentTypeCheckResult", "doc": "Cross-check a filename's extension against its sniffed content." }, + { + "name": "createAssistantDraftWriter", + "kind": "function", + "signature": "(options: AssistantDraftWriterOptions) => AssistantDraftWriter", + "doc": "Build the coalescing draft writer for one turn." + }, { "name": "createAttachmentUploadRoute", "kind": "function", @@ -6082,6 +6130,18 @@ "signature": "type DispatchPartsOutcome", "doc": "Resolve the outcome of dispatching parts with success status and corresponding value or error message" }, + { + "name": "DraftPersistenceTuning", + "kind": "interface", + "signature": "interface DraftPersistenceTuning", + "doc": "Product-tunable cadence." + }, + { + "name": "DraftStoredMessage", + "kind": "interface", + "signature": "interface DraftStoredMessage", + "doc": "Message row shape the writer reads back when re-entering a turn." + }, { "name": "FileIndexAuthorization", "kind": "type", @@ -6148,6 +6208,12 @@ "signature": "950000", "doc": "Define the maximum byte size allowed for inline parts in data processing" }, + { + "name": "isDraftContentEvent", + "kind": "function", + "signature": "(event: { type?: unknown; }) => boolean", + "doc": "True when this event should arm a draft write." + }, { "name": "MAX_ATTACHMENT_TOTAL_BYTES", "kind": "const", @@ -6430,6 +6496,12 @@ "signature": "type StaleTurnLockSessionProbeResult", "doc": "What the thing running the turn says about it." }, + { + "name": "storeSupportsDraftPersistence", + "kind": "function", + "signature": "(store: AssistantDraftStore) => boolean", + "doc": "True when a store can support incremental persistence at all." + }, { "name": "UPLOAD_INLINE_MAX_BYTES", "kind": "const", @@ -6834,6 +6906,12 @@ "signature": "(parts: Record[]) => ChatMessagePart[]", "doc": "The typed projection at the `/stream` → `/chat-store` boundary." }, + { + "name": "UpdateMessageInput", + "kind": "interface", + "signature": "interface UpdateMessageInput", + "doc": "Fields an existing message row may be patched with." + }, { "name": "WorkspaceAccessCheck", "kind": "type", @@ -15364,6 +15442,12 @@ "signature": "interface D1LikeForTurns", "doc": "Minimal structural D1 contract (Cloudflare `D1Database` satisfies it)." }, + { + "name": "draftAssistantParts", + "kind": "function", + "signature": "(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[]", + "doc": "The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer." + }, { "name": "encodeEvent", "kind": "function", @@ -15475,7 +15559,7 @@ { "name": "resolveChatTurn", "kind": "function", - "signature": "(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso…", + "signature": "(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning…", "doc": "Resolve a chat turn by determining message reuse and constructing user message parts" }, { diff --git a/docs/llms-full.txt b/docs/llms-full.txt index b77b360..fa28ba3 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -1888,6 +1888,14 @@ interface DispatchOptions interface DistributionSummary ``` +### `draftAssistantParts` + +`function` — The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer. + +```ts +(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[] +``` + ### `driveSandboxTurn` `function` — Resolve a sandbox turn by processing a message with given configuration and options @@ -4501,7 +4509,7 @@ interface RequestContext `function` — Resolve a chat turn by determining message reuse and constructing user message parts ```ts -(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso… +(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning… ``` ### `ResolvedAgentProfile` @@ -7456,6 +7464,54 @@ ReadonlySet (parts: ChatTurnPartInput[], maxBytes?: number) => void ``` +### `AssistantDraftSnapshot` + +`interface` — Live snapshot of the assistant body, taken from the producer's own accumulators. + +```ts +interface AssistantDraftSnapshot +``` + +### `AssistantDraftStore` + +`interface` — The store capability incremental persistence needs on top of `appendMessage`. + +```ts +interface AssistantDraftStore +``` + +### `AssistantDraftWriter` + +`interface` — Coalescing writer that keeps one durable assistant row in step with a streaming turn. + +```ts +interface AssistantDraftWriter +``` + +### `AssistantDraftWriterOptions` + +`interface` — Define the inputs required to construct an assistant draft writer + +```ts +interface AssistantDraftWriterOptions +``` + +### `assistantRowIdForTurn` + +`function` — The default deterministic assistant-row id for a turn. + +```ts +(turnKey: string) => string +``` + +### `AssistantRowValues` + +`interface` — Values written to the assistant row — the intersection of the append and patch shapes, so one snapshot serves both. + +```ts +interface AssistantRowValues +``` + ### `ATTACHMENT_ACCEPT` `const` — Accept list for the composer file picker + type validation, same grammar as the native `` attribute. @@ -7800,6 +7856,14 @@ interface ChatTurnUsage (fileName: string, sniff: SniffResult, allowed?: ReadonlySet) => AttachmentTypeCheckResult ``` +### `createAssistantDraftWriter` + +`function` — Build the coalescing draft writer for one turn. + +```ts +(options: AssistantDraftWriterOptions) => AssistantDraftWriter +``` + ### `createAttachmentUploadRoute` `function` — Resolve an attachment upload route handler with customizable limits and validation options @@ -7968,6 +8032,22 @@ number type DispatchPartsOutcome ``` +### `DraftPersistenceTuning` + +`interface` — Product-tunable cadence. + +```ts +interface DraftPersistenceTuning +``` + +### `DraftStoredMessage` + +`interface` — Message row shape the writer reads back when re-entering a turn. + +```ts +interface DraftStoredMessage +``` + ### `FileIndexAuthorization` `type` — Define authorization details and parameters for indexing a file workspace with optional caching and ignore rules @@ -8056,6 +8136,14 @@ type FilePartPromotionOutcome 950000 ``` +### `isDraftContentEvent` + +`function` — True when this event should arm a draft write. + +```ts +(event: { type?: unknown; }) => boolean +``` + ### `MAX_ATTACHMENT_TOTAL_BYTES` `const` — Aggregate raw-byte ceiling across one message's attachments. @@ -8432,6 +8520,14 @@ type StaleTurnLockSandboxProbeResult type StaleTurnLockSessionProbeResult ``` +### `storeSupportsDraftPersistence` + +`function` — True when a store can support incremental persistence at all. + +```ts +(store: AssistantDraftStore) => boolean +``` + ### `UPLOAD_INLINE_MAX_BYTES` `const` — 700 KiB: base64 inflates ~4/3, so an inline part stays comfortably under the ~1 MiB gateway body cap alongside the JSON envelope. @@ -8957,6 +9053,14 @@ type StorableHarnessPartKind (parts: Record[]) => ChatMessagePart[] ``` +### `UpdateMessageInput` + +`interface` — Fields an existing message row may be patched with. + +```ts +interface UpdateMessageInput +``` + ### `WorkspaceAccessCheck` `type` — Product-injected access check. @@ -20002,6 +20106,14 @@ interface BufferedTurnTap interface D1LikeForTurns ``` +### `draftAssistantParts` + +`function` — The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, collapsed projection MINUS the dangling-tool terminalizer. + +```ts +(partOrder: string[], partMap: Map, finalText: string) => JsonRecord[] +``` + ### `encodeEvent` `function` — Encode a StreamEvent object into a Uint8Array using the provided TextEncoder @@ -20151,7 +20263,7 @@ interface ReplayTurnEventsOptions `function` — Resolve a chat turn by determining message reuse and constructing user message parts ```ts -(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; }) => Reso… +(input: { existingMessages: PersistedChatMessageForTurn[]; userContent: string; turnId?: string | undefined; hasRunning… ``` ### `ResolvedChatTurn` diff --git a/docs/llms.txt b/docs/llms.txt index 4259d75..a2457af 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -6,7 +6,7 @@ _Generated by agent-docs from tsup.config `entry`; 73 entries. Regenerate with ` ## Modules -- [`.`](api/index.md): 766 exports — AddCitationArgs, AddCitationResult, addSecurityHeaders, AgentAppConfig, agentAppConfigJsonSchema, AgentAppTheme, AgentIdentityConfig, AgentIntegrationsConfig, … +- [`.`](api/index.md): 767 exports — AddCitationArgs, AddCitationResult, addSecurityHeaders, AgentAppConfig, agentAppConfigJsonSchema, AgentAppTheme, AgentIdentityConfig, AgentIntegrationsConfig, … - [`./app-auth`](api/app-auth.md): 11 exports — AppAuth, AppAuthConfig, AppAuthEmailClient, AppAuthEmailConfig, AppAuthInstance, AppAuthSchema, AppAuthSession, AppAuthSocialConfig, … - [`./assets`](api/assets.md): 43 exports — ApprovalEvent, ApprovalEventSchema, AssetContentMap, AssetFormat, AssetSpec, AssetStatus, AssetVariant, BrandTokens, … - [`./assistant`](api/assistant.md): 55 exports — adaptTranscript, AssistantChat, AssistantClient, AssistantClientConfig, AssistantClientInputError, AssistantClientProvider, AssistantDeliveryMode, AssistantDock, … @@ -14,8 +14,8 @@ _Generated by agent-docs from tsup.config `entry`; 73 entries. Regenerate with ` - [`./brand`](api/brand.md): 5 exports — BrandHeader, BrandHeaderProps, Logo, LogoProps, TangleKnot - [`./brand-extraction`](api/brand-extraction.md): 19 exports — BrandColor, BrandExtractionResult, BrandFont, BrandImage, BrandKit, BrandLogoCandidate, decideBrandKit, DecidedBrandKit, … - [`./catalog`](api/catalog.md): 6 exports — buildCatalog, CatalogModel, fetchModelCatalog, ModelCatalog, normalizeModelId, RouterModel -- [`./chat-routes`](api/chat-routes.md): 131 exports — ALLOWED_ATTACHMENT_SNIFFED_MIMES, assertPromptPartsWithinCap, ATTACHMENT_ACCEPT, ATTACHMENT_MAX_COUNT, AttachmentPathArgs, AttachmentPathCheck, AttachmentReadResult, attachmentSizeErrorMessage, … -- [`./chat-store`](api/chat-store.md): 59 exports — AppendMessageInput, attachmentInputToPart, attachmentKindForMime, attachmentPartKey, attachmentPartsFromMessageParts, buildAttachmentPromptBlock, BULK_DELETE_MAX_THREADS, BulkDeleteThreadsInput, … +- [`./chat-routes`](api/chat-routes.md): 142 exports — ALLOWED_ATTACHMENT_SNIFFED_MIMES, assertPromptPartsWithinCap, AssistantDraftSnapshot, AssistantDraftStore, AssistantDraftWriter, AssistantDraftWriterOptions, assistantRowIdForTurn, AssistantRowValues, … +- [`./chat-store`](api/chat-store.md): 60 exports — AppendMessageInput, attachmentInputToPart, attachmentKindForMime, attachmentPartKey, attachmentPartsFromMessageParts, buildAttachmentPromptBlock, BULK_DELETE_MAX_THREADS, BulkDeleteThreadsInput, … - [`./composer`](api/composer.md): 22 exports — AgentComposer, AgentComposerProps, AgentProfileCapability, AgentProfileDraft, AgentProfileOption, AgentProfilePicker, AgentProfilePickerProps, AgentSessionControls, … - [`./config`](api/config.md): 13 exports — AgentAppConfig, agentAppConfigJsonSchema, AgentIdentityConfig, AgentIntegrationsConfig, AgentKnowledgeConfig, AgentTaxonomyConfig, AgentUiConfig, defineAgentApp, … - [`./crypto`](api/crypto.md): 10 exports — createFieldCrypto, decodeHexKey, decryptAesGcm, decryptBytes, decryptWithKey, deriveKey, DeriveKeyOptions, encryptAesGcm, … @@ -56,7 +56,7 @@ _Generated by agent-docs from tsup.config `entry`; 73 entries. Regenerate with ` - [`./skills`](api/skills.md): 27 exports — assertSkillDeliveryDisjoint, ComposedSkills, composeShellResources, ComposeShellResourcesInput, composeSkills, ComposeSkillsInput, CorpusEntry, CorpusLoadResult, … - [`./skills-placement`](api/skills-placement.md): 6 exports — ComposedSkills, composeSkillsForHarness, ComposeSkillsForHarnessInput, resolveSkillDir, SkillEntry, unsupportedSkillHarnesses - [`./store`](api/store.md): 8 exports — createDatabaseProvider, createInMemoryKV, DatabaseProvider, DatabaseProviderOptions, KVGetWithMetadataResult, KVListResult, KVPutOptions, KVStore -- [`./stream`](api/stream.md): 44 exports — asRecord, asString, attachmentPartKey, BufferedTurnEvent, BufferedTurnOptions, BufferedTurnTap, buildUserTextParts, coalesceChatStreamEvents, … +- [`./stream`](api/stream.md): 45 exports — asRecord, asString, attachmentPartKey, BufferedTurnEvent, BufferedTurnOptions, BufferedTurnTap, buildUserTextParts, coalesceChatStreamEvents, … - [`./studio`](api/studio.md): 37 exports — buildGenerationRequestBody, buildPublishPackage, CADENCES, DESTINATIONS, failedOptimisticGeneration, Generation, GENERATION_TYPES, generationError, … - [`./studio-react`](api/studio-react.md): 30 exports — AvatarComposer, ComposerDisclosure, ComposerHero, Field, filterGenerations, GenerationCard, GenerationDetail, GenerationDetailModal, … - [`./tangle`](api/tangle.md): 7 exports — BrokerToken, BrokerTokenMinter, BrokerTokenProvider, BrokerTokenProviderOptions, buildConsentUrl, ConsentUrlInput, createBrokerTokenProvider diff --git a/src/chat-routes/detached-turn.ts b/src/chat-routes/detached-turn.ts index 0c7f22b..8598f0e 100644 --- a/src/chat-routes/detached-turn.ts +++ b/src/chat-routes/detached-turn.ts @@ -23,11 +23,21 @@ * `AsyncIterable`. */ +import { toChatMessageParts } from '../chat-store/parts' import { coalesceDeltas, createBufferedTurnTap, type TurnEventStore, } from '../stream/index' +import { + assistantRowIdForTurn, + createAssistantDraftWriter, + storeSupportsDraftPersistence, + type AssistantDraftStore, + type AssistantDraftWriter, + type AssistantRowValues, + type DraftPersistenceTuning, +} from './draft-persistence' import { createSandboxChatProducer, type SandboxChatProducerOptions, @@ -91,6 +101,35 @@ export interface DetachedTurnOptions { * Unset, a re-stream over a `running` buffer is still attempted but logged * as a possible-duplication hazard. */ resetBuffer?: (turnId: string) => Promise + /** Own the durable assistant row for this turn instead of returning the body + * for the caller to insert — and keep it in step with the stream. + * + * An autonomous run is exactly the case a late viewer hits: nobody is + * watching when it starts, so by the time a browser opens the session the + * streaming gateway's hot event buffer may already have expired it. Keeping + * that buffer short is what makes it affordable at scale (its Redis + * footprint is linear in `ttl x concurrent sessions`), so the durable row — + * written incrementally here — is what serves the late viewer. + * + * WIRING THIS TRANSFERS ROW OWNERSHIP: the returned {@link + * DetachedTurnResult.messageId} names the row this call wrote (draft rows + * during the stream, authoritative values at the end, retraction when the + * turn produced nothing). The caller must NOT insert its own assistant row + * for the turn. Omit the seam and nothing changes — the result is returned + * and the caller persists it exactly as today. + * + * Idempotency reuses the turn's own identity: the row id defaults to + * `assistant:`, so a durable driver re-invoking after a crash + * patches the same row instead of duplicating parts. */ + persist?: DraftPersistenceTuning & { + store: AssistantDraftStore + threadId: string + /** Deterministic row id. Default `assistant:`. */ + messageId?: string + /** Pre-persist text transform (`/redact`), applied to drafts AND the final + * write — parity with the interactive lane's `transformFinalText`. */ + transformText?: (text: string) => string | Promise + } log?: (message: string, meta?: Record) => void } @@ -110,6 +149,10 @@ export interface DetachedTurnResult { /** True when a prior buffer meant this call returned a cached/finished result * WITHOUT re-streaming (durable-driver retry after a crash). */ cached: boolean + /** The durable assistant row this call wrote, when `persist` was wired. + * `null` when the turn produced nothing and the row was retracted. Absent + * when the caller owns persistence (today's behavior). */ + messageId?: string | null } /** Terminal failure event types a producer may forward verbatim. */ @@ -151,6 +194,74 @@ function cachedResultFrom(final: DetachedTurnFinal | null): DetachedTurnResult { export async function runDetachedTurn(opts: DetachedTurnOptions): Promise { const { store, turnId, scopeId } = opts + // Hoisted so the draft writer's snapshot can read the producer's live + // accumulators; assigned once the idempotency branches decide to re-stream. + let producer: ReturnType | undefined + + // Durable row ownership (opt-in). Built before the idempotency branches so a + // cached/finished-server-side re-invoke still converges the row instead of + // leaving whatever partial state a crashed attempt wrote. + let draft: AssistantDraftWriter | undefined + if (opts.persist) { + const { store: persistStore, threadId, messageId, transformText, ...tuning } = opts.persist + if (!storeSupportsDraftPersistence(persistStore)) { + throw new Error( + 'runDetachedTurn persist requires a store with updateMessage() — `/chat-store`\'s createChatStore has it', + ) + } + draft = createAssistantDraftWriter({ + ...tuning, + store: persistStore, + threadId, + messageId: messageId ?? assistantRowIdForTurn(turnId), + snapshot: () => (producer + ? { + content: producer.finalText?.() ?? '', + ...(producer.draftParts ? { parts: producer.draftParts() } : {}), + ...(producer.usage ? { usage: producer.usage() } : {}), + ...(opts.model ? { model: opts.model } : {}), + } + : null), + ...(transformText ? { transformText } : {}), + ...(opts.log ? { log: opts.log } : {}), + }) + } + + /** Settle the durable row with authoritative values (or retract it when the + * turn produced nothing), and stamp the id onto the result. */ + const settleRow = async (result: DetachedTurnResult): Promise => { + if (!draft) return result + const transform = opts.persist?.transformText + const content = transform ? await transform(result.text) : result.text + const rawParts = transform + ? await Promise.all( + result.parts.map(async (part) => + String((part as { type?: unknown }).type ?? '') === 'text' + ? { ...part, text: await transform(String((part as { text?: unknown }).text ?? '')) } + : part, + ), + ) + : result.parts + const parts = toChatMessageParts(rawParts) + if (!content.trim() && parts.length === 0) { + await draft.discard() + return { ...result, messageId: null } + } + const values: AssistantRowValues = { + content, + ...(parts.length > 0 ? { parts } : {}), + ...(opts.model ? { model: opts.model } : {}), + ...(result.usage.inputTokens !== undefined ? { inputTokens: result.usage.inputTokens } : {}), + ...(result.usage.outputTokens !== undefined ? { outputTokens: result.usage.outputTokens } : {}), + ...(result.usage.reasoningTokens !== undefined ? { reasoningTokens: result.usage.reasoningTokens } : {}), + ...(result.usage.cacheReadTokens !== undefined ? { cacheReadTokens: result.usage.cacheReadTokens } : {}), + ...(result.usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: result.usage.cacheWriteTokens } : {}), + ...(result.usage.costUsd !== undefined ? { costUsd: result.usage.costUsd } : {}), + } + await draft.finalize(values) + return { ...result, messageId: draft.rowId() ?? null } + } + const completed = async (): Promise => { if (!opts.completedResult) return null try { @@ -170,7 +281,7 @@ export async function runDetachedTurn(opts: DetachedTurnOptions): Promise { opts.log?.('[chat-routes] runDetachedTurn failed to settle a completed running turn', { turnId, err: String(err) }) }) - return cachedResultFrom(final) + return await settleRow(cachedResultFrom(final)) } // Genuine re-run: clear the partial buffer first, or the fresh tap's seq // (restarting at 0) interleaves with the orphaned rows. @@ -205,7 +316,7 @@ export async function runDetachedTurn(opts: DetachedTurnOptions): Promise {}) + // Settle any in-flight draft before propagating so the partial row a + // re-invoke will adopt is a complete write, not a half-landed one. + await draft?.close().catch(() => {}) throw err } @@ -235,10 +353,10 @@ export async function runDetachedTurn(opts: DetachedTurnOptions): Promise + appendMessage(input: AssistantRowValues & { + id?: string + threadId: string + role: 'user' | 'assistant' + }): Promise + updateMessage?(id: string, patch: AssistantRowValues): Promise + deleteMessage?(id: string): Promise +} + +/** Live snapshot of the assistant body, taken from the producer's own + * accumulators. `parts` is the DRAFT projection (`draftParts()`), never the + * finalized one — see `draftAssistantParts`. */ +export interface AssistantDraftSnapshot { + content: string + parts?: Array> + usage?: ChatTurnUsage + model?: string +} + +/** Product-tunable cadence. Defaults are stated on each field; a product with + * a chattier or heavier workload moves them without forking the writer. */ +export interface DraftPersistenceTuning { + /** Minimum wall-clock gap between draft writes, in ms. Default 2000. + * + * Justification for 2 s, from a measured tool-heavy production run (517 + * stream events over ~90 s wall = ~5.7 events/s): a 2 s floor with the + * dirty gate turns 517 candidate writes into <= 45, while leaving the + * durable row at most 2 s stale — an order of magnitude below the time it + * takes a viewer to open a tab and render, so a late viewer never perceives + * the lag. Fleet arithmetic at 10k concurrent 60 s runs: <= 30 updates per + * run x 167 run-starts/s = ~334 row-updates/s spread over per-tenant + * shards. Lower it and write amplification grows with no perceptible + * freshness gain; raise it past ~5 s and a late viewer starts seeing a + * visibly truncated answer. */ + intervalMs?: number + /** Serialized-parts size (bytes) past which the interval backs off, so a + * turn accumulating a megabyte-scale `parts` blob does not rewrite it every + * interval. Default 262144 (256 KiB) -> interval x 2.5; ten times that -> + * interval x 5. The final write is never throttled. */ + backoffBytes?: number + /** Per-tool-part output cap applied to DRAFTS ONLY (bytes). A tool returning + * a large blob would otherwise be rewritten in full on every draft. The + * final write always carries the untruncated value. Default 32768 (32 KiB); + * 0 disables truncation. */ + maxDraftToolOutputBytes?: number +} + +/** Define the inputs required to construct an assistant draft writer */ +export interface AssistantDraftWriterOptions extends DraftPersistenceTuning { + store: AssistantDraftStore + threadId: string + /** DETERMINISTIC row id for this turn's assistant message — the whole + * idempotency mechanism. Derived from the turn's existing identity + * (`deriveExecutionId` in the interactive lane, the turn id in the detached + * lane), so a re-entered turn addresses the SAME row: the writer looks the + * id up before its first insert and patches what it finds. */ + messageId: string + /** Read the producer's live accumulators. Returns null before the producer + * is resolved (the assembly defers box resolution into the first pull). */ + snapshot(): AssistantDraftSnapshot | null + /** Pre-persist text transform (`/redact`'s `redactPII`). Applied to the + * draft's scalar content AND every draft text part — parity with the final + * write, or incremental persistence would re-open the at-rest PII leak that + * transform closed, just seconds earlier and on every turn. */ + transformText?(text: string): string | Promise + log?: (message: string, meta?: Record) => void +} + +/** Coalescing writer that keeps one durable assistant row in step with a + * streaming turn. Created per turn; not reusable. */ +export interface AssistantDraftWriter { + /** Arm/trigger a draft write from one engine event. Synchronous by design — + * the write itself is fire-and-forget so the stream is never blocked on + * store latency. */ + notify(event: { type?: unknown }): void + /** Stop drafting and settle any in-flight write. Called before the final + * write so a late draft can never clobber the authoritative row. */ + close(): Promise + /** Write the AUTHORITATIVE completion values onto this turn's row — + * insert-or-patch under the same deterministic id, un-throttled and + * un-truncated. Errors propagate: the final write is the one that must not + * fail silently. Implies {@link close}. */ + finalize(values: AssistantRowValues): Promise + /** The durable row this turn is writing, once one exists. */ + rowId(): string | undefined + /** Retract the row for a turn that produced nothing (mirrors the final + * write's empty-turn skip, which leaves no row at all today). Also retracts + * a row a PREVIOUS attempt left behind, so a re-entered turn that ends + * empty converges on "no row" rather than a stale partial. */ + discard(): Promise + /** Diagnostics: how many draft writes actually reached the store. */ + writeCount(): number +} + +/** Event types that carry assistant content and therefore arm a draft write. + * An allowlist on purpose: an unknown type (a product heartbeat, a bespoke + * passthrough) must NEVER arm a write, or a silent producer would rewrite the + * same row forever. */ +const CONTENT_EVENT_TYPES: ReadonlySet = new Set([ + 'text', + 'reasoning', + 'tool_call', + 'tool_result', + 'usage', + 'notice', + 'error', + 'file', + 'interaction', + 'interaction.cancel', + 'plan.submitted', + 'message.part.updated', +]) + +const DEFAULT_INTERVAL_MS = 2000 +const DEFAULT_BACKOFF_BYTES = 262144 +const DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES = 32768 + +/** True when this event should arm a draft write. */ +export function isDraftContentEvent(event: { type?: unknown }): boolean { + return typeof event?.type === 'string' && CONTENT_EVENT_TYPES.has(event.type) +} + +/** Caps one tool part's `output` for a DRAFT write. The value is replaced by a + * truncated string plus a marker, so a reader can tell a clipped draft from a + * real short output; the final write restores the untruncated value. */ +function capDraftToolOutput(part: Record, maxBytes: number): Record { + if (maxBytes <= 0) return part + if (String(part.type ?? '') !== 'tool') return part + const state = part.state + if (!state || typeof state !== 'object') return part + const record = state as Record + const output = record.output + if (output === undefined || output === null) return part + const serialized = typeof output === 'string' ? output : safeStringify(output) + if (serialized.length <= maxBytes) return part + const metadata = (record.metadata && typeof record.metadata === 'object' ? record.metadata : {}) as Record + return { + ...part, + state: { + ...record, + output: `${serialized.slice(0, maxBytes)}…[draft-truncated ${serialized.length - maxBytes} chars]`, + metadata: { ...metadata, draftTruncated: true }, + }, + } +} + +function safeStringify(value: unknown): string { + try { + return JSON.stringify(value) ?? '' + } catch { + return String(value) + } +} + +/** Build the coalescing draft writer for one turn. */ +export function createAssistantDraftWriter(options: AssistantDraftWriterOptions): AssistantDraftWriter { + const log = options.log ?? (() => {}) + const baseIntervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS + const backoffBytes = options.backoffBytes ?? DEFAULT_BACKOFF_BYTES + const maxToolOutput = options.maxDraftToolOutputBytes ?? DEFAULT_MAX_DRAFT_TOOL_OUTPUT_BYTES + + let dirty = false + let closed = false + let inFlight: Promise | undefined + let lastWriteAt = 0 + let lastBlobBytes = 0 + let rowId: string | undefined + let writes = 0 + + /** Interval for the NEXT write, backed off by the size of the last blob + * written — a turn accumulating a huge parts array rewrites it less often. */ + function currentIntervalMs(): number { + if (lastBlobBytes >= backoffBytes * 10) return baseIntervalMs * 5 + if (lastBlobBytes >= backoffBytes) return Math.round(baseIntervalMs * 2.5) + return baseIntervalMs + } + + async function projectValues(snapshot: AssistantDraftSnapshot): Promise { + const transform = options.transformText + const content = transform ? await transform(snapshot.content) : snapshot.content + let parts: ChatMessagePart[] | undefined + if (snapshot.parts) { + const capped = snapshot.parts.map((part) => capDraftToolOutput(part, maxToolOutput)) + const redacted = transform + ? await Promise.all( + capped.map(async (part) => + String((part as { type?: unknown }).type ?? '') === 'text' + ? { ...part, text: await transform(String((part as { text?: unknown }).text ?? '')) } + : part, + ), + ) + : capped + parts = toChatMessageParts(redacted) + } + const usage = snapshot.usage ?? {} + return { + content, + ...(parts && parts.length > 0 ? { parts } : {}), + ...(snapshot.model ? { model: snapshot.model } : {}), + ...(usage.inputTokens !== undefined ? { inputTokens: usage.inputTokens } : {}), + ...(usage.outputTokens !== undefined ? { outputTokens: usage.outputTokens } : {}), + ...(usage.reasoningTokens !== undefined ? { reasoningTokens: usage.reasoningTokens } : {}), + ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), + ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), + ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}), + } + } + + /** Adopt the row a PREVIOUS attempt at this turn already inserted, if any. + * This lookup — by the deterministic id, through the store's ordinary read + * — is the whole crash-safety mechanism: no new state, no extra column, no + * second index. */ + async function adoptRow(): Promise { + if (rowId) return rowId + const existing = (await options.store.listMessages(options.threadId)).find( + (message) => message.id === options.messageId, + ) + if (existing) rowId = existing.id + return rowId + } + + /** Insert-or-patch one set of values onto this turn's single row. */ + async function writeOnce(values: AssistantRowValues): Promise { + if (await adoptRow()) { + await options.store.updateMessage!(rowId!, values) + writes += 1 + return + } + const inserted = await options.store.appendMessage({ + id: options.messageId, + threadId: options.threadId, + role: 'assistant', + ...values, + }) + const insertedId = (inserted as { id?: unknown } | null | undefined)?.id + rowId = typeof insertedId === 'string' && insertedId ? insertedId : options.messageId + writes += 1 + } + + function trigger(): void { + if (closed) return + if (!dirty) return + // Single-flight: a write already in flight SUPPRESSES this trigger rather + // than queueing behind it. `dirty` stays armed, so the next event after it + // lands writes the newer snapshot — one write is never spent on state a + // later one supersedes. + if (inFlight) return + const now = Date.now() + // The first content event writes immediately (a late viewer sees a row at + // once); the time floor governs every write after it. + if (lastWriteAt !== 0 && now - lastWriteAt < currentIntervalMs()) return + const snapshot = options.snapshot() + if (!snapshot) return + // Nothing to persist yet: an armed-but-empty snapshot (first event is a + // tool call with no text and no parts) would insert a blank row. + if (!snapshot.content && (!snapshot.parts || snapshot.parts.length === 0)) return + dirty = false + lastWriteAt = now + inFlight = (async () => { + try { + const values = await projectValues(snapshot) + lastBlobBytes = values.parts ? safeStringify(values.parts).length : 0 + await writeOnce(values) + } catch (err) { + // Best-effort by contract: a store outage degrades freshness for late + // viewers, it does not fail a healthy turn. + log('[chat-routes] incremental assistant persistence failed', { + messageId: options.messageId, + error: err instanceof Error ? err.message : String(err), + }) + } finally { + inFlight = undefined + // A trigger that arrived while this write was in flight left `dirty` + // armed with a NEWER snapshot. Re-run it here or that state would sit + // unwritten until the next content event — and if this was the last + // event of a quiet stretch, until the final write. + if (dirty && !closed) trigger() + } + })() + } + + return { + notify(event) { + if (closed) return + if (isDraftContentEvent(event)) dirty = true + trigger() + }, + async close() { + closed = true + if (inFlight) await inFlight + }, + async finalize(values) { + closed = true + if (inFlight) await inFlight + await writeOnce(values) + }, + rowId: () => rowId, + async discard() { + closed = true + if (inFlight) await inFlight + if (!options.store.deleteMessage) return + try { + if (!(await adoptRow())) return + await options.store.deleteMessage(rowId!) + rowId = undefined + } catch (err) { + log('[chat-routes] draft assistant row discard failed', { + messageId: options.messageId, + error: err instanceof Error ? err.message : String(err), + }) + } + }, + writeCount: () => writes, + } +} + +/** True when a store can support incremental persistence at all. Without + * `updateMessage` a draft row could never be patched, so the caller keeps + * today's exact single-write behavior. */ +export function storeSupportsDraftPersistence(store: AssistantDraftStore): boolean { + return typeof store.updateMessage === 'function' +} + +/** The default deterministic assistant-row id for a turn. Readable on purpose + * (an operator grepping a transcript row id finds the run), and stable across + * re-entries because every input already is. */ +export function assistantRowIdForTurn(turnKey: string): string { + return `assistant:${turnKey}` +} diff --git a/src/chat-routes/index.ts b/src/chat-routes/index.ts index e2f0f97..1899143 100644 --- a/src/chat-routes/index.ts +++ b/src/chat-routes/index.ts @@ -13,6 +13,7 @@ export * from './attachment-validation' export * from './turn-routes' export * from './stale-turn-lock' export * from './sandbox-producer' +export * from './draft-persistence' export * from './detached-turn' export * from './durable-projection' export * from './upload' diff --git a/src/chat-routes/sandbox-producer.ts b/src/chat-routes/sandbox-producer.ts index 100b898..64e5ede 100644 --- a/src/chat-routes/sandbox-producer.ts +++ b/src/chat-routes/sandbox-producer.ts @@ -31,6 +31,7 @@ import { import { asRecord, asString, + draftAssistantParts, finalizeAssistantParts, finalizePendingInteractionParts, getPartKey, @@ -610,6 +611,12 @@ export function createSandboxChatProducer(options: SandboxChatProducerOptions): finalizeAssistantParts(partOrder, partMap, fullText), interactionOutcome, ), + // Mid-stream snapshot for incremental persistence: the same accumulators, + // WITHOUT the two completion-time settlements (`terminalizeDanglingTool*` + // and `finalizePendingInteractionParts`). A running tool and an unanswered + // ask are the correct live state; settling them early would persist + // phantom failures the final write then reverses. + draftParts: () => draftAssistantParts(partOrder, partMap, fullText), usage: () => usage, ...(options.model ? { model: options.model } : {}), } diff --git a/src/chat-routes/turn-routes.ts b/src/chat-routes/turn-routes.ts index 542f758..beb5f41 100644 --- a/src/chat-routes/turn-routes.ts +++ b/src/chat-routes/turn-routes.ts @@ -43,6 +43,14 @@ import { deriveExecutionId, handleChatTurn } from '@tangle-network/agent-runtime' import type { ChatTurnIdentity, ChatTurnProducer } from '@tangle-network/agent-runtime' import { mentionInputToPart, toChatMessageParts, type ChatMessagePart } from '../chat-store/parts' +import { + assistantRowIdForTurn, + createAssistantDraftWriter, + storeSupportsDraftPersistence, + type AssistantDraftStore, + type AssistantDraftWriter, + type DraftPersistenceTuning, +} from './draft-persistence' import { createInteractionAnswerRoute, type InteractionAnswerRoute, @@ -93,6 +101,10 @@ export interface ChatTurnMessageStore { parts?: ChatMessagePart[] | null }>> appendMessage(input: { + /** Caller-assigned row id. Honored by `/chat-store`'s `createChatStore`; + * a product store free to ignore it (the writer then adopts whatever id + * the insert returns). Set only by incremental persistence. */ + id?: string threadId: string role: 'user' | 'assistant' content: string @@ -105,6 +117,23 @@ export interface ChatTurnMessageStore { cacheWriteTokens?: number | null costUsd?: number | null }): Promise + /** Patch an existing row. Its PRESENCE is what enables incremental assistant + * persistence — a store without it keeps today's exact single-write-on- + * completion behavior, which is what makes this additive. */ + updateMessage?(id: string, patch: { + content?: string + parts?: ChatMessagePart[] + model?: string | null + inputTokens?: number | null + outputTokens?: number | null + reasoningTokens?: number | null + cacheReadTokens?: number | null + cacheWriteTokens?: number | null + costUsd?: number | null + }): Promise + /** Remove a row. Used to retract a draft assistant row for a turn that ended + * producing nothing, so the empty-turn case still leaves no row at all. */ + deleteMessage?(id: string): Promise } /** `ChatTurnProducer` plus the persisted projection the assembly reads after @@ -112,6 +141,12 @@ export interface ChatTurnMessageStore { * may omit the optional members (finalText persists as a single text part). */ export interface ChatTurnRouteProducer extends ChatTurnProducer { assistantParts?(): Array> + /** MID-STREAM snapshot of the same projection, safe to call while the turn + * is running: no dangling-tool terminalization, no pending-ask settlement + * (see `/stream`'s `draftAssistantParts`). Read by incremental persistence. + * A producer that omits it still drafts — the scalar text — but persists no + * parts until the turn completes. */ + draftParts?(): Array> usage?(): ChatTurnUsage model?: string } @@ -297,6 +332,30 @@ export interface CreateChatTurnRoutesOptions { /** Pre-persist transform of the final text (e.g. `/redact`'s `redactPII`). * Live stream is never altered. */ transformFinalText?(text: string): string | Promise + /** Incremental persistence of the assistant row WHILE the turn streams + * (`./draft-persistence`), so a viewer arriving mid-run is served from + * durable storage instead of the streaming gateway's hot event buffer. + * + * This is what lets the gateway keep that hot buffer SHORT at scale: its + * Redis footprint is one sorted set per session refreshed on every push, so + * memory grows LINEARLY with `ttl x concurrent sessions`. Stretching the + * TTL to cover late viewers buys memory proportional to the increase and + * still serves nothing past the new horizon. The buffer stays a reconnect + * window; durable storage — kept at most one cadence interval stale by this + * option — is the history tier. + * + * Enabled by DEFAULT whenever `store.updateMessage` exists (every + * `/chat-store` consumer); a store without it keeps today's exact + * single-write behavior. Pass `false` to opt out, or an object to tune the + * cadence. Wiring it against a store that cannot patch rows throws at route + * construction rather than silently no-op'ing. */ + incrementalPersistence?: false | DraftPersistenceTuning + /** Deterministic durable row id for this turn's assistant message. Default + * `assistant:` — already stable across retries because + * `deriveExecutionId` is. Override only if the product's message ids have a + * format constraint; it MUST stay deterministic per turn or a re-entered + * turn will duplicate its row instead of converging. */ + draftMessageId?(args: { identity: ChatTurnIdentity; executionId: string; threadId: string }): string /** Post-processing after a turn settles (billing, titles, audit). Fires with * `failed:true` + `failureReason` when the turn carried a terminal error * event (model 402 / rate-limit / server error) instead of a clean @@ -493,6 +552,20 @@ export function createChatTurnRoutes( ): ChatTurnRoutes { const log = options.log ?? ((message, meta) => console.error(message, meta ?? '')) + // Incremental persistence is on whenever the store can patch a row. An + // EXPLICIT opt-in against a store that cannot is a wiring bug, so it fails + // here — at construction — instead of silently degrading every turn. + const draftStore: AssistantDraftStore = options.store + if (options.incrementalPersistence && !storeSupportsDraftPersistence(draftStore)) { + throw new Error( + 'incrementalPersistence requires a store with updateMessage() — `/chat-store`\'s createChatStore has it; a product store must implement it or omit the option', + ) + } + const draftTuning: DraftPersistenceTuning | null = + options.incrementalPersistence === false || !storeSupportsDraftPersistence(draftStore) + ? null + : (options.incrementalPersistence ?? {}) + async function turn(request: Request, ctx?: { waitUntil?(p: Promise): void }): Promise { const [rawBody, badBody] = await parseJsonObjectBody(request) if (badBody) return badBody @@ -518,7 +591,23 @@ export function createChatTurnRoutes( content: m.content, parts: (m.parts ?? null) as PersistedChatMessageForTurn['parts'], })) - const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId }) + // An assistant row already trailing the thread is either this turn's + // in-flight draft (retry — walk past it) or a settled answer (a genuine + // repeat — new turn). The turn buffer's running index is the discriminator; + // it is only consulted in the one case that can be ambiguous, so the common + // path costs nothing. + let hasRunningTurn = false + if (draftTuning && existingMessages.at(-1)?.role === 'assistant') { + try { + hasRunningTurn = ((await options.turnStore.listRunning?.(payload.threadId)) ?? []).length > 0 + } catch (err) { + log('[chat-routes] listRunning probe failed; treating the thread as idle', { + threadId: payload.threadId, + error: err instanceof Error ? err.message : String(err), + }) + } + } + const chatTurn = resolveChatTurn({ existingMessages, userContent: content, turnId, hasRunningTurn }) const identity: ChatTurnIdentity = { tenantId, @@ -583,6 +672,7 @@ export function createChatTurnRoutes( // synchronously before the drain — the drain would otherwise be the only // path that runs the terminal hook. let producer: ChatTurnRouteProducer | undefined + let draft: AssistantDraftWriter | undefined let runFailed = false // Data of the event that marked the run failed — handed to `onTurnError` // when no drain throw supplies a richer cause. @@ -664,6 +754,34 @@ export function createChatTurnRoutes( const turnMarker = { type: 'turn', turnId: turnStreamId } await tap.onEvent(turnMarker) + // Durable projection: the turn buffer above serves LIVE reconnect; this + // keeps the persisted transcript at most one cadence interval behind, so + // a viewer arriving after the buffer's (deliberately short) hot window + // reads the in-flight answer from storage instead of an empty thread. + // Row identity is the turn's own `executionId`, so a re-entered turn + // patches the row a previous attempt started instead of duplicating it. + if (draftTuning) { + draft = createAssistantDraftWriter({ + ...draftTuning, + store: draftStore, + threadId: payload.threadId, + messageId: options.draftMessageId + ? options.draftMessageId({ identity, executionId, threadId: payload.threadId }) + : assistantRowIdForTurn(executionId), + snapshot: () => { + if (!producer) return null + return { + content: producer.finalText(), + ...(producer.draftParts ? { parts: producer.draftParts() } : {}), + ...(producer.usage ? { usage: producer.usage() } : {}), + ...(producer.model ? { model: producer.model } : {}), + } + }, + ...(options.transformFinalText ? { transformText: options.transformFinalText } : {}), + log, + }) + } + turnStartedAtMs = Date.now() turnStarted = true if (options.lifecycle?.onTurnStart) { @@ -706,6 +824,9 @@ export function createChatTurnRoutes( lastFailureData = event.data } await tap.onEvent(event) + // Synchronous bookkeeping; the write it may start is fire-and- + // forget, so store latency never back-pressures the live stream. + draft?.notify(event) if (options.onEvent) await options.onEvent(event, context) }, ...(options.transformFinalText ? { transformFinalText: options.transformFinalText } : {}), @@ -728,11 +849,14 @@ export function createChatTurnRoutes( ) : rawParts const parts = projected ? toChatMessageParts(projected) : undefined - if (!finalText.trim() && (!parts || parts.length === 0)) return + if (!finalText.trim() && (!parts || parts.length === 0)) { + // Empty turn: today this leaves no assistant row at all, so a + // draft row started earlier is retracted, not left behind. + await draft?.discard() + return + } const usage = producer?.usage?.() ?? {} - await options.store.appendMessage({ - threadId: payload.threadId, - role: 'assistant', + const values = { content: finalText, ...(parts && parts.length > 0 ? { parts } : {}), ...(producer?.model ? { model: producer.model } : {}), @@ -742,6 +866,20 @@ export function createChatTurnRoutes( ...(usage.cacheReadTokens !== undefined ? { cacheReadTokens: usage.cacheReadTokens } : {}), ...(usage.cacheWriteTokens !== undefined ? { cacheWriteTokens: usage.cacheWriteTokens } : {}), ...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}), + } + // ONE row per turn either way. With drafting, `finalize` settles + // any in-flight draft and then insert-or-patches the same + // deterministic id — so the row this turn ends with is identical to + // the one the single-write path produces. Without it, today's + // append is untouched. + if (draft) { + await draft.finalize(values) + return + } + await options.store.appendMessage({ + threadId: payload.threadId, + role: 'assistant', + ...values, }) }, ...(options.onTurnComplete @@ -785,6 +923,12 @@ export function createChatTurnRoutes( }) } const failed = runFailed || drainError !== undefined + // Settle any in-flight draft INSIDE the waitUntil-tracked drain. On the + // path where the producer throws, `persistAssistantMessage` never runs + // to close the writer, and an unawaited write would race the isolate's + // teardown — leaving a torn row instead of a clean partial one for a + // re-entered turn to adopt. + await draft?.close() try { await tap.done(failed ? 'error' : 'complete') } catch (err) { diff --git a/src/chat-store/store.ts b/src/chat-store/store.ts index 4ea80a0..7c150c4 100644 --- a/src/chat-store/store.ts +++ b/src/chat-store/store.ts @@ -68,6 +68,12 @@ export interface CreateThreadInput { /** Define input parameters for appending a message to a chat thread with optional metadata */ export interface AppendMessageInput { + /** Caller-assigned primary key. Omitted, the column default assigns a random + * hex id (today's behavior). Incremental assistant persistence passes a + * DETERMINISTIC id derived from the turn's own identity, so a re-entered + * turn (crashed worker, durable-driver retry) finds and updates the row a + * previous attempt started instead of inserting a second one. */ + id?: string threadId: string role: 'user' | 'assistant' | 'system' | 'tool' content: string @@ -84,6 +90,29 @@ export interface AppendMessageInput { extras?: Record } +/** Fields an existing message row may be patched with. Every field is + * optional and only DEFINED fields are written, so a partial patch never + * clears a column it does not mention. `threadId` and `role` are absent on + * purpose: a message never moves thread or changes speaker. + * + * Exists for incremental assistant persistence — the streaming turn writes + * the row once and then patches it as content accumulates, so the durable + * transcript is at most one cadence interval behind the live stream. */ +export interface UpdateMessageInput { + content?: string + parts?: ChatMessagePart[] + toolName?: string | null + model?: string | null + inputTokens?: number | null + outputTokens?: number | null + reasoningTokens?: number | null + cacheReadTokens?: number | null + cacheWriteTokens?: number | null + costUsd?: number | null + /** Opaque product-column values written verbatim in the SAME update. */ + extras?: Record +} + /** Define options to configure message listing with optional limit and offset parameters */ export interface ListMessagesOptions { limit?: number @@ -115,6 +144,14 @@ export interface ChatStore { /** Inserts the message and bumps the thread's `updatedAt` in one batch so * workspace recency sorts stay truthful. */ appendMessage(input: AppendMessageInput): Promise + /** Patches an existing message and bumps its thread's `updatedAt` in the + * same batch. Resolves `null` when the id does not exist. Only defined + * patch fields are written. */ + updateMessage(id: string, patch: UpdateMessageInput): Promise + /** Removes one message. Resolves false when the id does not exist. Used by + * incremental persistence to retract a draft row for a turn that ended + * producing nothing, so an empty assistant row is never left behind. */ + deleteMessage(id: string): Promise } /** One driver round trip when `db.batch` exists; sequential awaits in the @@ -265,6 +302,7 @@ export function createChatStore( async appendMessage(input) { const values = { + ...(input.id !== undefined ? { id: input.id } : {}), threadId: input.threadId, role: input.role, content: input.content, @@ -287,5 +325,39 @@ export function createChatStore( if (!row) throw new Error('message insert returned no row') return row }, + + async updateMessage(id, patch) { + const values = { + ...(patch.content !== undefined ? { content: patch.content } : {}), + ...(patch.parts !== undefined ? { parts: patch.parts } : {}), + ...(patch.toolName !== undefined ? { toolName: patch.toolName } : {}), + ...(patch.model !== undefined ? { model: patch.model } : {}), + ...(patch.inputTokens !== undefined ? { inputTokens: patch.inputTokens } : {}), + ...(patch.outputTokens !== undefined ? { outputTokens: patch.outputTokens } : {}), + ...(patch.reasoningTokens !== undefined ? { reasoningTokens: patch.reasoningTokens } : {}), + ...(patch.cacheReadTokens !== undefined ? { cacheReadTokens: patch.cacheReadTokens } : {}), + ...(patch.cacheWriteTokens !== undefined ? { cacheWriteTokens: patch.cacheWriteTokens } : {}), + ...(patch.costUsd !== undefined ? { costUsd: patch.costUsd } : {}), + ...(patch.extras ?? {}), + } + if (Object.keys(values).length === 0) { + const [current] = await db.select().from(messages).where(eq(messages.id, id)) + return (current as TMessage | undefined) ?? null + } + // The thread bump reads the row's own `thread_id` rather than trusting a + // caller-supplied one: a message never moves thread, so the subquery is + // the authoritative source and one fewer parameter to get wrong. + const [updateResult] = await runStatements(db, [ + db.update(messages).set(values).where(eq(messages.id, id)).returning(), + db.update(threads).set({ updatedAt: new Date() }) + .where(eq(threads.id, sql`(select ${messages.threadId} from ${messages} where ${messages.id} = ${id})`)), + ]) + return (updateResult as TMessage[] | undefined)?.[0] ?? null + }, + + async deleteMessage(id) { + const deleted = await db.delete(messages).where(eq(messages.id, id)).returning() + return (deleted as unknown[]).length > 0 + }, } } diff --git a/src/stream/stream-normalizer.ts b/src/stream/stream-normalizer.ts index 971544d..02aa77d 100644 --- a/src/stream/stream-normalizer.ts +++ b/src/stream/stream-normalizer.ts @@ -500,6 +500,30 @@ export function finalizeAssistantParts( )) } +/** The MID-STREAM twin of {@link finalizeAssistantParts}: the same assembled, + * collapsed projection MINUS the dangling-tool terminalizer. + * + * Incremental persistence snapshots the assistant body while the turn is + * still running, and mid-stream a tool part sitting at `state.status: + * 'running'` is the NORMAL in-flight state — not the abnormal end + * {@link terminalizeDanglingToolPart} exists to settle. Running a live + * snapshot through `finalizeAssistantParts` would persist every in-flight + * tool call as a failure (`state.status:'error'`, `metadata.terminalized`), + * so a reader of the durable row would see phantom tool errors that the final + * write then silently un-does. Terminalization stays a completion-time + * decision: the final write is the only writer allowed to settle a tool part. + * + * Pending `interaction` parts are likewise left `pending` here (the caller + * skips {@link finalizePendingInteractionParts}) — an ask is genuinely + * unanswered until the turn settles. */ +export function draftAssistantParts( + partOrder: string[], + partMap: Map, + finalText: string, +): JsonRecord[] { + return collapseRedundantTextParts(assembleAssistantParts(partOrder, partMap, finalText)) +} + function partStatus(part: JsonRecord | undefined): string { const state = asRecord(part?.state) return String(state?.status ?? part?.status ?? '') diff --git a/src/stream/turn-identity.ts b/src/stream/turn-identity.ts index 991e56b..2c98da6 100644 --- a/src/stream/turn-identity.ts +++ b/src/stream/turn-identity.ts @@ -51,9 +51,29 @@ export function resolveChatTurn(input: { existingMessages: PersistedChatMessageForTurn[] userContent: string turnId?: string + /** True when the thread has a turn still RUNNING in the turn-event buffer + * (`turnStore.listRunning(threadId)`). + * + * Without incremental persistence the trailing row of a thread mid-turn is + * always the user row, so the content fallback below could assume it. With + * incremental persistence the assistant row lands seconds into the turn, so + * a retry of that same turn finds an ASSISTANT row trailing and would + * insert a duplicate user row. + * + * This flag is the discriminator that keeps both cases right, and it needs + * no new state: an assistant row trailing a turn that is still running is + * that turn's in-flight draft (walk past it — this is a retry), whereas an + * assistant row trailing a SETTLED turn is a completed answer (stop — the + * user genuinely repeated a message and deserves a new turn). */ + hasRunningTurn?: boolean }): ResolvedChatTurn { const { existingMessages, userContent, turnId } = input - const reusableIndex = findReusableUserMessageIndex(existingMessages, userContent, turnId) + const reusableIndex = findReusableUserMessageIndex( + existingMessages, + userContent, + turnId, + input.hasRunningTurn === true, + ) if (reusableIndex >= 0) { return { turnIndex: countUserMessages(existingMessages.slice(0, reusableIndex)), @@ -75,6 +95,7 @@ function findReusableUserMessageIndex( messages: PersistedChatMessageForTurn[], userContent: string, turnId: string | undefined, + hasRunningTurn: boolean, ): number { if (turnId) { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -83,10 +104,16 @@ function findReusableUserMessageIndex( } } - const latest = messages.at(-1) - if (latest?.role === 'user' && latest.content === userContent) { - return messages.length - 1 + // Content fallback for a client that sends no turnId. Only the trailing rows + // of a turn still RUNNING are walked past (they are that turn's incrementally + // persisted assistant draft); a settled assistant row still ends the scan, so + // a user who genuinely repeats a message gets a new turn exactly as before. + let index = messages.length - 1 + if (hasRunningTurn) { + while (index >= 0 && messages[index]?.role === 'assistant') index -= 1 } + const latest = index >= 0 ? messages[index] : undefined + if (latest?.role === 'user' && latest.content === userContent) return index return -1 } diff --git a/tests/chat-routes/incremental-persistence.live.test.ts b/tests/chat-routes/incremental-persistence.live.test.ts new file mode 100644 index 0000000..20be862 --- /dev/null +++ b/tests/chat-routes/incremental-persistence.live.test.ts @@ -0,0 +1,141 @@ +/** + * LIVE proof of incremental assistant persistence against real infrastructure: + * a real sandbox on `sandbox.tangle.tools`, a real agent turn from a real + * model, driven through the real `createChatTurnRoutes` assembly into a real + * SQLite chat store — with the durable row QUERIED WHILE THE TURN STREAMS. + * + * Skipped unless `LIVE_SANDBOX=1` and `SANDBOX_API_KEY` are set: it spends + * money and needs credentials CI does not hold. Run it by hand: + * + * LIVE_SANDBOX=1 SANDBOX_API_KEY=… SANDBOX_API_URL=https://sandbox.tangle.tools \ + * pnpm vitest run tests/chat-routes/incremental-persistence.live.test.ts + */ + +import { describe, expect, it } from 'vitest' +import { SandboxClient } from '@tangle-network/sandbox' + +import { + createChatTurnRoutes, + createSandboxChatProducer, + type ChatTurnMessageStore, +} from '../../src/chat-routes/index' +import { createChatTables } from '../../src/chat-store/schema' +import { createChatStore, type ChatDatabase } from '../../src/chat-store/store' +import type { ChatMessagePart } from '../../src/chat-store/parts' +import { createMemoryTurnEventStore } from '../../src/stream/index' +import { openDatabase, workspacesTable } from '../teams/db-helper' + +const LIVE = process.env.LIVE_SANDBOX === '1' && Boolean(process.env.SANDBOX_API_KEY) +// Model override is OPT-IN: a box provisioned against an openai-compat backend +// rejects an `anthropic/*` override, so the box's own default is the safe path. + +const tables = createChatTables({ workspaceTable: workspacesTable }) + +describe.skipIf(!LIVE)('LIVE: incremental persistence against a real sandbox turn', () => { + it('a real streaming turn is readable from durable storage while it runs, and settles byte-identical to the single-write projection', async () => { + const client = new SandboxClient({ + apiKey: process.env.SANDBOX_API_KEY!, + baseUrl: process.env.SANDBOX_API_URL ?? 'https://sandbox.tangle.tools', + }) + // Reuse a running box when one is named (cheap reruns); otherwise provision. + const reuseId = process.env.LIVE_BOX_ID + const box = reuseId ? (await client.get(reuseId))! : await client.create({ name: `agent-app-incremental-${Date.now()}`.slice(0, 63) }) + if (!box) throw new Error(`LIVE_BOX_ID ${reuseId} not found`) + console.log(`[live] box ${box.id} status=${box.status} reused=${Boolean(reuseId)}`) + + const db = openDatabase([workspacesTable, tables.threads, tables.messages]) as unknown as ChatDatabase + await db.insert(workspacesTable).values([{ id: 'ws1', organizationId: 'org1', name: 'WS' }]) + const store = createChatStore(db, tables) + const thread = await store.createThread({ workspaceId: 'ws1', title: 'live' }) + + // Samples of the DURABLE row, taken by an independent poller — the exact + // read a late viewer's page load performs. + const samples: Array<{ atMs: number; contentLen: number; parts: number; toolStates: string[] }> = [] + const startedAt = Date.now() + let polling = true + const poller = (async () => { + while (polling) { + const rows = (await store.listMessages(thread.id)) as unknown as Array<{ role: string; content: string; parts: ChatMessagePart[] | null }> + const assistant = rows.find((row) => row.role === 'assistant') + if (assistant) { + samples.push({ + atMs: Date.now() - startedAt, + contentLen: assistant.content.length, + parts: (assistant.parts ?? []).length, + toolStates: (assistant.parts ?? []) + .filter((part) => part.type === 'tool') + .map((part) => String((part as { state?: { status?: string } }).state?.status ?? '?')), + }) + } + await new Promise((resolve) => setTimeout(resolve, 100)) + } + })() + + let capturedProducer: ReturnType | undefined + const pending: Promise[] = [] + const routes = createChatTurnRoutes({ + projectId: 'agent-app-live', + authorize: async () => ({ ok: true, tenantId: 'ws1', userId: 'u1', context: undefined }), + store: store as unknown as ChatTurnMessageStore, + turnStore: createMemoryTurnEventStore(), + // Production default cadence — no test-only tuning. + produce: () => { + capturedProducer = createSandboxChatProducer({ + events: box.streamPrompt( + 'Use bash to write a four-line poem about durable storage to /tmp/poem.txt, then read the file back and quote it to me. Take your time and narrate each step.', + { ...(process.env.LIVE_MODEL ? { model: process.env.LIVE_MODEL } : {}), sessionId: thread.id }, + ) as AsyncIterable, + ...(process.env.LIVE_MODEL ? { model: process.env.LIVE_MODEL } : {}), + }) + return capturedProducer + }, + log: () => {}, + }) + + const res = await routes.turn( + new Request('http://live.test/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId: thread.id, content: 'write and read back a poem' }), + }), + { waitUntil: (p: Promise) => void pending.push(p) }, + ) + await new Response(res.body!).text() + await Promise.all(pending) + polling = false + await poller + + const rows = (await store.listMessages(thread.id)) as unknown as Array<{ id: string; role: string; content: string; parts: ChatMessagePart[] | null; inputTokens: number | null; model: string | null }> + const assistants = rows.filter((row) => row.role === 'assistant') + + const midRun = samples.filter((sample) => sample.contentLen > 0) + const finalRow = assistants[0]! + console.log('[live] durable-row timeline (ms, chars, parts, toolStates):') + for (const sample of samples) { + console.log(` t+${String(sample.atMs).padStart(6)}ms chars=${String(sample.contentLen).padStart(5)} parts=${sample.parts} tools=[${sample.toolStates.join(',')}]`) + } + console.log('[live] final content:', JSON.stringify(finalRow.content.slice(0, 800))) + console.log(`[live] final: chars=${finalRow.content.length} parts=${(finalRow.parts ?? []).length} inputTokens=${finalRow.inputTokens} rowId=${finalRow.id}`) + + // 1. The row was readable mid-run, and it GREW. + expect(midRun.length).toBeGreaterThan(1) + expect(midRun.at(-1)!.contentLen).toBeGreaterThan(midRun[0]!.contentLen) + // 2. It was fresh well before the turn ended. + expect(midRun[0]!.atMs).toBeLessThan(Date.now() - startedAt) + // 3. Exactly one assistant row survives. + expect(assistants).toHaveLength(1) + // 4. The settled row equals what the single-write path would have written — + // the producer's own final projection, which is the ONLY thing today's + // `persistAssistantMessage` writes. + expect(finalRow.content).toBe(capturedProducer!.finalText()) + const expectedParts = capturedProducer!.assistantParts!() + expect(JSON.stringify(finalRow.parts)).toBe(JSON.stringify(expectedParts)) + // 5. No tool part was left terminalized by a mid-stream snapshot. + for (const part of finalRow.parts ?? []) { + if (part.type !== 'tool') continue + expect((part as { state?: { metadata?: Record } }).state?.metadata?.terminalized).toBeUndefined() + } + + if (!reuseId) await box.delete().catch(() => {}) + }, 600_000) +}) diff --git a/tests/chat-routes/incremental-persistence.test.ts b/tests/chat-routes/incremental-persistence.test.ts new file mode 100644 index 0000000..bae003d --- /dev/null +++ b/tests/chat-routes/incremental-persistence.test.ts @@ -0,0 +1,510 @@ +/** + * Incremental assistant persistence, proven against the REAL durable store — + * `createChatTables` + `createChatStore` over better-sqlite3, the same drizzle + * path a product runs on D1 — driven through the real `createChatTurnRoutes` + * assembly and the real `createSandboxChatProducer` normalizers. No store fake: + * every assertion below reads rows back out of SQLite. + * + * The four claims that matter: + * 1. the durable row EXISTS and GROWS while the turn is still streaming + * (queried mid-run, from inside the producer), + * 2. the row a drafted turn ends with is byte-identical to the row the + * single-write path produces for the same events, + * 3. a crashed turn re-entered converges onto the SAME row with no duplicated + * parts, in both the interactive and the autonomous lane, + * 4. writes are coalesced — a token-per-write implementation fails this. + */ + +import { describe, expect, it } from 'vitest' + +import { + createChatTurnRoutes, + createSandboxChatProducer, + runDetachedTurn, + type ChatTurnMessageStore, +} from '../../src/chat-routes/index' +import { createChatStore, type ChatDatabase } from '../../src/chat-store/store' +import { createChatTables } from '../../src/chat-store/schema' +import type { ChatMessagePart } from '../../src/chat-store/parts' +import { createMemoryTurnEventStore } from '../../src/stream/index' +import { openDatabase, workspacesTable } from '../teams/db-helper' + +const tables = createChatTables({ workspaceTable: workspacesTable }) + +async function freshStore() { + const db = openDatabase([workspacesTable, tables.threads, tables.messages]) as unknown as ChatDatabase + await db.insert(workspacesTable).values([{ id: 'ws1', organizationId: 'org1', name: 'WS 1' }]) + const store = createChatStore(db, tables) + const thread = await store.createThread({ workspaceId: 'ws1', title: 'T' }) + return { db, store, threadId: thread.id } +} + +type Row = { id: string; role: string; content: string; parts: ChatMessagePart[] | null; model: string | null; inputTokens: number | null; outputTokens: number | null; costUsd: number | null } + +async function rows(store: Awaited>['store'], threadId: string): Promise { + return (await store.listMessages(threadId)) as unknown as Row[] +} + +async function assistantRow(store: Awaited>['store'], threadId: string): Promise { + return (await rows(store, threadId)).find((row) => row.role === 'assistant') +} + +/** Poll until `predicate` holds — the draft write is fire-and-forget by design + * (the stream must never block on store latency), so a mid-run reader waits + * for it rather than assuming a synchronous write. */ +async function waitUntil(read: () => Promise, predicate: (value: T) => boolean, label: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + const value = await read() + if (predicate(value)) return value + await new Promise((resolve) => setTimeout(resolve, 5)) + } + throw new Error(`waitUntil timed out: ${label}`) +} + +function partUpdated(part: Record, delta?: string): Record { + return { type: 'message.part.updated', data: { part, ...(delta !== undefined ? { delta } : {}) } } +} + +/** One canonical turn: text, a tool that runs then completes, more text, a + * usage receipt, a result. The exact vocabulary the sidecar emits. */ +const TURN_EVENTS: Array> = [ + partUpdated({ type: 'text', id: 'txt1', text: 'Checking ' }, 'Checking '), + partUpdated({ type: 'tool', id: 'call-1', tool: 'vault_search', state: { status: 'running', input: { query: 'lease' } } }), + partUpdated({ type: 'tool', id: 'call-1', tool: 'vault_search', state: { status: 'completed', input: { query: 'lease' }, output: { hits: 2 } } }), + partUpdated({ type: 'text', id: 'txt1', text: 'Checking the lease. Found 2.' }, 'the lease. Found 2.'), + partUpdated({ type: 'step-finish', reason: 'stop', tokens: { input: 40, output: 20, reasoning: 5, cache: { read: 10, write: 2 } }, cost: 0.0123 }), + { type: 'result', data: { finalText: 'Checking the lease. Found 2.' } }, +] + +function turnRequest(threadId: string, content: string, extra: Record = {}): Request { + return new Request('http://app.test/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ threadId, content, ...extra }), + }) +} + +function routesOver( + store: ChatTurnMessageStore, + produce: () => ReturnType, + overrides: Record = {}, +) { + const turnStore = createMemoryTurnEventStore() + const pending: Promise[] = [] + const routes = createChatTurnRoutes({ + projectId: 'incremental-test', + authorize: async () => ({ ok: true, tenantId: 'ws1', userId: 'u1', context: undefined }), + store, + turnStore, + produce, + log: () => {}, + ...overrides, + }) + return { routes, turnStore, ctx: { waitUntil: (p: Promise) => void pending.push(p) }, settle: () => Promise.all(pending) } +} + +async function drain(res: Response): Promise { + await new Response(res.body!).text() +} + +// ── 1. the row exists and grows mid-run ───────────────────────────────────── + +describe('incremental assistant persistence — mid-run durability', () => { + it('writes the assistant row WHILE the turn streams, growing it, with in-flight tools left running', async () => { + const { store, threadId } = await freshStore() + const samples: Array<{ content: string; parts: ChatMessagePart[] }> = [] + + // The producer's own stream is the mid-run probe: between yields it reads + // the DURABLE store, exactly as a late viewer's page load would. + async function* events(): AsyncGenerator { + yield TURN_EVENTS[0] + const afterText = await waitUntil( + () => assistantRow(store, threadId), + (row) => Boolean(row && row.content.length > 0), + 'assistant row after first text', + ) + samples.push({ content: afterText!.content, parts: afterText!.parts ?? [] }) + + yield TURN_EVENTS[1] + const withRunningTool = await waitUntil( + () => assistantRow(store, threadId), + (row) => (row?.parts ?? []).some((part) => part.type === 'tool'), + 'assistant row carrying the in-flight tool part', + ) + samples.push({ content: withRunningTool!.content, parts: withRunningTool!.parts ?? [] }) + + yield TURN_EVENTS[2] + yield TURN_EVENTS[3] + const afterMoreText = await waitUntil( + () => assistantRow(store, threadId), + (row) => (row?.content ?? '').includes('Found 2.'), + 'assistant row carrying the later text', + ) + samples.push({ content: afterMoreText!.content, parts: afterMoreText!.parts ?? [] }) + + yield TURN_EVENTS[4] + yield TURN_EVENTS[5] + } + + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: events(), model: 'anthropic/claude' }), + // 0 ms floor keeps the probe deterministic; coalescing is proven below. + { incrementalPersistence: { intervalMs: 0 } }, + ) + + await drain(await routes.turn(turnRequest(threadId, 'find the lease'), ctx)) + await settle() + + // The durable row was READABLE mid-run, three times, and grew. + expect(samples).toHaveLength(3) + expect(samples[0]!.content).toBe('Checking ') + expect(samples[2]!.content).toBe('Checking the lease. Found 2.') + expect(samples[1]!.parts.length).toBeGreaterThan(samples[0]!.parts.length) + + // The non-obvious one: an IN-FLIGHT tool must persist as `running`, not as + // the terminalized error `finalizeAssistantParts` stamps on a dangling tool. + const midTool = samples[1]!.parts.find((part) => part.type === 'tool') as + | { state?: { status?: string; metadata?: Record } } + | undefined + expect(midTool?.state?.status).toBe('running') + expect(midTool?.state?.metadata?.terminalized).toBeUndefined() + + // Exactly one assistant row survives the turn — drafts patched it, never + // appended beside it. + const all = await rows(store, threadId) + expect(all.filter((row) => row.role === 'assistant')).toHaveLength(1) + }) + + it('the final row is byte-identical to the one the single-write path produces', async () => { + async function runTurn(incremental: false | { intervalMs: number }) { + const { store, threadId } = await freshStore() + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: (async function* () { for (const e of TURN_EVENTS) yield e })(), model: 'anthropic/claude' }), + { incrementalPersistence: incremental }, + ) + await drain(await routes.turn(turnRequest(threadId, 'find the lease'), ctx)) + await settle() + const row = (await assistantRow(store, threadId))! + // `id` and `createdAt` are the only fields allowed to differ (one is + // deterministic, one is a clock). + return { + content: row.content, + parts: JSON.stringify(row.parts), + model: row.model, + inputTokens: row.inputTokens, + outputTokens: row.outputTokens, + costUsd: row.costUsd, + } + } + + const drafted = await runTurn({ intervalMs: 0 }) + const single = await runTurn(false) + expect(drafted).toEqual(single) + // And the completion-time settlement still ran: the tool is `completed`, + // not left at whatever the last draft saw. + const tool = (JSON.parse(drafted.parts) as Array>).find((p) => p.type === 'tool') + expect(tool!.state.status).toBe('completed') + expect(tool!.state.output).toEqual({ hits: 2 }) + }) +}) + +// ── 2. coalescing ─────────────────────────────────────────────────────────── + +describe('incremental assistant persistence — write cadence', () => { + it('coalesces: 200 token events produce a handful of writes, not 200', async () => { + const { store, threadId } = await freshStore() + let appends = 0 + let updates = 0 + const counting: ChatTurnMessageStore = { + listMessages: (id) => store.listMessages(id) as never, + appendMessage: (input) => { appends += 1; return store.appendMessage(input as never) }, + updateMessage: (id, patch) => { updates += 1; return store.updateMessage(id, patch as never) }, + deleteMessage: (id) => store.deleteMessage(id), + } + + async function* events(): AsyncGenerator { + let text = '' + for (let i = 0; i < 200; i += 1) { + text += 'x' + yield partUpdated({ type: 'text', id: 'txt1', text }, 'x') + } + yield { type: 'result', data: { finalText: text } } + } + + const { routes, ctx, settle } = routesOver( + counting, + () => createSandboxChatProducer({ events: events(), model: 'm' }), + // Default 2 s cadence: the whole 200-event burst lands inside one window. + { incrementalPersistence: {} }, + ) + await drain(await routes.turn(turnRequest(threadId, 'stream me'), ctx)) + await settle() + + // 1 user append + 1 draft append. Everything after the first draft is a + // patch, and the 2 s floor admits at most a couple of them. + expect(appends).toBe(2) + expect(updates).toBeLessThanOrEqual(3) + const row = (await assistantRow(store, threadId))! + expect(row.content).toBe('x'.repeat(200)) + }) + + it('leaves NO row when the turn produces nothing, even after a draft started', async () => { + const { store, threadId } = await freshStore() + async function* events(): AsyncGenerator { + yield partUpdated({ type: 'text', id: 'txt1', text: 'partial' }, 'partial') + // The harness retracts it: the final projection is empty. + yield { type: 'result', data: { finalText: '' } } + } + const producer = createSandboxChatProducer({ events: events(), model: 'm' }) + const emptyingProducer = { ...producer, finalText: () => '', assistantParts: () => [] } + + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => emptyingProducer as ReturnType, + { incrementalPersistence: { intervalMs: 0 } }, + ) + await drain(await routes.turn(turnRequest(threadId, 'nothing'), ctx)) + await settle() + + expect((await rows(store, threadId)).filter((row) => row.role === 'assistant')).toHaveLength(0) + }) + + it('an errored turn still settles as FAILED and unbilled, with its partial answer kept as an error row', async () => { + const { store, threadId } = await freshStore() + const completions: Array<{ failed: boolean; failureReason?: string }> = [] + async function* events(): AsyncGenerator { + yield TURN_EVENTS[0] + await waitUntil( + () => assistantRow(store, threadId), + (row) => Boolean(row && row.content.length > 0), + 'draft row before the failure', + ) + // Terminal error EVENT (not a throw) — the 402 / rate-limit shape. + yield { type: 'error', data: { message: 'model quota exhausted' } } + } + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: events(), model: 'm' }), + { + incrementalPersistence: { intervalMs: 0 }, + onTurnComplete: async (input: { failed: boolean; failureReason?: string }) => { + completions.push({ failed: input.failed, failureReason: input.failureReason }) + }, + }, + ) + await drain(await routes.turn(turnRequest(threadId, 'ask'), ctx)) + await settle() + + // Billing branches on this, and drafting must not have changed it. + expect(completions).toEqual([{ failed: true, failureReason: 'model quota exhausted' }]) + // The partial answer is kept as ONE row carrying the visible error text — + // not a second row, and not an empty one. + const assistants = (await rows(store, threadId)).filter((row) => row.role === 'assistant') + expect(assistants).toHaveLength(1) + expect(assistants[0]!.content).toContain('Checking ') + expect(assistants[0]!.content).toContain('model quota exhausted') + }) + + it('applies transformFinalText to DRAFT text and DRAFT text parts (the at-rest leak must not reopen mid-stream)', async () => { + const { store, threadId } = await freshStore() + // Sampled from INSIDE the stream — the final write re-redacts, so only a + // mid-run read can catch an unredacted draft. + let draftSnapshot: Row | undefined + async function* events(): AsyncGenerator { + yield partUpdated({ type: 'text', id: 'txt1', text: 'SSN 123-45-6789 on file' }, 'SSN 123-45-6789 on file') + draftSnapshot = await waitUntil( + () => assistantRow(store, threadId), + (row) => Boolean(row && row.content.length > 0), + 'draft row while streaming', + ) + yield { type: 'result', data: { finalText: 'SSN 123-45-6789 on file' } } + } + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: events(), model: 'm' }), + { + incrementalPersistence: { intervalMs: 0 }, + transformFinalText: (text: string) => text.replace(/\d{3}-\d{2}-\d{4}/g, '[redacted]'), + }, + ) + await drain(await routes.turn(turnRequest(threadId, 'file it'), ctx)) + await settle() + + expect(draftSnapshot).toBeDefined() + expect(draftSnapshot!.content).toBe('SSN [redacted] on file') + expect(JSON.stringify(draftSnapshot!.parts)).not.toContain('123-45-6789') + const finalRow = (await assistantRow(store, threadId))! + expect(finalRow.content).toBe('SSN [redacted] on file') + expect(JSON.stringify(finalRow.parts)).not.toContain('123-45-6789') + }) +}) + +// ── 3. crash + re-entry convergence ───────────────────────────────────────── + +describe('incremental assistant persistence — crash-safe convergence', () => { + it('interactive lane: a crashed turn re-entered converges onto ONE row with no duplicated parts', async () => { + const { store, threadId } = await freshStore() + + // Attempt 1: the worker dies mid-stream. Faithful simulation — the producer + // hangs forever and the request promise is abandoned, so the drain never + // runs, the turn buffer stays `running`, and the draft row is left partial. + const hang = new Promise(() => {}) + async function* crashing(): AsyncGenerator { + yield TURN_EVENTS[0] + yield TURN_EVENTS[1] + await hang + } + const first = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: crashing(), model: 'anthropic/claude' }), + { incrementalPersistence: { intervalMs: 0 } }, + ) + const firstRes = await first.routes.turn(turnRequest(threadId, 'find the lease'), first.ctx) + void new Response(firstRes.body!).text().catch(() => {}) + const partial = await waitUntil( + () => assistantRow(store, threadId), + (row) => Boolean(row && row.content.length > 0), + 'partial row from the crashed attempt', + ) + const partialRowId = partial!.id + expect(partial!.content).toBe('Checking ') + + // Attempt 2: the durable driver / client retries the SAME turn against the + // SAME turn store (its running index is what tells the retry apart from a + // user genuinely repeating themselves). + const second = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: (async function* () { for (const e of TURN_EVENTS) yield e })(), model: 'anthropic/claude' }), + { incrementalPersistence: { intervalMs: 0 }, turnStore: first.turnStore }, + ) + await drain(await second.routes.turn(turnRequest(threadId, 'find the lease'), second.ctx)) + await second.settle() + + const all = await rows(store, threadId) + // Converged: one user row, one assistant row — and it is the SAME row the + // crashed attempt started. + expect(all.filter((row) => row.role === 'user')).toHaveLength(1) + const assistants = all.filter((row) => row.role === 'assistant') + expect(assistants).toHaveLength(1) + expect(assistants[0]!.id).toBe(partialRowId) + expect(assistants[0]!.content).toBe('Checking the lease. Found 2.') + // No duplicated parts: one text segment, one tool part — not two of each. + const parts = assistants[0]!.parts ?? [] + expect(parts.filter((part) => part.type === 'text')).toHaveLength(1) + expect(parts.filter((part) => part.type === 'tool')).toHaveLength(1) + }) + + it('a user genuinely repeating a message after a SETTLED turn still gets a new turn', async () => { + const { store, threadId } = await freshStore() + const shared = createMemoryTurnEventStore() + async function run() { + const { routes, ctx, settle } = routesOver( + store as unknown as ChatTurnMessageStore, + () => createSandboxChatProducer({ events: (async function* () { for (const e of TURN_EVENTS) yield e })(), model: 'm' }), + { incrementalPersistence: { intervalMs: 0 }, turnStore: shared }, + ) + await drain(await routes.turn(turnRequest(threadId, 'same question'), ctx)) + await settle() + } + await run() + await run() + + const all = await rows(store, threadId) + expect(all.filter((row) => row.role === 'user')).toHaveLength(2) + expect(all.filter((row) => row.role === 'assistant')).toHaveLength(2) + }) + + it('autonomous lane: runDetachedTurn owns the row, drafts it mid-run, and converges after a crash', async () => { + const { store, threadId } = await freshStore() + const turnStore = createMemoryTurnEventStore() + // `createChatStore(...)` satisfies the draft-store contract directly — no cast. + const persist = { store, threadId, intervalMs: 0 } + + // Attempt 1: the worker is killed mid-run — the call never returns, the + // turn buffer is left `running`, the draft row is left partial. + const hang = new Promise(() => {}) + async function* crashing(): AsyncGenerator { + yield TURN_EVENTS[0] + await hang + } + void runDetachedTurn({ store: turnStore, turnId: 'mission-step-7', scopeId: threadId, events: crashing(), model: 'm', persist }).catch(() => {}) + const partial = (await waitUntil( + () => assistantRow(store, threadId), + (row) => Boolean(row && row.content.length > 0), + 'partial row from the crashed detached attempt', + ))! + expect(partial.content).toBe('Checking ') + expect(await turnStore.getStatus('mission-step-7')).toBe('running') + + // The durable driver re-invokes. `resetBuffer` clears the partial turn + // buffer; the assistant ROW is addressed by the same deterministic id. + const res = await runDetachedTurn({ + store: turnStore, + turnId: 'mission-step-7', + scopeId: threadId, + events: (async function* () { for (const e of TURN_EVENTS) yield e })(), + model: 'm', + persist, + resetBuffer: async () => {}, + }) + + expect(res.state).toBe('completed') + expect(res.messageId).toBe(partial.id) + const assistants = (await rows(store, threadId)).filter((row) => row.role === 'assistant') + expect(assistants).toHaveLength(1) + expect(assistants[0]!.id).toBe(partial.id) + expect(assistants[0]!.content).toBe('Checking the lease. Found 2.') + const parts = assistants[0]!.parts ?? [] + expect(parts.filter((part) => part.type === 'text')).toHaveLength(1) + expect(parts.filter((part) => part.type === 'tool')).toHaveLength(1) + expect(assistants[0]!.inputTokens).toBe(40) + }) + + it('autonomous lane without `persist` is byte-unchanged: no row is written at all', async () => { + const { store, threadId } = await freshStore() + const res = await runDetachedTurn({ + store: createMemoryTurnEventStore(), + turnId: 't-1', + scopeId: threadId, + events: (async function* () { for (const e of TURN_EVENTS) yield e })(), + model: 'm', + }) + expect(res.state).toBe('completed') + expect(res.messageId).toBeUndefined() + expect(await rows(store, threadId)).toHaveLength(0) + }) +}) + +// ── 4. the additive guarantee ─────────────────────────────────────────────── + +describe('incremental assistant persistence — additive by construction', () => { + it('a store without updateMessage keeps the single-write path, and an explicit opt-in fails loud', async () => { + const rowsWritten: Array> = [] + const legacyStore: ChatTurnMessageStore = { + async listMessages() { return [] }, + async appendMessage(input) { rowsWritten.push(input); return { id: `m${rowsWritten.length}` } }, + } + const { routes, ctx, settle } = routesOver( + legacyStore, + () => createSandboxChatProducer({ events: (async function* () { for (const e of TURN_EVENTS) yield e })(), model: 'm' }), + ) + await drain(await routes.turn(turnRequest('t-legacy', 'hello'), ctx)) + await settle() + expect(rowsWritten).toHaveLength(2) + expect(rowsWritten[1]).toMatchObject({ role: 'assistant', content: 'Checking the lease. Found 2.' }) + expect(rowsWritten[1]!.id).toBeUndefined() + + expect(() => + createChatTurnRoutes({ + projectId: 'p', + authorize: async () => ({ ok: true, tenantId: 'w', userId: 'u', context: undefined }), + store: legacyStore, + turnStore: createMemoryTurnEventStore(), + produce: () => createSandboxChatProducer({ events: (async function* () {})() }), + incrementalPersistence: {}, + }), + ).toThrow(/updateMessage/) + }) +})