refactor(input): distinguish Vault file references from mentions

This commit is contained in:
murashit 2026-07-18 11:10:07 +09:00
parent 9b2585b2bd
commit d8f4f8a899
35 changed files with 523 additions and 230 deletions

View file

@ -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.

View file

@ -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<string, AppServerAdditionalContextEntry> = {};
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<CodexInputItem, { type: "additionalContext" }> =>
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 [];
}

View file

@ -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<TurnItem, { type: "userMess
};
}
export function turnUserFileReferences(
item: Extract<TurnItem, { type: "userMessage" }>,
manifest: ReturnType<typeof userMessageContextProjection>["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<TurnItem, { type: "userMessage" }
.map((item) => {
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 };
}

View file

@ -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<string>();
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<string, unknown>;
@ -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<string, unknown>;
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;
}

View file

@ -1,9 +1,14 @@
export interface RequestMention {
export interface VaultFileReference {
name: string;
path: string;
}
export const ACTIVE_FILE_MENTION_NAME = "<active>";
export interface SkillReference {
name: string;
path: string;
}
export const ACTIVE_FILE_REFERENCE_NAME = "<active>";
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,

View file

@ -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);
}

View file

@ -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 } : {}),
};
}

View file

@ -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,
};

View file

@ -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;
}

View file

@ -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<string>();
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<string>();
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 }] : []),
];
}

View file

@ -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 };

View file

@ -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<LocalUserDialogueParams, "mentionedFiles"> {
export interface LocalUserDialogueFromInputParams extends Omit<LocalUserDialogueParams, "referencedFiles"> {
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),
});
}

View file

@ -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<string, ThreadStreamDialogueItem["contextAttachments"]>()
: fallbackContextAttachmentsByText(currentItems, completedTurnId);
const localMetadataByFallbackText = serverHasUserDialogueClientIds
? new Map<string, LocalDialogueMetadata>()
: 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<string, ThreadStreamDialogueItem["contextAttachments"]> {
const attachmentsByText = new Map<string, ThreadStreamDialogueItem["contextAttachments"]>();
): Map<string, LocalDialogueMetadata> {
const metadataByText = new Map<string, LocalDialogueMetadata>();
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" } {

View file

@ -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<string>();
const seenActiveNotePaths = new Set<string>();
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;
}

View file

@ -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<string>();
const seenActiveNotePaths = new Set<string>();
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;
}

View file

@ -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<string>();
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);

View file

@ -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;
}

View file

@ -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 };

View file

@ -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 },

View file

@ -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;

View file

@ -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(

View file

@ -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: "<active>", 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(

View file

@ -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: "<active>", 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: "<active>", path: "notes/Alpha.md" },
],
});
expect(params?.additionalContext).toEqual({
"codex_panel.local-user.00.codex_panel_obsidian_context.part_01_of_01": {
kind: "untrusted",

View file

@ -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<T>(value: T | null | undefined): T {
@ -25,8 +25,12 @@ function wikiLinkSuggestions(query: string, notes: Parameters<typeof activeCompo
return activeComposerSuggestions(`[[${query}`, notes, []);
}
function userInputWithWikiLinkMentionsAndSkills(text: string, resolveMention: WikiLinkMentionResolver, skills: readonly SkillMetadata[]) {
return preparedUserInputWithWikiLinkMentionsSkillsAndContext(text, resolveMention, skills, emptyComposerContextReferences(), {
function userInputWithWikiLinkReferencesAndSkills(
text: string,
resolveFileReference: WikiLinkFileReferenceResolver,
skills: readonly SkillMetadata[],
) {
return preparedUserInputWithWikiLinkReferencesSkillsAndContext(text, resolveFileReference, skills, emptyComposerContextReferences(), {
referenceActiveNoteOnSend: false,
}).input;
}
@ -178,10 +182,10 @@ describe("composer suggestions", () => {
});
});
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",

View file

@ -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: "<active>", path: "notes/Alpha.md" },
{ type: "fileReference", name: "<active>", 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: "<active>", path: "notes/Alpha.md" },
{ type: "fileReference", name: "Beta", path: "notes/Beta.md" },
{ type: "fileReference", name: "<active>", 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: "<active>", path: "notes/Alpha.md" },
{ type: "fileReference", name: "Beta", path: "notes/Beta.md" },
{ type: "fileReference", name: "Gamma", path: "notes/Gamma.md" },
{ type: "fileReference", name: "<active>", 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),
[],

View file

@ -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-/),

View file

@ -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(

View file

@ -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: "<active>", 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: "<active>", 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" },
],

View file

@ -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();

View file

@ -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"),

View file

@ -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");
});

View file

@ -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" },
],
});

View file

@ -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: "<active>", path: "notes/Alpha.md" },
{ type: "fileReference", name: "<active>", 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<NoteCandidateProvider> = {}): NoteCandi
candidates: () => [],
dailyNoteReferences: () => [],
tags: () => [],
resolveMention: () => null,
resolveFileReference: () => null,
dispose: vi.fn(),
...overrides,
};

View file

@ -354,7 +354,7 @@ function noteProvider(overrides: Partial<NoteCandidateProvider> = {}): NoteCandi
candidates: () => [],
dailyNoteReferences: () => [],
tags: () => [],
resolveMention: () => null,
resolveFileReference: () => null,
dispose: vi.fn(),
...overrides,
};

View file

@ -1057,7 +1057,7 @@ describe("thread stream rendering and action menu", () => {
expect(user.querySelector<HTMLElement>(".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/" }],
},
],