diff --git a/src/app-server/request-input.ts b/src/app-server/request-input.ts new file mode 100644 index 00000000..e264580b --- /dev/null +++ b/src/app-server/request-input.ts @@ -0,0 +1,26 @@ +import type { UserInput } from "../generated/app-server/v2/UserInput"; + +export interface AppServerRequestMention { + name: string; + path: string; +} + +export function appServerTextInputWithMentions( + text: string, + mentions: readonly AppServerRequestMention[], + skills: readonly AppServerRequestMention[] = [], +): UserInput[] { + return [ + ...appServerTextInput(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 })), + ]; +} + +export function appServerTextInputWithAttachments(text: string, input: readonly UserInput[]): UserInput[] { + return [...appServerTextInput(text), ...input.filter((item) => item.type !== "text")]; +} + +function appServerTextInput(text: string): UserInput[] { + return [{ type: "text", text, text_elements: [] }]; +} diff --git a/src/domain/threads/reference.ts b/src/domain/threads/reference.ts index 28d2bbd1..2d16d579 100644 --- a/src/domain/threads/reference.ts +++ b/src/domain/threads/reference.ts @@ -1,4 +1,3 @@ -import type { UserInput } from "../../generated/app-server/v2/UserInput"; import { shortThreadId } from "../../utils"; import { getThreadTitle, type Thread } from "./model"; import type { ThreadConversationSummary } from "./transcript"; @@ -23,8 +22,8 @@ export interface ReferencedThreadTurn { assistantText: string | null; } -export interface ReferencedThreadInput { - input: UserInput[]; +export interface ReferencedThreadPromptBundle { + prompt: string; referencedThread: ReferencedThreadDisplay; status: string; } @@ -69,15 +68,14 @@ function referencedThreadDisplay(thread: Thread, count: number): ReferencedThrea }; } -export function referencedThreadInput( +export function referencedThreadPromptBundle( thread: Thread, turns: readonly ReferencedThreadTurn[], userRequest: string, - messageInput: UserInput[], -): ReferencedThreadInput { +): ReferencedThreadPromptBundle { const prompt = referencedThreadPrompt(thread, [...turns], userRequest); return { - input: [{ type: "text", text: prompt, text_elements: [] }, ...messageInput.filter((item) => item.type !== "text")], + prompt, referencedThread: referencedThreadDisplay(thread, turns.length), status: referencedThreadStatus(thread, turns.length), }; diff --git a/src/features/chat/composer/wikilink-context.ts b/src/features/chat/composer/wikilink-context.ts index 4c6782dc..52e9278e 100644 --- a/src/features/chat/composer/wikilink-context.ts +++ b/src/features/chat/composer/wikilink-context.ts @@ -1,4 +1,4 @@ -import type { UserInput } from "../../../generated/app-server/v2/UserInput"; +import { appServerTextInputWithMentions, type AppServerRequestMention } from "../../../app-server/request-input"; import type { SkillMetadata } from "../../../domain/catalog/metadata"; import { parseObsidianWikiLink } from "../../../shared/obsidian/wikilinks"; @@ -31,7 +31,7 @@ export function parsedWikiLinks(text: string): ParsedWikiLink[] { return links; } -export function userInputWithWikiLinkMentions(text: string, resolveMention: WikiLinkMentionResolver): UserInput[] { +export function userInputWithWikiLinkMentions(text: string, resolveMention: WikiLinkMentionResolver) { return userInputWithWikiLinkMentionsAndSkills(text, resolveMention, []); } @@ -39,27 +39,28 @@ export function userInputWithWikiLinkMentionsAndSkills( text: string, resolveMention: WikiLinkMentionResolver, skills: readonly SkillMetadata[], -): UserInput[] { - const input: UserInput[] = [{ type: "text", text, text_elements: [] }]; +) { + const mentions: AppServerRequestMention[] = []; const seenPaths = new Set(); for (const link of parsedWikiLinks(text)) { const mention = resolveMention(link.target); if (!mention || seenPaths.has(mention.path)) continue; seenPaths.add(mention.path); - input.push({ type: "mention", name: mention.name, path: mention.path }); + mentions.push(mention); } const skillByName = firstEnabledSkillByName(skills); + const resolvedSkills: AppServerRequestMention[] = []; const seenSkillPaths = new Set(); for (const reference of parsedSkillReferences(text)) { const skill = skillByName.get(reference.toLowerCase()); if (!skill || seenSkillPaths.has(skill.path)) continue; seenSkillPaths.add(skill.path); - input.push({ type: "skill", name: skill.name, path: skill.path }); + resolvedSkills.push({ name: skill.name, path: skill.path }); } - return input; + return appServerTextInputWithMentions(text, mentions, resolvedSkills); } export function parsedSkillReferences(text: string): string[] { diff --git a/src/features/chat/turns/slash-command-actions.ts b/src/features/chat/turns/slash-command-actions.ts index 3a4ed3e9..7a0221f3 100644 --- a/src/features/chat/turns/slash-command-actions.ts +++ b/src/features/chat/turns/slash-command-actions.ts @@ -1,10 +1,7 @@ import type { AppServerClient } from "../../../app-server/client"; +import { appServerTextInputWithAttachments } from "../../../app-server/request-input"; import { chronologicalConversationSummariesFromAppServerTurns } from "../../../app-server/turn-model"; -import { - referencedThreadInput as buildReferencedThreadInput, - referencedThreadTurns, - REFERENCED_THREAD_TURN_LIMIT, -} from "../../../domain/threads/reference"; +import { referencedThreadPromptBundle, referencedThreadTurns, REFERENCED_THREAD_TURN_LIMIT } from "../../../domain/threads/reference"; import type { Thread } from "../../../domain/threads/model"; import { executeSlashCommand as runSlashCommand, @@ -139,9 +136,13 @@ async function referencedThreadInput( host.status.addSystemMessage("Referenced thread has no readable conversation turns."); return null; } - const reference = buildReferencedThreadInput(thread, turns, message, host.codexInput(message)); + const reference = referencedThreadPromptBundle(thread, turns, message); + const messageInput = host.codexInput(message); host.status.setStatus(reference.status); - return reference; + return { + input: appServerTextInputWithAttachments(reference.prompt, messageInput), + referencedThread: reference.referencedThread, + }; } catch (error) { host.status.addSystemMessage(error instanceof Error ? error.message : String(error)); return null; diff --git a/tests/app-server/request-input.test.ts b/tests/app-server/request-input.test.ts new file mode 100644 index 00000000..bb49a54f --- /dev/null +++ b/tests/app-server/request-input.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { appServerTextInputWithAttachments, appServerTextInputWithMentions } from "../../src/app-server/request-input"; +import type { UserInput } from "../../src/generated/app-server/v2/UserInput"; + +describe("app-server request input", () => { + it("builds text input with mentions and skills", () => { + expect( + appServerTextInputWithMentions( + "Use [[Note]] and $Skill", + [{ name: "Note", path: "Note.md" }], + [{ name: "Skill", path: ".codex/skills/skill/SKILL.md" }], + ), + ).toEqual([ + { type: "text", text: "Use [[Note]] and $Skill", text_elements: [] }, + { type: "mention", name: "Note", path: "Note.md" }, + { type: "skill", name: "Skill", path: ".codex/skills/skill/SKILL.md" }, + ]); + }); + + it("replaces text input while preserving non-text attachments", () => { + const input: UserInput[] = [ + { type: "text", text: "visible request", text_elements: [] }, + { type: "mention", name: "Note", path: "Note.md" }, + ]; + + expect(appServerTextInputWithAttachments("rewritten prompt", input)).toEqual([ + { type: "text", text: "rewritten prompt", text_elements: [] }, + { type: "mention", name: "Note", path: "Note.md" }, + ]); + }); +}); diff --git a/tests/domain/threads/reference.test.ts b/tests/domain/threads/reference.test.ts index 40e312d8..54cacd79 100644 --- a/tests/domain/threads/reference.test.ts +++ b/tests/domain/threads/reference.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import type { Thread } from "../../../src/domain/threads/model"; import { referencedThreadDisplayFromPrompt, - referencedThreadInput, + referencedThreadPromptBundle, referencedThreadPrompt, referencedThreadTurns, } from "../../../src/domain/threads/reference"; @@ -117,16 +117,12 @@ describe("thread reference context", () => { ).toBeNull(); }); - it("builds slash command input while preserving non-text attachments", () => { + it("builds a prompt bundle for slash command references", () => { const source = thread(); - const input = referencedThreadInput(source, [{ userText: "元の依頼", assistantText: "回答" }], "この続きです", [ - { type: "text", text: "この続きです", text_elements: [] }, - { type: "mention", name: "Note", path: "Note.md" }, - ]); + const input = referencedThreadPromptBundle(source, [{ userText: "元の依頼", assistantText: "回答" }], "この続きです"); expect(input.status).toBe("Referencing 019abcde (1/20 turns)."); expect(input.referencedThread).toMatchObject({ threadId: source.id, title: "参照元", includedTurns: 1 }); - expect(input.input[0]).toMatchObject({ type: "text" }); - expect(input.input[1]).toEqual({ type: "mention", name: "Note", path: "Note.md" }); + expect(input.prompt).toContain("Current user request:\nこの続きです"); }); });