diff --git a/ee/media/sharp-processor.ts b/ee/media/sharp-processor.ts index 8e74851a..c0b50b21 100644 --- a/ee/media/sharp-processor.ts +++ b/ee/media/sharp-processor.ts @@ -231,6 +231,19 @@ export function createSharpMediaProvider(config: SharpMediaProviderConfig): Medi return rowToAsset(row, usage) }, + async getAssetByPath(projectId: string, originalPath: string): Promise { + const row = await db.findMediaAssetByPath(projectId, originalPath) + if (!row) return null + const usageRows = await db.getMediaUsage(row.id as string) + const usage: MediaUsageRef[] = usageRows.map(u => ({ + modelId: u.model_id as string, + entryId: u.entry_id as string, + fieldId: u.field_id as string, + locale: u.locale as string, + })) + return rowToAsset(row, usage) + }, + async listAssets(projectId: string, options?: MediaListOptions) { const result = await db.listMediaAssets(projectId, options) return { diff --git a/server/api/workspaces/[workspaceId]/projects/[projectId]/content/[modelId].post.ts b/server/api/workspaces/[workspaceId]/projects/[projectId]/content/[modelId].post.ts index 697bc38f..aebcfa74 100644 --- a/server/api/workspaces/[workspaceId]/projects/[projectId]/content/[modelId].post.ts +++ b/server/api/workspaces/[workspaceId]/projects/[projectId]/content/[modelId].post.ts @@ -73,11 +73,14 @@ export default defineEventHandler(async (event) => { // tracking still resolves the asset after media is normalized to URLs. const mediaPath = extractMediaStoragePath(value) if (mediaPath) { - // Find asset by filename - const { assets } = await mediaProvider.listAssets(projectId, { search: mediaPath.split('/').pop(), limit: 1 }) - if (assets.length > 0) { + // Resolve by storage path. The old filename `search` never matched: + // the path's uuid names the FILE, while `filename`/`alt` (all + // `search` covers) hold the original upload name — so UI-path + // usage tracking silently recorded nothing. + const asset = await mediaProvider.getAssetByPath?.(projectId, mediaPath) + if (asset) { await db.trackMediaUsage({ - asset_id: assets[0]!.id, + asset_id: asset.id, project_id: projectId, model_id: modelId, entry_id: entryId, diff --git a/server/providers/ai.ts b/server/providers/ai.ts index 53dac987..870e870a 100644 --- a/server/providers/ai.ts +++ b/server/providers/ai.ts @@ -29,7 +29,7 @@ export interface AIMessage { export type AIContentBlock = | { type: 'text', text: string } | { type: 'tool_use', id: string, name: string, input: unknown } - | { type: 'tool_result', toolUseId: string, content: string } + | { type: 'tool_result', toolUseId: string, content: string, isError?: boolean } | { type: 'image', source: AIImageSource } | { type: 'document', source: AIDocumentSource } diff --git a/server/providers/anthropic-ai.ts b/server/providers/anthropic-ai.ts index 0765bf21..e0e0f1c3 100644 --- a/server/providers/anthropic-ai.ts +++ b/server/providers/anthropic-ai.ts @@ -220,7 +220,7 @@ export function toAnthropicMessages(messages: AICompletionRequest['messages']): case 'tool_use': return { type: 'tool_use' as const, id: block.id, name: block.name, input: block.input as Record } case 'tool_result': - return { type: 'tool_result' as const, tool_use_id: block.toolUseId, content: block.content } + return { type: 'tool_result' as const, tool_use_id: block.toolUseId, content: block.content, ...(block.isError ? { is_error: true } : {}) } case 'image': return block.source.type === 'url' ? { type: 'image' as const, source: { type: 'url' as const, url: block.source.url } } diff --git a/server/providers/database.ts b/server/providers/database.ts index 99042013..38614daa 100644 --- a/server/providers/database.ts +++ b/server/providers/database.ts @@ -471,6 +471,12 @@ export interface DatabaseProvider { createMediaAsset: (asset: MediaAssetInput) => Promise getMediaAsset: (assetId: string) => Promise + /** + * Look an asset up by its storage path (`media/original/`). The path's + * uuid is the FILE name, not the row id, so a path in hand (delivery URL, + * content field value) cannot be resolved through `getMediaAsset`. + */ + findMediaAssetByPath: (projectId: string, originalPath: string) => Promise listMediaAssets: (projectId: string, options?: PaginationOptions & { search?: string tags?: string[] diff --git a/server/providers/media.ts b/server/providers/media.ts index 734e433f..a9691536 100644 --- a/server/providers/media.ts +++ b/server/providers/media.ts @@ -96,6 +96,15 @@ export interface MediaProvider { /** Get asset metadata from DB. */ getAsset: (assetId: string) => Promise + /** + * Resolve an asset from its storage path (`media/original/`) — + * the uuid in the path names the FILE, not the DB row, so a delivery + * URL or content-field value cannot be resolved via `getAsset`. + * Optional so existing provider doubles keep compiling; callers must + * degrade when absent. + */ + getAssetByPath?: (projectId: string, originalPath: string) => Promise + /** List assets for a project with filtering and pagination. */ listAssets: (projectId: string, options?: MediaListOptions) => Promise<{ assets: MediaAsset[], total: number }> diff --git a/server/providers/postgres-db/media.ts b/server/providers/postgres-db/media.ts index 572ed137..97a79f6d 100644 --- a/server/providers/postgres-db/media.ts +++ b/server/providers/postgres-db/media.ts @@ -9,6 +9,7 @@ type MediaMethods = Pick< DatabaseProvider, | 'createMediaAsset' | 'getMediaAsset' + | 'findMediaAssetByPath' | 'listMediaAssets' | 'updateMediaAsset' | 'deleteMediaAsset' @@ -64,6 +65,22 @@ export function mediaMethods(): MediaMethods { } }, + async findMediaAssetByPath(projectId, originalPath) { + try { + const row = await getAdmin() + .selectFrom('media_assets') + .selectAll() + .where('project_id', '=', projectId) + .where('original_path', '=', originalPath) + .executeTakeFirst() + + return (row as DatabaseRow | undefined) ?? null + } + catch { + return null + } + }, + async listMediaAssets(projectId, options) { const page = options?.page ?? 1 const limit = options?.limit ?? 50 diff --git a/server/providers/supabase-db/media.ts b/server/providers/supabase-db/media.ts index ed0ce537..e9e24d50 100644 --- a/server/providers/supabase-db/media.ts +++ b/server/providers/supabase-db/media.ts @@ -8,6 +8,7 @@ type MediaMethods = Pick< DatabaseProvider, | 'createMediaAsset' | 'getMediaAsset' + | 'findMediaAssetByPath' | 'listMediaAssets' | 'updateMediaAsset' | 'deleteMediaAsset' @@ -41,6 +42,17 @@ export function mediaMethods(): MediaMethods { return (data as DatabaseRow) ?? null }, + async findMediaAssetByPath(projectId, originalPath) { + const { data } = await getAdmin() + .from('media_assets') + .select('*') + .eq('project_id', projectId) + .eq('original_path', originalPath) + .maybeSingle() + + return (data as DatabaseRow) ?? null + }, + async listMediaAssets(projectId, options) { const page = options?.page ?? 1 const limit = options?.limit ?? 50 diff --git a/server/utils/agent-system-prompt.ts b/server/utils/agent-system-prompt.ts index 7d4d2881..4107f573 100644 --- a/server/utils/agent-system-prompt.ts +++ b/server/utils/agent-system-prompt.ts @@ -407,6 +407,14 @@ function formatFieldDef(def: FieldDef, depth: number = 0): string { return `${parts.join(' ')}\n${nested}` } + // At the depth cap, still NAME the subfields. Dropping them entirely made + // the schema summary claim e.g. `cards: array (items: object)` with no + // hint an `image` existed — and the agent answered "this content cannot + // be managed here" without ever querying it. + if (def.fields) { + parts.push(`{${Object.keys(def.fields).join(', ')}}`) + } + return parts.join(' ') } diff --git a/server/utils/agent-tools.ts b/server/utils/agent-tools.ts index 194683b1..73b23f3e 100644 --- a/server/utils/agent-tools.ts +++ b/server/utils/agent-tools.ts @@ -234,7 +234,7 @@ Provide initial models with full field definitions using Contentrain's 27 type s }, { name: 'brain_search', - description: 'Full-text search across all project content. Returns matching entries with model ID, entry ID, and preview text.', + description: 'Full-text search across all project content. Multi-word queries match per word across all of an entry\'s fields (best matches first) — you do not need the exact stored phrasing. Returns matching entries with model ID, entry ID, and preview text.', inputSchema: { type: 'object', properties: { diff --git a/server/utils/brain-search.ts b/server/utils/brain-search.ts new file mode 100644 index 00000000..6efb4a2d --- /dev/null +++ b/server/utils/brain-search.ts @@ -0,0 +1,59 @@ +/** + * Token-scored matching for the agent's `brain_search` tool. + * + * The previous implementation substring-matched the query against + * `JSON.stringify(entry)`, so a query was only found when it appeared as + * one contiguous run inside the serialized JSON. Real queries concatenate + * across fields ("Review" title + "Approve branches…" description) and + * never match that way — in the 2026-08-13 staging sessions 5 of 7 + * searches returned zero results and pushed the agent into full-model + * dumps and a wrong-entry pick. + * + * Matching here is per-token over the entry's string VALUES only (no JSON + * syntax, no keys): every query token must appear somewhere, but not + * contiguously and not in field order. Pure functions — no Nuxt context — + * so the scoring is unit-testable in isolation. + */ + +/** Recursively collect string values (nested objects/arrays included). */ +export function collectSearchableText(value: unknown, depth = 0): string { + if (typeof value === 'string') return value + if (typeof value !== 'object' || value === null || depth > 6) return '' + + const parts: string[] = [] + const values = Array.isArray(value) ? value : Object.values(value as Record) + for (const v of values) { + if (typeof v === 'string') { + if (v.length > 0) parts.push(v) + } + else if (typeof v === 'object' && v !== null) { + const nested = collectSearchableText(v, depth + 1) + if (nested) parts.push(nested) + } + } + return parts.join(' ') +} + +/** Lowercased, deduplicated word tokens; 1-char tokens carry no signal. */ +export function tokenizeQuery(query: string): string[] { + const tokens = query.toLowerCase().match(/[\p{L}\p{N}]{2,}/gu) ?? [] + return [...new Set(tokens)] +} + +/** + * Fraction of query tokens present in the entry text (substring match, so + * "review" also hits "reviewer" — mirroring the forward tokenization the + * content panel's client-side search uses). + */ +export function scoreEntryText(text: string, tokens: string[]): number { + if (tokens.length === 0) return 0 + const haystack = text.toLowerCase() + let matched = 0 + for (const token of tokens) { + if (haystack.includes(token)) matched++ + } + return matched / tokens.length +} + +/** Entries scoring below this are noise, not partial matches. */ +export const BRAIN_SEARCH_MIN_SCORE = 0.5 diff --git a/server/utils/content-engine/helpers.ts b/server/utils/content-engine/helpers.ts index 41d21a81..93da0aa2 100644 --- a/server/utils/content-engine/helpers.ts +++ b/server/utils/content-engine/helpers.ts @@ -100,6 +100,34 @@ export function pinReaderToContentrain(git: GitProvider): RepoReader { } } +/** + * Whether every planned file is byte-identical to what `contentrain` + * already holds — i.e. the save is a no-op. Reliable because the plan + * output is deterministic (`canonicalStringify` for JSON, and Studio's + * meta override writes no timestamps), so identical input produces + * identical bytes. Deletions (`content: null`) and brand-new files never + * count as no-ops. + * + * The two extra reads per save are far cheaper than what a no-op used to + * cost: a branch, an empty commit, a merge to `contentrain`, and a + * `contentrain`→main advance (~18s wall-clock on staging). + */ +export async function planMatchesCurrent(reader: RepoReader, changes: FileChange[]): Promise { + if (changes.length === 0) return false + for (const change of changes) { + if (typeof change.content !== 'string') return false + let current: string + try { + current = await reader.readFile(change.path) + } + catch { + return false + } + if (current !== change.content) return false + } + return true +} + /** * Override the meta FileChange produced by `planContentSave` with * Studio's status semantics: diff --git a/server/utils/content-engine/save-content.ts b/server/utils/content-engine/save-content.ts index 7e74cdf3..617afaf0 100644 --- a/server/utils/content-engine/save-content.ts +++ b/server/utils/content-engine/save-content.ts @@ -10,6 +10,7 @@ import { createFeatureBranch, shapeEntriesForSave, toObjectMap, + planMatchesCurrent, } from './helpers' import { normalizeModelContentMedia } from '../media-rewrite' import { saveDocument } from './save-document' @@ -222,6 +223,19 @@ export async function saveContent( const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) + // Byte-identical plan → the requested state is already live on + // `contentrain`. Skip the branch/commit/merge cycle entirely (a no-op + // used to create an empty commit and a full ~18s merge round). + if (await planMatchesCurrent(reader, allChanges)) { + return { + branch: '', + commit: { sha: '', message: '', author: STUDIO_AUTHOR, timestamp: '' }, + diff: [], + validation, + unchanged: true, + } + } + const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) const commit = await ctx.git.applyPlan({ diff --git a/server/utils/content-engine/save-document.ts b/server/utils/content-engine/save-document.ts index 0c956ac5..fe6f84c0 100644 --- a/server/utils/content-engine/save-document.ts +++ b/server/utils/content-engine/save-document.ts @@ -3,7 +3,7 @@ import { CONTENTRAIN_BRANCH as MCP_CONTENTRAIN_BRANCH, parseMarkdownFrontmatter, import { planContentSave } from '@contentrain/mcp/core/ops' import type { EngineInternalContext, WriteResult } from './types' import { STUDIO_AUTHOR, CONTENT_BRANCH } from './types' -import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch } from './helpers' +import { applyStudioMetaOverrides, pinReaderToContentrain, createFeatureBranch, planMatchesCurrent } from './helpers' import { rewriteEntryMedia, rewriteMarkdownMedia } from '../media-rewrite' /** @@ -147,6 +147,18 @@ export async function saveDocument( const allChanges: FileChange[] = [...patchedChanges] .toSorted((a, b) => a.path.localeCompare(b.path)) + // Byte-identical plan → no-op; skip the branch/commit/merge cycle + // (same short-circuit as saveContent). + if (await planMatchesCurrent(reader, allChanges)) { + return { + branch: '', + commit: { sha: '', message: '', author: STUDIO_AUTHOR, timestamp: '' }, + diff: [], + validation, + unchanged: true, + } + } + const { branchName } = await createFeatureBranch(ctx, 'content', modelId, locale) const commit = await ctx.git.applyPlan({ diff --git a/server/utils/content-engine/types.ts b/server/utils/content-engine/types.ts index 72b7e17f..5b63ffe7 100644 --- a/server/utils/content-engine/types.ts +++ b/server/utils/content-engine/types.ts @@ -8,6 +8,12 @@ export interface WriteResult { commit: Commit diff: FileDiff[] validation: ValidationResult + /** + * The planned files were byte-identical to `contentrain` — no branch, + * commit, or merge was performed. Not a failure: the requested state was + * already live. + */ + unchanged?: boolean } export interface ContentEngineContext { diff --git a/server/utils/conversation-engine.ts b/server/utils/conversation-engine.ts index fa4efd1f..1991baf7 100644 --- a/server/utils/conversation-engine.ts +++ b/server/utils/conversation-engine.ts @@ -299,7 +299,7 @@ export async function* runConversationLoop( for (const tc of turn.currentToolCalls) { // Stop tool execution if client disconnected if (config.abortSignal?.aborted) { - toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify({ error: 'Request cancelled' }) }) + toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify({ error: 'Request cancelled' }), isError: true }) continue } @@ -308,7 +308,7 @@ export async function* runConversationLoop( if (!stateCheck.allowed) { const errorResult = { error: stateCheck.reason, suggestion: stateCheck.suggestion } yield { type: 'tool_result', id: tc.id, name: tc.name, input: tc.input, result: errorResult } - toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify(errorResult) }) + toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: JSON.stringify(errorResult), isError: true }) continue } @@ -335,7 +335,13 @@ export async function* runConversationLoop( // can refresh the context panel live (debounced) as each operation // lands, instead of only once the whole turn finishes on `done`. yield { type: 'tool_result', id: tc.id, name: tc.name, input: tc.input, result: result.result, affected: result.affected } - toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: resultStr }) + // Executor errors come back in-band as `{ error: ... }` (never the + // plural `errors` array, which successful validation results carry). + // Flag them so Anthropic's `is_error` and any transcript-level error + // telemetry see the failure — previously every tool error counted + // as a success at the protocol layer. + const isToolError = typeof result.result === 'object' && result.result !== null && 'error' in result.result + toolResultBlocks.push({ type: 'tool_result', toolUseId: tc.id, content: resultStr, ...(isToolError ? { isError: true } : {}) }) } config.messages.push({ role: 'assistant', content: turn.assistantBlocks }) @@ -566,7 +572,7 @@ export async function executeToolWithAutoMerge( } } - let writeResult: { branch: string, commit: { sha: string }, diff: unknown[], validation: { valid: boolean, errors: Array<{ message: string }> } } + let writeResult: { branch: string, commit: { sha: string }, diff: unknown[], validation: { valid: boolean, errors: Array<{ message: string }> }, unchanged?: boolean } // Document kind: expects { slug, frontmatter/data, body } if (params.slug && typeof params.slug === 'string') { @@ -591,6 +597,13 @@ export async function executeToolWithAutoMerge( break } + // A no-op save touched nothing — don't dirty the brain cache or + // report a pending merge for a state that is already live. + if (writeResult.unchanged) { + result = { ...summarizeWriteResult(writeResult), merged: true, workflow } + break + } + affected.models.push(modelId) affected.locales.push(locale) affected.branchesChanged = true @@ -899,59 +912,86 @@ export async function executeToolWithAutoMerge( result = { error: agentMessage('media.url_required') } break } - // SSRF guard — block internal/private/loopback targets, matching - // the session URL-import route. Without this an agent prompt could - // make the server fetch internal-only addresses. - if (!isAllowedWebhookUrl(url)) { - result = { error: agentMessage('media.url_blocked') } + + // Our own delivery URL (or a bare `media/...` path) means the asset + // is ALREADY in the library — re-ingesting it created a duplicate + // under a uuid filename. Resolve to the existing asset instead; if + // it does not resolve, the reference is stale/invented, and + // refetching our own CDN would only launder that mistake. + const ownPath = ownMediaStoragePath(projectId, url) + if (ownPath) { + const existing = await mediaProvider.getAssetByPath?.(projectId, ownPath) + if (!existing) { + result = { error: agentMessage('media.asset_not_found') } + break + } + result = { ...summarizeMediaAsset(projectId, existing), deduplicated: true } break } - let fetchResponse: Response + try { - fetchResponse = await fetch(url, { - headers: { 'User-Agent': 'Contentrain-Studio/1.0' }, - signal: AbortSignal.timeout(30_000), + // Shared ingest path: SSRF guard + MIME whitelist + per-plan size + // cap live in fetchRemoteMedia (this executor previously inlined + // an unguarded copy with no size cap and no storage quota). + const maxBytes = getPlanLimit(plan, 'media.max_file_size_mb') * 1024 * 1024 + const remote = await fetchRemoteMedia({ url, maxBytes }) + const variants = resolveVariantConfigWithPlan(params.variants as string | undefined, { + hasCustomVariants: hasFeature(plan, 'media.custom_variants'), + variantsPerFieldLimit: getPlanLimit(plan, 'media.variants_per_field'), }) - } - catch { - result = { error: agentMessage('media.fetch_failed') } - break - } - if (!fetchResponse.ok) { - result = { error: agentMessage('media.url_bad_status', { status: fetchResponse.status }) } - break - } - const mimeType = (fetchResponse.headers.get('content-type') ?? 'application/octet-stream').split(';')[0]!.trim() - if (!isAllowedMimeType(mimeType)) { - result = { error: agentMessage('media.type_not_allowed', { type: mimeType }) } - break - } - const fileBuffer = Buffer.from(await fetchResponse.arrayBuffer()) - const urlFilename = new URL(url).pathname.split('/').pop() ?? 'uploaded-file' - const variants = resolveVariantConfig(params.variants as string | undefined) - const asset = await mediaProvider.upload({ - projectId, - workspaceId, - file: fileBuffer, - filename: urlFilename, - contentType: mimeType, - alt: params.alt as string | undefined, - tags: params.tags as string[] | undefined, - variants, - // uploaded_by is a uuid FK to profiles(id) — pass the user id, not the - // email (the UI upload routes pass session.user.id). Passing the email - // tripped "invalid input syntax for type uuid". - uploadedBy: userId, - source: 'agent', - }) - result = { - id: asset.id, - path: asset.originalPath, - url: toDeliveryUrl(projectId, asset.originalPath), - filename: asset.filename, - dimensions: `${asset.width}x${asset.height}`, - variants: Object.fromEntries(Object.entries(asset.variants).map(([k, v]) => [k, v.path])), + const db = useDatabaseProvider() + const workspace = await db.getWorkspaceById(workspaceId, 'id, overage_settings') + const overageSettings = (workspace?.overage_settings as Record | null) ?? {} + const baseLimit = getPlanLimit(plan, 'media.storage_gb') * 1024 * 1024 * 1024 + const storageLimit = getEffectiveLimit(baseLimit, 'media.storage_gb', overageSettings) + + let storageReserved = false + if (storageLimit > 0) { + const reservation = await db.reserveStorageIfAllowed(workspaceId, remote.buffer.length, storageLimit) + if (!reservation.allowed) { + result = { error: errorMessage('storage.quota_exceeded') } + break + } + storageReserved = true + } + + try { + const asset = await mediaProvider.upload({ + projectId, + workspaceId, + file: remote.buffer, + filename: remote.filename, + contentType: remote.contentType, + alt: params.alt as string | undefined, + tags: params.tags as string[] | undefined, + variants, + // uploaded_by is a uuid FK to profiles(id) — pass the user id, not the + // email (the UI upload routes pass session.user.id). Passing the email + // tripped "invalid input syntax for type uuid". + uploadedBy: userId, + source: 'agent', + skipStorageIncrement: storageReserved, + }) + + // Reconcile the reservation to the post-optimization size. + if (storageReserved) { + const actualBytes = typeof asset.size === 'number' ? asset.size : 0 + const delta = actualBytes - remote.buffer.length + if (delta !== 0) + await db.incrementWorkspaceStorageBytes(workspaceId, delta) + } + + result = summarizeMediaAsset(projectId, asset) + } + catch (err) { + if (storageReserved) + await db.incrementWorkspaceStorageBytes(workspaceId, -remote.buffer.length).catch(() => {}) + throw err + } + } + catch (err) { + result = { error: err instanceof Error ? err.message : agentMessage('media.fetch_failed') } } break } @@ -1238,11 +1278,14 @@ export async function executeToolWithAutoMerge( case 'brain_search': { const brainData = await getOrBuildBrainCache(git, contentRoot, projectId) - const searchQuery = (params.query as string).toLowerCase() const targetModel = params.model as string | undefined const searchLimit = Math.min((params.limit as number) ?? 10, 50) - const searchResults: Array<{ modelId: string, entryId: string, locale: string, preview: string }> = [] + // Token-scored matching over string VALUES (see brain-search.ts) — + // the old contiguous-substring match over JSON.stringify(entry) + // returned zero results for any query that spanned two fields. + const tokens = tokenizeQuery(params.query as string) + const scored: Array<{ modelId: string, entryId: string, locale: string, preview: string, score: number }> = [] for (const [key, data] of brainData.content) { const [mId, loc] = key.split(':') @@ -1250,34 +1293,27 @@ export async function executeToolWithAutoMerge( if (targetModel && mId !== targetModel) continue if (permissions.specificModels && !permissions.allowedModels.includes(mId)) continue if (permissions.allowedLocales?.length && !permissions.allowedLocales.includes(loc)) continue + if (typeof data !== 'object' || data === null) continue - const stringified = JSON.stringify(data).toLowerCase() - if (!stringified.includes(searchQuery)) continue - - if (typeof data === 'object' && data !== null && !Array.isArray(data)) { - for (const [entryId, entry] of Object.entries(data as Record)) { - const entryStr = JSON.stringify(entry).toLowerCase() - if (entryStr.includes(searchQuery)) { - const preview = JSON.stringify(entry).substring(0, 200) - searchResults.push({ modelId: mId, entryId, locale: loc, preview }) - if (searchResults.length >= searchLimit) break - } - } - } - else if (Array.isArray(data)) { - for (const [idx, entry] of data.entries()) { - const entryStr = JSON.stringify(entry).toLowerCase() - if (entryStr.includes(searchQuery)) { + const entryPairs: Array<[string, unknown]> = Array.isArray(data) + ? data.map((entry, idx) => { const slug = typeof entry === 'object' && entry !== null ? (entry as Record).slug as string ?? `entry-${idx}` : `entry-${idx}` - searchResults.push({ modelId: mId, entryId: slug, locale: loc, preview: JSON.stringify(entry).substring(0, 200) }) - if (searchResults.length >= searchLimit) break - } - } + return [slug, entry] as [string, unknown] + }) + : Object.entries(data as Record) + + for (const [entryId, entry] of entryPairs) { + const score = scoreEntryText(collectSearchableText(entry), tokens) + if (score < BRAIN_SEARCH_MIN_SCORE) continue + scored.push({ modelId: mId, entryId, locale: loc, preview: JSON.stringify(entry).substring(0, 200), score }) } - - if (searchResults.length >= searchLimit) break } + // Full matches first, partials after; ranked before the cut so the + // best hits survive the limit regardless of model iteration order. + scored.sort((a, b) => b.score - a.score) + const searchResults = scored.slice(0, searchLimit) + result = { query: params.query, results: searchResults, total: searchResults.length } break } @@ -1308,13 +1344,30 @@ export async function executeToolWithAutoMerge( // ─── Helpers ─── -function summarizeWriteResult(result: { branch: string, commit: { sha: string }, diff: unknown[], validation: { valid: boolean, errors: Array<{ message: string }> } }): Record { +function summarizeWriteResult(result: { branch: string, commit: { sha: string }, diff: unknown[], validation: { valid: boolean, errors: Array<{ message: string }> }, unchanged?: boolean }): Record { return { branch: result.branch, commitSha: result.commit.sha, filesChanged: result.diff.length, valid: result.validation.valid, errors: result.validation.errors.map(e => e.message), + // A save whose planned files were byte-identical to `contentrain` — + // no branch, no commit, no merge happened. Distinct from failure: the + // requested state was ALREADY live, so the agent must not retry, but it + // must not claim it changed anything either. + ...(result.unchanged ? { unchanged: true } : {}), + } +} + +/** Tool-facing shape for a media asset (upload_media result contract). */ +function summarizeMediaAsset(projectId: string, asset: import('~~/server/providers/media').MediaAsset): Record { + return { + id: asset.id, + path: asset.originalPath, + url: toDeliveryUrl(projectId, asset.originalPath), + filename: asset.filename, + dimensions: `${asset.width}x${asset.height}`, + variants: Object.fromEntries(Object.entries(asset.variants).map(([k, v]) => [k, v.path])), } } diff --git a/server/utils/media-url.ts b/server/utils/media-url.ts index 65c10936..fbcad8c4 100644 --- a/server/utils/media-url.ts +++ b/server/utils/media-url.ts @@ -43,6 +43,21 @@ export function rewriteMediaUrl(projectId: string, value: unknown): unknown { return isStoredMediaPath(value) ? toDeliveryUrl(projectId, value) : value } +/** + * Resolve a value to THIS project's media storage path, when it is one. + * Accepts either the bare stored form (`media/...`) or the project's own + * absolute delivery URL; anything else — other hosts, other projects' + * delivery URLs — returns null. Query/hash suffixes are stripped. + */ +export function ownMediaStoragePath(projectId: string, value: unknown): string | null { + if (typeof value !== 'string') return null + if (/^media\//.test(value)) return value.split(/[?#]/)[0]! + const prefix = `${publicMediaBase(projectId)}/` + if (!value.startsWith(prefix)) return null + const rest = value.slice(prefix.length).split(/[?#]/)[0]! + return /^media\//.test(rest) ? rest : null +} + /** * Decorate an asset with ready-to-use delivery URLs for the original and * every variant, keeping the raw storage paths intact. diff --git a/tests/integration/content-routes.integration.test.ts b/tests/integration/content-routes.integration.test.ts index 0502c238..4a984b82 100644 --- a/tests/integration/content-routes.integration.test.ts +++ b/tests/integration/content-routes.integration.test.ts @@ -18,10 +18,10 @@ describe('content route integration', () => { diff: [], validation: { valid: true, errors: [] }, }) - const listAssets = vi.fn().mockResolvedValue({ - assets: [{ id: 'asset-1' }], - total: 1, - }) + // Usage tracking resolves assets by storage path now — the filename + // `search` lookup never matched (the path uuid names the file, while + // `search` only covers filename/alt). + const getAssetByPath = vi.fn().mockResolvedValue({ id: 'asset-1' }) const trackMediaUsage = vi.fn().mockResolvedValue(undefined) vi.stubGlobal('getRouterParam', vi.fn((_: unknown, key: string) => { @@ -51,7 +51,7 @@ describe('content route integration', () => { vi.stubGlobal('getOrBuildBrainCache', vi.fn().mockResolvedValue({ config: { workflow: 'auto-merge' } })) vi.stubGlobal('invalidateBrainCache', vi.fn()) vi.stubGlobal('createContentEngine', vi.fn().mockReturnValue({ saveContent, mergeBranch })) - vi.stubGlobal('useMediaProvider', vi.fn().mockReturnValue({ listAssets })) + vi.stubGlobal('useMediaProvider', vi.fn().mockReturnValue({ getAssetByPath })) vi.stubGlobal('emitWebhookEvent', vi.fn().mockResolvedValue(undefined)) vi.stubGlobal('useDatabaseProvider', vi.fn().mockReturnValue({ trackMediaUsage, diff --git a/tests/unit/agent-system-prompt.test.ts b/tests/unit/agent-system-prompt.test.ts index d686da14..62c609e0 100644 --- a/tests/unit/agent-system-prompt.test.ts +++ b/tests/unit/agent-system-prompt.test.ts @@ -94,6 +94,58 @@ describe('buildSystemPrompt', () => { expect(prompt).toContain('Workflow: review') }) + it('names array-item subfields at the schema depth cap instead of dropping them', () => { + // Regression (staging 2026-08-13): home-page's object → array → + // items.fields chain rendered as `cards: array (items: object)` with + // the subfields silently dropped — the agent concluded the card image + // "cannot be managed here" without querying content. + const prompt = buildSystemPrompt( + null, + [ + { + id: 'home-page', + name: 'Home Page', + kind: 'singleton', + domain: 'marketing', + i18n: true, + fields: { + operating_model: { + type: 'object', + fields: { + heading: { type: 'string' }, + cards: { + type: 'array', + items: { + type: 'object', + fields: { + title: { type: 'string' }, + image: { type: 'image' }, + cta_label: { type: 'string' }, + }, + }, + }, + }, + }, + }, + }, + ] as never, + { + workspaceRole: 'owner', + projectRole: null, + specificModels: false, + allowedModels: [], + availableTools: ['save_content'], + } as never, + { initialized: true, pendingBranches: [], projectStatus: 'active', phase: 'active' }, + { activeModelId: null, activeLocale: 'en', activeEntryId: null, panelState: 'content', activeBranch: null, contextItems: [] } as never, + { category: 'update_content', confidence: 'low', inferred: {} } as never, + null, + 'pro', + ) + + expect(prompt).toContain('{title, image, cta_label}') + }) + it('adds initialization guidance for uninitialized projects', () => { const prompt = buildSystemPrompt( null, diff --git a/tests/unit/anthropic-content-blocks.test.ts b/tests/unit/anthropic-content-blocks.test.ts index 2c8a6e5d..0ba9d59e 100644 --- a/tests/unit/anthropic-content-blocks.test.ts +++ b/tests/unit/anthropic-content-blocks.test.ts @@ -52,4 +52,17 @@ describe('toAnthropicMessages — attachment content blocks', () => { expect(blocks[0]!.type).toBe('document') expect(blocks[1]!.type).toBe('text') }) + + it('maps isError onto the wire is_error flag, omitting it otherwise', () => { + const out = toAnthropicMessages([{ + role: 'user', + content: [ + { type: 'tool_result', toolUseId: 't1', content: '{"error":"boom"}', isError: true }, + { type: 'tool_result', toolUseId: 't2', content: '{"ok":true}' }, + ], + }]) + const blocks = out[0]!.content as Array> + expect(blocks[0]).toEqual({ type: 'tool_result', tool_use_id: 't1', content: '{"error":"boom"}', is_error: true }) + expect(blocks[1]).toEqual({ type: 'tool_result', tool_use_id: 't2', content: '{"ok":true}' }) + }) }) diff --git a/tests/unit/brain-search.test.ts b/tests/unit/brain-search.test.ts new file mode 100644 index 00000000..f014a0fe --- /dev/null +++ b/tests/unit/brain-search.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'vitest' +import { BRAIN_SEARCH_MIN_SCORE, collectSearchableText, scoreEntryText, tokenizeQuery } from '../../server/utils/brain-search' + +describe('collectSearchableText', () => { + it('collects nested string values, skipping keys and non-strings', () => { + const entry = { + title: 'Review', + count: 3, + meta: { description: 'Approve branches before changes become production content.' }, + cards: [{ label: 'Nested card' }, 'bare string'], + } + const text = collectSearchableText(entry) + expect(text).toContain('Review') + expect(text).toContain('Approve branches') + expect(text).toContain('Nested card') + expect(text).toContain('bare string') + expect(text).not.toContain('title') + expect(text).not.toContain('meta') + }) + + it('returns the string itself for a string input and empty for scalars', () => { + expect(collectSearchableText('hello')).toBe('hello') + expect(collectSearchableText(42)).toBe('') + expect(collectSearchableText(null)).toBe('') + }) +}) + +describe('tokenizeQuery', () => { + it('lowercases, splits on non-word chars, dedupes, and drops 1-char tokens', () => { + expect(tokenizeQuery('Review: Approve branches, review!')).toEqual(['review', 'approve', 'branches']) + }) + + it('keeps unicode words (Turkish)', () => { + expect(tokenizeQuery('görseli değiştir')).toEqual(['görseli', 'değiştir']) + }) +}) + +describe('scoreEntryText', () => { + // The exact staging failure (2026-08-13): the entry's title is "Review" + // and its description is "Approve branches before changes become + // production content." — the user-visible card text concatenates them, + // so the old contiguous JSON.stringify substring match found nothing. + const entry = { + title: 'Review', + description: 'Approve branches before changes become production content.', + } + const text = collectSearchableText(entry) + + it('matches a query spanning two fields (the staging regression)', () => { + const tokens = tokenizeQuery('Review Approve branches before changes become production content') + expect(scoreEntryText(text, tokens)).toBe(1) + }) + + it('matches word subsets sampled from the middle of a sentence', () => { + const tokens = tokenizeQuery('Approve branches production content') + expect(scoreEntryText(text, tokens)).toBe(1) + }) + + it('scores partial matches proportionally, below full matches', () => { + const tokens = tokenizeQuery('approve branches kangaroo') + const score = scoreEntryText(text, tokens) + expect(score).toBeGreaterThanOrEqual(BRAIN_SEARCH_MIN_SCORE) + expect(score).toBeLessThan(1) + }) + + it('rejects unrelated queries', () => { + const tokens = tokenizeQuery('pricing plans enterprise tier') + expect(scoreEntryText(text, tokens)).toBeLessThan(BRAIN_SEARCH_MIN_SCORE) + }) + + it('substring-matches inside longer words (forward tokenization)', () => { + expect(scoreEntryText('the reviewer approves', tokenizeQuery('review'))).toBe(1) + }) +}) diff --git a/tests/unit/content-engine-noop-save.test.ts b/tests/unit/content-engine-noop-save.test.ts new file mode 100644 index 00000000..ef73feab --- /dev/null +++ b/tests/unit/content-engine-noop-save.test.ts @@ -0,0 +1,48 @@ +import type { RepoReader } from '@contentrain/types' +import { describe, expect, it } from 'vitest' +import { planMatchesCurrent } from '../../server/utils/content-engine/helpers' + +function readerOf(files: Record): RepoReader { + return { + readFile: async (path) => { + if (path in files) return files[path]! + throw new Error(`404: ${path}`) + }, + listDirectory: async () => [], + fileExists: async path => path in files, + } +} + +describe('planMatchesCurrent — no-op save detection', () => { + const content = '{\n "hero": "Hello"\n}\n' + const meta = '{\n "hero": { "status": "published" }\n}\n' + + it('detects a byte-identical plan as a no-op', async () => { + const reader = readerOf({ 'content/a/en.json': content, 'content/a/en.meta.json': meta }) + const matches = await planMatchesCurrent(reader, [ + { path: 'content/a/en.json', content }, + { path: 'content/a/en.meta.json', content: meta }, + ]) + expect(matches).toBe(true) + }) + + it('any differing file makes it a real save', async () => { + const reader = readerOf({ 'content/a/en.json': content, 'content/a/en.meta.json': meta }) + const matches = await planMatchesCurrent(reader, [ + { path: 'content/a/en.json', content: content.replace('Hello', 'Merhaba') }, + { path: 'content/a/en.meta.json', content: meta }, + ]) + expect(matches).toBe(false) + }) + + it('a brand-new file is never a no-op', async () => { + const reader = readerOf({}) + expect(await planMatchesCurrent(reader, [{ path: 'content/a/en.json', content }])).toBe(false) + }) + + it('deletions and empty plans are never no-ops', async () => { + const reader = readerOf({ 'content/a/en.json': content }) + expect(await planMatchesCurrent(reader, [{ path: 'content/a/en.json', content: null }])).toBe(false) + expect(await planMatchesCurrent(reader, [])).toBe(false) + }) +}) diff --git a/tests/unit/media-url.test.ts b/tests/unit/media-url.test.ts index af0557b9..03ae0dd1 100644 --- a/tests/unit/media-url.test.ts +++ b/tests/unit/media-url.test.ts @@ -50,4 +50,15 @@ describe('media URL helpers', () => { expect(out.variantUrls.thumb).toBe('https://studio.example.com/api/cdn/v1/proj-1/media/abc_thumb.webp') expect(out.originalPath).toBe('media/abc.webp') }) + + it('resolves own delivery URLs and bare paths to storage paths, rejecting foreign ones', async () => { + const { ownMediaStoragePath } = await import('../../server/utils/media-url') + expect(ownMediaStoragePath('proj-1', 'media/original/abc.webp')).toBe('media/original/abc.webp') + expect(ownMediaStoragePath('proj-1', 'https://studio.example.com/api/cdn/v1/proj-1/media/original/abc.webp?w=200')).toBe('media/original/abc.webp') + // Another project's delivery URL is NOT ours + expect(ownMediaStoragePath('proj-1', 'https://studio.example.com/api/cdn/v1/proj-2/media/original/abc.webp')).toBeNull() + expect(ownMediaStoragePath('proj-1', 'https://images.unsplash.com/photo-123')).toBeNull() + expect(ownMediaStoragePath('proj-1', 'https://studio.example.com/api/cdn/v1/proj-1/content/models.json')).toBeNull() + expect(ownMediaStoragePath('proj-1', 42)).toBeNull() + }) })