Model chat turn lifecycle

This commit is contained in:
murashit 2026-05-28 16:40:34 +09:00
parent 19fc1dbfd1
commit aa04e55c52
11 changed files with 392 additions and 126 deletions

View file

@ -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(),
{

View file

@ -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);
}

View file

@ -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 (

View file

@ -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) {

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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({

View file

@ -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();

View file

@ -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", () => {

View file

@ -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()),
},
};
}

View file

@ -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) => {