Model chat Codex input boundary

This commit is contained in:
murashit 2026-06-10 11:49:43 +09:00
parent 0b22597a36
commit edf95e8244
19 changed files with 88 additions and 64 deletions

View file

@ -98,7 +98,6 @@ const nonUiEventListenerFiles = ["src/shared/lifecycle/abortable.ts"];
const generatedAppServerImportLegacyFiles = [
"src/features/chat/chat-state-actions.ts",
"src/features/chat/chat-state.ts",
"src/features/chat/composer/controller.ts",
"src/features/chat/display/agent.ts",
"src/features/chat/display/goal-messages.ts",
"src/features/chat/display/hooks.ts",
@ -117,11 +116,8 @@ const generatedAppServerImportLegacyFiles = [
"src/features/chat/threads/thread-history-controller.ts",
"src/features/chat/threads/thread-rename-controller.ts",
"src/features/chat/threads/thread-resume.ts",
"src/features/chat/turns/composer-submission-actions.ts",
"src/features/chat/turns/slash-command-actions.ts",
"src/features/chat/turns/slash-command-execution.ts",
"src/features/chat/turns/turn-submission-controller.ts",
"src/features/chat/turns/turn-submission.ts",
"src/features/chat/ui/goal-banner.tsx",
"src/features/chat/ui/pending-request-message.tsx",
];

View file

@ -44,6 +44,7 @@ import type { ClientRequestMethod, ClientRequestParams, PendingRequest, RpcError
import type { ServerNotification } from "../generated/app-server/ServerNotification";
import type { ServerRequest } from "../generated/app-server/ServerRequest";
import type { JsonValue } from "../generated/app-server/serde_json/JsonValue";
import { toAppServerUserInput, type CodexInput } from "./request-input";
import type { ServiceTierRequest, ThreadSettingsUpdate } from "./thread-settings";
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
@ -122,9 +123,9 @@ type AppServerClientLifecycleState =
| { kind: "starting"; transport: AppServerTransport }
| { kind: "initialized"; transport: AppServerTransport; initializeResponse: InitializeResponse };
function toUserInput(input: string | UserInput[]): UserInput[] {
if (typeof input !== "string") return input;
return [{ type: "text", text: input, text_elements: [] }];
function toUserInput(input: string | CodexInput): UserInput[] {
if (typeof input !== "string") return toAppServerUserInput(input);
return toAppServerUserInput([{ type: "text", text: input }]);
}
export class AppServerClient {
@ -366,7 +367,7 @@ export class AppServerClient {
startTurn(
threadId: string,
cwd: string,
input: string | UserInput[],
input: string | CodexInput,
clientUserMessageId?: string | null,
serviceTier?: ServiceTierRequest,
collaborationMode?: CollaborationMode | null,
@ -416,7 +417,7 @@ export class AppServerClient {
steerTurn(
threadId: string,
expectedTurnId: string,
input: string | UserInput[],
input: string | CodexInput,
clientUserMessageId?: string | null,
): Promise<TurnSteerResponse> {
return this.request("turn/steer", {

View file

@ -5,22 +5,39 @@ export interface RequestMention {
path: string;
}
export function appServerTextInputWithMentions(
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 };
export type CodexInput = CodexInputItem[];
type UserInputImageDetail = "auto" | "low" | "high" | "original";
export function codexTextInputWithMentions(
text: string,
mentions: readonly RequestMention[],
skills: readonly RequestMention[] = [],
): UserInput[] {
): CodexInput {
return [
...appServerTextInput(text),
...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 })),
];
}
export function appServerTextInputWithAttachments(text: string, input: readonly UserInput[]): UserInput[] {
return [...appServerTextInput(text), ...input.filter((item) => item.type !== "text")];
export function codexTextInputWithAttachments(text: string, input: readonly CodexInputItem[]): CodexInput {
return [...codexTextInput(text), ...input.filter((item) => item.type !== "text")];
}
function appServerTextInput(text: string): UserInput[] {
return [{ type: "text", text, text_elements: [] }];
export function toAppServerUserInput(input: readonly CodexInputItem[]): UserInput[] {
return input.map((item) => {
if (item.type === "text") return { type: "text", text: item.text, text_elements: [] };
return { ...item };
});
}
function codexTextInput(text: string): CodexInput {
return [{ type: "text", text }];
}

View file

@ -1,6 +1,6 @@
import type { App, EventRef } from "obsidian";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import type { CodexInput } from "../../../app-server/request-input";
import { isComposerSendKey, type SendShortcut } from "../../../shared/ui/keyboard";
import { textareaCursorAtVisualBoundary } from "../../../shared/ui/textarea-caret";
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "../chat-state";
@ -172,7 +172,7 @@ export class ChatComposerController {
this.render(parent, options);
}
codexInput(text: string): UserInput[] {
codexInput(text: string): CodexInput {
return userInputWithWikiLinkMentionsAndSkills(
text,
(target) => resolveAppWikiLinkMention(this.options.app, target),

View file

@ -1,4 +1,4 @@
import { appServerTextInputWithMentions, type RequestMention } from "../../../app-server/request-input";
import { codexTextInputWithMentions, type RequestMention } from "../../../app-server/request-input";
import type { SkillMetadata } from "../../../domain/catalog/metadata";
import { parseObsidianWikiLink } from "../../../shared/obsidian/wikilinks";
@ -60,7 +60,7 @@ export function userInputWithWikiLinkMentionsAndSkills(
resolvedSkills.push({ name: skill.name, path: skill.path });
}
return appServerTextInputWithMentions(text, mentions, resolvedSkills);
return codexTextInputWithMentions(text, mentions, resolvedSkills);
}
export function parsedSkillReferences(text: string): string[] {

View file

@ -1,8 +1,8 @@
import type { DisplayDetailSection, DisplayFileChange, DisplayFileMention, DisplayItem } from "./types";
import type { CodexInput, CodexInputItem } from "../../../app-server/request-input";
import type { FileUpdateChange } from "../../../generated/app-server/v2/FileUpdateChange";
import type { ThreadItem } from "../../../generated/app-server/v2/ThreadItem";
import type { Turn } from "../../../generated/app-server/v2/Turn";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import { definedProp, truncate } from "../../../utils";
import { referencedThreadDisplayFromPrompt } from "../../../domain/threads/reference";
import { appServerUserItemText } from "../../../app-server/turn-model";
@ -127,7 +127,7 @@ function userMessageDisplayItem(item: UserMessageItem, turnId?: string): Display
};
}
export function fileMentionsFromInput(input: UserInput[]): DisplayFileMention[] {
export function fileMentionsFromInput(input: readonly CodexInputItem[]): DisplayFileMention[] {
const seen = new Set<string>();
const mentions: DisplayFileMention[] = [];
for (const item of input) {
@ -138,7 +138,7 @@ export function fileMentionsFromInput(input: UserInput[]): DisplayFileMention[]
return mentions;
}
export function userMessageDisplayText(text: string, input: readonly UserInput[]): string {
export function userMessageDisplayText(text: string, input: CodexInput): string {
const names = resolvedSkillNames(input);
if (names.length === 0) return text;
@ -150,7 +150,7 @@ export function userMessageDisplayText(text: string, input: readonly UserInput[]
});
}
function resolvedSkillNames(input: readonly UserInput[]): string[] {
function resolvedSkillNames(input: readonly CodexInputItem[]): string[] {
const seen = new Set<string>();
const names: string[] = [];
for (const item of input) {

View file

@ -1,11 +1,11 @@
import type { AppServerClient } from "../../../app-server/client";
import type { CodexInput } from "../../../app-server/request-input";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
import { parseSlashCommand } from "../composer/suggestions";
import type { SlashCommandExecutionResult } from "./slash-command-execution";
import type { SlashCommandName } from "../composer/slash-commands";
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
interface ComposerDraftPort {
readonly trimmedDraft: string;
@ -17,7 +17,7 @@ interface ComposerSlashCommandPort {
}
interface ComposerTurnSubmissionPort {
sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void>;
sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void>;
}
interface ComposerConnectionPort {
@ -91,13 +91,13 @@ function sendComposerTurn(host: ComposerSubmissionActionsHost, text: string): Pr
function sendComposerTurn(
host: ComposerSubmissionActionsHost,
text: string,
codexInputOverride: UserInput[] | undefined,
codexInputOverride: CodexInput | undefined,
referencedThread: ReferencedThreadDisplay | undefined,
): Promise<void>;
async function sendComposerTurn(
host: ComposerSubmissionActionsHost,
text: string,
codexInputOverride?: UserInput[],
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadDisplay,
): Promise<void> {
host.scroll.forceBottom();

View file

@ -1,5 +1,5 @@
import type { AppServerClient } from "../../../app-server/client";
import { appServerTextInputWithAttachments } from "../../../app-server/request-input";
import { codexTextInputWithAttachments, type CodexInput } from "../../../app-server/request-input";
import { chronologicalConversationSummariesFromAppServerTurns } from "../../../app-server/turn-model";
import { referencedThreadPromptBundle, referencedThreadTurns, REFERENCED_THREAD_TURN_LIMIT } from "../../../domain/threads/reference";
import type { Thread } from "../../../domain/threads/model";
@ -13,7 +13,6 @@ import type { DisplayDetailSection } from "../display/types";
import type { ReasoningEffort } from "../../../domain/catalog/metadata";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import { submissionStateSnapshot } from "../chat-state-selectors";
import type { ChatStateStore } from "../chat-state";
@ -58,7 +57,7 @@ export interface SlashCommandGoalPort {
export interface SlashCommandActionsHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => UserInput[];
codexInput: (text: string) => CodexInput;
threads: SlashCommandThreadPort;
runtime: SlashCommandRuntimePort;
goals: SlashCommandGoalPort;
@ -140,7 +139,7 @@ async function referencedThreadInput(
const messageInput = host.codexInput(message);
host.status.setStatus(reference.status);
return {
input: appServerTextInputWithAttachments(reference.prompt, messageInput),
input: codexTextInputWithAttachments(reference.prompt, messageInput),
referencedThread: reference.referencedThread,
};
} catch (error) {

View file

@ -1,8 +1,8 @@
import type { CodexInput } from "../../../app-server/request-input";
import type { ReasoningEffort } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import type { ThreadGoal } from "../../../generated/app-server/v2/ThreadGoal";
import type { ThreadGoalStatus } from "../../../generated/app-server/v2/ThreadGoalStatus";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import { getThreadTitle } from "../../../domain/threads/model";
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
import {
@ -55,13 +55,13 @@ export interface SlashCommandExecutionContext {
export interface SlashCommandExecutionResult {
sendText?: string;
sendInput?: UserInput[];
sendInput?: CodexInput;
referencedThread?: ReferencedThreadDisplay;
composerDraft?: string;
}
export interface ThreadReferenceInput {
input: UserInput[];
input: CodexInput;
referencedThread: ReferencedThreadDisplay;
}

View file

@ -1,5 +1,5 @@
import type { AppServerClient } from "../../../app-server/client";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import type { CodexInput } from "../../../app-server/request-input";
import type { ReferencedThreadDisplay } from "../../../domain/threads/reference";
import {
addTranscriptItemAction,
@ -37,7 +37,7 @@ export interface TurnSubmissionRuntimePort {
}
export interface TurnSubmissionComposerPort {
codexInput: (text: string) => UserInput[];
codexInput: (text: string) => CodexInput;
setDraft: (text: string, options?: { focus?: boolean; clearSuggestions?: boolean }) => void;
}
@ -65,7 +65,7 @@ export interface TurnSubmissionControllerHost {
export class TurnSubmissionController {
constructor(private readonly host: TurnSubmissionControllerHost) {}
async sendTurnText(text: string, codexInputOverride?: UserInput[], referencedThread?: ReferencedThreadDisplay): Promise<void> {
async sendTurnText(text: string, codexInputOverride?: CodexInput, referencedThread?: ReferencedThreadDisplay): Promise<void> {
if (!(await this.host.restoredThread.ensureRestoredThreadLoaded())) return;
const client = this.host.connection.currentClient();
if (!client) return;
@ -137,7 +137,7 @@ export class TurnSubmissionController {
private async steerCurrentTurn(
client: AppServerClient,
text: string,
codexInputOverride?: UserInput[],
codexInputOverride?: CodexInput,
referencedThread?: ReferencedThreadDisplay,
): Promise<void> {
const state = submissionStateSnapshot(this.host.stateStore.getState());

View file

@ -2,7 +2,7 @@ import type { PendingTurnStart } from "../chat-state";
import type { DisplayFileMention, DisplayItem, MessageDisplayItem } from "../display/types";
import { fileMentionsFromInput, userMessageDisplayText } from "../display/thread-items";
import { attachHookRunsToTurn } from "../display/hooks";
import type { UserInput } from "../../../generated/app-server/v2/UserInput";
import type { CodexInput } from "../../../app-server/request-input";
export interface LocalUserMessageParams {
id: string;
@ -21,7 +21,7 @@ export interface OptimisticTurnStartAckParams {
}
export interface LocalUserMessageFromInputParams extends Omit<LocalUserMessageParams, "mentionedFiles"> {
codexInput: readonly UserInput[];
codexInput: CodexInput;
}
export type OptimisticTurnStartParams = LocalUserMessageFromInputParams;

View file

@ -1,31 +1,42 @@
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";
import {
codexTextInputWithAttachments,
codexTextInputWithMentions,
toAppServerUserInput,
type CodexInput,
} from "../../src/app-server/request-input";
describe("app-server request input", () => {
it("builds text input with mentions and skills", () => {
expect(
appServerTextInputWithMentions(
codexTextInputWithMentions(
"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: "text", text: "Use [[Note]] and $Skill" },
{ 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: [] },
const input: CodexInput = [
{ type: "text", text: "visible request" },
{ type: "mention", name: "Note", path: "Note.md" },
];
expect(appServerTextInputWithAttachments("rewritten prompt", input)).toEqual([
{ type: "text", text: "rewritten prompt", text_elements: [] },
expect(codexTextInputWithAttachments("rewritten prompt", input)).toEqual([
{ type: "text", text: "rewritten prompt" },
{ type: "mention", name: "Note", path: "Note.md" },
]);
});
it("serializes text input for app-server requests", () => {
expect(toAppServerUserInput(codexTextInputWithMentions("Use [[Note]]", [{ name: "Note", path: "Note.md" }]))).toEqual([
{ type: "text", text: "Use [[Note]]", text_elements: [] },
{ type: "mention", name: "Note", path: "Note.md" },
]);
});

View file

@ -190,7 +190,7 @@ describe("composer suggestions", () => {
replacement: "[[Assets/Diagram.png]]",
});
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "text", text },
{ type: "mention", name: "Diagram", path: "Assets/Diagram.png" },
]);
});

View file

@ -23,7 +23,7 @@ describe("wikilink context", () => {
);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "text", text },
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
]);
expect(input).toHaveLength(2);
@ -46,7 +46,7 @@ describe("wikilink context", () => {
{ raw: "Assets/Diagram.png#crop|Diagram", target: "Assets/Diagram.png", subpath: "#crop", display: "Diagram" },
]);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ 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" },
@ -60,7 +60,7 @@ describe("wikilink context", () => {
);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "text", text },
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
]);
});
@ -90,7 +90,7 @@ describe("wikilink context", () => {
);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "text", text },
{ type: "mention", name: "Alpha", path: "thoughts/Alpha.md" },
{
type: "skill",
@ -134,7 +134,7 @@ describe("wikilink context", () => {
]);
expect(input).toEqual([
{ type: "text", text, text_elements: [] },
{ type: "text", text },
{ type: "skill", name: "First", path: "/skills/first/SKILL.md" },
]);
});

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/client";
import type { CodexInput } from "../../../../src/app-server/request-input";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import {
createSlashCommandActions,
@ -11,9 +12,8 @@ import {
type SlashCommandThreadPort,
} from "../../../../src/features/chat/turns/slash-command-actions";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import type { UserInput } from "../../../../src/generated/app-server/v2/UserInput";
const textInput = (text: string): UserInput[] => [{ type: "text", text, text_elements: [] }];
const textInput = (text: string): CodexInput => [{ type: "text", text }];
function thread(id: string, name: string | null = null): Thread & { archived: boolean } {
return {

View file

@ -14,7 +14,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
startThreadForGoal: vi.fn().mockResolvedValue("thread-new"),
resumeThread: vi.fn().mockResolvedValue(undefined),
referThread: vi.fn().mockResolvedValue({
input: [{ type: "text", text: "referenced", text_elements: [] }],
input: [{ type: "text", text: "referenced" }],
referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 },
}),
forkThread: vi.fn().mockResolvedValue(undefined),
@ -143,7 +143,7 @@ describe("slash commands", () => {
it("returns referenced input for /refer", async () => {
const target = thread({ id: "thread-alpha", name: "Alpha" });
const input = [{ type: "text" as const, text: "context\n質問です", text_elements: [] }];
const input = [{ type: "text" as const, text: "context\n質問です" }];
const referencedThread = { threadId: "thread-alpha", title: "Alpha", includedTurns: 2, turnLimit: 20 };
const ctx = context({
listedThreads: [thread({ id: "thread-current", name: "Current" }), target],

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../../../src/app-server/client";
import type { CodexInput } from "../../../../src/app-server/request-input";
import { createChatState, createChatStateStore } from "../../../../src/features/chat/chat-state";
import {
TurnSubmissionController,
@ -14,9 +15,8 @@ import {
type TurnSubmissionViewPort,
} from "../../../../src/features/chat/turns/turn-submission-controller";
import type { Thread } from "../../../../src/generated/app-server/v2/Thread";
import type { UserInput } from "../../../../src/generated/app-server/v2/UserInput";
const textInput = (text: string): UserInput[] => [{ type: "text", text, text_elements: [] }];
const textInput = (text: string): CodexInput => [{ type: "text", text }];
function thread(id: string): Thread & { archived: boolean } {
return {

View file

@ -23,7 +23,7 @@ describe("chat turn submission helpers", () => {
it("builds optimistic turn starts from immutable input snapshots", () => {
const input = [
{ type: "text" as const, text: "hello [[Note]]", text_elements: [] },
{ type: "text" as const, text: "hello [[Note]]" },
{ type: "mention" as const, name: "Note", path: "Note.md" },
];
@ -49,7 +49,7 @@ describe("chat turn submission helpers", () => {
it("formats resolved skill references in optimistic user messages only for display", () => {
const text = "Use $obsidian-codex-panel-maintain and $missing.";
const input = [
{ type: "text" as const, text, text_elements: [] },
{ type: "text" as const, text },
{
type: "skill" as const,
name: "obsidian-codex-panel-maintain",

View file

@ -491,7 +491,7 @@ describe("CodexChatView connection lifecycle", () => {
expect(client.startTurn).toHaveBeenCalledWith(
"thread-1",
"/vault",
[{ type: "text", text: "hello", text_elements: [] }],
[{ type: "text", text: "hello" }],
expect.stringMatching(/^local-user-\d+$/),
);
});