diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..f99cd3f2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +## Unreleased + +### Added + +- Export renderer-neutral `groupConversationMessages` from the existing `/web` + foundation. Repeated assistant updates share attribution until a real user, + speaker, or conversation boundary; original messages remain intact. +- Add attribution tests and usage/accessibility guidance for framework-neutral + and React consumers. No new rendering framework or execution state is added. + +### Internal + +- Move the existing web utilities unchanged into `web/core.ts` behind the same + public barrel. All previous exports and optional-peer boundaries are preserved. + +No release version is assigned here; publishing remains owned by the existing +repository release workflow. diff --git a/docs/CODEMAP.md b/docs/CODEMAP.md index 17be0ef1..b99c8e46 100644 --- a/docs/CODEMAP.md +++ b/docs/CODEMAP.md @@ -97,7 +97,7 @@ _99 entries — tsup.config `entry`. Regenerate with `agent-docs`._ | [`./vault`](api/vault.md) | 17 | — | | [`./vault/lazy`](api/vault-lazy.md) | 2 | — | | [`./vault/server`](api/vault-server.md) | 8 | — | -| [`./web`](api/web.md) | 33 | — | +| [`./web`](api/web.md) | 36 | — | | [`./web-react`](api/web-react.md) | 427 | `brand`, `chat-routes`, `chat-store`, `harness`, `interactions`, `missions`, `plans`, `platform`, `runtime`, `session-shell`, `trace`, `work-product` | | [`./web-react/async`](api/web-react-async.md) | 35 | — | | [`./web-react/session-gateway`](api/web-react-session-gateway.md) | 22 | `brand`, `chat-routes`, `chat-store`, `harness`, `interactions`, `missions`, `plans`, `platform`, `runtime`, `session-shell`, `trace`, `work-product` | @@ -924,9 +924,9 @@ Source: `src/vault/server.ts` · 8 exports ## `./web` -Source: `src/web/index.ts` · 33 exports +Source: `src/web/index.ts` · 36 exports -`addSecurityHeaders`, `assertMediaUrl`, `checkFreeRouteLimit`, `checkRateLimit`, `clearCookieHeader`, `CookieOptions`, `extractRequestContext`, `FREE_ROUTE_BUDGETS`, `FreeRouteAllowance`, `FreeRouteClass`, `FreeRouteDenialReason`, `FreeRouteDimension`, `FreeRouteIdentity`, `FreeRouteLimitError`, `FreeRouteLimitInput`, `FreeRouteLimitOutcome`, `freeRouteLimitResponse`, `FreeRouteLimitResponseOptions`, `isWorkspaceFileExportable`, `JsonObject`, `KvLike`, `parseJsonObjectBody`, `RateLimitBudget`, `RateLimitResult`, `readCookieValue`, `RequestContext`, `requireString`, `SecurityHeaderOptions`, `serializeCookie`, `STANDARD_SECURITY_HEADERS`, `withFreeRouteLimit`, `WithFreeRouteLimitOptions`, `WORKSPACE_BUDGET_MULTIPLIER` +`addSecurityHeaders`, `assertMediaUrl`, `checkFreeRouteLimit`, `checkRateLimit`, `clearCookieHeader`, `ConversationGroupItem`, `CookieOptions`, `extractRequestContext`, `FREE_ROUTE_BUDGETS`, `FreeRouteAllowance`, `FreeRouteClass`, `FreeRouteDenialReason`, `FreeRouteDimension`, `FreeRouteIdentity`, `FreeRouteLimitError`, `FreeRouteLimitInput`, `FreeRouteLimitOutcome`, `freeRouteLimitResponse`, `FreeRouteLimitResponseOptions`, `groupConversationMessages`, `GroupedConversationItem`, `isWorkspaceFileExportable`, `JsonObject`, `KvLike`, `parseJsonObjectBody`, `RateLimitBudget`, `RateLimitResult`, `readCookieValue`, `RequestContext`, `requireString`, `SecurityHeaderOptions`, `serializeCookie`, `STANDARD_SECURITY_HEADERS`, `withFreeRouteLimit`, `WithFreeRouteLimitOptions`, `WORKSPACE_BUDGET_MULTIPLIER` [Full API →](api/web.md) diff --git a/docs/api/web.md b/docs/api/web.md index fc8968a1..0ff942c5 100644 --- a/docs/api/web.md +++ b/docs/api/web.md @@ -4,7 +4,7 @@ Source: `src/web/index.ts` -33 exports. +36 exports. ### `addSecurityHeaders` @@ -46,6 +46,14 @@ Source: `src/web/index.ts` (opts: Omit) => string ``` +### `ConversationGroupItem` + +`interface` — Renderer-neutral attribution. + +```ts +interface ConversationGroupItem +``` + ### `CookieOptions` `interface` — Define options for configuring cookie attributes and behavior @@ -150,6 +158,22 @@ type FreeRouteLimitOutcome interface FreeRouteLimitResponseOptions ``` +### `groupConversationMessages` + +`function` — Annotate assistant rows until an actual user message, speaker or conversation change. + +```ts +(items?: readonly T[]) => GroupedConversationItem[] +``` + +### `GroupedConversationItem` + +`type` + +```ts +type GroupedConversationItem +``` + ### `isWorkspaceFileExportable` `function` — Workspace exports exclude runtime configuration and hidden credential stores. diff --git a/docs/codemap.json b/docs/codemap.json index b789c1da..af970b48 100644 --- a/docs/codemap.json +++ b/docs/codemap.json @@ -17604,6 +17604,12 @@ "signature": "(opts: Omit) => string", "doc": "Set-Cookie header value that deletes the cookie (empty value, Max-Age=0)." }, + { + "name": "ConversationGroupItem", + "kind": "interface", + "signature": "interface ConversationGroupItem", + "doc": "Renderer-neutral attribution." + }, { "name": "CookieOptions", "kind": "interface", @@ -17682,6 +17688,18 @@ "signature": "interface FreeRouteLimitResponseOptions", "doc": null }, + { + "name": "groupConversationMessages", + "kind": "function", + "signature": "(items?: readonly T[]) => GroupedConversationItem[]", + "doc": "Annotate assistant rows until an actual user message, speaker or conversation change." + }, + { + "name": "GroupedConversationItem", + "kind": "type", + "signature": "type GroupedConversationItem", + "doc": null + }, { "name": "isWorkspaceFileExportable", "kind": "function", diff --git a/docs/conversation-attribution.md b/docs/conversation-attribution.md new file mode 100644 index 00000000..18371cbf --- /dev/null +++ b/docs/conversation-attribution.md @@ -0,0 +1,49 @@ +# Conversation attribution without transcript merging + +`groupConversationMessages` is available from the existing browser-safe +`@tangle-network/agent-app/web` entrypoint. It is a pure L0 presentation helper: +no React, DOM, database, model loop, or provider imports. + +```ts +import { groupConversationMessages } from '@tangle-network/agent-app/web' + +const rows = groupConversationMessages(messagesInDisplayOrder) +for (const row of rows) { + // Keep accessible attribution on every message, even when its visual label is hidden. + renderMessage(row, { showSpeaker: !row.isContinuation }) +} +``` + +Each assistant turn receives a group ID. Consecutive assistant updates, streaming +rows and `kind: 'thinking'` rows continue that group. Ordinary notices, tool rows +and progress do not create another assistant attribution. A real user message +resets it. Different `speakerId` or `conversationId` values start a new group; +provide these identities when rendering multiple agents or threads. Missing IDs +receive a display-only fallback, not a durable identity. + +The helper annotates rows in display order. It does not combine content, move +messages, create timestamps, mutate inputs, change roles, or affect scheduling. +The first visible assistant row is always labeled, even if history pagination +removed the start of its turn. Run IDs are not speaker identities: a resumed run +alone does not require another label. Renderers retain original IDs and maintain +their own scroll, focus, accessibility, and streaming state. + +SUPER is the reference consumer: both its operator and public chat use the same +attribution behavior. This extraction is not a claim that SUPER's custom DOM +renderer was replaced with the maintained React surface. Layout and product copy +remain application code. + +The existing `/web` implementation is moved byte-for-byte to `core.ts` and +re-exported by `index.ts`; existing exports remain available. No package peer, +new public subpath, build entry, or dependency version is introduced. + +Run `pnpm test tests/web/message-groups.test.ts`, the browser-safe entrypoint +checks, the complete build/typecheck, and `pnpm signoff --source head` before +merge. Authoring verification ran nine identical assertion bodies via Node's +runner, plus a scoped TypeScript 5.8.3 build. A deliberately broken grouping +implementation made the tests fail; the source was restored and the tests passed. +The full package Vitest/build/signoff and rendered component qualification are +separate gates, not inferred from these pure-function checks. + +See CHANGELOG.md for unreleased notes. The existing release workflow owns version +selection; no unpublished version is guessed by this change. diff --git a/docs/llms-full.txt b/docs/llms-full.txt index cf405e06..2b6c54eb 100644 --- a/docs/llms-full.txt +++ b/docs/llms-full.txt @@ -22763,6 +22763,14 @@ Source: `src/web/index.ts` (opts: Omit) => string ``` +### `ConversationGroupItem` + +`interface` — Renderer-neutral attribution. + +```ts +interface ConversationGroupItem +``` + ### `CookieOptions` `interface` — Define options for configuring cookie attributes and behavior @@ -22867,6 +22875,22 @@ type FreeRouteLimitOutcome interface FreeRouteLimitResponseOptions ``` +### `groupConversationMessages` + +`function` — Annotate assistant rows until an actual user message, speaker or conversation change. + +```ts +(items?: readonly T[]) => GroupedConversationItem[] +``` + +### `GroupedConversationItem` + +`type` + +```ts +type GroupedConversationItem +``` + ### `isWorkspaceFileExportable` `function` — Workspace exports exclude runtime configuration and hidden credential stores. diff --git a/docs/llms.txt b/docs/llms.txt index 13c68349..77bf7c30 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -97,7 +97,7 @@ _Generated by agent-docs from tsup.config `entry`; 99 entries. Regenerate with ` - [`./vault`](api/vault.md): 17 exports — ConfirmDialog, ConfirmDialogProps, VaultArtifactRenderProps, VaultDataPort, VaultDockRenderProps, VaultDockToggle, VaultEditorMode, VaultFile, … - [`./vault/lazy`](api/vault-lazy.md): 2 exports — VaultPaneLazy, VaultPaneProps - [`./vault/server`](api/vault-server.md): 8 exports — assessVaultDeletionBatch, compareIncarnationBaseline, FilesystemIncarnationLike, IncarnationComparison, VAULT_DELETION_REFUSAL_MIN_LIVE_FILES, VAULT_DELETION_REFUSAL_RATIO, VaultDeletionAssessment, VaultDeletionPolicy -- [`./web`](api/web.md): 33 exports — addSecurityHeaders, assertMediaUrl, checkFreeRouteLimit, checkRateLimit, clearCookieHeader, CookieOptions, extractRequestContext, FREE_ROUTE_BUDGETS, … +- [`./web`](api/web.md): 36 exports — addSecurityHeaders, assertMediaUrl, checkFreeRouteLimit, checkRateLimit, clearCookieHeader, ConversationGroupItem, CookieOptions, extractRequestContext, … - [`./web-react`](api/web-react.md): 427 exports — acceptRejectionReason, activityTone, ActivityTone, AgentActivityPage, AgentActivityPanel, AgentActivityPanelProps, AgentActivityRecord, AgentSessionControls, … - [`./web-react/async`](api/web-react-async.md): 35 exports — AsyncEmptyAction, AsyncEmptySpec, asyncErrorMessage, AsyncErrorRenderProps, AsyncLoadContext, AsyncRequestError, AsyncResolution, AsyncResourceState, … - [`./web-react/session-gateway`](api/web-react-session-gateway.md): 22 exports — APPLIED_SEQ_CAP, createSessionGatewayLane, createSessionStreamGrantFetcher, GATEWAY_TERMINAL_EVENT_TYPES, GATEWAY_TRANSPORT_NOTICE_TYPES, gatewayFrameToTurnEvent, GatewayTurnEvent, isGatewayTransportNotice, … diff --git a/src/web/core.ts b/src/web/core.ts new file mode 100644 index 00000000..ba96defe --- /dev/null +++ b/src/web/core.ts @@ -0,0 +1,182 @@ +/** + * Web-boundary utilities every agent app's routes hand-roll: JSON body parsing + * + narrowing, request-context extraction (real client IP behind Cloudflare), + * a KV-backed sliding-window rate limiter, the free-route budget policy built + * on it, and security response headers. Pure mechanism — no DB, no domain. The + * KV is a structural interface so this needs no `@cloudflare/workers-types` + * dependency. + */ + +export * from './rate-limit' +export * from './free-route-limit' + +export type JsonObject = Record + +/** Parse + object-narrow a Request body. `[body, null]` on success, `[null, + * errorResponse]` on a non-object body (callers `if (err) return err`). */ +export async function parseJsonObjectBody(request: Request): Promise<[JsonObject, null] | [null, Response]> { + let raw: unknown + try { + raw = await request.json() + } catch { + return [null, Response.json({ error: 'Invalid JSON body' }, { status: 400 })] + } + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + return [null, Response.json({ error: 'Body must be a JSON object' }, { status: 400 })] + } + return [raw as JsonObject, null] +} + +/** Narrow one required string field, 400 if missing/empty. */ +export function requireString(body: JsonObject, field: string): string | Response { + const v = body[field] + if (typeof v !== 'string' || v.length === 0) { + return Response.json({ error: `Missing or non-string field: ${field}` }, { status: 400 }) + } + return v +} + +/** Define the context of a request including IP address, user agent, timestamp, and request ID */ +export interface RequestContext { + ipAddress: string + userAgent: string + timestamp: string + requestId: string +} + +/** Extract request context for audit trails. Uses `CF-Connecting-IP` for the + * real client IP behind Cloudflare. */ +export function extractRequestContext(request: Request): RequestContext { + const ipAddress = + request.headers.get('CF-Connecting-IP') ?? + request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ?? + '0.0.0.0' + return { + ipAddress, + userAgent: request.headers.get('User-Agent') ?? '', + timestamp: new Date().toISOString(), + requestId: crypto.randomUUID(), + } +} + +/** Define options for configuring cookie attributes and behavior */ +export interface CookieOptions { + name: string + /** Default '/'. */ + path?: string + /** Default true. */ + httpOnly?: boolean + /** Adds the `Secure` attribute. Default false. */ + secure?: boolean + /** Default 'Lax'. */ + sameSite?: 'Lax' | 'Strict' | 'None' + maxAgeSeconds?: number +} + +/** Serialize a Set-Cookie header value: `name=encodeURIComponent(value)` plus + * attributes in Path / HttpOnly / SameSite / Max-Age / Secure order. + * Throws on `SameSite=None` without `secure` — browsers silently drop that + * combination, which would otherwise fail invisibly. */ +export function serializeCookie(value: string, opts: CookieOptions): string { + if (opts.sameSite === 'None' && !opts.secure) { + throw new Error('SameSite=None cookies require secure: true (browsers reject them otherwise)') + } + const parts = [`${opts.name}=${encodeURIComponent(value)}`, `Path=${opts.path ?? '/'}`] + if (opts.httpOnly !== false) parts.push('HttpOnly') + parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`) + if (opts.maxAgeSeconds !== undefined) parts.push(`Max-Age=${opts.maxAgeSeconds}`) + if (opts.secure) parts.push('Secure') + return parts.join('; ') +} + +/** Set-Cookie header value that deletes the cookie (empty value, Max-Age=0). */ +export function clearCookieHeader(opts: Omit): string { + return serializeCookie('', { ...opts, maxAgeSeconds: 0 }) +} + +/** Read + decode one cookie from a Cookie request header; null when absent. */ +export function readCookieValue(cookieHeader: string | null, name: string): string | null { + if (!cookieHeader) return null + for (const part of cookieHeader.split(/;\s*/)) { + const [cookieName, ...rest] = part.split('=') + if (cookieName === name) { + try { + return decodeURIComponent(rest.join('=')) + } catch { + return null + } + } + } + return null +} + +/** Define options for configuring security-related HTTP headers including disclaimers and retention labels */ +export interface SecurityHeaderOptions { + /** Product disclaimer (e.g. "AI-powered tool. Not legal advice."). Omitted if absent. */ + disclaimer?: string + /** Data-retention label (e.g. "7-years"). Omitted if absent. */ + retention?: string + /** Extra headers to set. */ + extra?: Record +} + +/** Canonical generic response headers used by {@link addSecurityHeaders}. + * Exported so static-asset hosts can apply the same policy without copying + * values that silently drift from Worker/API responses. */ +export const STANDARD_SECURITY_HEADERS = Object.freeze({ + 'Strict-Transport-Security': + 'max-age=31536000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'SAMEORIGIN', + 'Referrer-Policy': 'same-origin', + 'X-XSS-Protection': '1; mode=block', +} as const) + +/** Set standard security headers on a response (HSTS, nosniff, frame-options, + * referrer-policy, XSS) + optional product disclaimer/retention. The security + * set is generic; the disclaimer/retention are the product's. */ +export function addSecurityHeaders(response: Response, opts: SecurityHeaderOptions = {}): Response { + for (const [name, value] of Object.entries(STANDARD_SECURITY_HEADERS)) { + response.headers.set(name, value) + } + if (opts.disclaimer) response.headers.set('X-AI-Disclaimer', opts.disclaimer) + if (opts.retention) response.headers.set('X-Data-Retention', opts.retention) + for (const [k, v] of Object.entries(opts.extra ?? {})) response.headers.set(k, v) + return response +} + +/** Local-sandbox / inline schemes a stored media reference must never use. + * Reachable from neither a browser nor the product worker, and a `file:`/`data:` + * url is the tell of an agent substituting local ffmpeg output for a real + * provider artifact. `blob:` and `javascript:` are inert/active client schemes + * with no server reachability. */ +const REJECTED_MEDIA_SCHEMES = ['file:', 'data:', 'blob:', 'javascript:', 'vbscript:'] as const + +/** + * Canonical media-reference boundary shared by every surface that persists a + * media url (sequences clips, design-canvas image/video src). The ONE rule: + * remote `http(s)` or a rooted `/api/` path are allowed; everything else is + * rejected, with a named reason for known-bad local/inline schemes so the + * thrown message is actionable for an LLM planner. The url is trimmed before + * the scheme check so leading whitespace cannot smuggle a rejected scheme past + * a naive `startsWith`. + * + * @param what - noun for the error message (e.g. 'media url', 'src'). + */ +export function assertMediaUrl(url: string, what = 'media url'): void { + const trimmed = url.trim() + if (/^https?:\/\//i.test(trimmed)) return + if (trimmed.startsWith('/api/')) return + const shown = trimmed.length > 96 ? `${trimmed.slice(0, 96)}…` : trimmed + const lower = trimmed.toLowerCase() + if ( + REJECTED_MEDIA_SCHEMES.some((scheme) => lower.startsWith(scheme)) || + lower.startsWith('/tmp/') || + lower.startsWith('/home/') + ) { + throw new Error(`${what} must reference a provider http(s) URL or a rooted /api/ path, not a local sandbox file (${shown})`) + } + throw new Error(`${what} must be http(s) or a rooted /api/ path (${shown})`) +} + +export { isWorkspaceFileExportable } from './file-export' diff --git a/src/web/index.ts b/src/web/index.ts index ba96defe..324d6698 100644 --- a/src/web/index.ts +++ b/src/web/index.ts @@ -1,182 +1,3 @@ -/** - * Web-boundary utilities every agent app's routes hand-roll: JSON body parsing - * + narrowing, request-context extraction (real client IP behind Cloudflare), - * a KV-backed sliding-window rate limiter, the free-route budget policy built - * on it, and security response headers. Pure mechanism — no DB, no domain. The - * KV is a structural interface so this needs no `@cloudflare/workers-types` - * dependency. - */ - -export * from './rate-limit' -export * from './free-route-limit' - -export type JsonObject = Record - -/** Parse + object-narrow a Request body. `[body, null]` on success, `[null, - * errorResponse]` on a non-object body (callers `if (err) return err`). */ -export async function parseJsonObjectBody(request: Request): Promise<[JsonObject, null] | [null, Response]> { - let raw: unknown - try { - raw = await request.json() - } catch { - return [null, Response.json({ error: 'Invalid JSON body' }, { status: 400 })] - } - if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { - return [null, Response.json({ error: 'Body must be a JSON object' }, { status: 400 })] - } - return [raw as JsonObject, null] -} - -/** Narrow one required string field, 400 if missing/empty. */ -export function requireString(body: JsonObject, field: string): string | Response { - const v = body[field] - if (typeof v !== 'string' || v.length === 0) { - return Response.json({ error: `Missing or non-string field: ${field}` }, { status: 400 }) - } - return v -} - -/** Define the context of a request including IP address, user agent, timestamp, and request ID */ -export interface RequestContext { - ipAddress: string - userAgent: string - timestamp: string - requestId: string -} - -/** Extract request context for audit trails. Uses `CF-Connecting-IP` for the - * real client IP behind Cloudflare. */ -export function extractRequestContext(request: Request): RequestContext { - const ipAddress = - request.headers.get('CF-Connecting-IP') ?? - request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ?? - '0.0.0.0' - return { - ipAddress, - userAgent: request.headers.get('User-Agent') ?? '', - timestamp: new Date().toISOString(), - requestId: crypto.randomUUID(), - } -} - -/** Define options for configuring cookie attributes and behavior */ -export interface CookieOptions { - name: string - /** Default '/'. */ - path?: string - /** Default true. */ - httpOnly?: boolean - /** Adds the `Secure` attribute. Default false. */ - secure?: boolean - /** Default 'Lax'. */ - sameSite?: 'Lax' | 'Strict' | 'None' - maxAgeSeconds?: number -} - -/** Serialize a Set-Cookie header value: `name=encodeURIComponent(value)` plus - * attributes in Path / HttpOnly / SameSite / Max-Age / Secure order. - * Throws on `SameSite=None` without `secure` — browsers silently drop that - * combination, which would otherwise fail invisibly. */ -export function serializeCookie(value: string, opts: CookieOptions): string { - if (opts.sameSite === 'None' && !opts.secure) { - throw new Error('SameSite=None cookies require secure: true (browsers reject them otherwise)') - } - const parts = [`${opts.name}=${encodeURIComponent(value)}`, `Path=${opts.path ?? '/'}`] - if (opts.httpOnly !== false) parts.push('HttpOnly') - parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`) - if (opts.maxAgeSeconds !== undefined) parts.push(`Max-Age=${opts.maxAgeSeconds}`) - if (opts.secure) parts.push('Secure') - return parts.join('; ') -} - -/** Set-Cookie header value that deletes the cookie (empty value, Max-Age=0). */ -export function clearCookieHeader(opts: Omit): string { - return serializeCookie('', { ...opts, maxAgeSeconds: 0 }) -} - -/** Read + decode one cookie from a Cookie request header; null when absent. */ -export function readCookieValue(cookieHeader: string | null, name: string): string | null { - if (!cookieHeader) return null - for (const part of cookieHeader.split(/;\s*/)) { - const [cookieName, ...rest] = part.split('=') - if (cookieName === name) { - try { - return decodeURIComponent(rest.join('=')) - } catch { - return null - } - } - } - return null -} - -/** Define options for configuring security-related HTTP headers including disclaimers and retention labels */ -export interface SecurityHeaderOptions { - /** Product disclaimer (e.g. "AI-powered tool. Not legal advice."). Omitted if absent. */ - disclaimer?: string - /** Data-retention label (e.g. "7-years"). Omitted if absent. */ - retention?: string - /** Extra headers to set. */ - extra?: Record -} - -/** Canonical generic response headers used by {@link addSecurityHeaders}. - * Exported so static-asset hosts can apply the same policy without copying - * values that silently drift from Worker/API responses. */ -export const STANDARD_SECURITY_HEADERS = Object.freeze({ - 'Strict-Transport-Security': - 'max-age=31536000; includeSubDomains; preload', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'SAMEORIGIN', - 'Referrer-Policy': 'same-origin', - 'X-XSS-Protection': '1; mode=block', -} as const) - -/** Set standard security headers on a response (HSTS, nosniff, frame-options, - * referrer-policy, XSS) + optional product disclaimer/retention. The security - * set is generic; the disclaimer/retention are the product's. */ -export function addSecurityHeaders(response: Response, opts: SecurityHeaderOptions = {}): Response { - for (const [name, value] of Object.entries(STANDARD_SECURITY_HEADERS)) { - response.headers.set(name, value) - } - if (opts.disclaimer) response.headers.set('X-AI-Disclaimer', opts.disclaimer) - if (opts.retention) response.headers.set('X-Data-Retention', opts.retention) - for (const [k, v] of Object.entries(opts.extra ?? {})) response.headers.set(k, v) - return response -} - -/** Local-sandbox / inline schemes a stored media reference must never use. - * Reachable from neither a browser nor the product worker, and a `file:`/`data:` - * url is the tell of an agent substituting local ffmpeg output for a real - * provider artifact. `blob:` and `javascript:` are inert/active client schemes - * with no server reachability. */ -const REJECTED_MEDIA_SCHEMES = ['file:', 'data:', 'blob:', 'javascript:', 'vbscript:'] as const - -/** - * Canonical media-reference boundary shared by every surface that persists a - * media url (sequences clips, design-canvas image/video src). The ONE rule: - * remote `http(s)` or a rooted `/api/` path are allowed; everything else is - * rejected, with a named reason for known-bad local/inline schemes so the - * thrown message is actionable for an LLM planner. The url is trimmed before - * the scheme check so leading whitespace cannot smuggle a rejected scheme past - * a naive `startsWith`. - * - * @param what - noun for the error message (e.g. 'media url', 'src'). - */ -export function assertMediaUrl(url: string, what = 'media url'): void { - const trimmed = url.trim() - if (/^https?:\/\//i.test(trimmed)) return - if (trimmed.startsWith('/api/')) return - const shown = trimmed.length > 96 ? `${trimmed.slice(0, 96)}…` : trimmed - const lower = trimmed.toLowerCase() - if ( - REJECTED_MEDIA_SCHEMES.some((scheme) => lower.startsWith(scheme)) || - lower.startsWith('/tmp/') || - lower.startsWith('/home/') - ) { - throw new Error(`${what} must reference a provider http(s) URL or a rooted /api/ path, not a local sandbox file (${shown})`) - } - throw new Error(`${what} must be http(s) or a rooted /api/ path (${shown})`) -} - -export { isWorkspaceFileExportable } from './file-export' +/** Browser-safe application-boundary helpers. No React or execution-engine peers. */ +export * from './core' +export * from './message-groups' diff --git a/src/web/message-groups.ts b/src/web/message-groups.ts new file mode 100644 index 00000000..be3aa149 --- /dev/null +++ b/src/web/message-groups.ts @@ -0,0 +1,38 @@ +/** Renderer-neutral attribution. Not a transcript merger or an execution state machine. */ +export interface ConversationGroupItem { + id?: string | number + kind?: string + role?: string + /** A different assistant/persona must never inherit the previous speaker's label. */ + speakerId?: string + /** Distinct threads must not be grouped if their rows share a viewport. */ + conversationId?: string +} +export type GroupedConversationItem = T & { isContinuation?: boolean; groupId?: string } + +/** + * Annotate assistant rows until an actual user message, speaker or conversation change. + * Tool/progress notices do not interrupt the group. The first visible assistant is + * always labeled, including when a renderer has paged earlier history away. + * Pass display order; stored content, IDs, timestamps and input objects are untouched. + */ +export function groupConversationMessages(items: readonly T[] = []): GroupedConversationItem[] { + let speaker: string | null = null + let conversation: string | undefined + let groupId: string | undefined + return items.map((item, index) => { + if (item.conversationId !== undefined && item.conversationId !== conversation) { + conversation = item.conversationId + speaker = null + groupId = undefined + } + const role = item.kind === 'thinking' ? 'assistant' : (!item.kind || item.kind === 'message' ? item.role : undefined) + if (role === 'user') { speaker = null; groupId = undefined; return { ...item, isContinuation: false } } + if (role !== 'assistant') return item + const current = item.speakerId ?? 'assistant' + const isContinuation = speaker === current + if (!isContinuation) groupId = String(item.id ?? `assistant-${index}`) + speaker = current + return { ...item, isContinuation, groupId } + }) +} diff --git a/tests/web/message-groups.test.ts b/tests/web/message-groups.test.ts new file mode 100644 index 00000000..ac16f7cc --- /dev/null +++ b/tests/web/message-groups.test.ts @@ -0,0 +1,54 @@ +import { test, expect } from 'vitest' +import { groupConversationMessages } from '../../src/web/message-groups.js' +const assistant = (id: string, extra = {}) => ({ id, kind: 'message', role: 'assistant', content: id, ...extra }) +function at(rows: readonly T[], index: number): T { + const value = rows[index] + if (value === undefined) throw new Error(`Expected row ${index}`) + return value +} + +test('Successive updates retain one attribution until a real user message', () => { + const r = groupConversationMessages([assistant('a'), assistant('b'), { id: 'u', role: 'user' }, assistant('c')]) + expect(r.map(x => x.isContinuation)).toEqual([false, true, false, false]) + expect(at(r, 0).groupId).toBe(at(r, 1).groupId) + expect(at(r, 1).groupId).not.toBe(at(r, 3).groupId) +}) +test('Tool and notice rows do not restart assistant attribution', () => { + const r = groupConversationMessages([assistant('a'), { id: 't', kind: 'tool' }, { id: 'n', role: 'notice' }, assistant('b')]) + expect(at(r, 3).isContinuation).toBe(true) + expect(Object.hasOwn(at(r, 1), 'isContinuation')).toBe(false) +}) +test('A working/streaming continuation shares the preceding assistant group', () => { + const r = groupConversationMessages([assistant('a'), { id: 'w', kind: 'thinking' }, assistant('stream', { isStreaming: true })]) + expect(r.slice(1).every(x => x.isContinuation)).toBe(true) + expect(at(r, 2).groupId).toBe('a') +}) +test('The first visible message is labeled after history pagination', () => { + const r = groupConversationMessages([assistant('later'), assistant('latest')]) + expect(at(r, 0).isContinuation).toBe(false) +}) +test('Distinct agents cannot inherit each other’s attribution', () => { + const r = groupConversationMessages([assistant('a', { speakerId: 'researcher' }), assistant('b', { speakerId: 'buyer' }), assistant('c', { speakerId: 'buyer' })]) + expect(r.map(x => x.isContinuation)).toEqual([false, false, true]) +}) +test('Distinct conversations reset grouping even without a user row', () => { + const r = groupConversationMessages([assistant('a', { conversationId: 'one' }), assistant('b', { conversationId: 'two' })]) + expect(at(r, 1).isContinuation).toBe(false) +}) +test('A non-message tool role cannot impersonate a user and reset the group', () => { + const r = groupConversationMessages([assistant('a'), { id: 't', kind: 'tool', role: 'user' }, assistant('b')]) + expect(at(r, 2).isContinuation).toBe(true) +}) +test('Stored objects, text, timestamps and identities are not merged or mutated', () => { + const input = Object.freeze([Object.freeze(assistant('a', { createdAt: 'yesterday' })), Object.freeze(assistant('b'))]) + const before = JSON.stringify(input), r = groupConversationMessages(input) + expect(JSON.stringify(input)).toBe(before) + expect(r).toHaveLength(2) + expect(at(r, 0)).toMatchObject({ id: 'a', content: 'a', createdAt: 'yesterday' }) + expect(at(r, 1).id).toBe('b') +}) +test('Empty input and missing IDs are supported without persistent global state', () => { + expect(groupConversationMessages()).toEqual([]) + expect(at(groupConversationMessages([{ role: 'assistant' }]), 0).groupId).toBe('assistant-0') + expect(at(groupConversationMessages([assistant('again')]), 0).isContinuation).toBe(false) +})