From 0cfe72e36b8edbd436f0b99928a8f4ae79c0975e Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 14:29:22 -0700 Subject: [PATCH 1/6] feat(web): extract renderer-neutral conversation attribution --- CHANGELOG.md | 19 ++++ docs/conversation-attribution.md | 49 ++++++++ src/web/core.ts | 182 ++++++++++++++++++++++++++++++ src/web/index.ts | 185 +------------------------------ src/web/message-groups.ts | 38 +++++++ tests/web/message-groups.test.ts | 41 +++++++ 6 files changed, 332 insertions(+), 182 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/conversation-attribution.md create mode 100644 src/web/core.ts create mode 100644 src/web/message-groups.ts create mode 100644 tests/web/message-groups.test.ts 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/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/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..44a0aa5f --- /dev/null +++ b/tests/web/message-groups.test.ts @@ -0,0 +1,41 @@ +import { test } from 'vitest' +import assert from 'node:assert/strict' +import { groupConversationMessages } from '../../src/web/message-groups.js' +const assistant = (id: string, extra = {}) => ({ id, kind: 'message', role: 'assistant', content: id, ...extra }) + +test('Successive updates retain one attribution until a real user message', () => { + const r = groupConversationMessages([assistant('a'), assistant('b'), { id: 'u', role: 'user' }, assistant('c')]) + assert.deepEqual(r.map(x => x.isContinuation), [false, true, false, false]); assert.equal(r[0].groupId, r[1].groupId); assert.notEqual(r[1].groupId, 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')]) + assert.equal(r[3].isContinuation, true); assert(!Object.hasOwn(r[1], 'isContinuation')) +}) +test('A working/streaming continuation shares the preceding assistant group', () => { + const r = groupConversationMessages([assistant('a'), { id: 'w', kind: 'thinking' }, assistant('stream', { isStreaming: true })]) + assert(r.slice(1).every(x => x.isContinuation)); assert.equal(r[2].groupId, 'a') +}) +test('The first visible message is labeled after history pagination', () => { + const r = groupConversationMessages([assistant('later'), assistant('latest')]); assert.equal(r[0].isContinuation, 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' })]) + assert.deepEqual(r.map(x => x.isContinuation), [false, false, true]) +}) +test('Distinct conversations reset grouping even without a user row', () => { + const r = groupConversationMessages([assistant('a', { conversationId: 'one' }), assistant('b', { conversationId: 'two' })]) + assert.equal(r[1].isContinuation, 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')]); assert.equal(r[2].isContinuation, 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) + assert.equal(JSON.stringify(input), before); assert.equal(r.length, 2); assert.equal(r[0].content, 'a'); assert.equal(r[1].id, 'b'); assert('createdAt' in r[0]); assert.equal(r[0].createdAt, 'yesterday') +}) +test('Empty input and missing IDs are supported without persistent global state', () => { + assert.deepEqual(groupConversationMessages(), []) + assert.equal(groupConversationMessages([{ role: 'assistant' }])[0].groupId, 'assistant-0') + assert.equal(groupConversationMessages([assistant('again')])[0].isContinuation, false) +}) From d23482d469cc0d36939e6aed2a5fd60ce39b1441 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 15:22:57 -0700 Subject: [PATCH 2/6] ci: regenerate attribution API docs on the authorized feature branch --- .../workflows/refresh-attribution-docs.yml | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 .github/workflows/refresh-attribution-docs.yml diff --git a/.github/workflows/refresh-attribution-docs.yml b/.github/workflows/refresh-attribution-docs.yml new file mode 100644 index 00000000..8186415c --- /dev/null +++ b/.github/workflows/refresh-attribution-docs.yml @@ -0,0 +1,51 @@ +# Temporary branch-scoped maintenance task; no release or external credentials. +name: Refresh attribution docs +on: + push: + branches: [feat/shared-conversation-attribution] + paths: [.github/workflows/refresh-attribution-docs.yml] +permissions: + contents: read +jobs: + docs: + if: github.repository == 'tangle-network/agent-app' && github.ref == 'refs/heads/feat/shared-conversation-attribution' + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + env: + NODE_OPTIONS: --max-old-space-size=12288 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile --ignore-scripts=false + - run: pnpm docs:gen + - name: Commit only deterministic generated documentation + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + node --input-type=module - <<'JS' + import {execFileSync} from 'node:child_process'; + const allowed=new Set(['docs/CODEMAP.md','docs/api/web.md','docs/codemap.json','docs/llms-full.txt','docs/llms.txt']); + const changed=execFileSync('git',['diff','--name-only'],{encoding:'utf8'}).trim().split('\n').filter(Boolean); + if(changed.some(path=>!allowed.has(path)))throw Error('Unexpected generated change: '+changed.join(', ')); + JS + git add -- docs/CODEMAP.md docs/api/web.md docs/codemap.json docs/llms-full.txt docs/llms.txt + git diff --cached --quiet && exit 0 + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git commit -m 'docs: regenerate web attribution API reference' + # Fast-forward only: a concurrent author update cannot be overwritten. + printf '%s\n' '#!/bin/sh' 'case "$1" in *Username*) echo x-access-token;; *) echo "$GH_TOKEN";; esac' > /tmp/docs-askpass + chmod 700 /tmp/docs-askpass + GIT_ASKPASS=/tmp/docs-askpass GIT_TERMINAL_PROMPT=0 git push https://github.com/tangle-network/agent-app.git HEAD:refs/heads/feat/shared-conversation-attribution + rm /tmp/docs-askpass From 0e0cbd07494f115a4286a29dceaabf18a99a3f17 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 22:23:34 +0000 Subject: [PATCH 3/6] docs: regenerate web attribution API reference --- docs/CODEMAP.md | 6 +++--- docs/api/web.md | 26 +++++++++++++++++++++++++- docs/codemap.json | 18 ++++++++++++++++++ docs/llms-full.txt | 24 ++++++++++++++++++++++++ docs/llms.txt | 2 +- 5 files changed, 71 insertions(+), 5 deletions(-) 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/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, … From f2f5783ffa32e12301bcf37679530b7b0cc72a96 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 15:28:04 -0700 Subject: [PATCH 4/6] chore: remove completed branch-scoped documentation maintenance task --- .../workflows/refresh-attribution-docs.yml | 51 ------------------- 1 file changed, 51 deletions(-) delete mode 100644 .github/workflows/refresh-attribution-docs.yml diff --git a/.github/workflows/refresh-attribution-docs.yml b/.github/workflows/refresh-attribution-docs.yml deleted file mode 100644 index 8186415c..00000000 --- a/.github/workflows/refresh-attribution-docs.yml +++ /dev/null @@ -1,51 +0,0 @@ -# Temporary branch-scoped maintenance task; no release or external credentials. -name: Refresh attribution docs -on: - push: - branches: [feat/shared-conversation-attribution] - paths: [.github/workflows/refresh-attribution-docs.yml] -permissions: - contents: read -jobs: - docs: - if: github.repository == 'tangle-network/agent-app' && github.ref == 'refs/heads/feat/shared-conversation-attribution' - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - env: - NODE_OPTIONS: --max-old-space-size=12288 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: ${{ github.sha }} - persist-credentials: false - - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 - with: - node-version-file: .nvmrc - cache: pnpm - - run: pnpm install --frozen-lockfile --ignore-scripts=false - - run: pnpm docs:gen - - name: Commit only deterministic generated documentation - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - node --input-type=module - <<'JS' - import {execFileSync} from 'node:child_process'; - const allowed=new Set(['docs/CODEMAP.md','docs/api/web.md','docs/codemap.json','docs/llms-full.txt','docs/llms.txt']); - const changed=execFileSync('git',['diff','--name-only'],{encoding:'utf8'}).trim().split('\n').filter(Boolean); - if(changed.some(path=>!allowed.has(path)))throw Error('Unexpected generated change: '+changed.join(', ')); - JS - git add -- docs/CODEMAP.md docs/api/web.md docs/codemap.json docs/llms-full.txt docs/llms.txt - git diff --cached --quiet && exit 0 - git config user.name 'github-actions[bot]' - git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git commit -m 'docs: regenerate web attribution API reference' - # Fast-forward only: a concurrent author update cannot be overwritten. - printf '%s\n' '#!/bin/sh' 'case "$1" in *Username*) echo x-access-token;; *) echo "$GH_TOKEN";; esac' > /tmp/docs-askpass - chmod 700 /tmp/docs-askpass - GIT_ASKPASS=/tmp/docs-askpass GIT_TERMINAL_PROMPT=0 git push https://github.com/tangle-network/agent-app.git HEAD:refs/heads/feat/shared-conversation-attribution - rm /tmp/docs-askpass From 926b0be2e71293cd3ebce8a39f8c7c6a16f48d04 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 15:30:16 -0700 Subject: [PATCH 5/6] test: assert row existence under the repository strict indexed-access rules --- tests/web/message-groups.test.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/tests/web/message-groups.test.ts b/tests/web/message-groups.test.ts index 44a0aa5f..643e6c56 100644 --- a/tests/web/message-groups.test.ts +++ b/tests/web/message-groups.test.ts @@ -2,21 +2,26 @@ import { test } from 'vitest' import assert from 'node:assert/strict' 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) { + const value = rows[index] + assert(value !== undefined, `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')]) - assert.deepEqual(r.map(x => x.isContinuation), [false, true, false, false]); assert.equal(r[0].groupId, r[1].groupId); assert.notEqual(r[1].groupId, r[3].groupId) + assert.deepEqual(r.map(x => x.isContinuation), [false, true, false, false]); assert.equal(at(r, 0).groupId, at(r, 1).groupId); assert.notEqual(at(r, 1).groupId, 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')]) - assert.equal(r[3].isContinuation, true); assert(!Object.hasOwn(r[1], 'isContinuation')) + assert.equal(at(r, 3).isContinuation, true); assert(!Object.hasOwn(at(r, 1), 'isContinuation')) }) test('A working/streaming continuation shares the preceding assistant group', () => { const r = groupConversationMessages([assistant('a'), { id: 'w', kind: 'thinking' }, assistant('stream', { isStreaming: true })]) - assert(r.slice(1).every(x => x.isContinuation)); assert.equal(r[2].groupId, 'a') + assert(r.slice(1).every(x => x.isContinuation)); assert.equal(at(r, 2).groupId, 'a') }) test('The first visible message is labeled after history pagination', () => { - const r = groupConversationMessages([assistant('later'), assistant('latest')]); assert.equal(r[0].isContinuation, false) + const r = groupConversationMessages([assistant('later'), assistant('latest')]); assert.equal(at(r, 0).isContinuation, 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' })]) @@ -24,18 +29,18 @@ test('Distinct agents cannot inherit each other’s attribution', () => { }) test('Distinct conversations reset grouping even without a user row', () => { const r = groupConversationMessages([assistant('a', { conversationId: 'one' }), assistant('b', { conversationId: 'two' })]) - assert.equal(r[1].isContinuation, false) + assert.equal(at(r, 1).isContinuation, 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')]); assert.equal(r[2].isContinuation, true) + const r = groupConversationMessages([assistant('a'), { id: 't', kind: 'tool', role: 'user' }, assistant('b')]); assert.equal(at(r, 2).isContinuation, 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) - assert.equal(JSON.stringify(input), before); assert.equal(r.length, 2); assert.equal(r[0].content, 'a'); assert.equal(r[1].id, 'b'); assert('createdAt' in r[0]); assert.equal(r[0].createdAt, 'yesterday') + const before = JSON.stringify(input), r = groupConversationMessages(input), first = at(r, 0) + assert.equal(JSON.stringify(input), before); assert.equal(r.length, 2); assert.equal(first.content, 'a'); assert.equal(at(r, 1).id, 'b'); assert('createdAt' in first); assert.equal(first.createdAt, 'yesterday') }) test('Empty input and missing IDs are supported without persistent global state', () => { assert.deepEqual(groupConversationMessages(), []) - assert.equal(groupConversationMessages([{ role: 'assistant' }])[0].groupId, 'assistant-0') - assert.equal(groupConversationMessages([assistant('again')])[0].isContinuation, false) + assert.equal(at(groupConversationMessages([{ role: 'assistant' }]), 0).groupId, 'assistant-0') + assert.equal(at(groupConversationMessages([assistant('again')]), 0).isContinuation, false) }) From cbaffe49cf1cee16b54b062c96ec4a168c696217 Mon Sep 17 00:00:00 2001 From: drewstone Date: Tue, 15 Sep 2026 15:38:26 -0700 Subject: [PATCH 6/6] test: use native Vitest assertions for attribution contract coverage --- tests/web/message-groups.test.ts | 40 +++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/tests/web/message-groups.test.ts b/tests/web/message-groups.test.ts index 643e6c56..ac16f7cc 100644 --- a/tests/web/message-groups.test.ts +++ b/tests/web/message-groups.test.ts @@ -1,46 +1,54 @@ -import { test } from 'vitest' -import assert from 'node:assert/strict' +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) { +function at(rows: readonly T[], index: number): T { const value = rows[index] - assert(value !== undefined, `Expected row ${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')]) - assert.deepEqual(r.map(x => x.isContinuation), [false, true, false, false]); assert.equal(at(r, 0).groupId, at(r, 1).groupId); assert.notEqual(at(r, 1).groupId, at(r, 3).groupId) + 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')]) - assert.equal(at(r, 3).isContinuation, true); assert(!Object.hasOwn(at(r, 1), 'isContinuation')) + 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 })]) - assert(r.slice(1).every(x => x.isContinuation)); assert.equal(at(r, 2).groupId, 'a') + 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')]); assert.equal(at(r, 0).isContinuation, false) + 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' })]) - assert.deepEqual(r.map(x => x.isContinuation), [false, false, true]) + 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' })]) - assert.equal(at(r, 1).isContinuation, false) + 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')]); assert.equal(at(r, 2).isContinuation, true) + 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), first = at(r, 0) - assert.equal(JSON.stringify(input), before); assert.equal(r.length, 2); assert.equal(first.content, 'a'); assert.equal(at(r, 1).id, 'b'); assert('createdAt' in first); assert.equal(first.createdAt, 'yesterday') + 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', () => { - assert.deepEqual(groupConversationMessages(), []) - assert.equal(at(groupConversationMessages([{ role: 'assistant' }]), 0).groupId, 'assistant-0') - assert.equal(at(groupConversationMessages([assistant('again')]), 0).isContinuation, false) + expect(groupConversationMessages()).toEqual([]) + expect(at(groupConversationMessages([{ role: 'assistant' }]), 0).groupId).toBe('assistant-0') + expect(at(groupConversationMessages([assistant('again')]), 0).isContinuation).toBe(false) })