Extract turn submission derivations

This commit is contained in:
murashit 2026-05-29 05:31:59 +09:00
parent 172bacfe30
commit 6b0707112e
3 changed files with 124 additions and 15 deletions

View file

@ -1,6 +1,8 @@
import type { PendingTurnStart } from "./chat-state";
import type { DisplayFileMention, DisplayItem, MessageDisplayItem } from "./display/types";
import { fileMentionsFromInput } from "./display/thread-items";
import { attachHookRunsToTurn } from "./hook-display";
import type { UserInput } from "../../generated/app-server/v2/UserInput";
export interface LocalUserMessageParams {
id: string;
@ -17,6 +19,24 @@ export interface OptimisticTurnStartAckParams {
pendingTurnStart: PendingTurnStart | null;
}
export interface LocalUserMessageFromInputParams extends Omit<LocalUserMessageParams, "mentionedFiles"> {
codexInput: readonly UserInput[];
}
export type OptimisticTurnStartParams = LocalUserMessageFromInputParams;
export interface OptimisticTurnStart {
item: MessageDisplayItem;
pendingTurnStart: PendingTurnStart;
}
export interface TurnStartAckMatchParams {
pendingTurnStart: PendingTurnStart | null;
activeTurnId: string | null;
optimisticUserId: string;
responseTurnId: string;
}
export interface FailedTurnStartCleanupParams {
items: readonly DisplayItem[];
optimisticUserId: string | null;
@ -38,6 +58,30 @@ export function localUserMessageItem(params: LocalUserMessageParams): MessageDis
};
}
export function localUserMessageItemFromInput(params: LocalUserMessageFromInputParams): MessageDisplayItem {
return localUserMessageItem({
id: params.id,
text: params.text,
...(params.turnId ? { turnId: params.turnId } : {}),
...(params.referencedThread ? { referencedThread: params.referencedThread } : {}),
mentionedFiles: fileMentionsFromInput([...params.codexInput]),
});
}
export function optimisticTurnStart(params: OptimisticTurnStartParams): OptimisticTurnStart {
return {
item: localUserMessageItemFromInput(params),
pendingTurnStart: { anchorItemId: params.id, promptSubmitHookItemIds: [] },
};
}
export function shouldAcknowledgeTurnStart(params: TurnStartAckMatchParams): boolean {
return (
params.pendingTurnStart?.anchorItemId === params.optimisticUserId ||
(!params.pendingTurnStart && params.activeTurnId === params.responseTurnId)
);
}
export function acknowledgeOptimisticTurnStart(params: OptimisticTurnStartAckParams): DisplayItem[] {
const displayItems = params.items.map((item) => (item.id === params.optimisticUserId ? { ...item, turnId: params.turnId } : item));
if (!params.pendingTurnStart) return displayItems;

View file

@ -8,7 +8,6 @@ import type { SlashCommandName } from "./composer/slash-commands";
import { parseSlashCommand } from "./composer/suggestions";
import { VIEW_TYPE_CODEX_PANEL } from "../../constants";
import { createSystemItem } from "./display/system";
import { fileMentionsFromInput } from "./display/thread-items";
import type { DisplayDetailSection, DisplayItem } from "./display/types";
import type { ReasoningEffort } from "../../generated/app-server/ReasoningEffort";
import type { Model } from "../../generated/app-server/v2/Model";
@ -86,7 +85,13 @@ import {
type ActiveChatResume,
type ChatViewRenderScheduleOptions,
} from "./view-lifecycle";
import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, localUserMessageItem } from "./turn-submission";
import {
acknowledgeOptimisticTurnStart,
cleanupFailedTurnStart,
localUserMessageItemFromInput,
optimisticTurnStart,
shouldAcknowledgeTurnStart,
} from "./turn-submission";
export interface CodexChatHost {
readonly settings: CodexPanelSettings;
@ -756,18 +761,17 @@ export class CodexChatView extends ItemView {
if (!(await this.runtimeSettings.applyPendingThreadSettings())) return;
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
const mentionedFiles = fileMentionsFromInput(codexInput);
optimisticUserId = `local-user-${String(Date.now())}`;
const optimisticUserItem = localUserMessageItem({
const optimistic = optimisticTurnStart({
id: optimisticUserId,
text,
codexInput,
referencedThread,
mentionedFiles,
});
this.dispatch({
type: "turn/optimistic-started",
item: optimisticUserItem,
pendingTurnStart: { anchorItemId: optimisticUserId, promptSubmitHookItemIds: [] },
item: optimistic.item,
pendingTurnStart: optimistic.pendingTurnStart,
});
this.queueMessagesBottomScroll();
this.composerController.setDraft("");
@ -775,10 +779,14 @@ export class CodexChatView extends ItemView {
const response = await client.startTurn(activeThreadId, this.plugin.vaultPath, codexInput);
const pendingTurnStart = this.pendingTurnStart;
const currentTurnId = this.activeTurnId;
const responseMatchesCurrentStart =
pendingTurnStart?.anchorItemId === optimisticUserId || (!pendingTurnStart && currentTurnId === response.turn.id);
if (responseMatchesCurrentStart) {
if (
shouldAcknowledgeTurnStart({
pendingTurnStart,
activeTurnId: this.activeTurnId,
optimisticUserId,
responseTurnId: response.turn.id,
})
) {
const displayItems = acknowledgeOptimisticTurnStart({
items: this.state.displayItems,
optimisticUserId,
@ -814,7 +822,6 @@ export class CodexChatView extends ItemView {
const threadId = this.state.activeThreadId;
const expectedTurnId = this.activeTurnId;
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
const mentionedFiles = fileMentionsFromInput(codexInput);
this.composerController.setDraft("", { clearSuggestions: true });
@ -822,12 +829,12 @@ export class CodexChatView extends ItemView {
await this.client.steerTurn(threadId, expectedTurnId, codexInput);
this.dispatch({
type: "system/message-added",
item: localUserMessageItem({
item: localUserMessageItemFromInput({
id: `local-steer-${String(Date.now())}`,
text,
turnId: expectedTurnId,
referencedThread,
mentionedFiles,
codexInput,
}),
});
this.queueMessagesBottomScroll();

View file

@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
import { acknowledgeOptimisticTurnStart, cleanupFailedTurnStart, localUserMessageItem } from "../../../src/features/chat/turn-submission";
import {
acknowledgeOptimisticTurnStart,
cleanupFailedTurnStart,
localUserMessageItem,
localUserMessageItemFromInput,
optimisticTurnStart,
shouldAcknowledgeTurnStart,
} from "../../../src/features/chat/turn-submission";
import type { DisplayItem } from "../../../src/features/chat/display/types";
describe("chat turn submission helpers", () => {
@ -14,6 +21,57 @@ describe("chat turn submission helpers", () => {
expect(item.mentionedFiles).toEqual([{ name: "Note", path: "Note.md" }]);
});
it("builds optimistic turn starts from immutable input snapshots", () => {
const input = [
{ type: "text" as const, text: "hello [[Note]]", text_elements: [] },
{ type: "mention" as const, name: "Note", path: "Note.md" },
];
const start = optimisticTurnStart({ id: "local-user", text: "hello [[Note]]", codexInput: input });
expect(start.pendingTurnStart).toEqual({ anchorItemId: "local-user", promptSubmitHookItemIds: [] });
expect(start.item).toMatchObject({
id: "local-user",
kind: "message",
role: "user",
text: "hello [[Note]]",
mentionedFiles: [{ name: "Note", path: "Note.md" }],
});
expect(localUserMessageItemFromInput({ id: "steer", text: "hello [[Note]]", turnId: "turn", codexInput: input })).toMatchObject({
id: "steer",
turnId: "turn",
mentionedFiles: [{ name: "Note", path: "Note.md" }],
});
});
it("keeps turn start acknowledgement matching explicit", () => {
expect(
shouldAcknowledgeTurnStart({
pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: [] },
activeTurnId: null,
optimisticUserId: "local-user",
responseTurnId: "turn",
}),
).toBe(true);
expect(
shouldAcknowledgeTurnStart({
pendingTurnStart: null,
activeTurnId: "turn",
optimisticUserId: "local-user",
responseTurnId: "turn",
}),
).toBe(true);
expect(
shouldAcknowledgeTurnStart({
pendingTurnStart: { anchorItemId: "other", promptSubmitHookItemIds: [] },
activeTurnId: "stale-turn",
optimisticUserId: "local-user",
responseTurnId: "turn",
}),
).toBe(false);
});
it("attaches acknowledged turn ids and pending hook runs immutably", () => {
const items: DisplayItem[] = [
localUserMessageItem({ id: "local-user", text: "hello" }),