mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Model chat turn lifecycle
This commit is contained in:
parent
19fc1dbfd1
commit
aa04e55c52
11 changed files with 392 additions and 126 deletions
|
|
@ -14,7 +14,7 @@ import {
|
|||
import { userInputWithWikiLinkMentionsAndSkills } from "./composer/wikilink-context";
|
||||
import type { UserInput } from "../../generated/app-server/v2/UserInput";
|
||||
import { renderComposerShell, renderComposerSuggestions, syncComposerHeight } from "./ui/composer";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
|
||||
import { unmountReactRoot } from "../../shared/ui/react-root";
|
||||
|
||||
export interface ChatComposerControllerOptions {
|
||||
|
|
@ -72,7 +72,7 @@ export class ChatComposerController {
|
|||
parent,
|
||||
this.options.viewId,
|
||||
state.composerDraft,
|
||||
state.busy,
|
||||
chatTurnBusy(state),
|
||||
this.options.canInterrupt(),
|
||||
this.options.composerPlaceholder(),
|
||||
{
|
||||
|
|
|
|||
|
|
@ -25,7 +25,13 @@ import type { ServerRequest } from "../../generated/app-server/ServerRequest";
|
|||
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 { ChatAction, ChatState, ChatStateStore } from "./chat-state";
|
||||
import {
|
||||
activeTurnId,
|
||||
pendingTurnStart as pendingTurnStartForState,
|
||||
type ChatAction,
|
||||
type ChatState,
|
||||
type ChatStateStore,
|
||||
} from "./chat-state";
|
||||
import { userInputResponse, type PendingUserInput } from "./user-input/model";
|
||||
import { jsonPreview } from "../../utils";
|
||||
import { classifyAppServerLog } from "./app-server-logs";
|
||||
|
|
@ -222,7 +228,7 @@ export class ChatController {
|
|||
});
|
||||
} else if (method === "guardianWarning") {
|
||||
const item = createReviewResultItem(this.localItemId("review"), params.message);
|
||||
if (!isUnstructuredAutoReviewWarning(item) || !hasStructuredAutoReviewResult(this.state.displayItems, this.state.activeTurnId)) {
|
||||
if (!isUnstructuredAutoReviewWarning(item) || !hasStructuredAutoReviewResult(this.state.displayItems, activeTurnId(this.state))) {
|
||||
this.dispatch({ type: "display/item-upserted", item });
|
||||
}
|
||||
}
|
||||
|
|
@ -236,9 +242,9 @@ export class ChatController {
|
|||
threadId: params.threadId,
|
||||
turnId: params.turn.id,
|
||||
displayItems: this.displayItemsWithPendingPromptSubmitHooks(params.turn.id),
|
||||
pendingTurnStart: null,
|
||||
});
|
||||
} else if (method === "turn/completed") {
|
||||
if (activeTurnId(this.state) !== params.turn.id) return;
|
||||
this.dispatch({
|
||||
type: "turn/completed",
|
||||
turnId: params.turn.id,
|
||||
|
|
@ -322,7 +328,7 @@ export class ChatController {
|
|||
private activeRouteScope(): { activeThreadId: string | null; activeTurnId: string | null } {
|
||||
return {
|
||||
activeThreadId: this.state.activeThreadId,
|
||||
activeTurnId: this.state.activeTurnId,
|
||||
activeTurnId: activeTurnId(this.state),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -406,12 +412,13 @@ export class ChatController {
|
|||
const resolvedTurnId = this.hookRunTurnId(run, turnId);
|
||||
const item = hookRunDisplayItem(run, resolvedTurnId, status);
|
||||
if (!item) return;
|
||||
let pendingTurnStart = this.state.pendingTurnStart;
|
||||
if (!resolvedTurnId && this.state.pendingTurnStart && run.eventName === "userPromptSubmit") {
|
||||
const hookIds = this.state.pendingTurnStart.promptSubmitHookItemIds;
|
||||
const currentPendingTurnStart = pendingTurnStartForState(this.state);
|
||||
let pendingTurnStart = currentPendingTurnStart;
|
||||
if (!resolvedTurnId && currentPendingTurnStart && run.eventName === "userPromptSubmit") {
|
||||
const hookIds = currentPendingTurnStart.promptSubmitHookItemIds;
|
||||
pendingTurnStart = hookIds.includes(item.id)
|
||||
? this.state.pendingTurnStart
|
||||
: { ...this.state.pendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] };
|
||||
? currentPendingTurnStart
|
||||
: { ...currentPendingTurnStart, promptSubmitHookItemIds: [...hookIds, item.id] };
|
||||
}
|
||||
this.dispatch({
|
||||
type: "display/pending-turn-item-upserted",
|
||||
|
|
@ -425,12 +432,12 @@ export class ChatController {
|
|||
turnId: string | null,
|
||||
): string | null {
|
||||
if (turnId) return turnId;
|
||||
if (run.eventName === "userPromptSubmit" && !this.state.pendingTurnStart) return this.state.activeTurnId;
|
||||
if (run.eventName === "userPromptSubmit" && !pendingTurnStartForState(this.state)) return activeTurnId(this.state);
|
||||
return null;
|
||||
}
|
||||
|
||||
private displayItemsWithPendingPromptSubmitHooks(turnId: string): readonly DisplayItem[] {
|
||||
const pending = this.state.pendingTurnStart;
|
||||
const pending = pendingTurnStartForState(this.state);
|
||||
if (!pending) return this.state.displayItems;
|
||||
return attachHookRunsToTurn(this.state.displayItems, turnId, pending.promptSubmitHookItemIds, pending.anchorItemId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { MessageScrollController, type MessageScrollIntent } from "./ui/scroll";
|
|||
import type { ChatTurnDiffViewState } from "./ui/turn-diff";
|
||||
import { MarkdownMessageRenderer } from "./markdown-message-renderer";
|
||||
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
|
||||
import { activeTurnId, chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
|
||||
import { unmountReactRoot } from "../../shared/ui/react-root";
|
||||
|
||||
export interface ChatMessageRendererOptions {
|
||||
|
|
@ -62,15 +62,17 @@ export class ChatMessageRenderer {
|
|||
const state = this.state;
|
||||
this.messagesEl = messagesEl;
|
||||
const scrollPlan = this.scrollController.prepareRender(messagesEl, this.options.consumeScrollIntent());
|
||||
const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems);
|
||||
const busy = chatTurnBusy(state);
|
||||
const activeTurn = activeTurnId(state);
|
||||
const rollbackCandidate = busy ? null : rollbackCandidateFromItems(state.displayItems);
|
||||
const implementPlanCandidate = implementPlanCandidateFromState(state);
|
||||
|
||||
const blocks = messageStreamBlocks({
|
||||
activeThreadId: state.activeThreadId,
|
||||
activeTurnId: state.activeTurnId,
|
||||
activeTurnId: activeTurn,
|
||||
historyCursor: state.historyCursor,
|
||||
loadingHistory: state.loadingHistory,
|
||||
busy: state.busy,
|
||||
busy,
|
||||
displayItems: state.displayItems,
|
||||
turnDiffs: state.turnDiffs,
|
||||
workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath,
|
||||
|
|
@ -128,9 +130,14 @@ export class ChatMessageRenderer {
|
|||
}
|
||||
|
||||
export function implementPlanCandidateFromState(
|
||||
state: Pick<ChatState, "activeThreadId" | "busy" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
|
||||
state: Pick<ChatState, "activeThreadId" | "turnLifecycle" | "composerDraft" | "requestedCollaborationMode" | "displayItems">,
|
||||
): DisplayItem | null {
|
||||
if (!state.activeThreadId || state.busy || state.composerDraft.trim().length > 0 || state.requestedCollaborationMode !== "plan") {
|
||||
if (
|
||||
!state.activeThreadId ||
|
||||
chatTurnBusy(state) ||
|
||||
state.composerDraft.trim().length > 0 ||
|
||||
state.requestedCollaborationMode !== "plan"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -24,13 +24,18 @@ export interface PendingTurnStart {
|
|||
promptSubmitHookItemIds: string[];
|
||||
}
|
||||
|
||||
export type ChatTurnLifecycleState =
|
||||
| { kind: "idle" }
|
||||
| { kind: "starting"; pendingTurnStart: PendingTurnStart }
|
||||
| { kind: "running"; turnId: string };
|
||||
|
||||
export interface ChatState {
|
||||
status: string;
|
||||
effectiveConfig: ConfigReadResponse | null;
|
||||
initializeResponse: InitializeResponse | null;
|
||||
activeThreadId: string | null;
|
||||
activeThreadCwd: string | null;
|
||||
activeTurnId: string | null;
|
||||
turnLifecycle: ChatTurnLifecycleState;
|
||||
activeModel: string | null;
|
||||
activeReasoningEffort: ReasoningEffort | null;
|
||||
activeCollaborationMode: ModeKind;
|
||||
|
|
@ -45,9 +50,7 @@ export interface ChatState {
|
|||
requestedServiceTier: ServiceTier | null;
|
||||
tokenUsage: ThreadTokenUsage | null;
|
||||
rateLimit: RateLimitSnapshot | null;
|
||||
busy: boolean;
|
||||
displayItems: readonly DisplayItem[];
|
||||
pendingTurnStart: PendingTurnStart | null;
|
||||
turnDiffs: ReadonlyMap<string, string>;
|
||||
approvals: readonly PendingApproval[];
|
||||
pendingUserInputs: readonly PendingUserInput[];
|
||||
|
|
@ -108,7 +111,6 @@ export type ChatAction =
|
|||
threadId: string;
|
||||
turnId: string;
|
||||
displayItems?: readonly DisplayItem[];
|
||||
pendingTurnStart?: PendingTurnStart | null;
|
||||
}
|
||||
| { type: "turn/completed"; turnId: string; status: string; displayItems: readonly DisplayItem[] }
|
||||
| { type: "request/approval-queued"; approval: PendingApproval }
|
||||
|
|
@ -166,7 +168,7 @@ export type ChatAction =
|
|||
| { type: "turn/local-cleared" }
|
||||
| { type: "turn/optimistic-started"; item: DisplayItem; pendingTurnStart: PendingTurnStart }
|
||||
| { type: "turn/start-acknowledged"; turnId: string; displayItems: readonly DisplayItem[] }
|
||||
| { type: "turn/start-failed"; displayItems: readonly DisplayItem[]; pendingTurnStart: PendingTurnStart | null }
|
||||
| { type: "turn/start-failed"; displayItems: readonly DisplayItem[] }
|
||||
| { type: "display/pending-turn-item-upserted"; item: DisplayItem; pendingTurnStart: PendingTurnStart | null };
|
||||
|
||||
export function createChatState(): ChatState {
|
||||
|
|
@ -176,7 +178,7 @@ export function createChatState(): ChatState {
|
|||
initializeResponse: null,
|
||||
activeThreadId: null,
|
||||
activeThreadCwd: null,
|
||||
activeTurnId: null,
|
||||
turnLifecycle: { kind: "idle" },
|
||||
activeModel: null,
|
||||
activeReasoningEffort: null,
|
||||
activeCollaborationMode: "default",
|
||||
|
|
@ -191,9 +193,7 @@ export function createChatState(): ChatState {
|
|||
requestedServiceTier: null,
|
||||
tokenUsage: null,
|
||||
rateLimit: null,
|
||||
busy: false,
|
||||
displayItems: [],
|
||||
pendingTurnStart: null,
|
||||
turnDiffs: new Map(),
|
||||
approvals: [],
|
||||
pendingUserInputs: [],
|
||||
|
|
@ -256,7 +256,7 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|||
return patchChatState(state, {
|
||||
activeThreadId: action.thread.id,
|
||||
activeThreadCwd: action.cwd,
|
||||
activeTurnId: null,
|
||||
turnLifecycle: { kind: "idle" },
|
||||
activeModel: action.model,
|
||||
activeReasoningEffort: action.reasoningEffort,
|
||||
activeServiceTier: action.serviceTier,
|
||||
|
|
@ -283,17 +283,15 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|||
case "turn/started":
|
||||
return patchChatState(state, {
|
||||
activeThreadId: action.threadId,
|
||||
activeTurnId: action.turnId,
|
||||
busy: true,
|
||||
turnLifecycle: { kind: "running", turnId: action.turnId },
|
||||
status: "Turn running...",
|
||||
displayItems: action.displayItems ?? state.displayItems,
|
||||
pendingTurnStart: action.pendingTurnStart === undefined ? state.pendingTurnStart : action.pendingTurnStart,
|
||||
});
|
||||
case "turn/completed":
|
||||
if (activeTurnId(state) !== action.turnId) return state;
|
||||
return patchChatState(state, {
|
||||
turnLifecycle: { kind: "idle" },
|
||||
displayItems: action.displayItems,
|
||||
busy: false,
|
||||
activeTurnId: null,
|
||||
status: `Turn ${action.status}.`,
|
||||
});
|
||||
case "request/approval-queued":
|
||||
|
|
@ -397,27 +395,32 @@ export function chatReducer(state: ChatState, action: ChatAction): ChatState {
|
|||
return clearActiveTurnState(state);
|
||||
case "turn/optimistic-started":
|
||||
return patchChatState(state, {
|
||||
turnLifecycle: { kind: "starting", pendingTurnStart: action.pendingTurnStart },
|
||||
displayItems: [...state.displayItems, action.item],
|
||||
pendingTurnStart: action.pendingTurnStart,
|
||||
busy: true,
|
||||
});
|
||||
case "turn/start-acknowledged":
|
||||
return patchChatState(state, { activeTurnId: action.turnId, displayItems: action.displayItems, pendingTurnStart: null });
|
||||
if (state.turnLifecycle.kind === "idle") return state;
|
||||
if (state.turnLifecycle.kind === "running" && state.turnLifecycle.turnId !== action.turnId) return state;
|
||||
return patchChatState(state, {
|
||||
turnLifecycle: { kind: "running", turnId: action.turnId },
|
||||
displayItems: action.displayItems,
|
||||
});
|
||||
case "turn/start-failed":
|
||||
return patchChatState(state, { busy: false, displayItems: action.displayItems, pendingTurnStart: action.pendingTurnStart });
|
||||
return patchChatState(state, {
|
||||
turnLifecycle: { kind: "idle" },
|
||||
displayItems: action.displayItems,
|
||||
});
|
||||
case "display/pending-turn-item-upserted":
|
||||
return patchChatState(state, {
|
||||
displayItems: upsertDisplayItem(state.displayItems, action.item),
|
||||
pendingTurnStart: action.pendingTurnStart,
|
||||
turnLifecycle: withPendingTurnStart(state.turnLifecycle, action.pendingTurnStart),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveTurnState(state: ChatState): ChatState {
|
||||
return patchChatState(state, {
|
||||
activeTurnId: null,
|
||||
busy: false,
|
||||
pendingTurnStart: null,
|
||||
turnLifecycle: { kind: "idle" },
|
||||
approvals: [],
|
||||
pendingUserInputs: [],
|
||||
userInputDrafts: new Map(),
|
||||
|
|
@ -500,6 +503,23 @@ function resolveRequest(state: ChatState, requestId: PendingApproval["requestId"
|
|||
});
|
||||
}
|
||||
|
||||
export function chatTurnBusy(state: Pick<ChatState, "turnLifecycle">): boolean {
|
||||
return state.turnLifecycle.kind !== "idle";
|
||||
}
|
||||
|
||||
export function activeTurnId(state: Pick<ChatState, "turnLifecycle">): string | null {
|
||||
return state.turnLifecycle.kind === "running" ? state.turnLifecycle.turnId : null;
|
||||
}
|
||||
|
||||
export function pendingTurnStart(state: Pick<ChatState, "turnLifecycle">): PendingTurnStart | null {
|
||||
return state.turnLifecycle.kind === "starting" ? state.turnLifecycle.pendingTurnStart : null;
|
||||
}
|
||||
|
||||
function withPendingTurnStart(lifecycle: ChatTurnLifecycleState, nextPendingTurnStart: PendingTurnStart | null): ChatTurnLifecycleState {
|
||||
if (nextPendingTurnStart) return { kind: "starting", pendingTurnStart: nextPendingTurnStart };
|
||||
return lifecycle.kind === "starting" ? { kind: "idle" } : lifecycle;
|
||||
}
|
||||
|
||||
function updatedTurnDiffs(turnDiffs: ReadonlyMap<string, string>, turnId: string, diff: string): ReadonlyMap<string, string> {
|
||||
const next = new Map(turnDiffs);
|
||||
if (diff.trim().length > 0) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { exportArchivedThreadMarkdown } from "../../domain/threads/export";
|
|||
import { inheritedForkThreadName, upsertThread } from "../../domain/threads/model";
|
||||
import type { CodexPanelSettings } from "../../settings/model";
|
||||
import type { ArchiveExportAdapter } from "../../domain/threads/export";
|
||||
import type { ChatAction, ChatState, ChatStateStore } from "./chat-state";
|
||||
import { chatTurnBusy, type ChatAction, type ChatState, type ChatStateStore } from "./chat-state";
|
||||
import type { ThreadHistoryLoader } from "./thread-history";
|
||||
import { rollbackCandidateFromItems } from "./rollback";
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ export class ChatThreadActionController {
|
|||
}
|
||||
|
||||
async archiveThread(threadId: string, saveMarkdown = this.host.settings().archiveExportEnabled): Promise<void> {
|
||||
if (this.state.busy) {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before archiving threads.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -65,7 +65,7 @@ export class ChatThreadActionController {
|
|||
}
|
||||
|
||||
async forkThread(threadId: string): Promise<void> {
|
||||
if (this.state.busy) {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Finish or interrupt the current turn before forking threads.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -98,7 +98,7 @@ export class ChatThreadActionController {
|
|||
}
|
||||
|
||||
async rollbackThread(threadId: string): Promise<void> {
|
||||
if (this.state.busy) {
|
||||
if (chatTurnBusy(this.state)) {
|
||||
this.host.addSystemMessage("Interrupt the current turn before rolling back.");
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,14 @@ import type { CodexPanelSettings } from "../../settings/model";
|
|||
import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model";
|
||||
import { ChatComposerController } from "./chat-composer-controller";
|
||||
import { attachHookRunsToTurn } from "./hook-display";
|
||||
import { createChatStateStore, type ChatAction, type ChatState } from "./chat-state";
|
||||
import {
|
||||
activeTurnId,
|
||||
chatTurnBusy,
|
||||
createChatStateStore,
|
||||
pendingTurnStart as pendingTurnStartForState,
|
||||
type ChatAction,
|
||||
type ChatState,
|
||||
} from "./chat-state";
|
||||
import { codexPanelDisplayTitle, explicitThreadName, getThreadTitle, upsertThread } from "../../domain/threads/model";
|
||||
import {
|
||||
referencedThreadDisplay,
|
||||
|
|
@ -152,7 +159,7 @@ export class CodexChatView extends ItemView {
|
|||
stateStore: this.chatState,
|
||||
viewId: this.viewId,
|
||||
sendShortcut: () => this.plugin.settings.sendShortcut,
|
||||
canInterrupt: () => this.state.busy && Boolean(this.state.activeThreadId && this.state.activeTurnId),
|
||||
canInterrupt: () => this.turnBusy && Boolean(this.state.activeThreadId && this.activeTurnId),
|
||||
composerPlaceholder: () => this.composerPlaceholder(),
|
||||
currentModelForSuggestions: () => currentModel(this.runtimeSnapshot()),
|
||||
renderIfDetached: () => {
|
||||
|
|
@ -299,6 +306,18 @@ export class CodexChatView extends ItemView {
|
|||
return this.chatState.getState();
|
||||
}
|
||||
|
||||
private get turnBusy(): boolean {
|
||||
return chatTurnBusy(this.state);
|
||||
}
|
||||
|
||||
private get activeTurnId(): string | null {
|
||||
return activeTurnId(this.state);
|
||||
}
|
||||
|
||||
private get pendingTurnStart(): ReturnType<typeof pendingTurnStartForState> {
|
||||
return pendingTurnStartForState(this.state);
|
||||
}
|
||||
|
||||
private dispatch(action: ChatAction): void {
|
||||
this.chatState.dispatch(action);
|
||||
}
|
||||
|
|
@ -369,8 +388,8 @@ export class CodexChatView extends ItemView {
|
|||
return {
|
||||
viewId: this.viewId,
|
||||
threadId: this.closing ? null : this.state.activeThreadId,
|
||||
busy: this.state.busy,
|
||||
activeTurnId: this.state.activeTurnId,
|
||||
busy: this.turnBusy,
|
||||
activeTurnId: this.activeTurnId,
|
||||
pendingApprovals: this.state.approvals.length,
|
||||
pendingUserInputs: this.state.pendingUserInputs.length,
|
||||
hasComposerDraft: this.state.composerDraft.trim().length > 0,
|
||||
|
|
@ -529,7 +548,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
async startNewThread(): Promise<void> {
|
||||
if (this.state.busy) return;
|
||||
if (this.turnBusy) return;
|
||||
|
||||
this.invalidateResumeWork();
|
||||
this.restoredThread = null;
|
||||
|
|
@ -578,7 +597,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
private async resumeThread(threadId: string): Promise<void> {
|
||||
if (this.state.busy && threadId !== this.state.activeThreadId) {
|
||||
if (this.turnBusy && threadId !== this.state.activeThreadId) {
|
||||
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
return;
|
||||
}
|
||||
|
|
@ -686,7 +705,7 @@ export class CodexChatView extends ItemView {
|
|||
const client = this.client;
|
||||
if (!client) return;
|
||||
|
||||
if (this.state.busy) {
|
||||
if (this.turnBusy) {
|
||||
await this.steerCurrentTurn(text, codexInputOverride, referencedThread);
|
||||
return;
|
||||
}
|
||||
|
|
@ -726,31 +745,35 @@ export class CodexChatView extends ItemView {
|
|||
this.render();
|
||||
|
||||
const response = await client.startTurn(activeThreadId, this.plugin.vaultPath, codexInput);
|
||||
const pendingTurnStart = this.state.pendingTurnStart;
|
||||
let displayItems = this.state.displayItems.map((item) =>
|
||||
item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item,
|
||||
);
|
||||
if (pendingTurnStart) {
|
||||
displayItems = attachHookRunsToTurn(
|
||||
displayItems,
|
||||
response.turn.id,
|
||||
pendingTurnStart.promptSubmitHookItemIds,
|
||||
pendingTurnStart.anchorItemId,
|
||||
const pendingTurnStart = this.pendingTurnStart;
|
||||
const currentTurnId = this.activeTurnId;
|
||||
const responseMatchesCurrentStart =
|
||||
pendingTurnStart?.anchorItemId === optimisticUserId || (!pendingTurnStart && currentTurnId === response.turn.id);
|
||||
if (responseMatchesCurrentStart) {
|
||||
let displayItems = this.state.displayItems.map((item) =>
|
||||
item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item,
|
||||
);
|
||||
if (pendingTurnStart) {
|
||||
displayItems = attachHookRunsToTurn(
|
||||
displayItems,
|
||||
response.turn.id,
|
||||
pendingTurnStart.promptSubmitHookItemIds,
|
||||
pendingTurnStart.anchorItemId,
|
||||
);
|
||||
}
|
||||
this.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems });
|
||||
this.setStatus("Turn running...");
|
||||
}
|
||||
this.dispatch({ type: "turn/start-acknowledged", turnId: response.turn.id, displayItems });
|
||||
this.setStatus("Turn running...");
|
||||
} catch (error) {
|
||||
let displayItems = optimisticUserId
|
||||
? this.state.displayItems.filter((item) => item.id !== optimisticUserId)
|
||||
: this.state.displayItems;
|
||||
let pendingTurnStart = this.state.pendingTurnStart;
|
||||
if (this.state.pendingTurnStart) {
|
||||
const hookIds = new Set(this.state.pendingTurnStart.promptSubmitHookItemIds);
|
||||
const pendingTurnStart = this.pendingTurnStart;
|
||||
if (pendingTurnStart) {
|
||||
const hookIds = new Set(pendingTurnStart.promptSubmitHookItemIds);
|
||||
displayItems = displayItems.filter((item) => !hookIds.has(item.id));
|
||||
pendingTurnStart = null;
|
||||
}
|
||||
this.dispatch({ type: "turn/start-failed", displayItems, pendingTurnStart });
|
||||
this.dispatch({ type: "turn/start-failed", displayItems });
|
||||
this.composerController.setDraft(text);
|
||||
this.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
|
|
@ -762,13 +785,13 @@ export class CodexChatView extends ItemView {
|
|||
codexInputOverride?: UserInput[],
|
||||
referencedThread?: ReferencedThreadDisplay,
|
||||
): Promise<void> {
|
||||
if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) {
|
||||
if (!this.client || !this.state.activeThreadId || !this.activeTurnId) {
|
||||
this.addSystemMessage("Current turn is not steerable yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = this.state.activeThreadId;
|
||||
const expectedTurnId = this.state.activeTurnId;
|
||||
const expectedTurnId = this.activeTurnId;
|
||||
const codexInput = codexInputOverride ?? this.composerController.codexInput(text);
|
||||
const mentionedFiles = fileMentionsFromInput(codexInput);
|
||||
|
||||
|
|
@ -811,9 +834,9 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
private async interruptTurn(): Promise<void> {
|
||||
if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) return;
|
||||
if (!this.client || !this.state.activeThreadId || !this.activeTurnId) return;
|
||||
try {
|
||||
await this.client.interruptTurn(this.state.activeThreadId, this.state.activeTurnId);
|
||||
await this.client.interruptTurn(this.state.activeThreadId, this.activeTurnId);
|
||||
this.setStatus("Interrupt requested.");
|
||||
} catch (error) {
|
||||
this.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
@ -822,7 +845,7 @@ export class CodexChatView extends ItemView {
|
|||
|
||||
private async submitComposerAction(): Promise<void> {
|
||||
const draft = this.composerController.trimmedDraft;
|
||||
if (this.state.busy && this.state.activeThreadId && this.state.activeTurnId && draft.length === 0) {
|
||||
if (this.turnBusy && this.state.activeThreadId && this.activeTurnId && draft.length === 0) {
|
||||
await this.interruptTurn();
|
||||
return;
|
||||
}
|
||||
|
|
@ -843,7 +866,7 @@ export class CodexChatView extends ItemView {
|
|||
await this.client?.compactThread(threadId);
|
||||
},
|
||||
archiveThread: (threadId) => this.threadActions.archiveThread(threadId),
|
||||
busy: this.state.busy,
|
||||
busy: this.turnBusy,
|
||||
toggleFastMode: () => this.toggleFastMode(),
|
||||
toggleCollaborationMode: () => this.toggleCollaborationMode(),
|
||||
toggleAutoReview: () => void this.toggleAutoReview(),
|
||||
|
|
@ -968,7 +991,7 @@ export class CodexChatView extends ItemView {
|
|||
|
||||
private canImplementPlanItem(item: DisplayItem): boolean {
|
||||
if (item.kind !== "message" || item.role !== "assistant" || item.proposedPlan !== true) return false;
|
||||
if (!this.state.activeThreadId || this.state.busy || this.state.composerDraft.trim().length > 0) return false;
|
||||
if (!this.state.activeThreadId || this.turnBusy || this.state.composerDraft.trim().length > 0) return false;
|
||||
if (this.state.requestedCollaborationMode !== "plan") return false;
|
||||
return latestProposedPlanItem(this.state.displayItems)?.id === item.id;
|
||||
}
|
||||
|
|
@ -1180,9 +1203,9 @@ export class CodexChatView extends ItemView {
|
|||
private readonly toolbarSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.status,
|
||||
state.busy,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
state.activeTurnId,
|
||||
activeTurnId(state),
|
||||
state.activeModel,
|
||||
state.activeReasoningEffort,
|
||||
state.activeCollaborationMode,
|
||||
|
|
@ -1208,11 +1231,11 @@ export class CodexChatView extends ItemView {
|
|||
private readonly messagesSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.activeThreadId,
|
||||
state.activeTurnId,
|
||||
activeTurnId(state),
|
||||
state.activeThreadCwd,
|
||||
state.historyCursor,
|
||||
state.loadingHistory,
|
||||
state.busy,
|
||||
chatTurnBusy(state),
|
||||
state.messagesPinnedToBottom,
|
||||
state.composerDraft,
|
||||
state.requestedCollaborationMode,
|
||||
|
|
@ -1225,9 +1248,9 @@ export class CodexChatView extends ItemView {
|
|||
private readonly composerSnapshot = (state: ChatState): ChatPanelSlotSnapshot =>
|
||||
signatureParts(
|
||||
state.composerDraft,
|
||||
state.busy,
|
||||
chatTurnBusy(state),
|
||||
state.activeThreadId,
|
||||
state.activeTurnId,
|
||||
activeTurnId(state),
|
||||
currentModel(this.runtimeSnapshotForState(state), readRuntimeConfig(state.effectiveConfig)),
|
||||
state.availableSkills.length,
|
||||
skillsSignature(state.availableSkills),
|
||||
|
|
@ -1281,7 +1304,7 @@ export class CodexChatView extends ItemView {
|
|||
void this.refreshThreads();
|
||||
},
|
||||
resumeThread: (threadId) => {
|
||||
if (this.state.busy && threadId !== this.state.activeThreadId) return;
|
||||
if (this.turnBusy && threadId !== this.state.activeThreadId) return;
|
||||
this.dispatch({ type: "ui/panel-set", panel: null });
|
||||
void this.selectThread(threadId);
|
||||
},
|
||||
|
|
@ -1311,7 +1334,7 @@ export class CodexChatView extends ItemView {
|
|||
const historyOpen = this.state.openDetails.has("history");
|
||||
const statusPanelOpen = this.state.openDetails.has("status-panel");
|
||||
const runtimeOpen = this.state.runtimePicker !== null;
|
||||
const statusState = this.state.busy ? "running" : this.connection.isConnected() ? "connected" : "offline";
|
||||
const statusState = this.turnBusy ? "running" : this.connection.isConnected() ? "connected" : "offline";
|
||||
const model = currentModel(snapshot, config);
|
||||
const effort = currentReasoningEffort(snapshot, config);
|
||||
const threads = this.state.listedThreads;
|
||||
|
|
@ -1338,7 +1361,7 @@ export class CodexChatView extends ItemView {
|
|||
title: getThreadTitle(thread),
|
||||
threadId,
|
||||
selected: threadId === this.state.activeThreadId,
|
||||
disabled: this.state.busy && threadId !== this.state.activeThreadId,
|
||||
disabled: this.turnBusy && threadId !== this.state.activeThreadId,
|
||||
canArchive: true,
|
||||
archiveConfirm: {
|
||||
active: this.archiveConfirmThreadId === threadId,
|
||||
|
|
@ -1416,7 +1439,7 @@ export class CodexChatView extends ItemView {
|
|||
}
|
||||
|
||||
private async selectThread(threadId: string): Promise<void> {
|
||||
if (this.state.busy && threadId !== this.state.activeThreadId) {
|
||||
if (this.turnBusy && threadId !== this.state.activeThreadId) {
|
||||
this.addSystemMessage("Finish or interrupt the current turn before switching threads.");
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,16 @@ import { describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { ChatController } from "../../../src/features/chat/chat-controller";
|
||||
import { attachHookRunsToTurn } from "../../../src/features/chat/hook-display";
|
||||
import { chatReducer, createChatState, type ChatAction, type ChatState, type ChatStateStore } from "../../../src/features/chat/chat-state";
|
||||
import {
|
||||
activeTurnId,
|
||||
chatReducer,
|
||||
chatTurnBusy,
|
||||
createChatState,
|
||||
pendingTurnStart,
|
||||
type ChatAction,
|
||||
type ChatState,
|
||||
type ChatStateStore,
|
||||
} from "../../../src/features/chat/chat-state";
|
||||
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../../../src/generated/app-server/ServerRequest";
|
||||
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
||||
|
|
@ -49,7 +58,7 @@ describe("ChatController", () => {
|
|||
it("applies matching streaming deltas as assistant markdown", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -63,8 +72,7 @@ describe("ChatController", () => {
|
|||
it("marks active reasoning completed when assistant text starts", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.busy = true;
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.displayItems = [{ id: "r1", kind: "reasoning", role: "tool", text: "thinking", turnId: "turn-active" }];
|
||||
const controller = controllerForState(state);
|
||||
|
||||
|
|
@ -84,7 +92,7 @@ describe("ChatController", () => {
|
|||
it("streams plan deltas as plain assistant text until completion", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -98,7 +106,7 @@ describe("ChatController", () => {
|
|||
it("updates structured turn plan progress", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -139,7 +147,7 @@ describe("ChatController", () => {
|
|||
it("stores the latest aggregated turn diff for the active turn", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -157,7 +165,7 @@ describe("ChatController", () => {
|
|||
it("ignores aggregated turn diffs outside the active scope", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -175,7 +183,7 @@ describe("ChatController", () => {
|
|||
it("formats hook runs as compact summaries with details", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -228,7 +236,7 @@ describe("ChatController", () => {
|
|||
it("omits hook duration details while duration is unavailable", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -271,8 +279,7 @@ describe("ChatController", () => {
|
|||
it("attaches unscoped hook runs to the active turn while streaming", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.busy = true;
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -305,8 +312,7 @@ describe("ChatController", () => {
|
|||
it("leaves non-prompt unscoped hook runs outside the active turn", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.busy = true;
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -340,7 +346,7 @@ describe("ChatController", () => {
|
|||
it("keeps repeated hook runs with the same run id as separate display items", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
const baseRun: Extract<ServerNotification, { method: "hook/completed" }>["params"]["run"] = {
|
||||
id: "hook-1",
|
||||
|
|
@ -374,7 +380,10 @@ describe("ChatController", () => {
|
|||
it("attaches pre-turn prompt submit hook runs when the turn starts", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] };
|
||||
state.turnLifecycle = {
|
||||
kind: "starting",
|
||||
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
|
||||
};
|
||||
state.displayItems = [
|
||||
{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true },
|
||||
{
|
||||
|
|
@ -407,7 +416,7 @@ describe("ChatController", () => {
|
|||
|
||||
expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(state.displayItems[1]).toMatchObject({ id: "hook-hook-1-1", turnId: "turn-active" });
|
||||
expect(state.pendingTurnStart).toBeNull();
|
||||
expect(pendingTurnStart(state)).toBeNull();
|
||||
});
|
||||
|
||||
it("moves pre-turn hook runs after the optimistic user message when a turn id is assigned", () => {
|
||||
|
|
@ -444,7 +453,7 @@ describe("ChatController", () => {
|
|||
it("captures only prompt-submit hooks observed during the pending turn start", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] };
|
||||
state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -473,13 +482,13 @@ describe("ChatController", () => {
|
|||
|
||||
expect(expectPresent(state.displayItems[0])).toMatchObject({ id: "hook-hook-1-1", kind: "hook" });
|
||||
expect(expectPresent(state.displayItems[0]).turnId).toBeUndefined();
|
||||
expect(expectPresent(state.pendingTurnStart).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
});
|
||||
|
||||
it("keeps pre-turn prompt submit hooks through turn start and completed-turn reconciliation", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.pendingTurnStart = { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] };
|
||||
state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: [] } };
|
||||
state.displayItems = [{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true }];
|
||||
const controller = controllerForState(state);
|
||||
|
||||
|
|
@ -490,7 +499,7 @@ describe("ChatController", () => {
|
|||
|
||||
expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(expectPresent(state.displayItems[1]).turnId).toBeUndefined();
|
||||
expect(expectPresent(state.pendingTurnStart).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
expect(expectPresent(pendingTurnStart(state)).promptSubmitHookItemIds).toEqual(["hook-hook-1-1"]);
|
||||
|
||||
controller.handleNotification({
|
||||
method: "turn/started",
|
||||
|
|
@ -512,7 +521,7 @@ describe("ChatController", () => {
|
|||
expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(state.displayItems.find((item) => item.id === "local-user-1")).not.toHaveProperty("turnId");
|
||||
expect(state.displayItems.find((item) => item.id === "hook-hook-1-1")).toMatchObject({ turnId: "turn-active" });
|
||||
expect(state.pendingTurnStart).toBeNull();
|
||||
expect(pendingTurnStart(state)).toBeNull();
|
||||
|
||||
controller.handleNotification({
|
||||
method: "turn/completed",
|
||||
|
|
@ -545,6 +554,51 @@ describe("ChatController", () => {
|
|||
expect(state.displayItems.some((item) => item.id === "local-user-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores completed turn notifications while a new turn is still starting", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.turnLifecycle = {
|
||||
kind: "starting",
|
||||
pendingTurnStart: { anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] },
|
||||
};
|
||||
state.displayItems = [
|
||||
{ id: "local-user-1", kind: "message", role: "user", text: "hello", markdown: true },
|
||||
{
|
||||
id: "hook-hook-1-1",
|
||||
kind: "hook",
|
||||
role: "tool",
|
||||
text: "userPromptSubmit: Saving jj baseline",
|
||||
toolLabel: "hook",
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
const maybeNameThread = vi.fn();
|
||||
const refreshThreads = vi.fn();
|
||||
const controller = controllerForState(state, { maybeNameThread, refreshThreads });
|
||||
|
||||
controller.handleNotification({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-active",
|
||||
turn: {
|
||||
id: "stale-turn",
|
||||
status: "completed",
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
durationMs: 1,
|
||||
error: null,
|
||||
itemsView: "full",
|
||||
items: [{ type: "agentMessage", id: "a1", text: "stale", phase: "final_answer", memoryCitation: null }],
|
||||
},
|
||||
},
|
||||
} satisfies Extract<ServerNotification, { method: "turn/completed" }>);
|
||||
|
||||
expect(pendingTurnStart(state)).toEqual({ anchorItemId: "local-user-1", promptSubmitHookItemIds: ["hook-hook-1-1"] });
|
||||
expect(state.displayItems.map((item) => item.id)).toEqual(["local-user-1", "hook-hook-1-1"]);
|
||||
expect(maybeNameThread).not.toHaveBeenCalled();
|
||||
expect(refreshThreads).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores account rate limit updates outside thread scope", () => {
|
||||
const state = createChatState();
|
||||
const publishSessionMetadata = vi.fn();
|
||||
|
|
@ -740,7 +794,7 @@ describe("ChatController", () => {
|
|||
it("rejects server requests scoped to a different active thread or turn", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const rejectServerRequest = vi.fn(() => true);
|
||||
const controller = controllerForState(state, { rejectServerRequest });
|
||||
|
||||
|
|
@ -855,7 +909,7 @@ describe("ChatController", () => {
|
|||
it("clears all active-thread scoped state when the active thread is archived", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.activeModel = "gpt-5.5";
|
||||
state.activeServiceTier = "fast";
|
||||
state.activeThreadCreationCliVersion = "codex-cli 1.0.0";
|
||||
|
|
@ -869,7 +923,6 @@ describe("ChatController", () => {
|
|||
state.displayItems = [{ id: "message", kind: "message", role: "assistant", text: "stale" }];
|
||||
state.turnDiffs = new Map([["turn-active", "@@\n-stale\n+stale"]]);
|
||||
state.composerDraft = "keep local draft";
|
||||
state.busy = true;
|
||||
state.approvals = [
|
||||
{
|
||||
requestId: 10,
|
||||
|
|
@ -902,7 +955,7 @@ describe("ChatController", () => {
|
|||
} satisfies Extract<ServerNotification, { method: "thread/archived" }>);
|
||||
|
||||
expect(state.activeThreadId).toBeNull();
|
||||
expect(state.activeTurnId).toBeNull();
|
||||
expect(activeTurnId(state)).toBeNull();
|
||||
expect(state.activeModel).toBeNull();
|
||||
expect(state.activeServiceTier).toBeNull();
|
||||
expect(state.activeThreadCreationCliVersion).toBeNull();
|
||||
|
|
@ -912,7 +965,7 @@ describe("ChatController", () => {
|
|||
expect(state.displayItems).toEqual([]);
|
||||
expect(state.turnDiffs.size).toBe(0);
|
||||
expect(state.composerDraft).toBe("keep local draft");
|
||||
expect(state.busy).toBe(false);
|
||||
expect(chatTurnBusy(state)).toBe(false);
|
||||
expect(state.approvals).toEqual([]);
|
||||
expect(state.pendingUserInputs).toEqual([]);
|
||||
expect(state.userInputDrafts.size).toBe(0);
|
||||
|
|
@ -949,7 +1002,7 @@ describe("ChatController", () => {
|
|||
it("replaces optimistic user echoes when completed turns are reconciled", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
state.displayItems = [
|
||||
{ id: "local-user-1", kind: "message", role: "user", text: "hello", turnId: "turn-active", markdown: true },
|
||||
{
|
||||
|
|
@ -994,7 +1047,7 @@ describe("ChatController", () => {
|
|||
it("asks the view to auto-name completed turns", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const maybeNameThread = vi.fn();
|
||||
const controller = controllerForState(state, { maybeNameThread });
|
||||
const turn: Turn = {
|
||||
|
|
@ -1171,7 +1224,7 @@ describe("ChatController", () => {
|
|||
it("renders auto approval review notifications as upserted review results", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -1218,7 +1271,7 @@ describe("ChatController", () => {
|
|||
it("replaces guardian auto-review warnings when structured auto-review notifications arrive", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
@ -1252,7 +1305,7 @@ describe("ChatController", () => {
|
|||
it("ignores guardian auto-review warnings after structured auto-review notifications", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread-active";
|
||||
state.activeTurnId = "turn-active";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn-active" };
|
||||
const controller = controllerForState(state);
|
||||
|
||||
controller.handleNotification({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { chatReducer, createChatState, createChatStateStore, type ChatState } from "../../../src/features/chat/chat-state";
|
||||
import {
|
||||
activeTurnId,
|
||||
chatReducer,
|
||||
chatTurnBusy,
|
||||
createChatState,
|
||||
createChatStateStore,
|
||||
pendingTurnStart,
|
||||
type ChatState,
|
||||
} from "../../../src/features/chat/chat-state";
|
||||
import type { DisplayItem } from "../../../src/features/chat/display/types";
|
||||
import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
||||
|
||||
|
|
@ -8,7 +16,7 @@ describe("chatReducer", () => {
|
|||
it("clears active turn and thread state without dropping local composer draft", () => {
|
||||
const state = createChatState();
|
||||
state.activeThreadId = "thread";
|
||||
state.activeTurnId = "turn";
|
||||
state.turnLifecycle = { kind: "running", turnId: "turn" };
|
||||
state.activeModel = "gpt-5.1";
|
||||
state.historyCursor = "cursor";
|
||||
state.loadingHistory = true;
|
||||
|
|
@ -23,7 +31,7 @@ describe("chatReducer", () => {
|
|||
|
||||
expect(next).not.toBe(state);
|
||||
expect(next.activeThreadId).toBeNull();
|
||||
expect(next.activeTurnId).toBeNull();
|
||||
expect(activeTurnId(next)).toBeNull();
|
||||
expect(next.displayItems).toEqual([]);
|
||||
expect(next.turnDiffs.size).toBe(0);
|
||||
expect(next.historyCursor).toBeNull();
|
||||
|
|
@ -70,6 +78,85 @@ describe("chatReducer", () => {
|
|||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("keeps turn lifecycle fields synchronized through start and completion", () => {
|
||||
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: [] };
|
||||
const optimisticItem = { id: "local-user", kind: "message", role: "user", text: "hello" } satisfies DisplayItem;
|
||||
const acknowledgedItem = { ...optimisticItem, turnId: "turn" } satisfies DisplayItem;
|
||||
|
||||
const optimistic = chatReducer(createChatState(), {
|
||||
type: "turn/optimistic-started",
|
||||
item: optimisticItem,
|
||||
pendingTurnStart: pending,
|
||||
});
|
||||
expect(chatTurnBusy(optimistic)).toBe(true);
|
||||
expect(activeTurnId(optimistic)).toBeNull();
|
||||
expect(pendingTurnStart(optimistic)).toEqual(pending);
|
||||
|
||||
const running = chatReducer(optimistic, {
|
||||
type: "turn/start-acknowledged",
|
||||
turnId: "turn",
|
||||
displayItems: [acknowledgedItem],
|
||||
});
|
||||
expect(chatTurnBusy(running)).toBe(true);
|
||||
expect(activeTurnId(running)).toBe("turn");
|
||||
expect(pendingTurnStart(running)).toBeNull();
|
||||
|
||||
const completed = chatReducer(running, {
|
||||
type: "turn/completed",
|
||||
turnId: "turn",
|
||||
status: "completed",
|
||||
displayItems: [acknowledgedItem],
|
||||
});
|
||||
expect(chatTurnBusy(completed)).toBe(false);
|
||||
expect(activeTurnId(completed)).toBeNull();
|
||||
expect(pendingTurnStart(completed)).toBeNull();
|
||||
});
|
||||
|
||||
it("clears running state when a turn start fails", () => {
|
||||
const state = createChatState();
|
||||
state.turnLifecycle = { kind: "starting", pendingTurnStart: { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] } };
|
||||
|
||||
const next = chatReducer(state, { type: "turn/start-failed", displayItems: [] });
|
||||
|
||||
expect(chatTurnBusy(next)).toBe(false);
|
||||
expect(activeTurnId(next)).toBeNull();
|
||||
expect(pendingTurnStart(next)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores turn start acknowledgements after the turn has already gone idle", () => {
|
||||
const state = createChatState();
|
||||
state.turnLifecycle = { kind: "idle" };
|
||||
|
||||
const next = chatReducer(state, {
|
||||
type: "turn/start-acknowledged",
|
||||
turnId: "completed-turn",
|
||||
displayItems: [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true, turnId: "completed-turn" }],
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
expect(chatTurnBusy(next)).toBe(false);
|
||||
expect(activeTurnId(next)).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores completed turns while a new turn is still starting", () => {
|
||||
const pending = { anchorItemId: "local-user", promptSubmitHookItemIds: ["hook"] };
|
||||
const state = createChatState();
|
||||
state.turnLifecycle = { kind: "starting", pendingTurnStart: pending };
|
||||
state.displayItems = [{ id: "local-user", kind: "message", role: "user", text: "hello", markdown: true }];
|
||||
|
||||
const next = chatReducer(state, {
|
||||
type: "turn/completed",
|
||||
turnId: "stale-turn",
|
||||
status: "completed",
|
||||
displayItems: [],
|
||||
});
|
||||
|
||||
expect(next).toBe(state);
|
||||
expect(chatTurnBusy(next)).toBe(true);
|
||||
expect(pendingTurnStart(next)).toEqual(pending);
|
||||
expect(next.displayItems).toEqual(state.displayItems);
|
||||
});
|
||||
|
||||
it("keeps toolbar panels mutually exclusive", () => {
|
||||
let state = createChatState();
|
||||
|
||||
|
|
|
|||
|
|
@ -776,7 +776,7 @@ describe("message stream block identity and message actions", () => {
|
|||
} as const;
|
||||
const baseState = {
|
||||
activeThreadId: "thread",
|
||||
busy: false,
|
||||
turnLifecycle: { kind: "idle" as const },
|
||||
composerDraft: "",
|
||||
requestedCollaborationMode: "plan" as const,
|
||||
displayItems: [firstPlan, { id: "a1", kind: "message", role: "assistant", text: "answer" } as const, secondPlan],
|
||||
|
|
@ -785,7 +785,7 @@ describe("message stream block identity and message actions", () => {
|
|||
expect(implementPlanCandidateFromState(baseState)).toBe(secondPlan);
|
||||
expect(implementPlanCandidateFromState({ ...baseState, requestedCollaborationMode: "default" })).toBeNull();
|
||||
expect(implementPlanCandidateFromState({ ...baseState, composerDraft: "edit first" })).toBeNull();
|
||||
expect(implementPlanCandidateFromState({ ...baseState, busy: true })).toBeNull();
|
||||
expect(implementPlanCandidateFromState({ ...baseState, turnLifecycle: { kind: "running", turnId: "turn-2" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render copy actions for tool items", () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { act } from "react";
|
||||
|
||||
import { createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import { chatTurnBusy, createChatStateStore } from "../../../../src/features/chat/chat-state";
|
||||
import { renderChatPanelShell, unmountChatPanelShell } from "../../../../src/features/chat/ui/shell";
|
||||
import { installObsidianDomShims } from "./dom-test-helpers";
|
||||
|
||||
|
|
@ -123,9 +123,9 @@ function shellRenderers(store: ReturnType<typeof createChatStateStore>) {
|
|||
},
|
||||
composer: {
|
||||
render: vi.fn((composer: HTMLElement) => {
|
||||
composer.textContent = store.getState().busy ? "busy" : "ready";
|
||||
composer.textContent = chatTurnBusy(store.getState()) ? "busy" : "ready";
|
||||
}),
|
||||
snapshot: () => store.getState().busy,
|
||||
snapshot: () => chatTurnBusy(store.getState()),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
|||
import type { CodexChatHost } from "../../../src/features/chat/view";
|
||||
import { createAppServerDiagnostics } from "../../../src/app-server/compatibility";
|
||||
import { createChatState, type ChatState } from "../../../src/features/chat/chat-state";
|
||||
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
|
||||
import { notices } from "../../mocks/obsidian";
|
||||
import { installObsidianDomShims } from "./ui/dom-test-helpers";
|
||||
|
||||
|
|
@ -301,6 +302,33 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
||||
});
|
||||
|
||||
it("does not revive a completed turn when the startTurn response arrives late", async () => {
|
||||
const startTurn = deferred<{ turn: { id: string } }>();
|
||||
const client = connectedClient({
|
||||
startTurn: vi.fn(() => startTurn.promise),
|
||||
});
|
||||
connectionMock.state.client = client;
|
||||
const view = await chatView();
|
||||
|
||||
await view.openThread("thread-1");
|
||||
view.setComposerText("hello");
|
||||
const submit = (view as unknown as { submitComposerAction: () => Promise<void> }).submitComposerAction();
|
||||
await vi.waitFor(() => {
|
||||
expect(client.startTurn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const controller = (view as unknown as { controller: { handleNotification: (notification: ServerNotification) => void } }).controller;
|
||||
controller.handleNotification(turnStartedNotification("thread-1", "turn-1"));
|
||||
controller.handleNotification(turnCompletedNotification("thread-1", "turn-1"));
|
||||
expect((view as unknown as { state: ChatState }).state.turnLifecycle).toEqual({ kind: "idle" });
|
||||
|
||||
startTurn.resolve({ turn: { id: "turn-1" } });
|
||||
await submit;
|
||||
|
||||
expect((view as unknown as { state: ChatState }).state.turnLifecycle).toEqual({ kind: "idle" });
|
||||
expect(view.openPanelSnapshot()).toMatchObject({ busy: false, activeTurnId: null });
|
||||
});
|
||||
|
||||
it("requests a workspace layout save after resuming a thread", async () => {
|
||||
const requestSaveLayout = vi.fn();
|
||||
const client = connectedClient();
|
||||
|
|
@ -775,6 +803,47 @@ function turnWithUserMessage(text: string) {
|
|||
};
|
||||
}
|
||||
|
||||
function turnStartedNotification(threadId: string, turnId: string): Extract<ServerNotification, { method: "turn/started" }> {
|
||||
return {
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId,
|
||||
turn: {
|
||||
id: turnId,
|
||||
status: "inProgress",
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
completedAt: null,
|
||||
durationMs: null,
|
||||
itemsView: "full",
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function turnCompletedNotification(threadId: string, turnId: string): Extract<ServerNotification, { method: "turn/completed" }> {
|
||||
return {
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId,
|
||||
turn: {
|
||||
id: turnId,
|
||||
status: "completed",
|
||||
error: null,
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
durationMs: 1,
|
||||
itemsView: "full",
|
||||
items: [
|
||||
{ type: "userMessage", id: "user-1", content: [{ type: "text", text: "hello", text_elements: [] }] },
|
||||
{ type: "agentMessage", id: "agent-1", text: "done", phase: "final_answer", memoryCitation: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue