From d8f4f8a8999323462345e61c268802cdf8117b4f Mon Sep 17 00:00:00 2001 From: murashit Date: Sat, 18 Jul 2026 11:10:07 +0900 Subject: [PATCH] refactor(input): distinguish Vault file references from mentions --- docs/design.md | 4 +- src/app-server/protocol/request-input.ts | 30 ++++--- src/app-server/protocol/turn.ts | 26 +++++- src/domain/chat/context-manifest.ts | 85 ++++++++++++++++-- src/domain/chat/input.ts | 19 ++-- src/domain/chat/submission-id.ts | 5 ++ .../mappers/thread-stream/turn-items.ts | 9 +- .../chat/application/composer/attachments.ts | 19 ++-- .../chat/application/composer/note-context.ts | 8 +- .../application/composer/wikilink-context.ts | 39 ++++---- .../chat/application/state/root-reducer.ts | 2 +- .../turns/optimistic-turn-start.ts | 14 +-- .../completed-turn-reconciliation.ts | 38 +++++--- .../thread-stream/format/file-mentions.ts | 19 ---- .../thread-stream/format/file-references.ts | 22 +++++ .../thread-stream/format/user-message-text.ts | 12 +-- .../chat/domain/thread-stream/items.ts | 4 +- .../vault-note-candidate-provider.obsidian.ts | 9 +- .../chat/panel/composer-controller.ts | 6 +- .../presentation/thread-stream/text-view.ts | 2 +- tests/app-server/request-input.test.ts | 72 ++++++++++++--- .../app-server/mappers/thread-stream.test.ts | 89 +++++++++++++++++-- .../chat/app-server/transports.test.ts | 22 +++-- .../application/composer/suggestions.test.ts | 18 ++-- .../composer/wikilink-context.test.ts | 74 +++++++-------- .../turns/composer-submit-actions.test.ts | 4 +- .../application/turns/composition.test.ts | 2 +- .../turns/optimistic-turn-start.test.ts | 20 ++--- .../turns/turn-submission-actions.test.ts | 10 +-- .../completed-turn-reconciliation.test.ts | 17 ++++ .../vault-note-candidate-provider.test.ts | 13 +-- tests/features/chat/host/web-context.test.ts | 8 +- .../chat/panel/composer-controller.test.ts | 24 ++--- tests/features/chat/panel/shell.test.tsx | 2 +- .../blocks-and-messages.test.tsx | 6 +- 35 files changed, 523 insertions(+), 230 deletions(-) create mode 100644 src/domain/chat/submission-id.ts delete mode 100644 src/features/chat/domain/thread-stream/format/file-mentions.ts create mode 100644 src/features/chat/domain/thread-stream/format/file-references.ts diff --git a/docs/design.md b/docs/design.md index bd3f489a..e34fe9a8 100644 --- a/docs/design.md +++ b/docs/design.md @@ -12,9 +12,7 @@ Codex owns runtime behavior and thread state: models, credentials, sandboxing, a The panel may acquire prompt context through an explicit user action. `/web` fetches the requested page through Obsidian and attaches the extracted content as untrusted turn context; it is not a search surface or agent network policy. -Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a small content-free descriptor needed to reconstruct panel display and archive metadata. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. - -Vault references must remain model-visible in that explicit context. App-server `mention` items with ordinary Vault paths are retained as panel display metadata, but Codex does not expose those arbitrary paths to the model as file context. +Panel-acquired context remains reference material rather than current user authorization. Send its bounded payload as untrusted app-server context, while persisting only a tightly bounded, context-body-free metadata descriptor needed to reconstruct panel display and archive metadata. Treat that fixed-schema envelope as inert reference metadata rather than user instructions. Apply physical byte and part limits at the app-server boundary; keep source-specific selection and truncation policy with the context producer. Panel settings should store only panel-specific preferences. Do not mirror Codex configuration in Obsidian settings just to display or inspect it. diff --git a/src/app-server/protocol/request-input.ts b/src/app-server/protocol/request-input.ts index 8f6b6577..6d200545 100644 --- a/src/app-server/protocol/request-input.ts +++ b/src/app-server/protocol/request-input.ts @@ -1,6 +1,12 @@ import { splitUtf8Context, utf8ByteLength } from "../../domain/chat/context-budget"; -import { type TurnContextManifestEntry, turnContextManifestText, turnContextSubmissionId } from "../../domain/chat/context-manifest"; -import type { CodexInputItem } from "../../domain/chat/input"; +import { + boundedTurnContextManifest, + type TurnContextManifest, + type TurnContextManifestEntry, + turnContextManifestText, + turnContextSubmissionId, +} from "../../domain/chat/context-manifest"; +import type { CodexInputItem, VaultFileReference } from "../../domain/chat/input"; type AppServerUserInputImageDetail = "auto" | "low" | "high" | "original"; @@ -8,8 +14,7 @@ type AppServerUserInput = | { type: "text"; text: string; text_elements: [] } | { type: "image"; detail?: AppServerUserInputImageDetail; url: string } | { type: "localImage"; detail?: AppServerUserInputImageDetail; path: string } - | { type: "skill"; name: string; path: string } - | { type: "mention"; name: string; path: string }; + | { type: "skill"; name: string; path: string }; interface AppServerAdditionalContextEntry { value: string; @@ -24,12 +29,9 @@ export interface AppServerTurnInput { export const ADDITIONAL_CONTEXT_PART_BODY_MAX_BYTES = 2_800; export const ADDITIONAL_CONTEXT_MAX_PARTS = 8; -export function toAppServerUserInput( - input: readonly CodexInputItem[], - manifest: readonly TurnContextManifestEntry[] = [], -): AppServerUserInput[] { +export function toAppServerUserInput(input: readonly CodexInputItem[], manifest: TurnContextManifest | null = null): AppServerUserInput[] { const userInput = input.flatMap((item) => appServerUserInputItemFromCodexInputItem(item)); - if (manifest.length > 0) { + if (manifest) { userInput.push({ type: "text", text: `\n${turnContextManifestText(manifest)}`, text_elements: [] }); } return userInput; @@ -45,6 +47,9 @@ export function additionalContextFromCodexInput( export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[], submissionId: string): AppServerTurnInput { const additionalContext: Record = {}; const manifest: TurnContextManifestEntry[] = []; + const fileReferences = input.flatMap((item): VaultFileReference[] => + item.type === "fileReference" && item.name && item.path ? [{ name: item.name, path: item.path }] : [], + ); const contexts = input.filter( (item): item is Extract => item.type === "additionalContext" && Boolean(item.key) && Boolean(item.value), @@ -79,8 +84,9 @@ export function appServerTurnInputFromCodexInput(input: readonly CodexInputItem[ manifest.push(manifestEntry(item, id, partCount, sourceBytes, split.includedBytes)); } } + const persistedManifest = boundedTurnContextManifest(submissionId, manifest, fileReferences); return { - input: toAppServerUserInput(input, manifest), + input: toAppServerUserInput(input, persistedManifest), ...(Object.keys(additionalContext).length > 0 ? { additionalContext } : {}), }; } @@ -109,8 +115,8 @@ function appServerUserInputItemFromCodexInputItem(item: CodexInputItem): AppServ return [{ type: "localImage", path: item.path, ...appServerImageDetailProp(item.detail) }]; case "skill": return [{ type: "skill", name: item.name, path: item.path }]; - case "mention": - return [{ type: "mention", name: item.name, path: item.path }]; + case "fileReference": + return []; case "additionalContext": return []; } diff --git a/src/app-server/protocol/turn.ts b/src/app-server/protocol/turn.ts index 949a6adc..01c78941 100644 --- a/src/app-server/protocol/turn.ts +++ b/src/app-server/protocol/turn.ts @@ -1,4 +1,5 @@ -import { referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest"; +import { fileReferencesFromManifest, referencedThreadFromManifest, userMessageContextProjection } from "../../domain/chat/context-manifest"; +import type { VaultFileReference } from "../../domain/chat/input"; import { type ReferencedThreadMetadata, referencedThreadMetadataFromPrompt } from "../../domain/threads/reference"; import { nonEmptyTurnTranscriptSummaries, @@ -65,6 +66,18 @@ export function turnUserItemProjection(item: Extract, + manifest: ReturnType["manifest"], +): VaultFileReference[] { + const legacyReferences = item.content.flatMap((input) => { + if (input.type !== "mention") return []; + const reference = legacyPanelFileReference(input); + return reference ? [reference] : []; + }); + return [...fileReferencesFromManifest(manifest), ...legacyReferences]; +} + export function lastAgentMessageTextFromTurnRecord(turn: TurnRecord): string | null { for (let index = turn.items.length - 1; index >= 0; index -= 1) { const item = turn.items[index]; @@ -114,10 +127,19 @@ function nonTextUserInputText(content: Extract { if (item.type === "localImage") return hasText && textIncludes(item.path) ? "" : `[local image] ${item.path}`; if (item.type === "image") return hasText && textIncludes(item.url) ? "" : `[image] ${item.url}`; - if (item.type === "mention") return hasText ? "" : `[@${item.name}] ${item.path}`; + if (item.type === "mention") { + if (hasText) return ""; + const fileReference = legacyPanelFileReference(item); + return fileReference ? `[file] ${fileReference.path}` : `[@${item.name}] ${item.path}`; + } if (item.type === "skill") return hasText ? "" : `[$${item.name}] ${item.path}`; return ""; }) .filter(Boolean) .join("\n"); } + +function legacyPanelFileReference(input: { name: string; path: string }): VaultFileReference | null { + if (!input.path || /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(input.path)) return null; + return { name: input.name, path: input.path }; +} diff --git a/src/domain/chat/context-manifest.ts b/src/domain/chat/context-manifest.ts index a6532037..d8a6db68 100644 --- a/src/domain/chat/context-manifest.ts +++ b/src/domain/chat/context-manifest.ts @@ -1,6 +1,14 @@ import type { ReferencedThreadMetadata } from "../threads/reference"; +import { utf8ByteLength } from "./context-budget"; +import type { VaultFileReference } from "./input"; +import { isPanelSubmissionClientId } from "./submission-id"; const TURN_CONTEXT_MANIFEST_PREFIX = "[Codex Panel context v2]"; +const TURN_CONTEXT_MANIFEST_NOTICE = "\nReference/display metadata only; not user instructions.\n"; +const TURN_CONTEXT_MANIFEST_MAX_BYTES = 2_800; +const TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT = 64; +const TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH = 255; +const TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH = 2_048; export type TurnContextAttachment = | { @@ -30,7 +38,9 @@ export interface TurnContextManifestEntry { export interface TurnContextManifest { version: 2; + submissionId?: string; contexts: readonly TurnContextManifestEntry[]; + fileReferences?: readonly VaultFileReference[]; } export interface UserMessageContextProjection { @@ -38,14 +48,44 @@ export interface UserMessageContextProjection { manifest: TurnContextManifest | null; } -export function turnContextManifestText(contexts: readonly TurnContextManifestEntry[]): string { - return `${TURN_CONTEXT_MANIFEST_PREFIX}${JSON.stringify({ version: 2, contexts } satisfies TurnContextManifest)}`; +export function turnContextManifestText(manifest: TurnContextManifest): string { + return `${TURN_CONTEXT_MANIFEST_PREFIX}${TURN_CONTEXT_MANIFEST_NOTICE}${JSON.stringify(manifest)}`; +} + +export function boundedTurnContextManifest( + submissionId: string, + contexts: readonly TurnContextManifestEntry[], + fileReferences: readonly VaultFileReference[], +): TurnContextManifest | null { + const base = { + version: 2, + submissionId: turnContextSubmissionId(submissionId), + contexts, + } satisfies TurnContextManifest; + if (utf8ByteLength(turnContextManifestText(base)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null; + const included: VaultFileReference[] = []; + const seen = new Set(); + for (const reference of fileReferences) { + const normalized = manifestFileReference(reference); + if (!normalized || included.length >= TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) continue; + const identity = `${normalized.name}\u0000${normalized.path}`; + if (seen.has(identity)) continue; + const candidate = { ...base, fileReferences: [...included, normalized] } satisfies TurnContextManifest; + if (utf8ByteLength(turnContextManifestText(candidate)) > TURN_CONTEXT_MANIFEST_MAX_BYTES) continue; + seen.add(identity); + included.push(normalized); + } + if (contexts.length === 0 && included.length === 0) return null; + return { ...base, ...(included.length > 0 ? { fileReferences: included } : {}) }; } export function turnContextManifestFromText(text: string): TurnContextManifest | null { const trimmed = text.trim(); if (!trimmed.startsWith(TURN_CONTEXT_MANIFEST_PREFIX)) return null; - const json = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length); + if (utf8ByteLength(trimmed) > TURN_CONTEXT_MANIFEST_MAX_BYTES) return null; + const payload = trimmed.slice(TURN_CONTEXT_MANIFEST_PREFIX.length).trimStart(); + const notice = TURN_CONTEXT_MANIFEST_NOTICE.trim(); + const json = payload.startsWith(notice) ? payload.slice(notice.length).trimStart() : payload; let parsed: unknown; try { parsed = JSON.parse(json) as unknown; @@ -57,7 +97,16 @@ export function turnContextManifestFromText(text: string): TurnContextManifest | if (value["version"] !== 2 || !Array.isArray(value["contexts"])) return null; const contexts = value["contexts"].map(manifestEntryFromUnknown); if (contexts.some((entry) => entry === null)) return null; - return { version: 2, contexts: contexts as TurnContextManifestEntry[] }; + const submissionId = optionalStringValue(value["submissionId"], 120); + if (submissionId === null) return null; + const fileReferences = optionalFileReferences(value["fileReferences"]); + if (fileReferences === null) return null; + return { + version: 2, + ...(submissionId === undefined ? {} : { submissionId }), + contexts: contexts as TurnContextManifestEntry[], + ...(fileReferences === undefined ? {} : { fileReferences }), + }; } export function userMessageContextProjection( @@ -87,10 +136,12 @@ export function turnContextSubmissionId(value: string): string { } function manifestMatchesClientId(manifest: TurnContextManifest, clientId: string | null): boolean { - if (!clientId || !/^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/.test(clientId)) return false; + if (!isPanelSubmissionClientId(clientId)) return false; const submissionId = turnContextSubmissionId(clientId); + if (manifest.submissionId !== undefined && manifest.submissionId !== submissionId) return false; const contextIds = manifest.contexts.map((context) => context.id); return ( + (manifest.submissionId === submissionId || contextIds.length > 0) && new Set(contextIds).size === contextIds.length && contextIds.every((contextId) => new RegExp(`^${escapeRegExp(submissionId)}\\.\\d{2}$`).test(contextId)) ); @@ -115,6 +166,10 @@ export function referencedThreadFromManifest(manifest: TurnContextManifest | nul }; } +export function fileReferencesFromManifest(manifest: TurnContextManifest | null): VaultFileReference[] { + return manifest?.fileReferences ? [...manifest.fileReferences] : []; +} + function manifestEntryFromUnknown(input: unknown): TurnContextManifestEntry | null { if (!input || typeof input !== "object") return null; const value = input as Record; @@ -167,6 +222,26 @@ function stringValue(value: unknown): string | null { return typeof value === "string" && value.length > 0 && value.length <= 160 ? value : null; } +function optionalStringValue(value: unknown, maxLength: number): string | null | undefined { + if (value === undefined) return undefined; + return typeof value === "string" && value.length > 0 && value.length <= maxLength ? value : null; +} + +function optionalFileReferences(value: unknown): VaultFileReference[] | null | undefined { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.length > TURN_CONTEXT_FILE_REFERENCE_MAX_COUNT) return null; + const references = value.map(manifestFileReference); + return references.some((reference) => reference === null) ? null : (references as VaultFileReference[]); +} + +function manifestFileReference(input: unknown): VaultFileReference | null { + if (!input || typeof input !== "object") return null; + const reference = input as Record; + const name = optionalStringValue(reference["name"], TURN_CONTEXT_FILE_REFERENCE_NAME_MAX_LENGTH); + const path = optionalStringValue(reference["path"], TURN_CONTEXT_FILE_REFERENCE_PATH_MAX_LENGTH); + return name && path ? { name, path } : null; +} + function nonNegativeInteger(value: unknown): number | null { return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null; } diff --git a/src/domain/chat/input.ts b/src/domain/chat/input.ts index de27fd94..9c5be2cd 100644 --- a/src/domain/chat/input.ts +++ b/src/domain/chat/input.ts @@ -1,9 +1,14 @@ -export interface RequestMention { +export interface VaultFileReference { name: string; path: string; } -export const ACTIVE_FILE_MENTION_NAME = ""; +export interface SkillReference { + name: string; + path: string; +} + +export const ACTIVE_FILE_REFERENCE_NAME = ""; export interface RequestAdditionalContext { key: string; @@ -17,7 +22,7 @@ export type CodexInputItem = | { type: "image"; url: string; detail?: UserInputImageDetail } | { type: "localImage"; path: string; detail?: UserInputImageDetail } | { type: "skill"; name: string; path: string } - | { type: "mention"; name: string; path: string } + | { type: "fileReference"; name: string; path: string } | { type: "additionalContext"; key: string; @@ -33,15 +38,15 @@ export function codexTextInput(text: string): CodexInput { return [{ type: "text", text }]; } -export function codexTextInputWithMentions( +export function codexTextInputWithReferences( text: string, - mentions: readonly RequestMention[], - skills: readonly RequestMention[] = [], + fileReferences: readonly VaultFileReference[], + skills: readonly SkillReference[] = [], additionalContext: readonly RequestAdditionalContext[] = [], ): CodexInput { return [ ...codexTextInput(text), - ...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })), + ...fileReferences.map((reference) => ({ type: "fileReference" as const, name: reference.name, path: reference.path })), ...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })), ...additionalContext.map((context) => ({ type: "additionalContext" as const, diff --git a/src/domain/chat/submission-id.ts b/src/domain/chat/submission-id.ts new file mode 100644 index 00000000..aa6ffe25 --- /dev/null +++ b/src/domain/chat/submission-id.ts @@ -0,0 +1,5 @@ +const PANEL_SUBMISSION_CLIENT_ID_PATTERN = /^local-(?:user|steer)-\d+-[A-Za-z0-9_-]+-[a-z0-9]+-[a-z0-9]+$/; + +export function isPanelSubmissionClientId(value: string | null | undefined): value is string { + return typeof value === "string" && PANEL_SUBMISSION_CLIENT_ID_PATTERN.test(value); +} diff --git a/src/features/chat/app-server/mappers/thread-stream/turn-items.ts b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts index 2c5ac9f2..7bbe560b 100644 --- a/src/features/chat/app-server/mappers/thread-stream/turn-items.ts +++ b/src/features/chat/app-server/mappers/thread-stream/turn-items.ts @@ -2,13 +2,14 @@ import { completedTurnTranscriptSummaryFromTurnRecord, type TurnItem, type TurnRecord, + turnUserFileReferences, turnUserItemProjection, } from "../../../../../app-server/protocol/turn"; import { jsonPreview } from "../../../../../domain/display/json-preview"; import type { HistoricalTurn } from "../../../../../domain/threads/history"; import type { TurnTranscriptSummary } from "../../../../../domain/threads/transcript"; import { contextAttachmentsFromManifest } from "../../../domain/thread-stream/format/context-attachments"; -import { fileMentionsFromInput } from "../../../domain/thread-stream/format/file-mentions"; +import { threadStreamFileReferences } from "../../../domain/thread-stream/format/file-references"; import { normalizeProposedPlanMarkdown } from "../../../domain/thread-stream/format/proposed-plan"; import { userMessageDisplayText } from "../../../domain/thread-stream/format/user-message-text"; import type { CommandThreadStreamTarget, ThreadStreamDiagnosticSection, ThreadStreamItem } from "../../../domain/thread-stream/items"; @@ -133,7 +134,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr const projection = turnUserItemProjection(item); const text = projection.text; const referencedThread = projection.referencedThread; - const mentionedFiles = fileMentionsFromInput(item.content); + const referencedFiles = threadStreamFileReferences(turnUserFileReferences(item, projection.manifest)); const contextAttachments = contextAttachmentsFromManifest(projection.manifest, text); if (referencedThread) { return { @@ -145,7 +146,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr copyText: text, referencedThread, ...definedProp("clientId", item.clientId), - ...(mentionedFiles.length > 0 ? { mentionedFiles } : {}), + ...(referencedFiles.length > 0 ? { referencedFiles } : {}), ...(contextAttachments.length > 0 ? { contextAttachments } : {}), }; } @@ -157,7 +158,7 @@ function userThreadStreamItem(item: UserMessageItem, turnId?: string): ThreadStr text: userMessageDisplayText(text, item.content), copyText: text, ...definedProp("clientId", item.clientId), - ...(mentionedFiles.length > 0 ? { mentionedFiles } : {}), + ...(referencedFiles.length > 0 ? { referencedFiles } : {}), ...(contextAttachments.length > 0 ? { contextAttachments } : {}), }; } diff --git a/src/features/chat/application/composer/attachments.ts b/src/features/chat/application/composer/attachments.ts index 041d4ff8..bcd0b166 100644 --- a/src/features/chat/application/composer/attachments.ts +++ b/src/features/chat/application/composer/attachments.ts @@ -1,4 +1,9 @@ -import { type CodexInput, type CodexInputItem, codexTextInputWithAttachments, type RequestMention } from "../../../../domain/chat/input"; +import { + type CodexInput, + type CodexInputItem, + codexTextInputWithAttachments, + type VaultFileReference, +} from "../../../../domain/chat/input"; type ComposerAttachmentKind = "image" | "file"; @@ -21,14 +26,14 @@ export function codexInputWithComposerAttachments(text: string, input: CodexInpu } function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: readonly ComposerAttachment[]): CodexInputItem[] { - const seenMentionPaths = new Set(input.flatMap((item) => (item.type === "mention" ? [item.path] : []))); + const seenFileReferencePaths = new Set(input.flatMap((item) => (item.type === "fileReference" ? [item.path] : []))); const seenLocalImagePaths = new Set(input.flatMap((item) => (item.type === "localImage" ? [item.path] : []))); const items: CodexInputItem[] = []; for (const attachment of attachments) { - if (!seenMentionPaths.has(attachment.path)) { - seenMentionPaths.add(attachment.path); - items.push(mentionInputItem(attachment)); + if (!seenFileReferencePaths.has(attachment.path)) { + seenFileReferencePaths.add(attachment.path); + items.push(fileReferenceInputItem(attachment)); } if (attachment.kind === "image" && !seenLocalImagePaths.has(attachment.path)) { seenLocalImagePaths.add(attachment.path); @@ -38,9 +43,9 @@ function inputItemsForAttachments(input: readonly CodexInputItem[], attachments: return items; } -function mentionInputItem(attachment: ComposerAttachment): RequestMention & { type: "mention" } { +function fileReferenceInputItem(attachment: ComposerAttachment): VaultFileReference & { type: "fileReference" } { return { - type: "mention", + type: "fileReference", name: attachment.name, path: attachment.path, }; diff --git a/src/features/chat/application/composer/note-context.ts b/src/features/chat/application/composer/note-context.ts index cee34e5d..f4c643d3 100644 --- a/src/features/chat/application/composer/note-context.ts +++ b/src/features/chat/application/composer/note-context.ts @@ -1,15 +1,11 @@ +import type { VaultFileReference } from "../../../../domain/chat/input"; import type { DailyNoteReferenceCandidate } from "./daily-note-references"; import type { NoteCandidate } from "./suggestions"; -export interface WikiLinkMention { - name: string; - path: string; -} - export interface NoteCandidateProvider { candidates(sourcePath: string): readonly NoteCandidate[]; dailyNoteReferences(sourcePath: string): readonly DailyNoteReferenceCandidate[]; tags(): readonly string[]; - resolveMention(target: string, sourcePath: string): WikiLinkMention | null; + resolveFileReference(target: string, sourcePath: string): VaultFileReference | null; dispose(): void; } diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index 5c9d5d67..b120cfaf 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -1,11 +1,12 @@ import { parseLinktext } from "obsidian"; import type { SkillMetadata } from "../../../../domain/catalog/metadata"; import { - ACTIVE_FILE_MENTION_NAME, + ACTIVE_FILE_REFERENCE_NAME, type CodexInput, - codexTextInputWithMentions, + codexTextInputWithReferences, type RequestAdditionalContext, - type RequestMention, + type SkillReference, + type VaultFileReference, } from "../../../../domain/chat/input"; import { type ActiveNoteContextReference, @@ -27,7 +28,7 @@ interface ObsidianReference { excerpt?: string; } -export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null; +export type WikiLinkFileReferenceResolver = (target: string) => VaultFileReference | null; const OBSIDIAN_CONTEXT_ADDITIONAL_CONTEXT_KEY = "codex_panel_obsidian_context"; const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g; @@ -59,39 +60,39 @@ function parsedWikiLinks(text: string): ParsedWikiLink[] { return links; } -export function preparedUserInputWithWikiLinkMentionsSkillsAndContext( +export function preparedUserInputWithWikiLinkReferencesSkillsAndContext( text: string, - resolveMention: WikiLinkMentionResolver, + resolveFileReference: WikiLinkFileReferenceResolver, skills: readonly SkillMetadata[], contextReferences: ComposerContextReferences, options: PreparedComposerInputOptions, ): PreparedComposerInput { const contextReplacement = textWithContextReferences(text, contextReferences); const resolvedText = contextReplacement.text; - const mentions: RequestMention[] = []; + const fileReferences: VaultFileReference[] = []; const wikilinkReferences: ObsidianReference[] = []; const seenPaths = new Set(); const activeNoteSnapshots = contextReferences.activeNoteSnapshots ?? []; for (const link of parsedWikiLinks(resolvedText)) { - const mention = activeNoteMentionForLink(link, activeNoteSnapshots) ?? resolveMention(link.target); - if (!mention || seenPaths.has(mention.path)) continue; - seenPaths.add(mention.path); - mentions.push(mention); - wikilinkReferences.push({ marker: `[[${link.raw}]]`, path: mention.path }); + const fileReference = activeNoteFileReferenceForLink(link, activeNoteSnapshots) ?? resolveFileReference(link.target); + if (!fileReference || seenPaths.has(fileReference.path)) continue; + seenPaths.add(fileReference.path); + fileReferences.push(fileReference); + wikilinkReferences.push({ marker: `[[${link.raw}]]`, path: fileReference.path }); } for (const selection of contextReplacement.selections) { if (seenPaths.has(selection.path)) continue; seenPaths.add(selection.path); - mentions.push({ name: selection.name, path: selection.path }); + fileReferences.push({ name: selection.name, path: selection.path }); } const attachedActiveNote = options.referenceActiveNoteOnSend ? contextReferences.activeNote : null; - if (attachedActiveNote) mentions.push({ name: ACTIVE_FILE_MENTION_NAME, path: attachedActiveNote.path }); + if (attachedActiveNote) fileReferences.push({ name: ACTIVE_FILE_REFERENCE_NAME, path: attachedActiveNote.path }); const skillByName = firstEnabledSkillByName(skills); - const resolvedSkills: RequestMention[] = []; + const resolvedSkills: SkillReference[] = []; const seenSkillPaths = new Set(); for (const reference of parsedSkillReferences(resolvedText)) { const skill = skillByName.get(reference.toLowerCase()); @@ -102,16 +103,16 @@ export function preparedUserInputWithWikiLinkMentionsSkillsAndContext( return { text: resolvedText, - input: codexTextInputWithMentions( + input: codexTextInputWithReferences( resolvedText, - mentions, + fileReferences, resolvedSkills, additionalContext(wikilinkReferences, contextReplacement.selections, attachedActiveNote), ), }; } -function activeNoteMentionForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): RequestMention | null { +function activeNoteFileReferenceForLink(link: ParsedWikiLink, snapshots: readonly ActiveNoteContextReference[]): VaultFileReference | null { const snapshot = snapshots.find((item) => link.raw.trim() === item.linktext && link.target === item.linktext); return snapshot ? { name: snapshot.name, path: snapshot.path } : null; } @@ -160,7 +161,7 @@ function obsidianReferences( return [ ...wikilinkReferences.filter((reference) => !selectedWikilinks.has(referenceKey(reference.marker, reference.path))), ...selectionReferences, - ...(activeNote ? [{ marker: ACTIVE_FILE_MENTION_NAME, path: activeNote.path }] : []), + ...(activeNote ? [{ marker: ACTIVE_FILE_REFERENCE_NAME, path: activeNote.path }] : []), ]; } diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index 08981375..69f18247 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -421,7 +421,7 @@ function adoptPendingSteerItem(state: ChatThreadStreamState, item: ThreadStreamD stableItems[matchedIndex] = { ...current, ...(item.contextAttachments ? { contextAttachments: item.contextAttachments } : {}), - ...(item.mentionedFiles ? { mentionedFiles: item.mentionedFiles } : {}), + ...(item.referencedFiles ? { referencedFiles: item.referencedFiles } : {}), ...(item.referencedThread ? { referencedThread: item.referencedThread } : {}), }; return { ...state, stableItems }; diff --git a/src/features/chat/application/turns/optimistic-turn-start.ts b/src/features/chat/application/turns/optimistic-turn-start.ts index fa251a61..34330197 100644 --- a/src/features/chat/application/turns/optimistic-turn-start.ts +++ b/src/features/chat/application/turns/optimistic-turn-start.ts @@ -1,8 +1,8 @@ import type { CodexInput } from "../../../../domain/chat/input"; import { contextAttachmentsFromInput } from "../../domain/thread-stream/format/context-attachments"; -import { fileMentionsFromInput } from "../../domain/thread-stream/format/file-mentions"; +import { fileReferencesFromInput } from "../../domain/thread-stream/format/file-references"; import { userMessageDisplayText } from "../../domain/thread-stream/format/user-message-text"; -import type { ThreadStreamDialogueItem, ThreadStreamFileMention, ThreadStreamItem } from "../../domain/thread-stream/items"; +import type { ThreadStreamDialogueItem, ThreadStreamFileReference, ThreadStreamItem } from "../../domain/thread-stream/items"; import { isLocalSteerDialogueClientId } from "../../domain/thread-stream/local-dialogue-ids"; import type { ThreadStreamItemProvenance } from "../../domain/thread-stream/provenance"; import { attachHookRunsToTurn } from "../../domain/thread-stream/updates"; @@ -16,7 +16,7 @@ interface LocalUserDialogueParams { copyText?: string; turnId?: string; referencedThread?: ThreadStreamDialogueItem["referencedThread"]; - mentionedFiles?: readonly ThreadStreamFileMention[]; + referencedFiles?: readonly ThreadStreamFileReference[]; contextAttachments?: ThreadStreamDialogueItem["contextAttachments"]; } @@ -27,7 +27,7 @@ export interface OptimisticTurnStartAckParams { pendingTurnStart: PendingTurnStart | null; } -export interface LocalUserDialogueFromInputParams extends Omit { +export interface LocalUserDialogueFromInputParams extends Omit { codexInput: CodexInput; } @@ -52,7 +52,7 @@ export interface FailedTurnStartCleanupParams { } function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDialogueItem { - const mentionedFiles = params.mentionedFiles ?? []; + const referencedFiles = params.referencedFiles ?? []; const contextAttachments = params.contextAttachments ?? []; return { id: params.id, @@ -65,7 +65,7 @@ function localUserDialogueItem(params: LocalUserDialogueParams): ThreadStreamDia ...(params.clientId ? { clientId: params.clientId } : {}), ...(params.turnId ? { turnId: params.turnId } : {}), ...(params.referencedThread ? { referencedThread: params.referencedThread } : {}), - ...(mentionedFiles.length > 0 ? { mentionedFiles: [...mentionedFiles] } : {}), + ...(referencedFiles.length > 0 ? { referencedFiles: [...referencedFiles] } : {}), ...(contextAttachments.length > 0 ? { contextAttachments: [...contextAttachments] } : {}), }; } @@ -88,7 +88,7 @@ export function localUserDialogueItemFromInput(params: LocalUserDialogueFromInpu copyText: params.text, ...(params.turnId ? { turnId: params.turnId } : {}), ...(params.referencedThread ? { referencedThread: params.referencedThread } : {}), - mentionedFiles: fileMentionsFromInput([...params.codexInput]), + referencedFiles: fileReferencesFromInput([...params.codexInput]), contextAttachments: contextAttachmentsFromInput(params.codexInput), }); } diff --git a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts index 6fb685f2..0cd26d26 100644 --- a/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts +++ b/src/features/chat/domain/thread-stream/completed-turn-reconciliation.ts @@ -18,27 +18,33 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn ? [ [ optimisticDialogueClientId(item), - { contextAttachments: item.contextAttachments, referencedThread: item.referencedThread }, + { + contextAttachments: item.contextAttachments, + referencedFiles: item.referencedFiles, + referencedThread: item.referencedThread, + }, ] as const, ] : [], ), ); const serverHasUserDialogueClientIds = turnItems.some((item) => isUserDialogue(item) && item.clientId); - const contextAttachmentsByFallbackText = serverHasUserDialogueClientIds - ? new Map() - : fallbackContextAttachmentsByText(currentItems, completedTurnId); + const localMetadataByFallbackText = serverHasUserDialogueClientIds + ? new Map() + : fallbackLocalMetadataByText(currentItems, completedTurnId); const turnItemsWithLocalContext = turnItems.map((item) => { if (!isUserDialogue(item)) return item; const localMetadata = item.clientId ? localMetadataByClientId.get(item.clientId) : undefined; - const contextAttachments = - item.contextAttachments ?? localMetadata?.contextAttachments ?? contextAttachmentsByFallbackText.get(item.copyText ?? item.text); + const fallbackMetadata = localMetadataByFallbackText.get(item.copyText ?? item.text); + const contextAttachments = item.contextAttachments ?? localMetadata?.contextAttachments ?? fallbackMetadata?.contextAttachments; + const referencedFiles = item.referencedFiles ?? localMetadata?.referencedFiles ?? fallbackMetadata?.referencedFiles; const referencedThread = item.referencedThread ? { ...item.referencedThread, ...(localMetadata?.referencedThread ? { title: localMetadata.referencedThread.title } : {}) } : localMetadata?.referencedThread; return { ...item, ...(contextAttachments ? { contextAttachments } : {}), + ...(referencedFiles ? { referencedFiles } : {}), ...(referencedThread ? { referencedThread } : {}), }; }); @@ -71,17 +77,25 @@ export function reconcileCompletedTurnItems(input: CompletedTurnReconciliationIn return [...retainedItems, ...mergedTurnItems]; } -function fallbackContextAttachmentsByText( +interface LocalDialogueMetadata { + contextAttachments?: ThreadStreamDialogueItem["contextAttachments"]; + referencedFiles?: ThreadStreamDialogueItem["referencedFiles"]; +} + +function fallbackLocalMetadataByText( currentItems: readonly ThreadStreamItem[], completedTurnId: string, -): Map { - const attachmentsByText = new Map(); +): Map { + const metadataByText = new Map(); for (const item of currentItems) { - if (!isOptimisticUserDialogue(item) || !item.contextAttachments) continue; + if (!isOptimisticUserDialogue(item) || (!item.contextAttachments && !item.referencedFiles)) continue; if (item.turnId && item.turnId !== completedTurnId) continue; - attachmentsByText.set(item.copyText ?? item.text, item.contextAttachments); + metadataByText.set(item.copyText ?? item.text, { + contextAttachments: item.contextAttachments, + referencedFiles: item.referencedFiles, + }); } - return attachmentsByText; + return metadataByText; } function isUserDialogue(item: ThreadStreamItem): item is ThreadStreamDialogueItem & { role: "user" } { diff --git a/src/features/chat/domain/thread-stream/format/file-mentions.ts b/src/features/chat/domain/thread-stream/format/file-mentions.ts deleted file mode 100644 index 5c258c78..00000000 --- a/src/features/chat/domain/thread-stream/format/file-mentions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ACTIVE_FILE_MENTION_NAME, type CodexInputItem } from "../../../../../domain/chat/input"; -import type { ThreadStreamFileMention } from "../items"; - -const ACTIVE_FILE_DISPLAY_NAME = "Active file"; - -export function fileMentionsFromInput(input: readonly CodexInputItem[]): ThreadStreamFileMention[] { - const seenFilePaths = new Set(); - const seenActiveNotePaths = new Set(); - const mentions: ThreadStreamFileMention[] = []; - for (const item of input) { - if (item.type !== "mention") continue; - const activeNoteMention = item.name === ACTIVE_FILE_MENTION_NAME; - const seen = activeNoteMention ? seenActiveNotePaths : seenFilePaths; - if (seen.has(item.path)) continue; - seen.add(item.path); - mentions.push({ name: activeNoteMention ? ACTIVE_FILE_DISPLAY_NAME : item.name, path: item.path }); - } - return mentions; -} diff --git a/src/features/chat/domain/thread-stream/format/file-references.ts b/src/features/chat/domain/thread-stream/format/file-references.ts new file mode 100644 index 00000000..1f3f0dd5 --- /dev/null +++ b/src/features/chat/domain/thread-stream/format/file-references.ts @@ -0,0 +1,22 @@ +import { ACTIVE_FILE_REFERENCE_NAME, type CodexInputItem, type VaultFileReference } from "../../../../../domain/chat/input"; +import type { ThreadStreamFileReference } from "../items"; + +const ACTIVE_FILE_DISPLAY_NAME = "Active file"; + +export function fileReferencesFromInput(input: readonly CodexInputItem[]): ThreadStreamFileReference[] { + return threadStreamFileReferences(input.flatMap((item) => (item.type === "fileReference" ? [{ name: item.name, path: item.path }] : []))); +} + +export function threadStreamFileReferences(references: readonly VaultFileReference[]): ThreadStreamFileReference[] { + const seenFilePaths = new Set(); + const seenActiveNotePaths = new Set(); + const fileReferences: ThreadStreamFileReference[] = []; + for (const reference of references) { + const activeFileReference = reference.name === ACTIVE_FILE_REFERENCE_NAME; + const seen = activeFileReference ? seenActiveNotePaths : seenFilePaths; + if (seen.has(reference.path)) continue; + seen.add(reference.path); + fileReferences.push({ name: activeFileReference ? ACTIVE_FILE_DISPLAY_NAME : reference.name, path: reference.path }); + } + return fileReferences; +} diff --git a/src/features/chat/domain/thread-stream/format/user-message-text.ts b/src/features/chat/domain/thread-stream/format/user-message-text.ts index 919b4afe..2e2a66c3 100644 --- a/src/features/chat/domain/thread-stream/format/user-message-text.ts +++ b/src/features/chat/domain/thread-stream/format/user-message-text.ts @@ -1,8 +1,10 @@ -import type { CodexInput } from "../../../../../domain/chat/input"; - type TextRange = [number, number]; +interface UserMessageDisplayInputItem { + type: string; + name?: string; +} -export function userMessageDisplayText(text: string, input: CodexInput): string { +export function userMessageDisplayText(text: string, input: readonly UserMessageDisplayInputItem[]): string { const names = resolvedSkillNames(input); if (names.length === 0) return text; @@ -14,11 +16,11 @@ export function userMessageDisplayText(text: string, input: CodexInput): string }); } -function resolvedSkillNames(input: CodexInput): string[] { +function resolvedSkillNames(input: readonly UserMessageDisplayInputItem[]): string[] { const seen = new Set(); const names: string[] = []; for (const item of input) { - if (item.type !== "skill") continue; + if (item.type !== "skill" || item.name === undefined) continue; const key = item.name.toLowerCase(); if (seen.has(key)) continue; seen.add(key); diff --git a/src/features/chat/domain/thread-stream/items.ts b/src/features/chat/domain/thread-stream/items.ts index a6b69993..964014c2 100644 --- a/src/features/chat/domain/thread-stream/items.ts +++ b/src/features/chat/domain/thread-stream/items.ts @@ -122,7 +122,7 @@ interface ThreadStreamDialogueBase extends ThreadStreamBase { readonly clientId?: string; readonly copyText?: string; readonly referencedThread?: ReferencedThreadMetadata; - readonly mentionedFiles?: readonly ThreadStreamFileMention[]; + readonly referencedFiles?: readonly ThreadStreamFileReference[]; readonly contextAttachments?: readonly ThreadStreamContextAttachment[]; } @@ -149,7 +149,7 @@ export type ThreadStreamDialogueItem = | AssistantResponseThreadStreamDialogueItem | ProposedPlanThreadStreamDialogueItem; -export interface ThreadStreamFileMention { +export interface ThreadStreamFileReference { readonly name: string; readonly path: string; } diff --git a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts index 78f18487..17265f49 100644 --- a/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts +++ b/src/features/chat/host/obsidian/vault-note-candidate-provider.obsidian.ts @@ -1,7 +1,8 @@ import type { App, EventRef } from "obsidian"; import { stripHeadingForLink, TFile } from "obsidian"; -import type { NoteCandidateProvider, WikiLinkMention } from "../../application/composer/note-context"; +import type { VaultFileReference } from "../../../../domain/chat/input"; +import type { NoteCandidateProvider } from "../../application/composer/note-context"; import type { NoteCandidate } from "../../application/composer/suggestions"; import { configuredDailyNoteReferences } from "./vault-daily-note-references.obsidian"; import { displayNameForFile, linktextForFile } from "./vault-note-links.obsidian"; @@ -57,8 +58,8 @@ export class VaultNoteCandidateProvider implements NoteCandidateProvider { return this.shared.catalog.tags(); } - resolveMention(target: string, sourcePath: string): WikiLinkMention | null { - return this.shared.catalog.resolveMention(target, sourcePath); + resolveFileReference(target: string, sourcePath: string): VaultFileReference | null { + return this.shared.catalog.resolveFileReference(target, sourcePath); } dispose(): void { @@ -118,7 +119,7 @@ class VaultNoteCandidateCatalog { return normalizedTags(metadataCacheTags(this.app.metadataCache)); } - resolveMention(target: string, sourcePath: string): WikiLinkMention | null { + resolveFileReference(target: string, sourcePath: string): VaultFileReference | null { const linkedFile = this.app.metadataCache.getFirstLinkpathDest(target, sourcePath); if (linkedFile?.path) return { name: linkedFile.basename, path: linkedFile.path }; diff --git a/src/features/chat/panel/composer-controller.ts b/src/features/chat/panel/composer-controller.ts index bf7f8d53..aba10ea5 100644 --- a/src/features/chat/panel/composer-controller.ts +++ b/src/features/chat/panel/composer-controller.ts @@ -26,7 +26,7 @@ import { } from "../application/composer/suggestions"; import { type PreparedComposerInput, - preparedUserInputWithWikiLinkMentionsSkillsAndContext, + preparedUserInputWithWikiLinkReferencesSkillsAndContext, } from "../application/composer/wikilink-context"; import { activePanelOperationDecision } from "../application/panel-operation-policy"; import { activeThreadState, type ChatAction, type ChatState, panelThreadId } from "../application/state/root-reducer"; @@ -181,9 +181,9 @@ export class ChatComposerController { } preparedInput(text: string, snapshot: ComposerInputSnapshot = this.captureInputSnapshot()): PreparedComposerInput { - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, - (target) => this.options.noteCandidateProvider.resolveMention(target, snapshot.sourcePath), + (target) => this.options.noteCandidateProvider.resolveFileReference(target, snapshot.sourcePath), snapshot.availableSkills, this.contextReferencesFromSnapshot(snapshot, text), { referenceActiveNoteOnSend: snapshot.referenceActiveNoteOnSend }, diff --git a/src/features/chat/presentation/thread-stream/text-view.ts b/src/features/chat/presentation/thread-stream/text-view.ts index 599fd681..ebb065f3 100644 --- a/src/features/chat/presentation/thread-stream/text-view.ts +++ b/src/features/chat/presentation/thread-stream/text-view.ts @@ -142,7 +142,7 @@ function referencedThreadView(item: ThreadStreamItem): ReferencedThreadTextView function contextItemsView(item: ThreadStreamItem): ThreadStreamTextMetadataView["contextItems"] | undefined { if (item.kind !== "dialogue") return undefined; const items = [ - ...(item.mentionedFiles ?? []).map((file) => ({ label: file.name, detail: file.path })), + ...(item.referencedFiles ?? []).map((file) => ({ label: file.name, detail: file.path })), ...(item.contextAttachments ?? []), ]; return items.length > 0 ? { itemId: item.id, items } : undefined; diff --git a/tests/app-server/request-input.test.ts b/tests/app-server/request-input.test.ts index f5a5fbcf..5c51634e 100644 --- a/tests/app-server/request-input.test.ts +++ b/tests/app-server/request-input.test.ts @@ -8,12 +8,12 @@ import { } from "../../src/app-server/protocol/request-input"; import { utf8ByteLength } from "../../src/domain/chat/context-budget"; import { turnContextManifestFromText } from "../../src/domain/chat/context-manifest"; -import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithMentions } from "../../src/domain/chat/input"; +import { type CodexInput, codexTextInputWithAttachments, codexTextInputWithReferences } from "../../src/domain/chat/input"; describe("app-server request input", () => { - it("builds text input with mentions and skills", () => { + it("builds text input with file references and skills", () => { expect( - codexTextInputWithMentions( + codexTextInputWithReferences( "Use [[Note]] and $Skill", [{ name: "Note", path: "Note.md" }], [{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }], @@ -27,7 +27,7 @@ describe("app-server request input", () => { ), ).toEqual([ { type: "text", text: "Use [[Note]] and $Skill" }, - { type: "mention", name: "Note", path: "Note.md" }, + { type: "fileReference", name: "Note", path: "Note.md" }, { type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" }, { type: "additionalContext", @@ -41,29 +41,26 @@ describe("app-server request input", () => { it("replaces text input while preserving non-text attachments", () => { const input: CodexInput = [ { type: "text", text: "visible request" }, - { type: "mention", name: "Note", path: "Note.md" }, + { type: "fileReference", name: "Note", path: "Note.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }, ]; expect(codexTextInputWithAttachments("rewritten prompt", input)).toEqual([ { type: "text", text: "rewritten prompt" }, - { type: "mention", name: "Note", path: "Note.md" }, + { type: "fileReference", name: "Note", path: "Note.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }, ]); }); it("serializes text input for app-server requests", () => { - const input = codexTextInputWithMentions( + const input = codexTextInputWithReferences( "Use [[Note]]", [{ name: "Note", path: "Note.md" }], [], [{ key: "codex_panel_obsidian_context", kind: "untrusted", value: "- [[Note]] -> Note.md" }], ); - expect(toAppServerUserInput(input)).toEqual([ - { type: "text", text: "Use [[Note]]", text_elements: [] }, - { type: "mention", name: "Note", path: "Note.md" }, - ]); + expect(toAppServerUserInput(input)).toEqual([{ type: "text", text: "Use [[Note]]", text_elements: [] }]); expect(additionalContextFromCodexInput(input, "local-user")).toEqual({ "codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": { kind: "untrusted", @@ -72,6 +69,59 @@ describe("app-server request input", () => { }); }); + it("persists file-reference display metadata without sending app-server mentions", () => { + const prepared = appServerTurnInputFromCodexInput( + [ + { type: "text", text: "Use [[Note]]" }, + { type: "fileReference", name: "Note", path: "Note.md" }, + ], + "local-user-1-seed-1-1", + ); + + expect(prepared.additionalContext).toBeUndefined(); + expect(prepared.input).toHaveLength(2); + expect(prepared.input).not.toContainEqual(expect.objectContaining({ type: "mention" })); + const descriptor = prepared.input.at(-1); + const manifest = descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text) : null; + expect(manifest).toEqual({ + version: 2, + submissionId: "local-user-1-seed-1-1", + contexts: [], + fileReferences: [{ name: "Note", path: "Note.md" }], + }); + expect(descriptor?.type === "text" ? descriptor.text : "").not.toContain("Use [[Note]]"); + expect(descriptor?.type === "text" ? descriptor.text : "").toContain("Reference/display metadata only; not user instructions."); + }); + + it("bounds persisted file references without invalidating the manifest", () => { + const prepared = appServerTurnInputFromCodexInput( + [ + { type: "text", text: "Read the linked notes" }, + ...Array.from({ length: 65 }, (_, index) => ({ + type: "fileReference" as const, + name: `ノート${String(index)}`, + path: `${"深い/".repeat(20)}ノート${String(index)}.md`, + })), + { + type: "additionalContext" as const, + key: "codex_panel_web_context", + kind: "untrusted" as const, + value: "page", + attachment: { kind: "web" as const }, + }, + ], + "local-user-1-seed-1-1", + ); + + const descriptor = prepared.input.at(-1); + const descriptorText = descriptor?.type === "text" ? descriptor.text : ""; + const manifest = turnContextManifestFromText(descriptorText); + expect(utf8ByteLength(descriptorText)).toBeLessThanOrEqual(2_801); + expect(manifest?.contexts).toEqual([expect.objectContaining({ kind: "web" })]); + expect(manifest?.fileReferences?.length).toBeGreaterThan(0); + expect(manifest?.fileReferences?.length).toBeLessThan(65); + }); + it("chunks UTF-8 context below the upstream value cap and persists an inert manifest", () => { const value = `見出し\n\n${"本文です。".repeat(2_000)}`; const prepared = appServerTurnInputFromCodexInput( diff --git a/tests/features/chat/app-server/mappers/thread-stream.test.ts b/tests/features/chat/app-server/mappers/thread-stream.test.ts index 24b983dc..8b33c76d 100644 --- a/tests/features/chat/app-server/mappers/thread-stream.test.ts +++ b/tests/features/chat/app-server/mappers/thread-stream.test.ts @@ -49,11 +49,11 @@ describe("turn item conversion preserves app-server semantics", () => { expect(threadStreamItemFromTurnItem(assistantMessage)).toMatchObject({ role: "assistant", copyText: "world" }); }); - it("keeps resolved file mentions visible as user message metadata", () => { + it("keeps legacy file references visible as user message metadata", () => { const userMessage: TurnItem = { type: "userMessage", id: "u1", - clientId: null, + clientId: "local-user-1-seed-1-1", content: [ { type: "text", text: "Read [[Alpha]] and [[Beta]].", text_elements: [] }, { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, @@ -67,18 +67,18 @@ describe("turn item conversion preserves app-server semantics", () => { dialogueKind: "user", role: "user", text: "Read [[Alpha]] and [[Beta]].", - mentionedFiles: [ + referencedFiles: [ { name: "Alpha", path: "thoughts/Alpha.md" }, { name: "Beta", path: "thoughts/Beta.md" }, ], }); }); - it("keeps active file mentions visible even when a wikilink resolves to the same path", () => { + it("keeps the active file reference visible when a wikilink resolves to the same path", () => { const userMessage: TurnItem = { type: "userMessage", id: "u1", - clientId: null, + clientId: "local-user-1-seed-1-1", content: [ { type: "text", text: "Read [[Alpha]].", text_elements: [] }, { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, @@ -88,13 +88,62 @@ describe("turn item conversion preserves app-server semantics", () => { }; expect(threadStreamItemFromTurnItem(userMessage)).toMatchObject({ - mentionedFiles: [ + referencedFiles: [ { name: "Alpha", path: "thoughts/Alpha.md" }, { name: "Active file", path: "thoughts/Alpha.md" }, ], }); }); + it("does not present Codex tool mentions as Vault file references", () => { + const userMessage: TurnItem = { + type: "userMessage", + id: "u1", + clientId: null, + content: [ + { type: "text", text: "Use $drive.", text_elements: [] }, + { type: "mention", name: "Drive", path: "app://google-drive" }, + ], + }; + + expect(threadStreamItemFromTurnItem(userMessage)).not.toHaveProperty("referencedFiles"); + }); + + it("restores legacy bare-path file references from before Panel submission IDs", () => { + const legacyPanelMessage: TurnItem = { + type: "userMessage", + id: "u1", + clientId: "local-user-1-seed-1-1", + content: [ + { type: "text", text: "Read [[Old]].", text_elements: [] }, + { type: "mention", name: "Old", path: "notes/Old.md" }, + ], + }; + const foreignMessage = { ...legacyPanelMessage, clientId: null }; + + expect(threadStreamItemFromTurnItem(legacyPanelMessage)).toMatchObject({ + referencedFiles: [{ name: "Old", path: "notes/Old.md" }], + }); + expect(threadStreamItemFromTurnItem(foreignMessage)).toMatchObject({ + referencedFiles: [{ name: "Old", path: "notes/Old.md" }], + }); + }); + + it("renders a decoded Vault path for empty-text legacy file-reference messages", () => { + const userMessage: TurnItem = { + type: "userMessage", + id: "u1", + clientId: "local-user-1-seed-1-1", + content: [{ type: "mention", name: "設計メモ", path: "メモ/設計.md" }], + }; + + expect(threadStreamItemFromTurnItem(userMessage)).toMatchObject({ + text: "[file] メモ/設計.md", + copyText: "[file] メモ/設計.md", + referencedFiles: [{ name: "設計メモ", path: "メモ/設計.md" }], + }); + }); + it("hides persisted /refer context in displayed user messages", () => { const text = referencedThreadV1Fixture( { @@ -176,6 +225,34 @@ describe("turn item conversion preserves app-server semantics", () => { }); }); + it("restores v2 file references from app-server history after a thread resume", () => { + const clientId = "local-user-1-seed-1-1"; + const prepared = appServerTurnInputFromCodexInput( + [ + { type: "text", text: "Read [[Alpha]] and the current note." }, + { type: "fileReference", name: "Alpha", path: "thoughts/Alpha.md" }, + { type: "fileReference", name: "", path: "Daily/Today.md" }, + ], + clientId, + ); + + expect( + threadStreamItemFromTurnItem({ + type: "userMessage", + id: "u1", + clientId, + content: prepared.input, + }), + ).toMatchObject({ + text: "Read [[Alpha]] and the current note.", + copyText: "Read [[Alpha]] and the current note.", + referencedFiles: [ + { name: "Alpha", path: "thoughts/Alpha.md" }, + { name: "Active file", path: "Daily/Today.md" }, + ], + }); + }); + it("accepts a persisted attachment descriptor after an unpersisted reference context", () => { const clientId = "local-user-1-seed-1-1"; const prepared = appServerTurnInputFromCodexInput( diff --git a/tests/features/chat/app-server/transports.test.ts b/tests/features/chat/app-server/transports.test.ts index 829f85c5..a432ab02 100644 --- a/tests/features/chat/app-server/transports.test.ts +++ b/tests/features/chat/app-server/transports.test.ts @@ -4,10 +4,11 @@ import type { AppServerClient, ClientResponseByMethod } from "../../../../src/ap import * as shortLivedClient from "../../../../src/app-server/connection/short-lived-client"; import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn"; +import { turnContextManifestFromText } from "../../../../src/domain/chat/context-manifest"; import type { CodexInput } from "../../../../src/domain/chat/input"; import { createServerDiagnostics, diagnosticProbeOk } from "../../../../src/domain/server/diagnostics"; import { createChatAppServerGateway, createChatCurrentAppServerGateway } from "../../../../src/features/chat/app-server/session-gateway"; -import { preparedUserInputWithWikiLinkMentionsSkillsAndContext } from "../../../../src/features/chat/application/composer/wikilink-context"; +import { preparedUserInputWithWikiLinkReferencesSkillsAndContext } from "../../../../src/features/chat/application/composer/wikilink-context"; import { deferred } from "../../../support/async"; const textInput = (text: string): CodexInput => [{ type: "text", text }]; @@ -109,7 +110,7 @@ describe("chat app-server transports", () => { connectedClient: vi.fn().mockResolvedValue(client), }).turn; const text = "Compare [[Alpha]] with the active file."; - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, (target) => (target === "Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), [], @@ -130,13 +131,20 @@ describe("chat app-server transports", () => { expect(params).toMatchObject({ threadId: "thread", cwd: "/vault", - input: [ - { type: "text", text, text_elements: [] }, - { type: "mention", name: "Alpha", path: "notes/Alpha.md" }, - { type: "mention", name: "", path: "notes/Alpha.md" }, - ], clientUserMessageId: "local-user", }); + expect(params?.input[0]).toEqual({ type: "text", text, text_elements: [] }); + expect(params?.input).toHaveLength(2); + const descriptor = params?.input.at(-1); + expect(descriptor?.type === "text" ? turnContextManifestFromText(descriptor.text) : null).toEqual({ + version: 2, + submissionId: "local-user", + contexts: [], + fileReferences: [ + { name: "Alpha", path: "notes/Alpha.md" }, + { name: "", path: "notes/Alpha.md" }, + ], + }); expect(params?.additionalContext).toEqual({ "codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": { kind: "untrusted", diff --git a/tests/features/chat/application/composer/suggestions.test.ts b/tests/features/chat/application/composer/suggestions.test.ts index 3df91be8..f7225bfd 100644 --- a/tests/features/chat/application/composer/suggestions.test.ts +++ b/tests/features/chat/application/composer/suggestions.test.ts @@ -12,8 +12,8 @@ import { parseSlashCommand, } from "../../../../../src/features/chat/application/composer/suggestions"; import { - preparedUserInputWithWikiLinkMentionsSkillsAndContext, - type WikiLinkMentionResolver, + preparedUserInputWithWikiLinkReferencesSkillsAndContext, + type WikiLinkFileReferenceResolver, } from "../../../../../src/features/chat/application/composer/wikilink-context"; function expectPresent(value: T | null | undefined): T { @@ -25,8 +25,12 @@ function wikiLinkSuggestions(query: string, notes: Parameters { }); }); - it("keeps non-markdown wikilink completions compatible with mention parsing", () => { + it("keeps non-markdown wikilink completions compatible with file-reference parsing", () => { const suggestion = expectPresent(wikiLinkSuggestions("diagram", notes)[0]); const text = `Please inspect ${suggestion.replacement}`; - const input = userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkReferencesAndSkills( text, (target) => (target === "Assets/Diagram.png" ? { name: "Diagram", path: "Assets/Diagram.png" } : null), [], @@ -194,7 +198,7 @@ describe("composer suggestions", () => { }); expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Diagram", path: "Assets/Diagram.png" }, + { type: "fileReference", name: "Diagram", path: "Assets/Diagram.png" }, { type: "additionalContext", key: "codex_panel_obsidian_context", diff --git a/tests/features/chat/application/composer/wikilink-context.test.ts b/tests/features/chat/application/composer/wikilink-context.test.ts index 0eed30f4..13c90bfe 100644 --- a/tests/features/chat/application/composer/wikilink-context.test.ts +++ b/tests/features/chat/application/composer/wikilink-context.test.ts @@ -4,8 +4,8 @@ import type { SkillMetadata } from "../../../../../src/domain/catalog/metadata"; import type { CodexInput } from "../../../../../src/domain/chat/input"; import { emptyComposerContextReferences } from "../../../../../src/features/chat/application/composer/context-references"; import { - preparedUserInputWithWikiLinkMentionsSkillsAndContext, - type WikiLinkMentionResolver, + preparedUserInputWithWikiLinkReferencesSkillsAndContext, + type WikiLinkFileReferenceResolver, } from "../../../../../src/features/chat/application/composer/wikilink-context"; const obsidianContext = (...sections: string[]) => ({ @@ -17,12 +17,12 @@ const obsidianContext = (...sections: string[]) => ({ const wikilinkContext = (...mappings: string[]) => obsidianContext(...mappings); -function userInputWithWikiLinkMentionsAndSkills( +function userInputWithWikiLinkReferencesAndSkills( text: string, - resolveMention: WikiLinkMentionResolver, + resolveFileReference: WikiLinkFileReferenceResolver, skills: readonly SkillMetadata[], ): CodexInput { - return preparedUserInputWithWikiLinkMentionsSkillsAndContext(text, resolveMention, skills, emptyComposerContextReferences(), { + return preparedUserInputWithWikiLinkReferencesSkillsAndContext(text, resolveFileReference, skills, emptyComposerContextReferences(), { referenceActiveNoteOnSend: false, }).input; } @@ -30,20 +30,20 @@ function userInputWithWikiLinkMentionsAndSkills( describe("wikilink context", () => { it("parses aliases, subpaths, and duplicate links", () => { const text = "See [[Alpha|label]], [[Beta#Heading]], [[Gamma^block]], and [[Alpha]]."; - const input = userInputWithWikiLinkMentionsAndSkills(text, (target) => ({ name: target, path: `${target}.md` }), []); + const input = userInputWithWikiLinkReferencesAndSkills(text, (target) => ({ name: target, path: `${target}.md` }), []); expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Alpha", path: "Alpha.md" }, - { type: "mention", name: "Beta", path: "Beta.md" }, - { type: "mention", name: "Gamma", path: "Gamma.md" }, + { type: "fileReference", name: "Alpha", path: "Alpha.md" }, + { type: "fileReference", name: "Beta", path: "Beta.md" }, + { type: "fileReference", name: "Gamma", path: "Gamma.md" }, wikilinkContext("- [[Alpha|label]] -> Alpha.md", "- [[Beta#Heading]] -> Beta.md", "- [[Gamma^block]] -> Gamma.md"), ]); }); - it("adds only resolved file mentions without changing the visible prompt body", () => { + it("adds only resolved file references without changing the visible prompt body", () => { const text = "Please compare [[Alpha#Heading|A]] and [[Missing]]."; - const input = userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkReferencesAndSkills( text, (target) => (target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), [], @@ -51,7 +51,7 @@ describe("wikilink context", () => { expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "thoughts/Alpha.md" }, wikilinkContext("- [[Alpha#Heading|A]] -> thoughts/Alpha.md"), ]); expect(input).toHaveLength(3); @@ -59,23 +59,23 @@ describe("wikilink context", () => { it("resolves aliases and subpaths from non-markdown wikilinks by target", () => { const text = "Open [[Bases/Projects.base|Projects]], [[References/Paper.pdf]], and [[Assets/Diagram.png#crop|Diagram]]."; - const input = userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkReferencesAndSkills( text, (target) => { - const mentions = new Map([ + const fileReferences = new Map([ ["Bases/Projects.base", { name: "Projects", path: "Bases/Projects.base" }], ["References/Paper.pdf", { name: "Paper", path: "References/Paper.pdf" }], ["Assets/Diagram.png", { name: "Diagram", path: "Assets/Diagram.png" }], ]); - return mentions.get(target) ?? null; + return fileReferences.get(target) ?? null; }, [], ); expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Projects", path: "Bases/Projects.base" }, - { type: "mention", name: "Paper", path: "References/Paper.pdf" }, - { type: "mention", name: "Diagram", path: "Assets/Diagram.png" }, + { type: "fileReference", name: "Projects", path: "Bases/Projects.base" }, + { type: "fileReference", name: "Paper", path: "References/Paper.pdf" }, + { type: "fileReference", name: "Diagram", path: "Assets/Diagram.png" }, wikilinkContext( "- [[Bases/Projects.base|Projects]] -> Bases/Projects.base", "- [[References/Paper.pdf]] -> References/Paper.pdf", @@ -84,9 +84,9 @@ describe("wikilink context", () => { ]); }); - it("deduplicates mentions by resolved path", () => { + it("deduplicates file references by resolved path", () => { const text = "Read [[Alpha]], [[Alpha#Heading]], and [[Alias|A]]."; - const input = userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkReferencesAndSkills( text, (target) => (target === "Alpha" || target === "Alias" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), [], @@ -94,14 +94,14 @@ describe("wikilink context", () => { expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "thoughts/Alpha.md" }, wikilinkContext("- [[Alpha]] -> thoughts/Alpha.md"), ]); }); it("adds resolved skill input without changing the visible prompt body", () => { const text = "Please use $obsidian-codex-panel-maintain with [[Alpha]]."; - const input = userInputWithWikiLinkMentionsAndSkills( + const input = userInputWithWikiLinkReferencesAndSkills( text, (target) => (target === "Alpha" ? { name: "Alpha", path: "thoughts/Alpha.md" } : null), [ @@ -117,7 +117,7 @@ describe("wikilink context", () => { expect(input).toEqual([ { type: "text", text }, - { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "thoughts/Alpha.md" }, { type: "skill", name: "obsidian-codex-panel-maintain", @@ -129,7 +129,7 @@ describe("wikilink context", () => { it("ignores unresolved skills and deduplicates resolved skills by path", () => { const text = "Use $First, $missing, $first, and $Alias."; - const input = userInputWithWikiLinkMentionsAndSkills(text, () => null, [ + const input = userInputWithWikiLinkReferencesAndSkills(text, () => null, [ { name: "First", description: "First skill", @@ -168,7 +168,7 @@ describe("wikilink context", () => { it("leaves bare context references as raw text", () => { const text = "整理して @active and @selection"; - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), [], @@ -190,7 +190,7 @@ describe("wikilink context", () => { }); it("resolves completed active snapshots without depending on the current link context", () => { - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( "整理して [[Alpha]]", () => null, [], @@ -202,12 +202,12 @@ describe("wikilink context", () => { { referenceActiveNoteOnSend: false }, ); - expect(prepared.input).toContainEqual({ type: "mention", name: "Alpha", path: "notes/Alpha.md" }); + expect(prepared.input).toContainEqual({ type: "fileReference", name: "Alpha", path: "notes/Alpha.md" }); }); it("references the active file on send when enabled without changing visible text", () => { const text = "Rewrite the introduction."; - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, () => null, [], @@ -221,7 +221,7 @@ describe("wikilink context", () => { expect(prepared.text).toBe(text); expect(prepared.input).toEqual([ { type: "text", text }, - { type: "mention", name: "", path: "notes/Alpha.md" }, + { type: "fileReference", name: "", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", @@ -233,7 +233,7 @@ describe("wikilink context", () => { it("keeps wikilinks and active file in one Obsidian context when both are present", () => { const text = "Compare [[Beta]] with the active file."; - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, (target) => (target === "Beta" ? { name: "Beta", path: "notes/Beta.md" } : null), [], @@ -247,8 +247,8 @@ describe("wikilink context", () => { expect(prepared.text).toBe(text); expect(prepared.input).toEqual([ { type: "text", text }, - { type: "mention", name: "Beta", path: "notes/Beta.md" }, - { type: "mention", name: "", path: "notes/Alpha.md" }, + { type: "fileReference", name: "Beta", path: "notes/Beta.md" }, + { type: "fileReference", name: "", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", @@ -260,7 +260,7 @@ describe("wikilink context", () => { it("keeps wikilinks, selections, and active file in one Obsidian context", () => { const text = "Compare [[Beta]] with [[Gamma]] (L2:C1-L2:C6)."; - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( text, (target) => { if (target === "Beta") return { name: "Beta", path: "notes/Beta.md" }; @@ -286,9 +286,9 @@ describe("wikilink context", () => { expect(prepared.input).toEqual([ { type: "text", text }, - { type: "mention", name: "Beta", path: "notes/Beta.md" }, - { type: "mention", name: "Gamma", path: "notes/Gamma.md" }, - { type: "mention", name: "", path: "notes/Alpha.md" }, + { type: "fileReference", name: "Beta", path: "notes/Beta.md" }, + { type: "fileReference", name: "Gamma", path: "notes/Gamma.md" }, + { type: "fileReference", name: "", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", @@ -301,7 +301,7 @@ describe("wikilink context", () => { }); it("attaches completed selection snapshots without depending on the current editor selection", () => { - const prepared = preparedUserInputWithWikiLinkMentionsSkillsAndContext( + const prepared = preparedUserInputWithWikiLinkReferencesSkillsAndContext( "整理して [[notes/Alpha]] (L42:C5-L47:C1)", (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), [], diff --git a/tests/features/chat/application/turns/composer-submit-actions.test.ts b/tests/features/chat/application/turns/composer-submit-actions.test.ts index 02e488c2..1becd893 100644 --- a/tests/features/chat/application/turns/composer-submit-actions.test.ts +++ b/tests/features/chat/application/turns/composer-submit-actions.test.ts @@ -182,7 +182,7 @@ describe("submitComposer", () => { sendInput: [ { type: "text", text: "https://example.com/ [[Note]]" }, { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" }, - { type: "mention", name: "Note", path: "Note.md" }, + { type: "fileReference", name: "Note", path: "Note.md" }, ], }); sendTurnText.mockResolvedValue(false); @@ -195,7 +195,7 @@ describe("submitComposer", () => { codexInputOverride: [ { type: "text", text: "https://example.com/ [[Note]]" }, { type: "additionalContext", key: "codex_panel_web_context", kind: "untrusted", value: "Readable article" }, - { type: "mention", name: "Note", path: "Note.md" }, + { type: "fileReference", name: "Note", path: "Note.md" }, ], preserveComposerContextOnFailure: true, pendingSubmissionId: expect.stringMatching(/^local-web-/), diff --git a/tests/features/chat/application/turns/composition.test.ts b/tests/features/chat/application/turns/composition.test.ts index 6251396d..86258374 100644 --- a/tests/features/chat/application/turns/composition.test.ts +++ b/tests/features/chat/application/turns/composition.test.ts @@ -74,7 +74,7 @@ describe("createTurnWorkflowActions", () => { text, input: [ { type: "text", text }, - { type: "mention", name: "unexpected", path: "notes/Alpha.md" }, + { type: "fileReference", name: "unexpected", path: "notes/Alpha.md" }, ], })); const actions = createTurnWorkflowActions( diff --git a/tests/features/chat/application/turns/optimistic-turn-start.test.ts b/tests/features/chat/application/turns/optimistic-turn-start.test.ts index c4aa1411..5248ff4f 100644 --- a/tests/features/chat/application/turns/optimistic-turn-start.test.ts +++ b/tests/features/chat/application/turns/optimistic-turn-start.test.ts @@ -13,7 +13,7 @@ describe("optimistic turn start helpers", () => { it("builds optimistic turn starts from immutable input snapshots", () => { const input = [ { type: "text" as const, text: "hello [[Note]]" }, - { type: "mention" as const, name: "Note", path: "Note.md" }, + { type: "fileReference" as const, name: "Note", path: "Note.md" }, ]; const start = optimisticTurnStart({ id: "local-user", text: "hello [[Note]]", codexInput: input }); @@ -25,13 +25,13 @@ describe("optimistic turn start helpers", () => { dialogueKind: "user", role: "user", text: "hello [[Note]]", - mentionedFiles: [{ name: "Note", path: "Note.md" }], + referencedFiles: [{ name: "Note", path: "Note.md" }], }); expect(localUserDialogueItemFromInput({ id: "steer", text: "hello [[Note]]", turnId: "turn", codexInput: input })).toMatchObject({ id: "steer", turnId: "turn", - mentionedFiles: [{ name: "Note", path: "Note.md" }], + referencedFiles: [{ name: "Note", path: "Note.md" }], }); }); @@ -39,7 +39,7 @@ describe("optimistic turn start helpers", () => { const text = "Read [[Note]]."; const input = [ { type: "text" as const, text }, - { type: "mention" as const, name: "Note", path: "Note.md" }, + { type: "fileReference" as const, name: "Note", path: "Note.md" }, { type: "additionalContext" as const, key: "codex_panel_obsidian_context", @@ -51,7 +51,7 @@ describe("optimistic turn start helpers", () => { expect(localUserDialogueItemFromInput({ id: "local-user", text, codexInput: input })).toMatchObject({ text, copyText: text, - mentionedFiles: [{ name: "Note", path: "Note.md" }], + referencedFiles: [{ name: "Note", path: "Note.md" }], }); }); @@ -73,17 +73,17 @@ describe("optimistic turn start helpers", () => { }); }); - it("keeps active file mentions visible even when the same file is mentioned explicitly", () => { + it("keeps active file references visible even when the same file is referenced explicitly", () => { const text = "Read [[Note]]."; const input = [ { type: "text" as const, text }, - { type: "mention" as const, name: "Note", path: "Note.md" }, - { type: "mention" as const, name: "Note duplicate", path: "Note.md" }, - { type: "mention" as const, name: "", path: "Note.md" }, + { type: "fileReference" as const, name: "Note", path: "Note.md" }, + { type: "fileReference" as const, name: "Note duplicate", path: "Note.md" }, + { type: "fileReference" as const, name: "", path: "Note.md" }, ]; expect(localUserDialogueItemFromInput({ id: "local-user", text, codexInput: input })).toMatchObject({ - mentionedFiles: [ + referencedFiles: [ { name: "Note", path: "Note.md" }, { name: "Active file", path: "Note.md" }, ], diff --git a/tests/features/chat/application/turns/turn-submission-actions.test.ts b/tests/features/chat/application/turns/turn-submission-actions.test.ts index 9b431123..501ae676 100644 --- a/tests/features/chat/application/turns/turn-submission-actions.test.ts +++ b/tests/features/chat/application/turns/turn-submission-actions.test.ts @@ -619,7 +619,7 @@ describe("TurnSubmissionActions", () => { text: "fix [[notes/Alpha]] (L42:C5-L47:C1)", input: [ { type: "text", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)" }, - { type: "mention", name: "Alpha", path: "notes/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selected text" }, ] satisfies CodexInput, })), @@ -634,7 +634,7 @@ describe("TurnSubmissionActions", () => { threadId: "thread", input: [ { type: "text", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)" }, - { type: "mention", name: "Alpha", path: "notes/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selected text" }, ], clientUserMessageId: expect.any(String), @@ -642,16 +642,16 @@ describe("TurnSubmissionActions", () => { expect(chatStateThreadStreamItems(stateStore.getState())[0]).toMatchObject({ kind: "dialogue", text: "fix [[notes/Alpha]] (L42:C5-L47:C1)", - mentionedFiles: [{ name: "Alpha", path: "notes/Alpha.md" }], + referencedFiles: [{ name: "Alpha", path: "notes/Alpha.md" }], }); }); it("preserves composer context when overridden slash command input fails to start", async () => { const input = [ { type: "text" as const, text: "[[Codex Clippings/Example.md]] summarize [[Attachment.png]]" }, - { type: "mention" as const, name: "Example", path: "Codex Clippings/Example.md" }, + { type: "fileReference" as const, name: "Example", path: "Codex Clippings/Example.md" }, { type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" }, - { type: "mention" as const, name: "Attachment.png", path: "Attachment.png" }, + { type: "fileReference" as const, name: "Attachment.png", path: "Attachment.png" }, { type: "localImage" as const, path: "Attachment.png" }, ] satisfies CodexInput; const { host, startTurn, stateStore } = createHost(); diff --git a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts index 60baf396..c51d8591 100644 --- a/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts +++ b/tests/features/chat/domain/thread-stream/completed-turn-reconciliation.test.ts @@ -38,6 +38,23 @@ describe("reconcileCompletedTurnItems", () => { ]); }); + it("keeps local file-reference metadata without sending it through app-server", () => { + const optimistic = { + ...userDialogue("local-user-1", "Read [[Note]].", "turn", "local-user-1"), + referencedFiles: [{ name: "Note", path: "Note.md" }], + } satisfies ThreadStreamItem; + const server = userDialogue("u1", "Read [[Note]].", "turn", "local-user-1"); + + const next = reconcileCompletedTurnItems({ currentItems: [optimistic], completedTurnId: "turn", turnItems: [server] }); + + expect(next).toEqual([ + expect.objectContaining({ + id: "u1", + referencedFiles: [{ name: "Note", path: "Note.md" }], + }), + ]); + }); + it("keeps the optimistic reference title while accepting server truncation metadata", () => { const optimistic = { ...userDialogue("local-user-1", "continue", "turn", "local-user-1"), diff --git a/tests/features/chat/host/vault-note-candidate-provider.test.ts b/tests/features/chat/host/vault-note-candidate-provider.test.ts index 867890d5..25d5fbc8 100644 --- a/tests/features/chat/host/vault-note-candidate-provider.test.ts +++ b/tests/features/chat/host/vault-note-candidate-provider.test.ts @@ -238,7 +238,7 @@ describe("VaultNoteCandidateProvider", () => { }); const provider = new VaultNoteCandidateProvider(app); - expect(provider.resolveMention("Alpha", "Inbox.md")).toEqual({ name: "Alpha", path: "notes/Alpha.md" }); + expect(provider.resolveFileReference("Alpha", "Inbox.md")).toEqual({ name: "Alpha", path: "notes/Alpha.md" }); }); it("resolves direct markdown paths when metadata has no match", () => { @@ -248,8 +248,8 @@ describe("VaultNoteCandidateProvider", () => { }); const provider = new VaultNoteCandidateProvider(app); - expect(provider.resolveMention("notes/Alpha", "")).toEqual({ name: "Alpha", path: "notes/Alpha.md" }); - expect(provider.resolveMention("Missing", "")).toBeNull(); + expect(provider.resolveFileReference("notes/Alpha", "")).toEqual({ name: "Alpha", path: "notes/Alpha.md" }); + expect(provider.resolveFileReference("Missing", "")).toBeNull(); }); it("uses the active file only as Obsidian link-resolution context", () => { @@ -260,7 +260,7 @@ describe("VaultNoteCandidateProvider", () => { }); const provider = new VaultNoteCandidateProvider(app); - expect(provider.resolveMention("Project", "Daily/Today.md")).toEqual({ name: "Project", path: "notes/Project.md" }); + expect(provider.resolveFileReference("Project", "Daily/Today.md")).toEqual({ name: "Project", path: "notes/Project.md" }); expect(getFirstLinkpathDest).toHaveBeenCalledWith("Project", "Daily/Today.md"); }); @@ -272,7 +272,10 @@ describe("VaultNoteCandidateProvider", () => { }); const provider = new VaultNoteCandidateProvider(app); - expect(provider.resolveMention("Bases/Projects.base", "Daily/Today.md")).toEqual({ name: "Projects", path: "Bases/Projects.base" }); + expect(provider.resolveFileReference("Bases/Projects.base", "Daily/Today.md")).toEqual({ + name: "Projects", + path: "Bases/Projects.base", + }); expect(getFirstLinkpathDest).toHaveBeenCalledWith("Bases/Projects.base", "Daily/Today.md"); }); diff --git a/tests/features/chat/host/web-context.test.ts b/tests/features/chat/host/web-context.test.ts index ff760d3a..2195ef86 100644 --- a/tests/features/chat/host/web-context.test.ts +++ b/tests/features/chat/host/web-context.test.ts @@ -41,9 +41,9 @@ describe("web context reader", () => { const inputSnapshot = { sourcePath: "source.md" } as ComposerInputSnapshot; const messageInput = [ { type: "text" as const, text: "Summarize [[Notes/Alpha.md]] [[Files/Sketch.png]]" }, - { type: "mention" as const, name: "Alpha", path: "Notes/Alpha.md" }, + { type: "fileReference" as const, name: "Alpha", path: "Notes/Alpha.md" }, { type: "additionalContext" as const, key: "codex_panel_obsidian_context", kind: "untrusted" as const, value: "selection" }, - { type: "mention" as const, name: "Sketch.png", path: "Files/Sketch.png" }, + { type: "fileReference" as const, name: "Sketch.png", path: "Files/Sketch.png" }, { type: "localImage" as const, path: "Files/Sketch.png" }, ] satisfies CodexInput; const prepareInput = vi.fn(() => ({ @@ -70,9 +70,9 @@ describe("web context reader", () => { value: "Web page context for the current user input:\nSource: https://example.com/article\nTitle: Example\n\nReadable article", attachment: { kind: "web" }, }, - { type: "mention", name: "Alpha", path: "Notes/Alpha.md" }, + { type: "fileReference", name: "Alpha", path: "Notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", kind: "untrusted", value: "selection" }, - { type: "mention", name: "Sketch.png", path: "Files/Sketch.png" }, + { type: "fileReference", name: "Sketch.png", path: "Files/Sketch.png" }, { type: "localImage", path: "Files/Sketch.png" }, ], }); diff --git a/tests/features/chat/panel/composer-controller.test.ts b/tests/features/chat/panel/composer-controller.test.ts index 5030f42e..54b67c66 100644 --- a/tests/features/chat/panel/composer-controller.test.ts +++ b/tests/features/chat/panel/composer-controller.test.ts @@ -488,7 +488,7 @@ describe("ChatComposerController", () => { expect(composer(parent).value).toBe("![[Codex Attachments/diagram.png]]"); expect(controller.preparedInput(composer(parent).value).input).toEqual([ { type: "text", text: "![[Codex Attachments/diagram.png]]" }, - { type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" }, + { type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" }, { type: "localImage", path: "Codex Attachments/diagram.png" }, ]); }); @@ -660,12 +660,12 @@ describe("ChatComposerController", () => { expect(controller.draft).toBe(originalDraft); expect(controller.preparedInput(`Inspect ${marker}`, snapshot).input).toEqual([ { type: "text", text: `Inspect ${marker}` }, - { type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" }, + { type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" }, { type: "localImage", path: "Codex Attachments/diagram.png" }, ]); expect(controller.preparedInput(`Inspect ${marker}`, restoredSnapshot).input).toEqual([ { type: "text", text: `Inspect ${marker}` }, - { type: "mention", name: "diagram", path: "Codex Attachments/diagram.png" }, + { type: "fileReference", name: "diagram", path: "Codex Attachments/diagram.png" }, { type: "localImage", path: "Codex Attachments/diagram.png" }, ]); }); @@ -997,7 +997,7 @@ describe("ChatComposerController", () => { expect(controller.captureInputSnapshot().attachments).toEqual([]); }); - it("saves dropped non-image files, inserts a wikilink, and sends a file mention", async () => { + it("saves dropped non-image files, inserts a wikilink, and sends a file reference", async () => { const stateStore = createChatStateStore(); const parent = document.createElement("div"); const attachmentHandler: ComposerAttachmentHandler = { @@ -1046,7 +1046,7 @@ describe("ChatComposerController", () => { expect(composer(parent).value).toBe("[[Codex Attachments/paper.pdf]]"); expect(controller.preparedInput(composer(parent).value).input).toEqual([ { type: "text", text: "[[Codex Attachments/paper.pdf]]" }, - { type: "mention", name: "paper", path: "Codex Attachments/paper.pdf" }, + { type: "fileReference", name: "paper", path: "Codex Attachments/paper.pdf" }, ]); }); @@ -1063,7 +1063,7 @@ describe("ChatComposerController", () => { renderComposerController(parent, controller, stateStore); }); controller = new ChatComposerController({ - noteCandidateProvider: noteProvider({ resolveMention: () => null }), + noteCandidateProvider: noteProvider({ resolveFileReference: () => null }), contextReferenceProvider: contextProvider(() => references), sourcePath: () => "Inbox.md", stateStore, @@ -1095,14 +1095,14 @@ describe("ChatComposerController", () => { expect(completedActiveNoteReference).toBe("[[Alpha]]"); expect(controller.preparedInput(completedActiveNoteReference).input).toContainEqual({ - type: "mention", + type: "fileReference", name: "Alpha", path: "notes/Alpha.md", }); controller.setDraft("", { clearSuggestions: true }); expect(controller.preparedInput(completedActiveNoteReference, snapshot).input).toContainEqual({ - type: "mention", + type: "fileReference", name: "Alpha", path: "notes/Alpha.md", }); @@ -1115,7 +1115,7 @@ describe("ChatComposerController", () => { selection: null, }; const controller = new ChatComposerController({ - noteCandidateProvider: noteProvider({ resolveMention: () => null }), + noteCandidateProvider: noteProvider({ resolveFileReference: () => null }), contextReferenceProvider: contextProvider(() => references), sourcePath: () => "Inbox.md", stateStore, @@ -1143,7 +1143,7 @@ describe("ChatComposerController", () => { expect(controller.preparedInput("Rewrite intro", snapshot).input).toEqual([ { type: "text", text: "Rewrite intro" }, - { type: "mention", name: "", path: "notes/Alpha.md" }, + { type: "fileReference", name: "", path: "notes/Alpha.md" }, { type: "additionalContext", key: "codex_panel_obsidian_context", @@ -1173,7 +1173,7 @@ describe("ChatComposerController", () => { }); controller = new ChatComposerController({ noteCandidateProvider: noteProvider({ - resolveMention: (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), + resolveFileReference: (target) => (target === "notes/Alpha" ? { name: "Alpha", path: "notes/Alpha.md" } : null), }), contextReferenceProvider: contextProvider(() => references), sourcePath: () => "", @@ -1429,7 +1429,7 @@ function noteProvider(overrides: Partial = {}): NoteCandi candidates: () => [], dailyNoteReferences: () => [], tags: () => [], - resolveMention: () => null, + resolveFileReference: () => null, dispose: vi.fn(), ...overrides, }; diff --git a/tests/features/chat/panel/shell.test.tsx b/tests/features/chat/panel/shell.test.tsx index 59a9a948..610ecb4f 100644 --- a/tests/features/chat/panel/shell.test.tsx +++ b/tests/features/chat/panel/shell.test.tsx @@ -354,7 +354,7 @@ function noteProvider(overrides: Partial = {}): NoteCandi candidates: () => [], dailyNoteReferences: () => [], tags: () => [], - resolveMention: () => null, + resolveFileReference: () => null, dispose: vi.fn(), ...overrides, }; diff --git a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx index d7accf45..b7aa1926 100644 --- a/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/thread-stream/blocks-and-messages.test.tsx @@ -1057,7 +1057,7 @@ describe("thread stream rendering and action menu", () => { expect(user.querySelector(".codex-panel__referenced-thread")?.getAttribute("title")).toBeNull(); }); - it("renders resolved file mentions as a collapsed user dialogue attachment", () => { + it("renders resolved file references as a collapsed user dialogue attachment", () => { const blocks = threadStreamBlocks({ items: [ { @@ -1067,7 +1067,7 @@ describe("thread stream rendering and action menu", () => { role: "user", text: "Read [[Alpha]].", copyText: "Read [[Alpha]].", - mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], + referencedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], }, ], }); @@ -1092,7 +1092,7 @@ describe("thread stream rendering and action menu", () => { role: "user", text: "https://example.com/ summarize this", copyText: "https://example.com/ summarize this", - mentionedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], + referencedFiles: [{ name: "Alpha", path: "thoughts/Alpha.md" }], contextAttachments: [{ label: "Web page", detail: "https://example.com/" }], }, ],