From a7cf377e1c857cf79f57e84e70b372287be65487 Mon Sep 17 00:00:00 2001 From: murashit Date: Tue, 16 Jun 2026 11:01:21 +0900 Subject: [PATCH] Send resolved wikilinks as additional context --- src/app-server/connection/client.ts | 11 ++++++++- src/app-server/protocol/request-input.ts | 14 ++++++++++- src/domain/chat/input.ts | 16 ++++++++++++- .../application/composer/wikilink-context.ts | 18 +++++++++++++-- tests/app-server/app-server-client.test.ts | 22 ++++++++++++++++-- tests/app-server/request-input.test.ts | 23 +++++++++++++++++-- .../composer/composer-suggestions.test.ts | 6 +++++ .../composer/wikilink-context.test.ts | 18 ++++++++++++++- .../turns/optimistic-turn-start.test.ts | 20 ++++++++++++++++ 9 files changed, 138 insertions(+), 10 deletions(-) diff --git a/src/app-server/connection/client.ts b/src/app-server/connection/client.ts index 904ad70c..eab803ff 100644 --- a/src/app-server/connection/client.ts +++ b/src/app-server/connection/client.ts @@ -45,7 +45,7 @@ import type { ServerNotification } from "../../generated/app-server/ServerNotifi import type { ServerRequest } from "../../generated/app-server/ServerRequest"; import type { JsonValue } from "../../generated/app-server/serde_json/JsonValue"; import type { CodexInput } from "../../domain/chat/input"; -import { toAppServerUserInput } from "../protocol/request-input"; +import { additionalContextFromCodexInput, toAppServerUserInput } from "../protocol/request-input"; import { appServerThreadGoalUpdate, type ThreadGoalUpdate } from "../protocol/thread-goal"; import { appServerRuntimeSettingsPatch, type RuntimeServiceTierRequest, type RuntimeSettingsPatch } from "../protocol/thread-settings"; @@ -157,6 +157,11 @@ function toUserInput(input: string | CodexInput): UserInput[] { return toAppServerUserInput([{ type: "text", text: input }]); } +function toAdditionalContext(input: string | CodexInput): ClientRequestParams<"turn/start">["additionalContext"] | undefined { + if (typeof input === "string") return undefined; + return additionalContextFromCodexInput(input); +} + function appServerTurnRuntimeParams(runtime: AppServerTurnRuntimeOverrides | undefined): AppServerTurnRuntimeParams { const params: AppServerTurnRuntimeParams = {}; if (runtime?.serviceTier !== undefined) params.serviceTier = runtime.serviceTier; @@ -413,10 +418,12 @@ export class AppServerClient { startTurn(options: AppServerStartTurnOptions): Promise { const { threadId, cwd, input, clientUserMessageId, runtime } = options; + const additionalContext = toAdditionalContext(input); const params: ClientRequestParams<"turn/start"> = { threadId, cwd, ...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}), + ...(additionalContext !== undefined ? { additionalContext } : {}), ...appServerTurnRuntimeParams(runtime), input: toUserInput(input), }; @@ -447,11 +454,13 @@ export class AppServerClient { input: string | CodexInput, clientUserMessageId?: string | null, ): Promise { + const additionalContext = toAdditionalContext(input); return this.request("turn/steer", { threadId, expectedTurnId, input: toUserInput(input), ...(clientUserMessageId !== undefined ? { clientUserMessageId } : {}), + ...(additionalContext !== undefined ? { additionalContext } : {}), }); } diff --git a/src/app-server/protocol/request-input.ts b/src/app-server/protocol/request-input.ts index f45bf3b1..f7465aa8 100644 --- a/src/app-server/protocol/request-input.ts +++ b/src/app-server/protocol/request-input.ts @@ -1,11 +1,23 @@ +import type { AdditionalContextEntry } from "../../generated/app-server/v2/AdditionalContextEntry"; import type { UserInput } from "../../generated/app-server/v2/UserInput"; import type { CodexInputItem } from "../../domain/chat/input"; export type { CodexInput, CodexInputItem } from "../../domain/chat/input"; export function toAppServerUserInput(input: readonly CodexInputItem[]): UserInput[] { - return input.map((item) => { + return input.flatMap((item) => { if (item.type === "text") return { type: "text", text: item.text, text_elements: [] }; + if (item.type === "additionalContext") return []; return { ...item }; }); } + +export function additionalContextFromCodexInput(input: readonly CodexInputItem[]): Record | undefined { + const additionalContext: Record = {}; + for (const item of input) { + if (item.type !== "additionalContext") continue; + if (!item.key || !item.value) continue; + additionalContext[item.key] = { value: item.value, kind: item.kind }; + } + return Object.keys(additionalContext).length > 0 ? additionalContext : undefined; +} diff --git a/src/domain/chat/input.ts b/src/domain/chat/input.ts index 78f125d0..b5f08e54 100644 --- a/src/domain/chat/input.ts +++ b/src/domain/chat/input.ts @@ -3,12 +3,19 @@ export interface RequestMention { path: string; } +export interface RequestAdditionalContext { + key: string; + value: string; + kind: "untrusted" | "application"; +} + export type CodexInputItem = | { type: "text"; text: string } | { 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: "mention"; name: string; path: string } + | { type: "additionalContext"; key: string; value: string; kind: RequestAdditionalContext["kind"] }; export type CodexInput = CodexInputItem[]; type UserInputImageDetail = "auto" | "low" | "high" | "original"; @@ -17,11 +24,18 @@ export function codexTextInputWithMentions( text: string, mentions: readonly RequestMention[], skills: readonly RequestMention[] = [], + additionalContext: readonly RequestAdditionalContext[] = [], ): CodexInput { return [ ...codexTextInput(text), ...mentions.map((mention) => ({ type: "mention" as const, name: mention.name, path: mention.path })), ...skills.map((skill) => ({ type: "skill" as const, name: skill.name, path: skill.path })), + ...additionalContext.map((context) => ({ + type: "additionalContext" as const, + key: context.key, + value: context.value, + kind: context.kind, + })), ]; } diff --git a/src/features/chat/application/composer/wikilink-context.ts b/src/features/chat/application/composer/wikilink-context.ts index 6b6b7f47..7941f0a0 100644 --- a/src/features/chat/application/composer/wikilink-context.ts +++ b/src/features/chat/application/composer/wikilink-context.ts @@ -1,4 +1,4 @@ -import { codexTextInputWithMentions, type RequestMention } from "../../../../domain/chat/input"; +import { codexTextInputWithMentions, type RequestAdditionalContext, type RequestMention } from "../../../../domain/chat/input"; import type { SkillMetadata } from "../../../../domain/catalog/metadata"; import { parseObsidianWikiLink } from "../../../../shared/obsidian/wikilinks"; @@ -11,6 +11,7 @@ export interface ParsedWikiLink { export type WikiLinkMentionResolver = (target: string) => { name: string; path: string } | null; +const WIKILINK_ADDITIONAL_CONTEXT_KEY = "codex_panel_wikilinks"; const WIKILINK_PATTERN = /\[\[([^\]\n]+?)\]\]/g; const SKILL_REFERENCE_PATTERN = /(^|[\s([{])\$([^\s\])}.,;!?]{1,120})(?=$|[\s\])}.,;!?])/g; @@ -37,6 +38,7 @@ export function userInputWithWikiLinkMentionsAndSkills( skills: readonly SkillMetadata[], ) { const mentions: RequestMention[] = []; + const wikilinkMappings: string[] = []; const seenPaths = new Set(); for (const link of parsedWikiLinks(text)) { @@ -44,6 +46,7 @@ export function userInputWithWikiLinkMentionsAndSkills( if (!mention || seenPaths.has(mention.path)) continue; seenPaths.add(mention.path); mentions.push(mention); + wikilinkMappings.push(`- [[${link.raw}]] -> ${mention.path}`); } const skillByName = firstEnabledSkillByName(skills); @@ -56,7 +59,18 @@ export function userInputWithWikiLinkMentionsAndSkills( resolvedSkills.push({ name: skill.name, path: skill.path }); } - return codexTextInputWithMentions(text, mentions, resolvedSkills); + return codexTextInputWithMentions(text, mentions, resolvedSkills, wikilinkAdditionalContext(wikilinkMappings)); +} + +function wikilinkAdditionalContext(mappings: readonly string[]): RequestAdditionalContext[] { + if (mappings.length === 0) return []; + return [ + { + key: WIKILINK_ADDITIONAL_CONTEXT_KEY, + kind: "untrusted", + value: ["Resolved Obsidian wikilinks for the current user input:", ...mappings].join("\n"), + }, + ]; } function parsedSkillReferences(text: string): string[] { diff --git a/tests/app-server/app-server-client.test.ts b/tests/app-server/app-server-client.test.ts index e4370d6e..c5e1b8ba 100644 --- a/tests/app-server/app-server-client.test.ts +++ b/tests/app-server/app-server-client.test.ts @@ -428,7 +428,23 @@ describe("AppServerClient", () => { const input = [ { type: "text" as const, text: "Read [[Alpha]].", text_elements: [] }, { type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" }, + { + type: "additionalContext" as const, + key: "codex_panel_wikilinks", + kind: "untrusted" as const, + value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md", + }, ]; + const serializedInput = [ + { type: "text" as const, text: "Read [[Alpha]].", text_elements: [] }, + { type: "mention" as const, name: "Alpha", path: "thoughts/Alpha.md" }, + ]; + const additionalContext = { + codex_panel_wikilinks: { + kind: "untrusted", + value: "Resolved Obsidian wikilinks for the current user input:\n- [[Alpha]] -> thoughts/Alpha.md", + }, + }; const startingTurn = client.startTurn({ threadId: "thread-1", cwd: "/vault", input }); expect(transport.sent[2]).toMatchObject({ @@ -436,7 +452,8 @@ describe("AppServerClient", () => { params: { threadId: "thread-1", cwd: "/vault", - input, + input: serializedInput, + additionalContext, }, }); transport.emitLine({ id: 2, result: { turn: { id: "turn-1" } } }); @@ -448,7 +465,8 @@ describe("AppServerClient", () => { params: { threadId: "thread-1", expectedTurnId: "turn-1", - input, + input: serializedInput, + additionalContext, }, }); transport.emitLine({ id: 3, result: { turnId: "turn-1" } }); diff --git a/tests/app-server/request-input.test.ts b/tests/app-server/request-input.test.ts index 270ae7ee..c18c981b 100644 --- a/tests/app-server/request-input.test.ts +++ b/tests/app-server/request-input.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { codexTextInputWithAttachments, codexTextInputWithMentions, type CodexInput } from "../../src/domain/chat/input"; -import { toAppServerUserInput } from "../../src/app-server/protocol/request-input"; +import { additionalContextFromCodexInput, toAppServerUserInput } from "../../src/app-server/protocol/request-input"; describe("app-server request input", () => { it("builds text input with mentions and skills", () => { @@ -10,11 +10,18 @@ describe("app-server request input", () => { "Use [[Note]] and $Skill", [{ name: "Note", path: "Note.md" }], [{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }], + [{ key: "codex_panel_wikilinks", kind: "untrusted", value: "Resolved Obsidian wikilinks:\n- [[Note]] -> Note.md" }], ), ).toEqual([ { type: "text", text: "Use [[Note]] and $Skill" }, { type: "mention", name: "Note", path: "Note.md" }, { type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" }, + { + type: "additionalContext", + key: "codex_panel_wikilinks", + kind: "untrusted", + value: "Resolved Obsidian wikilinks:\n- [[Note]] -> Note.md", + }, ]); }); @@ -22,18 +29,30 @@ describe("app-server request input", () => { const input: CodexInput = [ { type: "text", text: "visible request" }, { type: "mention", name: "Note", path: "Note.md" }, + { type: "additionalContext", key: "codex_panel_wikilinks", 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: "additionalContext", key: "codex_panel_wikilinks", kind: "untrusted", value: "- [[Note]] -> Note.md" }, ]); }); it("serializes text input for app-server requests", () => { - expect(toAppServerUserInput(codexTextInputWithMentions("Use [[Note]]", [{ name: "Note", path: "Note.md" }]))).toEqual([ + const input = codexTextInputWithMentions( + "Use [[Note]]", + [{ name: "Note", path: "Note.md" }], + [], + [{ key: "codex_panel_wikilinks", 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(additionalContextFromCodexInput(input)).toEqual({ + codex_panel_wikilinks: { kind: "untrusted", value: "- [[Note]] -> Note.md" }, + }); }); }); diff --git a/tests/features/chat/conversation/composer/composer-suggestions.test.ts b/tests/features/chat/conversation/composer/composer-suggestions.test.ts index 6444cf2b..12fdf35a 100644 --- a/tests/features/chat/conversation/composer/composer-suggestions.test.ts +++ b/tests/features/chat/conversation/composer/composer-suggestions.test.ts @@ -181,6 +181,12 @@ describe("composer suggestions", () => { expect(input).toEqual([ { type: "text", text }, { type: "mention", name: "Diagram", path: "Assets/Diagram.png" }, + { + type: "additionalContext", + key: "codex_panel_wikilinks", + kind: "untrusted", + value: "Resolved Obsidian wikilinks for the current user input:\n- [[Assets/Diagram.png]] -> Assets/Diagram.png", + }, ]); }); diff --git a/tests/features/chat/conversation/composer/wikilink-context.test.ts b/tests/features/chat/conversation/composer/wikilink-context.test.ts index 40bc8e82..3518e7d7 100644 --- a/tests/features/chat/conversation/composer/wikilink-context.test.ts +++ b/tests/features/chat/conversation/composer/wikilink-context.test.ts @@ -2,6 +2,13 @@ import { describe, expect, it } from "vitest"; import { userInputWithWikiLinkMentionsAndSkills } from "../../../../../src/features/chat/application/composer/wikilink-context"; +const wikilinkContext = (...mappings: string[]) => ({ + type: "additionalContext" as const, + key: "codex_panel_wikilinks", + kind: "untrusted" as const, + value: ["Resolved Obsidian wikilinks for the current user input:", ...mappings].join("\n"), +}); + describe("wikilink context", () => { it("parses aliases, subpaths, and duplicate links", () => { const text = "See [[Alpha|label]], [[Beta#Heading]], [[Gamma^block]], and [[Alpha]]."; @@ -12,6 +19,7 @@ describe("wikilink context", () => { { type: "mention", name: "Alpha", path: "Alpha.md" }, { type: "mention", name: "Beta", path: "Beta.md" }, { type: "mention", name: "Gamma", path: "Gamma.md" }, + wikilinkContext("- [[Alpha|label]] -> Alpha.md", "- [[Beta#Heading]] -> Beta.md", "- [[Gamma^block]] -> Gamma.md"), ]); }); @@ -26,8 +34,9 @@ describe("wikilink context", () => { expect(input).toEqual([ { type: "text", text }, { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + wikilinkContext("- [[Alpha#Heading|A]] -> thoughts/Alpha.md"), ]); - expect(input).toHaveLength(2); + expect(input).toHaveLength(3); }); it("resolves aliases and subpaths from non-markdown wikilinks by target", () => { @@ -49,6 +58,11 @@ describe("wikilink context", () => { { type: "mention", name: "Projects", path: "Bases/Projects.base" }, { type: "mention", name: "Paper", path: "References/Paper.pdf" }, { type: "mention", name: "Diagram", path: "Assets/Diagram.png" }, + wikilinkContext( + "- [[Bases/Projects.base|Projects]] -> Bases/Projects.base", + "- [[References/Paper.pdf]] -> References/Paper.pdf", + "- [[Assets/Diagram.png#crop|Diagram]] -> Assets/Diagram.png", + ), ]); }); @@ -63,6 +77,7 @@ describe("wikilink context", () => { expect(input).toEqual([ { type: "text", text }, { type: "mention", name: "Alpha", path: "thoughts/Alpha.md" }, + wikilinkContext("- [[Alpha]] -> thoughts/Alpha.md"), ]); }); @@ -90,6 +105,7 @@ describe("wikilink context", () => { name: "obsidian-codex-panel-maintain", path: "/vault/___/skills/obsidian-codex-panel-maintain/SKILL.md", }, + wikilinkContext("- [[Alpha]] -> thoughts/Alpha.md"), ]); }); diff --git a/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts b/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts index 7d315533..fc875227 100644 --- a/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts +++ b/tests/features/chat/conversation/turns/optimistic-turn-start.test.ts @@ -35,6 +35,26 @@ describe("optimistic turn start helpers", () => { }); }); + it("keeps additional context out of optimistic user message text", () => { + const text = "Read [[Note]]."; + const input = [ + { type: "text" as const, text }, + { type: "mention" as const, name: "Note", path: "Note.md" }, + { + type: "additionalContext" as const, + key: "codex_panel_wikilinks", + kind: "untrusted" as const, + value: "Resolved Obsidian wikilinks for the current user input:\n- [[Note]] -> Note.md", + }, + ]; + + expect(localUserMessageItemFromInput({ id: "local-user", text, codexInput: input })).toMatchObject({ + text, + copyText: text, + mentionedFiles: [{ name: "Note", path: "Note.md" }], + }); + }); + it("formats resolved skill references in optimistic user messages only for display", () => { const text = "Use $obsidian-codex-panel-maintain and $missing."; const input = [